#!/usr/bin/env python3
"""Headless backend for the SwiftUI app (main app).

Starts only the FastAPI server (no PyObjC windows) so the SwiftUI host can
load the chat HTML in its own WKWebView while the agent runs here. The legacy
PyObjC UI (legacy/app_pyobjc2.py) is not used by this backend.

Usage:
    python mugi_backend.py --port 8321

The server prints ``MUGI_READY:<port>`` (or ``MUGI_READY:<port>:<auth_token>``
if no token was passed) to stdout once it is accepting connections.  When
launched by the SwiftUI host, the host sets ``MUGI_AUTH_TOKEN`` in the
environment and never receives the token on stdout.

Agent responses are streamed to ``GET /chat/events?thread_id=...`` as
Server-Sent Events carrying JS snippets the host evaluates in its WebView.
"""
from __future__ import annotations

import argparse
import asyncio
import atexit
import base64
import concurrent.futures
import copy
import hashlib
import importlib.util
import json
import os
import re
import subprocess
import sys
import queue
import threading
import tempfile
import uuid
from collections import deque
from datetime import datetime
from queue import Empty, Queue
from threading import Thread
import time
import signal
import logging
import math
from urllib.parse import urlparse
from contextvars import ContextVar

# Defense-in-depth: strip Metal debug/validation env BEFORE any import can initialize MLX.
# Xcode's "Metal API Validation"/"Shader Validation" inject these and the SwiftUI host can
# pass them through to this process; the debug layer turns tolerable Metal edge-conditions
# into hard abort()s (recurring SIGABRT in mlx::core::QuantizedMatmul::eval_gpu —
# tryCoalescingPreviousComputeCommandEncoder assertion — that killed in-flight runs, e.g.
# a Council batch dying before Captain synthesis). PythonBackend.swift also strips these,
# but this guard covers direct ``python -m mugi_backend`` launches too. Release Metal does
# not run these asserts.
for _metal_debug_key in (
    "MTL_DEBUG_LAYER",
    "MTL_DEBUG_LAYER_WARNING_MODE",
    "MTL_DEBUG_LAYER_ERROR_MODE",
    "METAL_DEVICE_WRAPPER_TYPE",
    "METAL_DEBUG_ERROR_MODE",
    "MTL_SHADER_VALIDATION",
    "MTL_SHADER_VALIDATION_GLOBAL_MEMORY",
    "MTL_SHADER_VALIDATION_TEXTURE_USAGE",
    "MTL_SHADER_VALIDATION_FAULT_MODE",
):
    os.environ.pop(_metal_debug_key, None)

from utils.conversation_sidebar import thread_id_allowed_in_main_conversation_sidebar
from utils.mugi_email_preferences import parse_email_config_payload, prepare_email_config_for_response

_log = logging.getLogger(__name__)
_request_id_ctx: ContextVar[str] = ContextVar("request_id", default="")
_PROCESS_START_TS = time.time()
_PROACTIVE_QUEUE_FILE_LOCK = threading.Lock()

# Short-lived cache for GET /dashboard/manifest (invalidated on plugin reload).
_dash_manifest_cache_lock = threading.Lock()
_dash_manifest_cache: tuple[float, dict] | None = None
_DASHBOARD_MANIFEST_TTL_S = 45.0


def _clear_dashboard_manifest_cache() -> None:
    global _dash_manifest_cache
    with _dash_manifest_cache_lock:
        _dash_manifest_cache = None


def _bundled_first_party_dashboard_tile_enabled(slug: str) -> bool:
    """Respect Settings → Dashboard (and app_prefs) for bundled dash_* tiles."""
    from plugin_security import get_plugin_approval_state

    st = get_plugin_approval_state(str(slug or "").strip())
    if not st:
        return True
    return bool(st.get("enabled", True))


# Shipped dashboard tiles (synced under ~/.mugi/dashboard_plugins/); merged into /dashboard/manifest.
BUNDLED_FIRST_PARTY_DASHBOARD_TILES: list[dict] = [
    {"slug": "dash_weather", "title": "Forecast", "id": "weather", "path": "/tile", "url": "/dashboard/bundled/dash_weather", "refresh_seconds": 1200, "priority": 1, "layout": "compact"},
    {"slug": "dash_system", "title": "System", "id": "system", "path": "/tile", "url": "/dashboard/bundled/dash_system", "refresh_seconds": 5, "priority": 0, "layout": "hero"},
    {"slug": "dash_kanban", "title": "Agent work", "id": "kanban", "path": "/tile", "url": "/dashboard/bundled/dash_kanban", "refresh_seconds": 30, "priority": 2},
    {"slug": "dash_calendar", "title": "Calendar", "id": "calendar", "path": "/tile", "url": "/dashboard/bundled/dash_calendar", "refresh_seconds": 900, "priority": 3},
    {"slug": "dash_reminders", "title": "Reminders", "id": "reminders", "path": "/tile", "url": "/dashboard/bundled/dash_reminders", "refresh_seconds": 600, "priority": 4},
    {"slug": "dash_memory", "title": "Memory", "id": "memory", "path": "/tile", "url": "/dashboard/bundled/dash_memory", "refresh_seconds": 14400, "priority": 5, "layout": "wide"},
    {"slug": "dash_markets", "title": "Markets", "id": "markets", "path": "/tile", "url": "/dashboard/bundled/dash_markets", "refresh_seconds": 300, "priority": 6},
    {"slug": "dash_news", "title": "News", "id": "news", "path": "/tile", "url": "/dashboard/bundled/dash_news", "refresh_seconds": 1800, "priority": 7, "layout": "compact"},
]
_BUNDLED_FIRST_PARTY_DASH_SLUGS = frozenset(str(t["slug"]) for t in BUNDLED_FIRST_PARTY_DASHBOARD_TILES)


def _merge_first_party_dashboard_tiles(content: dict) -> dict:
    out = dict(content or {})
    raw_tiles = out.get("tiles") if isinstance(out.get("tiles"), list) else []
    by_slug: dict[str, dict] = {}
    for t in raw_tiles:
        if isinstance(t, dict):
            slug = str(t.get("slug") or "")
            if slug in {"dash_agent", "dash_clock"}:
                continue
            by_slug[slug] = dict(t)
    for fp in BUNDLED_FIRST_PARTY_DASHBOARD_TILES:
        slug = str(fp["slug"])
        if not _bundled_first_party_dashboard_tile_enabled(slug):
            by_slug.pop(slug, None)
            continue
        merged = dict(by_slug.get(slug, {}))
        merged.update(fp)
        by_slug[slug] = merged
    tiles = list(by_slug.values())
    tiles.sort(key=lambda x: int(x.get("priority", 999)))
    out["tiles"] = tiles
    plugins = out.get("plugins") if isinstance(out.get("plugins"), list) else []
    plugins = [
        p for p in plugins
        if not (isinstance(p, dict) and str(p.get("slug") or "") in {"dash_agent", "dash_clock"})
    ]
    seen = {str(p.get("slug") or "") for p in plugins if isinstance(p, dict)}
    for fp in BUNDLED_FIRST_PARTY_DASHBOARD_TILES:
        slug = str(fp["slug"])
        if not _bundled_first_party_dashboard_tile_enabled(slug):
            continue
        if slug not in seen:
            plugins.append({"slug": slug, "name": fp["title"], "version": "bundled"})
    out["plugins"] = plugins
    return out


def _normalize_dashboard_tile_order(raw) -> list[str] | None:
    if raw is None:
        return None
    if not isinstance(raw, list):
        return None
    out: list[str] = []
    seen: set[str] = set()
    for x in raw:
        s = str(x or "").strip()
        if not s or len(s) > 120 or not s.startswith("dash_"):
            continue
        if s in seen:
            continue
        seen.add(s)
        out.append(s)
    return out


def _apply_dashboard_tile_order(tiles, order: list[str] | None) -> list:
    """Re-order dashboard tiles; unknown slugs in ``order`` are ignored. Omitted tiles follow by priority."""
    if not order:
        return list(tiles) if isinstance(tiles, list) else []
    if not isinstance(tiles, list):
        return []
    by_slug: dict[str, dict] = {}
    for t in tiles:
        if not isinstance(t, dict):
            continue
        sl = str(t.get("slug") or "").strip()
        if sl:
            by_slug[sl] = t
    reordered: list[dict] = []
    for sl in order:
        if sl in by_slug:
            reordered.append(by_slug.pop(sl))
    rest = list(by_slug.values())
    rest.sort(key=lambda x: int(x.get("priority", 999)))
    reordered.extend(rest)
    return reordered


_DASHBOARD_STORED_LAYOUTS = frozenset({"compact", "wide", "feature", "hero", "full"})
_DASHBOARD_TILE_LAYOUTS_MAX_KEYS = 64


def _normalize_dashboard_tile_layouts(raw) -> dict[str, str] | None:
    if raw is None:
        return None
    if not isinstance(raw, dict):
        return None
    out: dict[str, str] = {}
    for k, v in raw.items():
        sk = str(k or "").strip()
        if not sk.startswith("dash_") or len(sk) > 120:
            continue
        lv = str(v or "").lower().strip()
        if not lv or lv == "default":
            continue
        if lv not in _DASHBOARD_STORED_LAYOUTS:
            continue
        if lv == "hero":
            lv = "wide"
        out[sk] = lv
    if len(out) > _DASHBOARD_TILE_LAYOUTS_MAX_KEYS:
        return None
    return out


def _apply_dashboard_tile_layouts(tiles, layouts: dict[str, str] | None) -> list:
    if not layouts:
        return list(tiles) if isinstance(tiles, list) else []
    if not isinstance(tiles, list):
        return []
    out: list[dict] = []
    for t in tiles:
        if not isinstance(t, dict):
            out.append(t)
            continue
        sl = str(t.get("slug") or "").strip()
        if not sl or sl not in layouts:
            out.append(t)
            continue
        nv = layouts[sl]
        t2 = dict(t)
        t2["layout"] = nv
        out.append(t2)
    return out


def _with_dashboard_user_prefs_applied(manifest: dict) -> dict:
    """Shallow copy: applies ``dashboard_tile_order`` and ``dashboard_tile_layouts`` from app prefs; exposes overrides for the WK client."""
    try:
        prefs = _load_app_prefs()
    except Exception:
        prefs = {}
    order = _normalize_dashboard_tile_order(prefs.get("dashboard_tile_order"))
    raw_layouts = _normalize_dashboard_tile_layouts(prefs.get("dashboard_tile_layouts"))
    explicit_layouts: dict[str, str] = raw_layouts if raw_layouts is not None else {}
    base = dict(manifest) if isinstance(manifest, dict) else {}
    tiles0 = _apply_dashboard_tile_order(base.get("tiles") or [], order)
    base["tiles"] = _apply_dashboard_tile_layouts(tiles0, explicit_layouts)
    base["tile_layout_overrides"] = dict(explicit_layouts)
    return base


class _JsonLineFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.utcnow().isoformat(timespec="milliseconds") + "Z",
            "level": record.levelname,
            "logger": record.name,
            "msg": record.getMessage(),
        }
        req_id = _request_id_ctx.get("")
        if req_id:
            payload["request_id"] = req_id
        if record.exc_info:
            payload["exc_info"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False)


def _setup_structured_logging() -> None:
    logs_dir = os.path.join(os.path.expanduser("~"), ".mugi", "logs")
    os.makedirs(logs_dir, exist_ok=True)
    path = os.path.join(logs_dir, "backend.jsonl")
    root = logging.getLogger()
    root.setLevel(logging.INFO)
    if any(
        isinstance(h, logging.FileHandler) and getattr(h, "baseFilename", "") == path
        for h in root.handlers
    ):
        return
    handler = logging.FileHandler(path, encoding="utf-8")
    handler.setFormatter(_JsonLineFormatter())
    root.addHandler(handler)


def apply_debug_logging_level(config: dict | None = None) -> None:
    """Align backend logger verbosity with [debug].enabled."""
    try:
        cfg = config
        if cfg is None:
            from core import get_config
            cfg = get_config()
        enabled = bool(((cfg or {}).get("debug") or {}).get("enabled", False))
    except Exception:
        enabled = False
    target_level = logging.INFO if enabled else logging.WARNING
    for name in ("", __name__, "uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"):
        logging.getLogger(name).setLevel(target_level)

_project_root = os.path.dirname(os.path.abspath(__file__))
if _project_root not in sys.path:
    sys.path.insert(0, _project_root)

from backend.bootstrap import (  # noqa: F401
    _BACKEND_VENV_DIR, _SETUP_LOCK_PATH,
    _pid_exists, _acquire_setup_lock, _release_setup_lock,
    _running_in_backend_venv, _bootstrap_progress,
    _ensure_backend_venv_and_reexec, _ensure_backend_venv_and_reexec_impl,
    _module_available, _ensure_backend_runtime_deps,
    _ensure_darwin_backend_python_signed_before_native_imports,
)
from backend.event_buses import ChatEventBus, PluginStreamBus  # noqa: F401


# Bootstrap before any third-party imports. If we're not in ~/.mugi/venv, we don't have deps yet.
if os.getenv("MUGI_SKIP_BACKEND_BOOTSTRAP", "").strip() == "1":
    pass
elif not _running_in_backend_venv():
    _ensure_backend_venv_and_reexec()
else:
    _ensure_backend_runtime_deps()

# macOS: PyPI native extensions must load under the venv interpreter; ad-hoc sign + one execv if needed.
if sys.platform == "darwin" and _running_in_backend_venv():
    _ensure_darwin_backend_python_signed_before_native_imports()


def _log_unhandled(exc_type, exc_val, exc_tb):
    """Ensure unhandled exceptions are printed to stderr before exit."""
    import traceback
    msg = "".join(traceback.format_exception(exc_type, exc_val, exc_tb))
    print(f"[mugi] Unhandled exception:\n{msg}", file=sys.stderr, flush=True)
    sys.__excepthook__(exc_type, exc_val, exc_tb)


sys.excepthook = _log_unhandled

print("[mugi] Loading modules...", file=sys.stderr, flush=True)
try:
    from fastapi import BackgroundTasks, Body, Query as FQuery, Request as FRequest, WebSocket
    from fastapi.responses import JSONResponse, Response, StreamingResponse
    import uvicorn

    import aiagent
    import app_http_routes
    from mesh import MeshManager, set_mesh_manager
    from mesh.routes import build_mesh_router
    from activity_bus import (
        ActivityBus,
        emit_activity_enriched,
        emit_activity_event,
        set_activity_emitter,
        set_system_event_emitter,
    )
    from agent_loop import CHATS_DB_DIR, APP_PREFS_FILENAME
    from live_view_events import LiveViewEventBus
    from persistence import generate_secure_token, MUGI_LOGS_DIR, AGENT_NETWORK_LOG_PATH
    from run_context import sanitize_run_id, set_run_context, clear_run_context
    from tools._logging import set_terminal_logger
except Exception as e:
    import traceback
    tb = traceback.format_exc()
    print(f"[mugi] Import failed: {e}\n{tb}", file=sys.stderr, flush=True)
    sys.exit(1)


# ---------------------------------------------------------------------------
# Agent/network error logging (cleared on each app launch)
# ---------------------------------------------------------------------------

def _categorize_network_error(msg):
    """Return a short tag for network/stream/LLM errors, or None if not applicable."""
    if not msg or not isinstance(msg, str):
        return None
    s = msg.lower()
    if "stream stalled" in s or "no chunk received" in s:
        return "stream_stall"
    if "stream" in s and ("crashed" in s or "connection lost" in s):
        return "stream_crashed"
    if "llm request failed" in s and "retrying" in s:
        return "llm_retry"
    if "timeout" in s or "timed out" in s:
        return "timeout"
    if "429" in msg:
        return "rate_limit"
    if "502" in msg or "503" in msg or "500" in msg or "5xx" in s:
        return "server_error"
    if "connection" in s or "connect" in s:
        return "connection"
    if "llm" in s or "stream" in s or "retry" in s:
        return "network"
    return None


def _agent_network_log(level, msg):
    """Print to stdout and append categorized network/stream/LLM errors to ~/.mugi/logs/agent_network.log."""
    print(f"[{level}] {msg}", flush=True)
    if level not in ("ERROR", "WARN"):
        return
    tag = _categorize_network_error(msg)
    if tag is None:
        return
    try:
        os.makedirs(MUGI_LOGS_DIR, exist_ok=True)
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        with open(AGENT_NETWORK_LOG_PATH, "a", encoding="utf-8") as f:
            f.write(f"{ts} [{tag}] {msg}\n")
    except OSError:
        pass


# ---------------------------------------------------------------------------
# Helpers shared with app_pyobjc2 (duplicated to avoid importing AppKit)
# ---------------------------------------------------------------------------

def _load_app_prefs() -> dict:
    try:
        from utils.app_prefs_store import load_app_prefs_cached
        return load_app_prefs_cached()
    except Exception:
        return {}


def _save_app_prefs(prefs: dict) -> bool:
    try:
        from utils.app_prefs_store import save_app_prefs

        save_app_prefs(prefs)
        return True
    except Exception as exc:
        _log.error("_save_app_prefs failed: %s", exc, exc_info=True)
        return False


# Verbatim snippets asserted by plugin sandbox CI tests (handlers: mugi_backend_plugin_routes.py).
_PLUGIN_ROUTE_CI_ANCHORS = """
@fastapi_app.get("/preferences/plugins/sandbox/route_support")
@fastapi_app.post("/preferences/plugins/sandbox/kill_switch")
@fastapi_app.post("/preferences/plugins/{slug}/quarantine")
@fastapi_app.post("/preferences/plugins/{slug}/unquarantine")
@fastapi_app.post("/preferences/plugins/sandbox/rollback")
@fastapi_app.post("/preferences/plugins/{slug}/runtime/restart")
@fastapi_app.get("/preferences/plugins/{slug}/runtime/crash_dump")
row["runtime"] = pm.get_runtime_diagnostics(slug)
"websocket_proxy": False
"""


def _content_to_display_string(content, max_length: int | None = None) -> str:
    """Turn message content (str or list of parts) into a display string. Never includes base64/image URLs."""
    if content is None:
        return ""
    if isinstance(content, str):
        out = content
    elif isinstance(content, list):
        parts = []
        for b in content:
            if isinstance(b, dict):
                if b.get("type") == "text":
                    parts.append((b.get("text") or "").strip())
                elif b.get("type") == "image_url" or "image_url" in b:
                    parts.append("[Image]")
                elif b.get("type") == "file_attachment":
                    pass  # rendered as chips below the bubble, not inline text
                else:
                    parts.append((b.get("text", str(b)) or "").strip())
            else:
                parts.append(str(b).strip())
        out = " ".join(p for p in parts if p)
    else:
        out = str(content)
    if max_length is not None and len(out) > max_length:
        out = out[:max_length].rstrip() + " [Attachment omitted for display.]"
    return out


def _content_image_urls(content) -> list[str]:
    """Extract displayable image URLs from message content (list of parts).
    Returns http(s) and data: (base64) URLs for display."""
    if not isinstance(content, list):
        return []
    urls = []
    for b in content:
        if isinstance(b, dict) and (b.get("type") == "image_url" or "image_url" in b):
            url = (b.get("image_url") or {}).get("url") or ""
            if isinstance(url, str) and url.strip():
                lower = url.strip().lower()
                if lower.startswith("http://") or lower.startswith("https://") or lower.startswith("data:"):
                    urls.append(url.strip())
    return urls


def _content_file_attachments(content) -> list[dict[str, str]]:
    """Extract non-image file attachments from structured message content."""
    if not isinstance(content, list):
        return []
    out: list[dict[str, str]] = []
    for b in content:
        if not isinstance(b, dict) or b.get("type") != "file_attachment":
            continue
        filename = str(b.get("filename") or "").strip()
        url = str(b.get("url") or "").strip()
        if filename and url:
            out.append({"filename": filename, "url": url})
    return out


def _is_synthetic_system_user_line(text: str) -> bool:
    """True for idle/automation user rows stored as short bracket labels (not real operator text)."""
    s = (text or "").strip()
    if not s or s.lower() == "init":
        return True
    if not (s.startswith("[") and s.endswith("]")):
        return False
    inner = s[1:-1].strip().lower()
    markers = (
        "idle continuity",
        "kanban update",
        "vault note",
        "vault project",
        "action card",
        "topic raised",
        "approved work",
    )
    return any(m in inner for m in markers)


def _user_message_has_meaningful_text(message: dict) -> bool:
    """True when a stored user message has non-init typed text (attachments alone do not count)."""
    if (message.get("role") or "").strip() != "user":
        return False
    content = message.get("content")
    if isinstance(content, str):
        text = content.strip()
        return bool(text) and not _is_synthetic_system_user_line(text)
    if isinstance(content, list):
        for part in content:
            if isinstance(part, dict) and part.get("type") == "text":
                text = (part.get("text") or "").strip()
                if text and text.lower() != "init":
                    return True
        return False
    return bool(content)


def _only_init_or_attachment_only_user_messages(messages: list) -> bool:
    """True when every user message is ``init`` or attachment/image-only (no typed text yet)."""
    for m in messages:
        if (m.get("role") or "").strip() != "user":
            continue
        if _user_message_has_meaningful_text(m):
            return False
    return True


def _auto_title_from_user_send(text: str, file_refs: list[tuple[str, str]]) -> str | None:
    """Derive a sidebar title from typed text or attached filenames."""
    stripped = (text or "").replace("\n", " ").strip()
    if stripped and stripped.lower() != "init":
        return (stripped[:40] + "…") if len(stripped) > 40 else stripped
    names = [(fn or "").strip() for fn, _ in (file_refs or []) if (fn or "").strip()]
    if not names:
        return None
    primary = names[0]
    if len(names) == 1:
        return (primary[:40] + "…") if len(primary) > 40 else primary
    suffix = f" + {len(names) - 1} more"
    budget = max(1, 40 - len(suffix))
    if len(primary) <= budget:
        head = primary
    else:
        head = primary[: budget - 1].rstrip() + "…"
    return f"{head}{suffix}"


_CHATS_DB_FILENAME = "chats_db.json"
_CHATS_DB_LOCK = threading.Lock()

# Single-worker pool for DB writes so _save_chats_db never blocks the drain thread.
_db_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="mugi_db")

# Pending chats_db writes — used by ``_drain_chats_db_writes`` on shutdown so a
# trash / rename / organize that happens just before quit doesn't get silently
# dropped. Without this, the daemon thread holding the pending write is killed
# when the process exits, leaving chats_db.json on disk stale.
_db_pending_lock = threading.Lock()
_db_pending_futures: set[concurrent.futures.Future] = set()


def _schedule_save_chats_db(delegate) -> None:
    """Persist ``chats_db.json`` asynchronously from delegate state *when the job runs*.

    Previously we deep-copied ``_tabs``/``_conversation_meta`` at enqueue time. A slower
    job could run *after* a conversation delete and overwrite disk with stale thread IDs,
    making deleted chats reappear on the next app launch.

    Each submitted future is tracked in ``_db_pending_futures`` so the shutdown
    drain (``_drain_chats_db_writes``) can wait for in-flight writes to complete
    before the process exits.
    """
    if delegate is None:
        return
    bus = getattr(delegate, "_live_view_event_bus", None)

    def _job():
        try:
            tabs_snap = copy.deepcopy(getattr(delegate, "_tabs", None) or [])
            meta_snap = copy.deepcopy(getattr(delegate, "_conversation_meta", None) or {})
        except Exception:
            _log.exception("failed to snapshot delegate for chats_db persist")
            return
        _save_chats_db(tabs_snap, meta_snap, bus)

    fut = _db_executor.submit(_job)
    with _db_pending_lock:
        _db_pending_futures.add(fut)

    def _on_done(f: concurrent.futures.Future) -> None:
        with _db_pending_lock:
            _db_pending_futures.discard(f)

    fut.add_done_callback(_on_done)


def _drain_chats_db_writes(timeout_sec: float = 10.0) -> None:
    """Wait for queued ``_save_chats_db`` writes to finish before the process exits.

    Called from both the signal handler (SIGTERM/SIGINT) and an atexit hook so the
    user's most recent trash / rename / organize is durable on disk no matter how
    the process shuts down. Bounded by ``timeout_sec`` so a stuck write can't hang
    quit indefinitely; on timeout we accept the data loss and exit anyway.
    """
    with _db_pending_lock:
        pending = list(_db_pending_futures)
    if not pending:
        return
    try:
        done, not_done = concurrent.futures.wait(pending, timeout=timeout_sec)
        if not_done:
            print(
                f"[mugi_backend] chats_db drain: {len(not_done)} write(s) timed out "
                f"after {timeout_sec:.1f}s; some recent state may be lost",
                file=sys.stderr, flush=True,
            )
    except Exception as exc:
        print(f"[mugi_backend] chats_db drain error: {exc}", file=sys.stderr, flush=True)


def _chats_db_path() -> str:
    os.makedirs(CHATS_DB_DIR, exist_ok=True)
    return os.path.join(CHATS_DB_DIR, _CHATS_DB_FILENAME)


# --- chats_db.json backup-on-write -------------------------------------------------
#
# Every persisted write to ``chats_db.json`` first copies the current file into a
# rotating backup directory at ``~/.mugi/backups/chats_db/``. Filenames embed an
# ISO-UTC timestamp (``chats_db.json.YYYYMMDDTHHMMSSZ``) so lexical sort matches
# chronological sort, and recovery is a single ``cp``. Backup errors are *always*
# swallowed: losing a snapshot is strictly better than losing the ability to write.
_CHATS_DB_BACKUP_DIRNAME = "backups"
_CHATS_DB_BACKUP_SUBDIR = "chats_db"
# Hard cap on retained snapshots. Twenty is enough to cover several launch/edit
# sessions without unbounded growth (~6-8 MB at typical sizes).
_CHATS_DB_BACKUP_KEEP = 20
# A burst of writes (e.g. drag-reorder, multi-organize) only produces one snapshot.
# 60 s gives roughly ~20 minutes of recovery history at the cap.
_CHATS_DB_BACKUP_MIN_INTERVAL_SEC = 60.0


def _chats_db_backup_dir() -> str:
    d = os.path.join(CHATS_DB_DIR, _CHATS_DB_BACKUP_DIRNAME, _CHATS_DB_BACKUP_SUBDIR)
    os.makedirs(d, exist_ok=True)
    return d


def _list_chats_db_backups_desc() -> list[str]:
    """Return backup filenames newest-first. Best-effort; swallows directory errors.

    Excludes ``chats_db.json.PANIC.*`` files (see :func:`_take_chats_db_panic_backup`)
    from rotation accounting so a forensic snapshot is never silently rotated away.
    """
    try:
        d = _chats_db_backup_dir()
        names = [
            n for n in os.listdir(d)
            if n.startswith("chats_db.json.")
            and not n.endswith(".tmp")
            and not n.startswith("chats_db.json.PANIC.")
        ]
    except Exception:
        return []
    names.sort(reverse=True)
    return names


# Defensive: if a single _save_chats_db call would shrink conversation_meta by this
# many entries, take an unthrottled panic backup first AND log the call stack. The
# write still proceeds — bulk deletes (Empty Trash) can legitimately shrink by a lot
# — but the snapshot guarantees the user can roll back even if every regular backup
# slot has rotated.
_CHATS_DB_PANIC_SHRINK_THRESHOLD = 5


def _take_chats_db_panic_backup(reason: str) -> None:
    """Snapshot chats_db.json into the backup dir BEFORE a suspicious write.

    Bypasses the 60-second throttle of :func:`_backup_chats_db_before_write` so a
    sudden destructive shrink always captures the pre-write content. The resulting
    file is named ``chats_db.json.PANIC.<UTC-timestamp>`` and is exempt from the
    rotation cap so it survives normal cleanup.

    Also prints a stack trace to stderr so we can identify which call site asked
    for the destructive write — without this breadcrumb, a future bug repeats the
    May 2026 "all chats turned into Untitled" data-loss event with no forensic trail.
    """
    src = _chats_db_path()
    if not os.path.isfile(src):
        return
    try:
        d = _chats_db_backup_dir()
    except Exception:
        return
    from datetime import timezone

    ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
    dst = os.path.join(d, f"chats_db.json.PANIC.{ts}")
    try:
        import shutil
        shutil.copy2(src, dst)
        try:
            os.chmod(dst, 0o600)
        except OSError:
            pass
    except Exception:
        _log.warning("panic backup copy failed", exc_info=True)
        return

    import traceback
    stack = "".join(traceback.format_stack(limit=12))
    print(
        f"[mugi_backend] PANIC chats_db backup taken ({reason}): {os.path.basename(dst)}\n"
        f"  caller stack:\n{stack}",
        file=sys.stderr,
        flush=True,
    )


def _start_parent_watch(parent_pid: int, *, poll_interval_sec: float = 5.0) -> None:
    """Background watcher that exits the backend when the launching parent dies.

    Why: when Mugi.app gets SIGKILL'd (Xcode stops a debug session, Force Quit,
    crash) the child ``mugi_backend`` reparents to launchd (PID 1) and keeps
    running forever — that's how the user accumulated four ghost backends across
    a single afternoon, all racing the same ``~/.mugi/chats_db.json`` file.
    Swift's ``terminateStrayBackendRecordedInPIDFile`` only knows about ONE prior
    PID, so backends from before the most-recent PID-file write are missed.

    The watcher polls ``os.getppid()`` every ``poll_interval_sec`` seconds. When
    the recorded launching parent is no longer our parent (PPID flipped to 1, or
    the PID is gone entirely), we self-terminate via ``os.kill(self, SIGTERM)``
    so the existing ``_on_shutdown_signal`` drain hook still runs and the
    chats_db.json write finishes cleanly.
    """
    if parent_pid <= 1:
        return

    def _watch() -> None:
        while True:
            try:
                time.sleep(poll_interval_sec)
            except Exception:
                _log.debug("parent-watch sleep interrupted", exc_info=True)
            try:
                ppid_now = os.getppid()
            except OSError:
                ppid_now = 1
            parent_alive = True
            if ppid_now != parent_pid:
                # Reparented — parent definitely died.
                parent_alive = False
            else:
                try:
                    os.kill(parent_pid, 0)
                except ProcessLookupError:
                    parent_alive = False
                except PermissionError:
                    parent_alive = True
                except OSError:
                    parent_alive = True
            if not parent_alive:
                print(
                    f"[mugi] parent process {parent_pid} is gone (now ppid={ppid_now}); "
                    f"shutting down to avoid becoming a ghost backend.",
                    file=sys.stderr,
                    flush=True,
                )
                try:
                    os.kill(os.getpid(), signal.SIGTERM)
                except OSError:
                    os._exit(0)
                return

    Thread(target=_watch, daemon=True, name="parent-watch").start()


def _acquire_single_instance_lock() -> int | None:
    """Acquire an exclusive process lock so only one ``mugi_backend`` runs at once.

    Returns the open file descriptor on success (must be retained for the lifetime
    of the process — closing the fd releases the lock). Returns ``None`` if another
    backend is already running, after printing a clear ``MUGI_INSTANCE_LOCKED:<pid>``
    line on stdout and a diagnostic on stderr. The Swift host watches stdout for the
    locked-line so it can surface a friendly error.

    Why: without this lock, a stale ``mugi_backend`` from a previous Mugi launch
    can survive (the macOS host's process-group kill doesn't always reach orphaned
    children, especially when the process is mid-shutdown drain). Two backends
    racing the same ``~/.mugi/chats_db.json`` file is **the** root cause of the
    May 2026 "all my chats turned into Untitled" data-loss reports — last-writer
    wins, and a stale backend's in-memory state can wipe the live one's saves.
    """
    import fcntl

    home = os.path.expanduser("~/.mugi")
    try:
        os.makedirs(home, exist_ok=True)
    except OSError as exc:
        print(f"[mugi] cannot prepare lock dir {home}: {exc}", file=sys.stderr, flush=True)
        return None
    lock_path = os.path.join(home, "mugi_backend.pid")
    try:
        fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
    except OSError as exc:
        print(f"[mugi] cannot open instance lock {lock_path}: {exc}", file=sys.stderr, flush=True)
        return None
    try:
        fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except (BlockingIOError, OSError):
        try:
            os.lseek(fd, 0, os.SEEK_SET)
            holder = os.read(fd, 64).decode("utf-8", errors="replace").strip() or "unknown"
        except OSError:
            holder = "unknown"
        print(f"MUGI_INSTANCE_LOCKED:{holder}", flush=True)
        print(
            f"[mugi] another mugi_backend is already running (pid={holder}); refusing to start. "
            f"Two backends writing ~/.mugi/chats_db.json concurrently is how chats turn into "
            f"'Untitled'. Quit the existing one (or run `kill {holder}`) and relaunch.",
            file=sys.stderr,
            flush=True,
        )
        try:
            os.close(fd)
        except OSError:
            pass
        return None
    try:
        os.lseek(fd, 0, os.SEEK_SET)
        os.ftruncate(fd, 0)
        os.write(fd, str(os.getpid()).encode("ascii"))
    except OSError:
        pass
    return fd


def _backup_chats_db_before_write() -> None:
    """Copy the current ``chats_db.json`` to ``~/.mugi/backups/chats_db/`` first.

    Must be called *before* the atomic write replaces the file so the snapshot captures
    the pre-write content. Throttled by ``_CHATS_DB_BACKUP_MIN_INTERVAL_SEC`` to keep a
    write burst from churning the cap. All failures are logged and swallowed.
    """
    src = _chats_db_path()
    if not os.path.isfile(src):
        return
    try:
        if os.path.getsize(src) <= 0:
            return
    except OSError:
        return
    try:
        d = _chats_db_backup_dir()
    except Exception:
        _log.debug("chats_db backup dir create failed", exc_info=True)
        return

    existing = _list_chats_db_backups_desc()
    if existing:
        try:
            newest_mtime = os.path.getmtime(os.path.join(d, existing[0]))
            if (time.time() - newest_mtime) < _CHATS_DB_BACKUP_MIN_INTERVAL_SEC:
                return
        except OSError:
            # Newest backup unreadable for some reason → fall through and write a
            # fresh snapshot rather than silently skip.
            pass

    from datetime import timezone

    ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
    dst = os.path.join(d, f"chats_db.json.{ts}")
    try:
        import shutil
        shutil.copy2(src, dst)
        try:
            os.chmod(dst, 0o600)
        except OSError:
            pass
    except Exception:
        _log.warning("chats_db backup copy failed (write proceeds)", exc_info=True)
        return

    refreshed = _list_chats_db_backups_desc()
    for name in refreshed[_CHATS_DB_BACKUP_KEEP:]:
        try:
            os.remove(os.path.join(d, name))
        except OSError:
            pass


# --- Backend version banner --------------------------------------------------------
#
# Eliminates the "is the fix actually applied?" merry-go-round. On startup we emit
# a ``MUGI_BACKEND_INFO:<json>`` line on stdout right before ``MUGI_READY:`` so the
# Swift host (and the Python log) records exactly which backend file is running and
# whether it came from the dev tree or the bundled app zip.


def _resolve_git_sha() -> str:
    """Best-effort short SHA. Order: env var (set by bundler), git rev-parse, 'unknown'.

    The bundled standalone zip has no ``.git/`` so the env var is the only reliable
    signal in production; the bundler is expected to set ``MUGI_BACKEND_GIT_SHA``
    at build time. Dev runs from the source tree fall through to ``git rev-parse``.
    """
    env = os.environ.get("MUGI_BACKEND_GIT_SHA", "").strip()
    if env:
        return env[:12]
    here = os.path.dirname(os.path.abspath(__file__))
    try:
        out = subprocess.run(
            ["git", "-C", here, "rev-parse", "--short", "HEAD"],
            capture_output=True, text=True, timeout=2.0, check=False,
        )
        if out.returncode == 0 and out.stdout.strip():
            return out.stdout.strip()[:12]
    except (OSError, subprocess.SubprocessError):
        _log.debug("git rev-parse for backend banner failed", exc_info=True)
    return "unknown"


def _detect_bundled() -> bool:
    """True when running from the SwiftUI app bundle or the bundled venv.

    Two independent signals because the app bundle path can be renamed by the
    user (rare) and the bundled venv is the source-of-truth for "this is the
    frozen production environment, not the live source tree."
    """
    p = os.path.abspath(__file__)
    venv_root = os.path.expanduser("~/.mugi/venv/")
    return ("/Mugi.app/" in p) or (p.startswith(venv_root))


def _build_backend_banner() -> dict:
    """Pure helper so the test can call it without spawning a subprocess."""
    from datetime import timezone

    return {
        "path": os.path.abspath(__file__),
        "bundled": _detect_bundled(),
        "git_sha": _resolve_git_sha(),
        "mtime": datetime.fromtimestamp(os.path.getmtime(__file__), tz=timezone.utc).isoformat(),
        "py_version": sys.version.split()[0],
        "py_executable": os.path.abspath(sys.executable),
    }


def _emit_backend_banner() -> None:
    """Print MUGI_BACKEND_INFO:<json> + log the same record. Best-effort; never raises."""
    try:
        banner = _build_backend_banner()
        print(f"MUGI_BACKEND_INFO:{json.dumps(banner, sort_keys=True)}", flush=True)
        _log.info("backend banner: %s", banner)
    except Exception:
        _log.warning("backend banner emit failed", exc_info=True)


def _load_persisted_tabs():
    path = _chats_db_path()
    if not os.path.isfile(path):
        return None, None
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        tabs = data.get("tabs")
        if not isinstance(tabs, list):
            return None, None
        # Never treat `{}` as "no file": an empty-or-missing `conversation_meta`
        # must not trigger a full destructive re-init — Swift sidebar + messages
        # may still exist in SQLite (`messages` rows) while chats_db meta was wiped.
        raw_meta = data.get("conversation_meta")
        meta = raw_meta if isinstance(raw_meta, dict) else {}
        return tabs, meta
    except Exception:
        return None, None


def _load_chats_db() -> dict:
    """Load full chats_db payload (tabs/meta/folders), returning safe defaults."""
    with _CHATS_DB_LOCK:
        return _load_chats_db_unlocked()


def _load_chats_db_unlocked() -> dict:
    """Load full chats_db payload (tabs/meta/folders/meta_migrations), returning safe defaults.

    Caller must hold _CHATS_DB_LOCK.

    ``meta_migrations`` is a list of one-shot migration IDs that have already been applied
    to the persisted ``conversation_meta`` (see ``_has_meta_migration`` /
    ``_mark_meta_migration``). The list travels with the file, so restoring a pre-migration
    backup naturally re-arms the corresponding migrations.
    """
    path = _chats_db_path()
    if not os.path.isfile(path):
        return {"tabs": [], "conversation_meta": {}, "folders": [], "meta_migrations": []}
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, dict):
            return {"tabs": [], "conversation_meta": {}, "folders": [], "meta_migrations": []}
        tabs = data.get("tabs") if isinstance(data.get("tabs"), list) else []
        meta = data.get("conversation_meta") if isinstance(data.get("conversation_meta"), dict) else {}
        folders = data.get("folders") if isinstance(data.get("folders"), list) else []
        raw_migrations = data.get("meta_migrations")
        migrations = (
            [str(m) for m in raw_migrations if isinstance(m, str) and m.strip()]
            if isinstance(raw_migrations, list)
            else []
        )
        return {"tabs": tabs, "conversation_meta": meta, "folders": folders, "meta_migrations": migrations}
    except Exception:
        return {"tabs": [], "conversation_meta": {}, "folders": [], "meta_migrations": []}


def _write_chats_db_unlocked(data: dict) -> None:
    """Atomically write chats_db payload.

    Caller must hold _CHATS_DB_LOCK.

    Before the atomic write, snapshots the current persisted file under
    ``~/.mugi/backups/chats_db/chats_db.json.<UTC-timestamp>`` so a corrupt write or a
    surprise migration is recoverable with a single ``cp``. The backup hook is the
    single chokepoint for *every* persisted mutation (``_save_chats_db`` and
    ``_update_chats_db`` both reach disk through this function), and backup errors
    are swallowed so they can never block a write.
    """
    from persistence import secure_atomic_write
    # Defense in depth: even if a future bug makes ``_backup_chats_db_before_write``
    # raise before its own internal guards take effect, the persisted write must still
    # succeed. Losing a snapshot is strictly better than losing the user's metadata.
    try:
        _backup_chats_db_before_write()
    except Exception:
        _log.warning("chats_db backup hook raised (write proceeds)", exc_info=True)
    payload = json.dumps(data, indent=2)
    secure_atomic_write(_chats_db_path(), payload)


def _update_chats_db(mutator):
    """Run a read-modify-write transaction on chats_db under a single lock."""
    with _CHATS_DB_LOCK:
        data = _load_chats_db_unlocked()
        result = mutator(data)
        _write_chats_db_unlocked(data)
        return result


def _save_chats_db(tabs, conversation_meta, live_view_bus=None):
    try:
        with _CHATS_DB_LOCK:
            existing = _load_chats_db_unlocked()
            existing_folders = [
                f for f in (existing.get("folders") or [])
                if isinstance(f, dict)
            ]
            existing_migrations = list(existing.get("meta_migrations") or [])
            existing_meta = existing.get("conversation_meta") or {}
            existing_meta_count = len(existing_meta) if isinstance(existing_meta, dict) else 0
            new_meta_count = len(conversation_meta) if isinstance(conversation_meta, dict) else 0
            shrink = existing_meta_count - new_meta_count
            if (
                existing_meta_count > 0
                and shrink >= _CHATS_DB_PANIC_SHRINK_THRESHOLD
            ):
                _take_chats_db_panic_backup(
                    reason=f"meta shrink {existing_meta_count} -> {new_meta_count}"
                )
            tabs_serializable = []
            for tab in (tabs or []):
                t = {k: v for k, v in tab.items() if k != "scratchpads"}
                t["scratchpads"] = [
                    {"id": str(sp.get("id") or "").strip() or str(uuid.uuid4())}
                    for sp in (tab.get("scratchpads") or [])
                ]
                tabs_serializable.append(t)
            _write_chats_db_unlocked(
                {
                    "tabs": tabs_serializable,
                    "conversation_meta": conversation_meta,
                    "folders": existing_folders,
                    "meta_migrations": existing_migrations,
                }
            )
        if live_view_bus is not None:
            try:
                live_view_bus.save_to_disk()
            except Exception as lv_exc:
                print(f"[mugi_backend] live_view save_to_disk error: {lv_exc}", file=sys.stderr, flush=True)
    except Exception as exc:
        print(f"[mugi_backend] _save_chats_db error: {exc}", file=sys.stderr, flush=True)


META_MIGRATION_SIDEBAR_TITLE_FREEZE = "sidebar_title_freeze_v1"
META_MIGRATION_SIDEBAR_TITLE_BATCH_REGEN = "sidebar_title_batch_regen_v1"
META_MIGRATION_SIDEBAR_TITLE_UTILITY_SANITIZE_RETRY = "sidebar_title_utility_sanitize_retry_v1"
META_MIGRATION_SIDEBAR_TITLE_OUTLINE_JUNK_RETRY = "sidebar_title_outline_junk_retry_v1"
META_MIGRATION_SIDEBAR_TITLE_TRANSCRIPT_ECHO_RETRY = "sidebar_title_transcript_echo_retry_v1"
META_MIGRATION_SIDEBAR_TITLE_SYNTHETIC_USER_RETRY = "sidebar_title_synthetic_user_retry_v1"


def _has_meta_migration(name: str) -> bool:
    """True if migration ``name`` has already been applied to the persisted ``chats_db.json``."""
    if not name:
        return False
    return name in (_load_chats_db().get("meta_migrations") or [])


def _normalized_sidebar_folders(folders_raw) -> list[dict]:
    folders = folders_raw if isinstance(folders_raw, list) else []
    out = [f for f in folders if isinstance(f, dict)]
    out.sort(key=lambda f: int(f.get("sort_order", 0) or 0))
    return out


def _refresh_delegate_folders(delegate) -> list[dict]:
    data = _load_chats_db() or {}
    folders = _normalized_sidebar_folders(data.get("folders"))
    delegate._folders = folders
    return folders


def _delegate_sidebar_folders(delegate) -> list[dict]:
    cached = getattr(delegate, "_folders", None)
    if isinstance(cached, list):
        return _normalized_sidebar_folders(cached)
    return _refresh_delegate_folders(delegate)


def _thread_id_known_to_client(delegate, thread_id: str) -> bool:
    tid = (thread_id or "").strip()
    if not tid:
        return False
    cur = str(getattr(delegate, "_current_thread_id", None) or "").strip()
    if cur and tid == cur:
        return True
    if tid in (getattr(delegate, "_conversation_meta", None) or {}):
        return True
    for tab in getattr(delegate, "_tabs", None) or []:
        if not isinstance(tab, dict):
            continue
        if tid in (tab.get("thread_ids") or []):
            return True
        if str(tab.get("current_thread_id") or "").strip() == tid:
            return True
    return False


def _mark_meta_migration(name: str) -> None:
    """Record that migration ``name`` has been applied (idempotent, atomic under the chats_db lock)."""
    if not name:
        return

    def _mut(d: dict) -> None:
        existing = list(d.get("meta_migrations") or [])
        if name not in existing:
            existing.append(name)
            d["meta_migrations"] = existing

    _update_chats_db(_mut)


def _pretty_timestamp() -> str:
    now = datetime.now()
    h = (now.hour % 12) or 12
    ampm = now.strftime("%p").lower()
    return f"{now.strftime('%b')}. {now.day}, {now.year} {h}:{now.minute:02d}{ampm}"


# ---------------------------------------------------------------------------
# Headless delegate — implements the interface app_http_routes expects
# ---------------------------------------------------------------------------

def _is_pdf_or_media(file_item):
    """Return True if the attached file (filename, path) is PDF, image, or video (used for has_media_attachment)."""
    filename = file_item[0] if isinstance(file_item, (list, tuple)) else str(file_item)
    ext = os.path.splitext(filename)[1].lower()
    return ext in (
        ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp",
        ".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v",
    )


def _user_message_suggests_media_tools(text: str) -> bool:
    """True if the user message suggests they want PDF/OCR/file tools (e.g. 'OCR the PDFs in my Downloads with Kraken').
    Used to include media-tier tools even when no file is attached this turn."""
    if not text or not isinstance(text, str):
        return False
    t = text.lower().strip()
    # OCR / Kraken / Tesseract
    if any(x in t for x in ("ocr", "kraken", "tesseract", "scanned", "extract text from", "recognize text")):
        return True
    # PDF by path or task
    if "pdf" in t and any(x in t for x in ("split", "merge", "in my ", "in the ", "folder", "downloads", "that ", "those ", "the first ", "the file")):
        return True
    # File-by-path without saying "pdf"
    if any(x in t for x in ("pdfs in", "pdfs from", "the pdf", "those pdfs")):
        return True
    return False


def _sanitize_plugin_open_url(url: str | None) -> str | None:
    """Return URL only if it is a safe http/https URL for opening in the system browser. Otherwise None."""
    if url is None or not isinstance(url, str):
        return None
    s = url.strip()
    if not s or len(s) > 2048:
        return None
    lower = s.lower()
    if not (lower.startswith("http://") or lower.startswith("https://")):
        return None
    try:
        from urllib.parse import urlparse
        parsed = urlparse(s)
        if parsed.scheme not in ("http", "https") or not parsed.netloc:
            return None
        return s
    except Exception:
        return None


def _sanitize_plugin_copy_text(text: str | None) -> str | None:
    """Return text safe for clipboard: max length, no control characters. Otherwise None."""
    if text is None or not isinstance(text, str):
        return None
    s = text.strip()
    if not s or len(s) > 64 * 1024:
        return None
    # Strip ASCII and Unicode control characters
    s = "".join(c for c in s if (ord(c) >= 32 and ord(c) != 127) or c in "\n\r\t")
    return s if s else None


def _write_main_activity_state(*, busy: bool) -> None:
    """Atomically persist main-chat activity state for background runner gating."""
    try:
        from persistence import MAIN_ACTIVITY_TS_PATH, MAIN_ACTIVITY_STATE_PATH, secure_atomic_write
        ts = time.time()
        secure_atomic_write(MAIN_ACTIVITY_TS_PATH, str(ts))
        secure_atomic_write(
            MAIN_ACTIVITY_STATE_PATH,
            json.dumps({"busy": bool(busy), "ts": ts}),
        )
    except Exception:
        _log.debug("Failed to write activity state", exc_info=True)


def _write_last_user_input_timestamp() -> None:
    """Persist timestamp of the last explicit user input action."""
    try:
        from persistence import MAIN_USER_INPUT_TS_PATH, secure_atomic_write
        secure_atomic_write(MAIN_USER_INPUT_TS_PATH, str(time.time()))
    except Exception:
        _log.debug("Failed to write last user input timestamp", exc_info=True)


class HeadlessDelegate:
    """Minimal delegate for ``create_fastapi_app``.

    Replaces the PyObjC ``ChatAppDelegate`` with a plain Python object that
    runs agent work on background threads and pushes JS evaluation commands to
    ``ChatEventBus`` (consumed by the SwiftUI host via SSE).
    """

    def __init__(
        self,
        chat_bus: ChatEventBus,
        live_view_bus: LiveViewEventBus,
        http_port: int,
        activity_bus: ActivityBus | None = None,
    ):
        self._chat_bus = chat_bus
        self._structured_bus = ChatEventBus()
        # Set from FastAPI startup so `_push_event` can hop onto the uvicorn loop
        # when tool code runs on worker threads (asyncio.Queue is not thread-safe).
        self._structured_loop: asyncio.AbstractEventLoop | None = None
        self._plugin_stream_bus = PluginStreamBus()
        self._live_view_event_bus = live_view_bus
        self._activity_bus = activity_bus
        self._http_port = http_port
        self._main_thread_queue: Queue = Queue()
        self._attached_files: list = []
        self._running_thread_ids: set = set()
        self._workers: dict = {}
        self._agent_bubble_created_threads: set = set()
        self._thread_stream_buffer: dict = {}
        self._run_active = False
        self._edit_last_pending_thread_id = None
        self._plugin_manager = None
        self._plugin_route_mount_errors: dict[str, str] = {}
        # Tracks slugs already passed to app_http_routes.mount_plugin_http_routes (idempotent remounts).
        self._plugin_http_mounted_slugs: set = set()
        self._mesh_manager = None
        self._plugin_stream_meta_lock = threading.Lock()
        self._plugin_stream_meta: dict[str, dict] = {}
        # Quick Panel "recents" cache. The Swift client POSTs a snapshot of the
        # user's recently-opened documents (NSDocumentController + Finder
        # heuristics) to `/recents`; the `get_recent_files` tool reads this
        # in-process stash instead of hitting HTTP loopback from Python.
        self._recents_lock = threading.Lock()
        self._recents_items: list[dict] = []
        self._recents_updated_at: float = 0.0
        _write_main_activity_state(busy=False)

        # Load persisted tabs or create a default one
        self._repair_title_utility_seed_tids: list[str] = []
        loaded_tabs, loaded_meta = _load_persisted_tabs()
        if loaded_tabs is not None and isinstance(loaded_meta, dict):
            self._tabs = loaded_tabs
            self._conversation_meta = loaded_meta
            try:
                _db = _load_chats_db() or {}
                self._folders = _normalized_sidebar_folders(_db.get("folders"))
            except Exception:
                self._folders = []
            try:
                from chat_storage import repair_conversation_meta_from_message_threads

                inserted_rows, retitled_placeholders, repair_affected_tids = (
                    repair_conversation_meta_from_message_threads(self._conversation_meta)
                )
                self._repair_title_utility_seed_tids = repair_affected_tids
                if inserted_rows or retitled_placeholders:
                    _log.warning(
                        "Repaired conversation sidebar from SQLite `messages` (chats_db.json meta was "
                        "incomplete): %s new row(s), %s recovered placeholder(s). "
                        "Scheduling utility model title regeneration.",
                        inserted_rows,
                        retitled_placeholders,
                    )
            except Exception:
                _log.debug("conversation_meta SQLite repair skipped", exc_info=True)
        else:
            first_tab_id = str(uuid.uuid4())
            first_thread_id = str(uuid.uuid4())
            self._tabs = [{
                "tab_id": first_tab_id,
                "title": "New tab",
                "thread_ids": [first_thread_id],
                "current_thread_id": first_thread_id,
                "scratchpads": [],
            }]
            self._conversation_meta = {
                first_thread_id: {
                    "title": "New conversation",
                    "created_at": datetime.now().isoformat(),
                    "saved_path": None,
                }
            }
            self._folders = []

        new_thread_id = str(uuid.uuid4())
        self._tabs[0]["thread_ids"] = self._tabs[0].get("thread_ids") or []
        self._tabs[0]["thread_ids"].insert(0, new_thread_id)
        self._tabs[0]["current_thread_id"] = new_thread_id
        self._conversation_meta[new_thread_id] = {
            "title": _pretty_timestamp(),
            "created_at": datetime.now().isoformat(),
            "saved_path": None,
        }
        _save_chats_db(self._tabs, self._conversation_meta, self._live_view_event_bus)
        self._current_thread_id = new_thread_id

        if not _has_meta_migration(META_MIGRATION_SIDEBAR_TITLE_FREEZE):
            try:
                from core.config import get_conversation_title_utility_milestones

                milestones = get_conversation_title_utility_milestones() or [3, 15]
                max_ms = max(milestones)
                frozen = _freeze_existing_good_sidebar_titles(
                    self._conversation_meta, max_milestone=max_ms
                )
                if frozen > 0:
                    _log.info(
                        "[migration %s] Froze %s existing sidebar title(s) at milestone=%s; "
                        "utility model will not rewrite them.",
                        META_MIGRATION_SIDEBAR_TITLE_FREEZE,
                        frozen,
                        max_ms,
                    )
                    _save_chats_db(self._tabs, self._conversation_meta, self._live_view_event_bus)
                _mark_meta_migration(META_MIGRATION_SIDEBAR_TITLE_FREEZE)
            except Exception:
                _log.debug("freeze existing sidebar titles failed", exc_info=True)

        if not _has_meta_migration(META_MIGRATION_SIDEBAR_TITLE_BATCH_REGEN):
            try:
                self._schedule_batch_conversation_title_utility_refresh()
                _mark_meta_migration(META_MIGRATION_SIDEBAR_TITLE_BATCH_REGEN)
            except Exception:
                _log.debug("batch conversation_title utility enqueue failed", exc_info=True)

        if not _has_meta_migration(META_MIGRATION_SIDEBAR_TITLE_UTILITY_SANITIZE_RETRY):
            try:
                cleared_ms = _clear_title_utility_milestone_for_junk_sidebar_titles(
                    self._conversation_meta
                )
                if cleared_ms:
                    _save_chats_db(self._tabs, self._conversation_meta, self._live_view_event_bus)
                junk = self._collect_thread_ids_needing_sidebar_title_regen()
                if junk:
                    _log.info(
                        "[migration %s] Re-queuing utility title regen for %s junk sidebar title(s) "
                        "(cleared milestone on %s; sanitizer strips Thinking Process preambles).",
                        META_MIGRATION_SIDEBAR_TITLE_UTILITY_SANITIZE_RETRY,
                        len(junk),
                        cleared_ms,
                    )
                    self._schedule_batch_conversation_title_utility_refresh()
                _mark_meta_migration(META_MIGRATION_SIDEBAR_TITLE_UTILITY_SANITIZE_RETRY)
            except Exception:
                _log.debug("utility title sanitize retry migration failed", exc_info=True)

        if not _has_meta_migration(META_MIGRATION_SIDEBAR_TITLE_OUTLINE_JUNK_RETRY):
            try:
                cleared_ms = _clear_title_utility_milestone_for_junk_sidebar_titles(
                    self._conversation_meta
                )
                if cleared_ms:
                    _save_chats_db(self._tabs, self._conversation_meta, self._live_view_event_bus)
                junk = self._collect_thread_ids_needing_sidebar_title_regen()
                if junk:
                    _log.info(
                        "[migration %s] Re-queuing utility title regen for %s outline-junk title(s) "
                        "(cleared milestone on %s).",
                        META_MIGRATION_SIDEBAR_TITLE_OUTLINE_JUNK_RETRY,
                        len(junk),
                        cleared_ms,
                    )
                    self._schedule_batch_conversation_title_utility_refresh()
                _mark_meta_migration(META_MIGRATION_SIDEBAR_TITLE_OUTLINE_JUNK_RETRY)
            except Exception:
                _log.debug("outline junk title retry migration failed", exc_info=True)

        if not _has_meta_migration(META_MIGRATION_SIDEBAR_TITLE_TRANSCRIPT_ECHO_RETRY):
            try:
                cleared_ms = _clear_title_utility_milestone_for_junk_sidebar_titles(
                    self._conversation_meta
                )
                if cleared_ms:
                    _save_chats_db(self._tabs, self._conversation_meta, self._live_view_event_bus)
                junk = self._collect_thread_ids_needing_sidebar_title_regen()
                if junk:
                    _log.info(
                        "[migration %s] Re-queuing utility title regen for %s transcript-echo title(s) "
                        "(cleared milestone on %s).",
                        META_MIGRATION_SIDEBAR_TITLE_TRANSCRIPT_ECHO_RETRY,
                        len(junk),
                        cleared_ms,
                    )
                    self._schedule_batch_conversation_title_utility_refresh()
                _mark_meta_migration(META_MIGRATION_SIDEBAR_TITLE_TRANSCRIPT_ECHO_RETRY)
            except Exception:
                _log.debug("transcript echo title retry migration failed", exc_info=True)

        if not _has_meta_migration(META_MIGRATION_SIDEBAR_TITLE_SYNTHETIC_USER_RETRY):
            try:
                cleared_ms = _clear_title_utility_milestone_for_junk_sidebar_titles(
                    self._conversation_meta
                )
                if cleared_ms:
                    _save_chats_db(self._tabs, self._conversation_meta, self._live_view_event_bus)
                junk = self._collect_thread_ids_needing_sidebar_title_regen()
                if junk:
                    _log.info(
                        "[migration %s] Re-queuing utility title regen for %s synthetic/assistant-opener title(s) "
                        "(cleared milestone on %s).",
                        META_MIGRATION_SIDEBAR_TITLE_SYNTHETIC_USER_RETRY,
                        len(junk),
                        cleared_ms,
                    )
                    self._schedule_batch_conversation_title_utility_refresh()
                _mark_meta_migration(META_MIGRATION_SIDEBAR_TITLE_SYNTHETIC_USER_RETRY)
            except Exception:
                _log.debug("synthetic user title retry migration failed", exc_info=True)

        # Install terminal output so /terminal/stream has content
        try:
            import app_terminal
            app_terminal.install_terminal_output()
        except Exception as exc:
            _log.debug("Failed to install terminal output", exc_info=True)
        # Start draining the main-thread queue on a background thread
        self._drain_thread = Thread(target=self._drain_loop, daemon=True)
        self._drain_thread.start()

        # Plugin system: wire manager to agent loop, then discover in background
        # so plugin handshake timeouts don't block startup.
        try:
            from persistence import ensure_bundled_dashboard_plugins, ensure_bundled_plugins
            from plugin_manager import PluginManager
            from agent_loop import set_plugin_manager
            ensure_bundled_plugins()
            ensure_bundled_dashboard_plugins()
            self._plugin_manager = PluginManager()
            self._plugin_manager.set_stream_emitter(self._push_plugin_stream_event)
            set_plugin_manager(self._plugin_manager)

            def _bg_discover():
                try:
                    self._plugin_manager.discover()
                except Exception as e:
                    print(f"[mugi_backend] Plugin discovery failed: {e}", file=sys.stderr, flush=True)
                    try:
                        self._plugin_manager.mark_discover_skipped()
                    except Exception:
                        _log.debug("mark_discover_skipped failed after plugin discovery error", exc_info=True)

            Thread(target=_bg_discover, daemon=True, name="plugin-discover").start()
        except Exception as e:
            print(f"[mugi_backend] Plugin init failed: {e}", file=sys.stderr, flush=True)
            try:
                if getattr(self, "_plugin_manager", None) is not None:
                    self._plugin_manager.mark_discover_skipped()
            except Exception:
                _log.debug("mark_discover_skipped failed after plugin init error", exc_info=True)

    # -- queue drain --------------------------------------------------------

    def _drain_loop(self):
        while True:
            try:
                fn = self._main_thread_queue.get(timeout=0.05)
                try:
                    fn()
                except Exception as exc:
                    print(f"[mugi_backend] drain error: {exc}", file=sys.stderr, flush=True)
            except Empty:
                pass

    # -- JS push helpers ----------------------------------------------------

    def _push_js(self, js_code: str, thread_id: str | None = None):
        tid = thread_id or self._current_thread_id
        self._chat_bus.emit(tid, js_code)

    def _push_event(self, event: dict, thread_id: str | None = None):
        """Emit a structured event for the native SwiftUI chat (Phase 4)."""
        tid = thread_id or self._current_thread_id
        payload = {**event, "thread_id": tid}
        encoded = json.dumps(payload)

        def _emit() -> None:
            self._structured_bus.emit(tid, encoded)

        loop = getattr(self, "_structured_loop", None)
        if loop is None or not loop.is_running():
            _emit()
            return
        try:
            running = asyncio.get_running_loop()
        except RuntimeError:
            running = None
        if running is loop:
            _emit()
        else:
            loop.call_soon_threadsafe(_emit)

    def _push_activity(self, event: dict, thread_id: str | None = None):
        bus = self._activity_bus
        if bus is None:
            return
        payload = dict(event or {})
        tid = (thread_id or self._current_thread_id or "").strip()
        if tid and "thread_id" not in payload:
            payload["thread_id"] = tid
        try:
            bus.emit(payload)
        except Exception:
            _log.debug("Failed to emit chat event", exc_info=True)

    def _push_plugin_stream_event(self, event: dict, thread_id: str | None = None):
        """Emit a typed plugin stream event for native SwiftUI dashboards."""
        base_evt = dict(event or {})
        tid = (thread_id or base_evt.get("thread_id") or self._current_thread_id or "").strip()
        if not tid:
            return
        base_evt.setdefault("schema", "mugi.plugin_stream.v1")
        base_evt.setdefault("schema_version", 1)
        base_evt.setdefault("timestamp_ms", int(time.time() * 1000))
        self._track_plugin_stream_event(base_evt, tid)
        self._plugin_stream_bus.emit(tid, base_evt)
        evt_type = str(base_evt.get("event") or "").strip().lower()
        if evt_type:
            plugin_id = str(base_evt.get("plugin_id") or "").strip()
            # Tool-runtime update ticks are folded into the stacked feed row on the client.
            if evt_type == "update" and plugin_id == "tool_runtime":
                return
            state = "progress"
            if evt_type in {"open"}:
                state = "started"
            elif evt_type in {"close"}:
                state = "completed"
            elif evt_type in {"error"}:
                state = "error"
            title = str(base_evt.get("title") or base_evt.get("plugin_name") or base_evt.get("plugin_id") or "Plugin stream")
            data = base_evt.get("data") if isinstance(base_evt.get("data"), dict) else {}
            tool_name = str(data.get("tool_name") or "").strip()
            if tool_name:
                summary = f"Tool: {tool_name}"
            else:
                summary = str(base_evt.get("message") or f"{title}: {evt_type}")
            self._push_activity(
                {
                    "source": "plugin",
                    "state": state,
                    "summary": summary,
                    "job_id": str(base_evt.get("stream_id") or ""),
                    "meta": {"event": evt_type, "title": title},
                },
                tid,
            )

    def _track_plugin_stream_event(self, event: dict, thread_id: str) -> None:
        """Track stream metadata for validating reverse control events."""
        from utils.llm_token_normalize import normalize_stream_id_token

        stream_id = normalize_stream_id_token(str(event.get("stream_id") or "").strip())
        if not stream_id:
            return
        evt_type = str(event.get("event") or "").strip().lower()
        plugin_id = str(event.get("plugin_id") or "").strip()
        plugin_name = str(event.get("plugin_name") or "").strip()
        data = event.get("data")
        metadata = event.get("metadata")
        controls = None
        if isinstance(data, dict) and isinstance(data.get("controls"), list):
            controls = data.get("controls")
        elif isinstance(metadata, dict) and isinstance(metadata.get("controls"), list):
            controls = metadata.get("controls")
        with self._plugin_stream_meta_lock:
            if evt_type == "open":
                self._plugin_stream_meta[stream_id] = {
                    "stream_id": stream_id,
                    "thread_id": thread_id,
                    "plugin_id": plugin_id,
                    "plugin_name": plugin_name,
                    "controls": controls if isinstance(controls, list) else [],
                    "updated_at_ms": int(time.time() * 1000),
                    "status": "open",
                }
                return
            existing = self._plugin_stream_meta.get(stream_id)
            if existing is None:
                return
            existing["thread_id"] = thread_id or existing.get("thread_id")
            if plugin_id:
                existing["plugin_id"] = plugin_id
            if plugin_name:
                existing["plugin_name"] = plugin_name
            existing["updated_at_ms"] = int(time.time() * 1000)
            if isinstance(controls, list):
                existing["controls"] = controls
            if evt_type in {"close", "error"}:
                existing["status"] = evt_type
                # Stale streams should not accept new control writes.
                self._plugin_stream_meta.pop(stream_id, None)
            elif evt_type == "controls":
                existing["status"] = "open"

    def get_plugin_stream_meta(self, stream_id: str) -> dict | None:
        from utils.llm_token_normalize import normalize_stream_id_token

        sid = normalize_stream_id_token((stream_id or "").strip())
        if not sid:
            return None
        with self._plugin_stream_meta_lock:
            meta = self._plugin_stream_meta.get(sid)
            return dict(meta) if isinstance(meta, dict) else None

    # -- delegate methods called by app_http_routes -------------------------

    def onSend_text_(self, _widget, text=None, research_mode=False, quick_panel=False, dashboard_surface=False):
        text = (text or "").strip()
        if not text and not self._attached_files:
            return
        thread_id = self._current_thread_id
        if thread_id in self._running_thread_ids:
            return
        _write_last_user_input_timestamp()

        if getattr(self, "_edit_last_pending_thread_id", None) == thread_id:
            from chat_storage import get_messages, remove_last_message
            messages = get_messages(thread_id)
            if messages:
                last_user_idx = None
                for i in range(len(messages) - 1, -1, -1):
                    if (messages[i].get("role") or "").strip() == "user":
                        last_user_idx = i
                        break
                if last_user_idx is not None:
                    for _ in range(len(messages) - last_user_idx):
                        remove_last_message(thread_id)
                    self._reload_conversation(thread_id)
                    self._push_event({"type": "messages_reload"}, thread_id)
            self._edit_last_pending_thread_id = None

        file_refs = []
        image_exts = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp")
        for filename, filepath in self._attached_files:
            file_refs.append((filename, filepath))

        base = f"http://localhost:{self._http_port}/attachment/"
        image_urls = [base + os.path.basename(p) for (fn, p) in file_refs if os.path.splitext(fn)[1].lower() in image_exts]
        non_image_refs = [(fn, p) for (fn, p) in file_refs if os.path.splitext(fn)[1].lower() not in image_exts]
        file_attachments = [
            {"filename": fn, "url": base + os.path.basename(p)}
            for fn, p in non_image_refs
        ]
        # Bubble shows user text only; non-image files render as chips below (composer parity).
        if image_urls or file_attachments:
            display_msg = (text or "").strip() or " "
        else:
            display_msg = text
        image_paths = [(fn, p) for (fn, p) in file_refs if os.path.splitext(fn)[1].lower() in image_exts]

        ts = datetime.now().strftime("%I:%M %p")
        img_json = json.dumps(image_urls) if image_urls else "null"
        self._push_js(f"addMessage({json.dumps(display_msg)}, true, '', {json.dumps(ts)}, {img_json});", thread_id)
        self._push_event({
            "type": "message_add",
            "text": display_msg,
            "is_user": True,
            "timestamp": ts,
            "image_urls": image_urls or [],
            "file_attachments": file_attachments,
        }, thread_id)
        self._push_event({"type": "input_clear"}, thread_id)

        agent_msg = text or "I have attached some files. Please process them as needed."
        if file_refs:
            import attachment_registry as _ar
            agent_msg += "\n\n=== ATTACHED FILES ==="
            for filename, filepath in file_refs:
                abs_path = os.path.abspath(filepath)
                rec = _ar.get_record_by_path(abs_path)
                if rec is None:
                    rec = _ar.register(abs_path=abs_path, original_name=filename, thread_id=thread_id, source_route="onSend")
                agent_msg += f"\nFILE ATTACHED: {filename} (attachment_id={rec.attachment_id})"
            agent_msg += "\nUse the attachment_id values above when calling tools that take file paths. The system will resolve them automatically."

        # Auto-title from first user message (root threads only; skip branches)
        try:
            from chat_storage import get_messages, is_branch
            messages = get_messages(thread_id) or []
            in_meta = thread_id in (self._conversation_meta or {})
            is_br = is_branch(thread_id)
            # Allow when no messages yet, or prior user rows are init / attachment-only
            # (file-only first sends store list content — must not block later auto-title).
            first_real_user = len(messages) == 0 or _only_init_or_attachment_only_user_messages(messages)
            cond_ok = (not is_br and first_real_user and in_meta)
            if cond_ok:
                title = _auto_title_from_user_send(text, file_refs)
                if title:
                    self._conversation_meta[thread_id]["title"] = title
                    _save_chats_db(self._tabs, self._conversation_meta, self._live_view_event_bus)
                    self._push_event({"type": "conversations_changed"}, thread_id)
                    self._structured_bus.emit("__sidebar__", json.dumps({"type": "conversations_changed"}))
        except Exception:
            _log.debug("Failed to process attached files for send", exc_info=True)
        # Pass agent_msg to the model (so it sees file paths); pass display_text for storage/UI (bubble text + chips).
        # Include media tools (PDF/OCR, including Kraken) when any attachment is PDF/image/video, or when the user message suggests file/OCR work (e.g. "OCR the PDFs in my Downloads with Kraken").
        # Include CLI tools (brew, ffmpeg, yt-dlp, run_installed_cli) when include_cli_tools pref is True.
        has_media = (
            bool(image_paths) or any(_is_pdf_or_media(f) for f in file_refs)
            or _user_message_suggests_media_tools(agent_msg)
        )
        try:
            from tools.system import _load_app_prefs_tools as _lpt
            _cli_enabled = _lpt().get("include_cli_tools", False)
        except Exception:
            _cli_enabled = False
        # `surface` is UX-only; if client sent both quick and dashboard, dashboard wins.
        dash = bool(dashboard_surface)
        qp = bool(quick_panel) and not dash
        self._run_agent(
            agent_msg,
            thread_id=thread_id,
            image_urls=image_urls,
            image_paths=image_paths,
            research_mode=research_mode,
            display_text=((text or "").strip() or None) if (image_urls or file_refs) else None,
            file_attachments=file_attachments,
            has_media_attachment=has_media,
            include_cli=_cli_enabled,
            quick_panel=qp,
            dashboard_surface=dash,
        )
        self._attached_files = []
        self._push_js("var i=document.getElementById('chat-input');if(i)i.value='';", thread_id)

    def onStop_(self, _sender=None, thread_id=None):
        tid = (thread_id or "").strip() or self._current_thread_id
        worker = self._workers.get(tid)
        stop_run_id = ""
        if worker:
            worker.request_stop()
            stop_run_id = sanitize_run_id(str(getattr(worker, "run_id", "") or ""))
        # Immediate UI feedback
        self._push_js("hideThinking();", tid)
        self._push_js("setSendButtonState('send');", tid)
        self._push_js("setSpinnerVisible(false);", tid)
        self._push_event({"type": "thinking_hide"}, tid)
        self._push_event({"type": "send_button", "state": "send"}, tid)
        self._push_event({"type": "status", "text": "", "visible": False}, tid)
        # Clean up immediately so user can send again; worker may still exit in background.
        if self._mesh_manager is not None:
            self._mesh_manager.set_user_active(False)
        self._do_cleanup(
            tid,
            final_state="stopped",
            final_summary="Agent run stopped by user",
            run_id=stop_run_id,
        )

    def onRegenerateLastReply_(self, _sender=None):
        # Regenerate intentionally runs as a "main chat" turn: it reuses whatever
        # tools/prompt the normal chat surface exposes. The Quick Panel does not
        # expose a regenerate affordance today, so we do not propagate the
        # `quick_panel` flag here. If a quick-surface regenerate is added later,
        # plumb the flag through (or look it up from per-thread metadata).
        if self._run_active:
            return
        thread_id = self._current_thread_id
        from chat_storage import get_messages, remove_last_message
        messages = get_messages(thread_id)
        if not messages:
            return
        last_user_content = None
        for i in range(len(messages) - 1, -1, -1):
            if (messages[i].get("role") or "").strip() == "user":
                last_user_content = _content_to_display_string(messages[i].get("content")).strip()
                break
        if not last_user_content:
            return
        _write_last_user_input_timestamp()
        if messages and (messages[-1].get("role") or "").strip() == "assistant":
            remove_last_message(thread_id)
        self._reload_conversation(thread_id)
        self._push_event({"type": "messages_reload"}, thread_id)
        self._run_agent(last_user_content, thread_id=thread_id)

    def onAttachFile_(self, _sender=None):
        pass  # file picker is handled by the SwiftUI host

    def removeAttachmentAtIndex_(self, idx):
        try:
            if 0 <= idx < len(self._attached_files):
                self._attached_files.pop(idx)
        except Exception:
            _log.debug("Failed to remove attachment at index %s", idx, exc_info=True)

    def onVoiceToggle_(self, _send_on_release=False):
        pass

    def onVoiceStart_(self, _send_on_release=False):
        pass

    def onVoiceStop_(self):
        pass

    def handleMessageAction_withMessageIndex_(self, action, message_index):
        action = (action or "copy").strip()
        if action == "edit_last":
            if self._run_active:
                return
            thread_id = self._current_thread_id
            from chat_storage import get_messages
            messages = get_messages(thread_id)
            if not messages:
                return
            last_user_idx = None
            for i in range(len(messages) - 1, -1, -1):
                if (messages[i].get("role") or "").strip() == "user":
                    last_user_idx = i
                    break
            if last_user_idx is None:
                return
            content = _content_to_display_string(messages[last_user_idx].get("content")).strip()
            self._edit_last_pending_thread_id = thread_id
            escaped = json.dumps(content)
            self._push_js(
                f"var i=document.getElementById('chat-input');if(i){{ i.value={escaped}; i.dispatchEvent(new Event('input',{{bubbles:true}})); }}",
                thread_id,
            )
            return
        if action == "copy":
            thread_id = self._current_thread_id
            from chat_storage import get_messages
            messages = get_messages(thread_id)
            if message_index is not None and 0 <= message_index < len(messages):
                content = _content_to_display_string(messages[message_index].get("content"))
                if content:
                    try:
                        from tools.clipboard import set_clipboard_text
                        set_clipboard_text.invoke({"text": content})
                    except Exception:
                        _log.debug("Failed to copy message to clipboard", exc_info=True)
        if action in ("feedback_up", "feedback_down", "thumbs_up", "thumbs_down"):
            thread_id = self._current_thread_id
            from chat_storage import get_messages
            messages = get_messages(thread_id)
            try:
                idx = int(message_index)
            except (TypeError, ValueError):
                return
            if not (0 <= idx < len(messages)):
                return
            msg = messages[idx]
            snippet = _content_to_display_string(msg.get("content"))
            feedback = "up" if action in ("feedback_up", "thumbs_up") else "down"
            try:
                from core import get_workspace_path
                from persistence import append_feedback_entry
                append_feedback_entry(
                    get_workspace_path(),
                    thread_id,
                    idx,
                    feedback,
                    snippet,
                )
            except Exception:
                _log.debug("Failed to store feedback", exc_info=True)

    # -- conversation helpers -----------------------------------------------

    def _reload_conversation(self, thread_id: str):
        from chat_storage import get_messages
        messages = get_messages(thread_id) or []
        js_lines = ["clearMessages();"]
        for msg in messages:
            role = (msg.get("role") or "").strip()
            content = _content_to_display_string(msg.get("content")).strip()
            is_user = role == "user"
            js_lines.append(f"addMessage({json.dumps(content)}, {str(is_user).lower()});")
        self._push_js("\n".join(js_lines), thread_id)

    # -- agent execution ----------------------------------------------------

    def _resolve_tab_id_for_thread(self, thread_id: str) -> str:
        """Resolve tab_id from thread_id for scratchpad/research context."""
        tid = (thread_id or "").strip()
        if tid and self._tabs:
            for tab in self._tabs or []:
                if tid in (tab.get("thread_ids") or []):
                    return tab.get("tab_id", "") or ""
        if self._tabs:
            return self._tabs[0].get("tab_id", "") or ""
        return ""

    def _run_agent(self, user_text, thread_id=None, image_urls=None, image_paths=None, research_mode=False, display_text=None, file_attachments=None, has_media_attachment=False, include_cli=False, idle_tick=False, quick_panel=False, dashboard_surface=False):
        thread_id = thread_id or self._current_thread_id
        run_id = sanitize_run_id(generate_secure_token(9))
        if not idle_tick:
            try:
                from utils.embedding_model import release_for_foreground_chat

                release_for_foreground_chat()
            except Exception:
                pass
        _write_main_activity_state(busy=True)
        self._run_active = True
        self._running_thread_ids.add(thread_id)
        _tags = ["idle_tick"] if idle_tick else ["user_initiated"]
        self._push_activity(
            {
                "source": "agent",
                "state": "started",
                "summary": "Idle continuity tick" if idle_tick else "Agent run started",
                "job_id": thread_id,
                "run_id": run_id,
                "meta": {
                    "research_mode": bool(research_mode),
                    "quick_panel": bool(quick_panel),
                    "dashboard_surface": bool(dashboard_surface),
                },
                "tags": _tags,
            },
            thread_id,
        )
        self._push_js("showThinking();", thread_id)
        self._push_js("setSendButtonState('stop');", thread_id)
        self._push_event({"type": "thinking_show"}, thread_id)
        self._push_event({"type": "send_button", "state": "stop"}, thread_id)

        def on_chunk(chunk, _tid=thread_id):
            _worker = self._workers.get(_tid)
            if _worker and getattr(_worker, "_stop_requested", False):
                buf = self._thread_stream_buffer
                buf[_tid] = buf.get(_tid, "") + (chunk or "")
                return
            bubble_threads = self._agent_bubble_created_threads
            if _tid not in bubble_threads:
                bubble_threads.add(_tid)
                self._push_js("hideThinking();", _tid)
                ts = datetime.now().strftime("%I:%M %p")
                self._push_js(f"addMessage('', false, '', {json.dumps(ts)});", _tid)
                self._push_event({"type": "thinking_hide"}, _tid)
                self._push_event({"type": "message_add", "text": "", "is_user": False, "timestamp": ts}, _tid)
            self._push_js(f"appendToLastMessage({json.dumps(chunk)});", _tid)
            self._push_event({"type": "chunk", "text": chunk}, _tid)
            buf = self._thread_stream_buffer
            buf[_tid] = buf.get(_tid, "") + (chunk or "")

        def on_finished(full_text, _tid=thread_id):
            text_in = (full_text or "").strip()
            if text_in:
                bubble_threads = self._agent_bubble_created_threads
                if _tid not in bubble_threads:
                    bubble_threads.add(_tid)
                    self._push_js("hideThinking();", _tid)
                    ts = datetime.now().strftime("%I:%M %p")
                    self._push_js(f"addMessage('', false, '', {json.dumps(ts)});", _tid)
                    self._push_event({"type": "thinking_hide"}, _tid)
                    self._push_event({"type": "message_add", "text": "", "is_user": False, "timestamp": ts}, _tid)
                self._push_js(f"updateLastMessage({json.dumps(text_in)});", _tid)
                self._push_event({"type": "message_update", "text": text_in}, _tid)
            else:
                # Tool-only or fully stripped turns: a placeholder bubble can still be open.
                # Without this, the macOS client keeps empty agent rows in `isStreaming` and shows
                # indeterminate ProgressView spiners until a thread reload clears state.
                if _tid in self._agent_bubble_created_threads:
                    self._push_js(f"updateLastMessage({json.dumps('')});", _tid)
                    self._push_event({"type": "message_update", "text": text_in}, _tid)
            try:
                from operator_helpers import estimate_context_for_thread
                usage = estimate_context_for_thread(_tid)
                usage["type"] = "context_usage"
                usage["thread_id"] = _tid
                self._push_event(usage, _tid)
            except Exception:
                _log.debug("Failed to push context_usage after on_finished", exc_info=True)
            try:
                _mem_worker = self._workers.get(_tid)
                if _mem_worker is not None:
                    _mref = getattr(_mem_worker, "_memory_turn_summary_ref", None)
                    if isinstance(_mref, list) and _mref and _mref[0] is not None:
                        _mem_evt = dict(_mref[0])
                        _log.debug(
                            "Pushing memory_turn_summary thread=%s turn_id=%s skipped=%s mp_reads=%s",
                            _tid,
                            _mem_evt.get("turn_id"),
                            _mem_evt.get("skipped_reason"),
                            len((_mem_evt.get("memory_path_reads") or []) if isinstance(_mem_evt, dict) else []),
                        )
                        self._push_event(_mem_evt, _tid)
            except Exception:
                _log.debug("Failed to push memory_turn_summary after context_usage", exc_info=True)
            try:
                _kb_worker = self._workers.get(_tid)
                if _kb_worker is not None:
                    _kref = getattr(_kb_worker, "_knowledge_turn_summary_ref", None)
                    if isinstance(_kref, list) and _kref and _kref[0] is not None:
                        _kb_evt = dict(_kref[0])
                        _log.debug(
                            "Pushing knowledge_turn_summary thread=%s turn_id=%s skipped=%s tools=%s",
                            _tid,
                            _kb_evt.get("turn_id"),
                            _kb_evt.get("skipped_reason"),
                            len((_kb_evt.get("tool_calls") or []) if isinstance(_kb_evt, dict) else []),
                        )
                        self._push_event(_kb_evt, _tid)
            except Exception:
                _log.debug("Failed to push knowledge_turn_summary after context_usage", exc_info=True)

        def on_error(msg, _tid=thread_id, _run_id=run_id):
            ts = datetime.now().strftime("%I:%M %p")
            _is_init = str(user_text or "").strip().lower() == "init"
            _ready = _first_run_ready_for_chat()
            _has_real_user = _has_non_init_user_messages(_tid)
            suppress_chat_error = _is_init and (not _ready) and (not _has_real_user)
            self._push_js("hideThinking();", _tid)
            self._push_event({"type": "thinking_hide"}, _tid)
            if not suppress_chat_error:
                self._push_js(f"addMessage({json.dumps(str(msg))}, false, 'error');", _tid)
                self._push_event({
                    "type": "message_add",
                    "text": str(msg),
                    "is_user": False,
                    "bubble_type": "error",
                    "timestamp": ts,
                }, _tid)
            self._push_activity(
                {
                    "source": "agent",
                    "state": "error",
                    "summary": "Agent run failed" if not suppress_chat_error else "Agent startup check failed",
                    "job_id": _tid,
                    "run_id": _run_id,
                    "meta": {"error": str(msg)[:200]},
                },
                _tid,
            )
            self._do_cleanup(_tid, final_state="error", final_summary="Agent run failed", run_id=_run_id)

        def on_done(_tid=thread_id, _run_id=run_id):
            self._do_cleanup(_tid, run_id=_run_id)

        def on_status(status, _tid=thread_id, _run_id=run_id):
            self._push_js(f"setSpinnerVisible(true, {json.dumps(status)});", _tid)
            self._push_event({"type": "status", "text": status, "visible": True}, _tid)
            bus = self._live_view_event_bus
            if bus:
                bus.emit(_tid, {"type": "status", "message": status})
            self._push_activity(
                {
                    "source": "agent",
                    "state": "progress",
                    "summary": status[:200],
                    "job_id": _tid,
                    "run_id": _run_id,
                    "tags": ["user_initiated"],
                },
                _tid,
            )

        def on_agent_message(text, is_thinking=False, _tid=thread_id):
            if not text or not text.strip():
                return
            ts = datetime.now().strftime("%I:%M %p")
            self._push_js(
                f"if (typeof addAgentMessage === 'function') addAgentMessage({json.dumps(text)}, {str(is_thinking).lower()});",
                _tid,
            )
            self._push_event({
                "type": "message_add",
                "text": text,
                "is_user": False,
                "bubble_type": "thinking" if is_thinking else "agent",
                "timestamp": ts,
            }, _tid)

        def on_new_assistant_content(_tid=thread_id):
            ts = datetime.now().strftime("%I:%M %p")
            self._push_js(f"addMessage('', false, '', {json.dumps(ts)});", _tid)
            self._push_event({"type": "message_add", "text": "", "is_user": False, "timestamp": ts}, _tid)
            self._agent_bubble_created_threads.add(_tid)
            self._thread_stream_buffer[_tid] = ""

        def on_replace_last(normalized_text, _tid=thread_id):
            text = (normalized_text or "").strip()
            if not text:
                return
            if _tid not in self._agent_bubble_created_threads:
                self._agent_bubble_created_threads.add(_tid)
                self._push_js("hideThinking();", _tid)
                ts = datetime.now().strftime("%I:%M %p")
                self._push_js(f"addMessage('', false, '', {json.dumps(ts)});", _tid)
                self._push_event({"type": "thinking_hide"}, _tid)
                self._push_event({"type": "message_add", "text": "", "is_user": False, "timestamp": ts}, _tid)
            self._push_js(f"updateLastMessage({json.dumps(text)});", _tid)
            self._push_event({"type": "message_update", "text": text}, _tid)

        lv_bus = self._live_view_event_bus
        def on_live_view(evt, _t=thread_id, _run_id=run_id):
            evt = dict(evt or {})
            evt.setdefault("run_id", _run_id)
            if lv_bus:
                lv_bus.emit(_t, evt)
            etype = str((evt or {}).get("type") or "").strip().lower()
            if etype:
                if etype == "done":
                    state = "completed"
                elif etype == "error":
                    state = "error"
                elif etype in {"progress", "status"}:
                    state = "progress"
                else:
                    state = "info"
                summary = str((evt or {}).get("message") or (evt or {}).get("preview_text") or f"Live view {etype}")
                self._push_activity(
                    {
                        "source": "live_view",
                        "state": state,
                        "summary": summary[:200],
                        "job_id": _t,
                        "run_id": _run_id,
                        "meta": {"event_type": etype, "run_id": _run_id},
                        "tags": ["user_initiated"],
                    },
                    _t,
                )

        _port = self._http_port

        def on_chat_event(evt, _tid=thread_id):
            if evt.get("type") == "image_add":
                filenames = evt.get("files", [])
                if filenames:
                    bubble_threads = self._agent_bubble_created_threads
                    if _tid not in bubble_threads:
                        bubble_threads.add(_tid)
                        self._push_js("hideThinking();", _tid)
                        ts = datetime.now().strftime("%I:%M %p")
                        self._push_js(f"addMessage('', false, '', {json.dumps(ts)});", _tid)
                        self._push_event({"type": "thinking_hide"}, _tid)
                        self._push_event({"type": "message_add", "text": "", "is_user": False, "timestamp": ts}, _tid)
                    urls = [f"http://localhost:{_port}/files/{fn}" for fn in filenames]
                    self._push_event({"type": "image_add", "image_urls": urls}, _tid)
            elif evt.get("type") == "display_window":
                filenames = evt.get("files", [])
                title = evt.get("title", "Display")
                content_type = evt.get("content_type")
                if filenames:
                    fn = filenames[0]
                    url = f"http://localhost:{_port}/files/{fn}"
                    if content_type is None:
                        ext = os.path.splitext(fn)[1].lower()
                        content_type = "video" if ext in (".mp4", ".webm", ".mov", ".avi", ".m4v") else "image"
                    payload = {
                        "type": "display_window",
                        "url": url,
                        "content_type": content_type,
                        "title": title,
                    }
                    try:
                        last_events = getattr(self, "_last_display_window_events", None)
                        if not isinstance(last_events, dict):
                            last_events = {}
                        last_events[_tid] = dict(payload)
                        self._last_display_window_events = last_events
                        self._last_display_window_event = dict(payload)
                    except Exception:
                        _log.debug("Failed to cache display_window event for thread %s", _tid, exc_info=True)
                    self._push_event(payload, _tid)

        def on_plugin_stream(evt, _tid=thread_id):
            self._push_plugin_stream_event(evt, _tid)

        def on_tool_event(evt, _tid=thread_id):
            try:
                phase = str((evt or {}).get("phase") or "").strip().lower()
                tname = str((evt or {}).get("tool_name") or "")
                tcid = str((evt or {}).get("tool_call_id") or "")
                summary = str((evt or {}).get("summary") or "")
                if phase == "start":
                    self._push_event({
                        "type": "tool_start",
                        "thread_id": _tid,
                        "tool_name": tname,
                        "tool_call_id": tcid,
                        "summary": summary,
                    }, _tid)
                elif phase == "result":
                    self._push_event({
                        "type": "tool_result",
                        "thread_id": _tid,
                        "tool_name": tname,
                        "tool_call_id": tcid,
                        "success": bool((evt or {}).get("success")),
                        "summary": summary,
                    }, _tid)
            except Exception:
                _log.debug("on_tool_event push failed", exc_info=True)

        # Set tab context for scratchpad and research tools (current conversation's tab)
        tab_id = self._resolve_tab_id_for_thread(thread_id)
        if tab_id:
            try:
                from tools.research import set_research_tab_id
                set_research_tab_id(tab_id)
            except Exception:
                _log.debug("Failed to set research tab id", exc_info=True)
            try:
                from tools.scratchpad_tools import set_scratchpad_tab_id
                set_scratchpad_tab_id(tab_id)
            except Exception:
                _log.debug("Failed to set scratchpad tab id", exc_info=True)

        worker = aiagent.AgentWorker(
            user_text,
            on_chunk=on_chunk,
            on_finished=on_finished,
            on_error=on_error,
            on_done=on_done,
            on_log=_agent_network_log,
            on_status=on_status,
            on_live_view=on_live_view,
            on_chat_event=on_chat_event,
            on_plugin_stream=on_plugin_stream,
            on_tool_event=on_tool_event,
            on_agent_message=on_agent_message,
            on_new_assistant_content=on_new_assistant_content,
            on_replace_last_content=on_replace_last,
            thread_id=thread_id,
            image_urls=image_urls,
            image_paths=image_paths,
            display_text=display_text,
            file_attachments=file_attachments,
            vision_image_format=aiagent.get_vision_image_format(),
            research_mode=research_mode,
            has_media_attachment=has_media_attachment,
            include_cli=include_cli,
            run_id=run_id,
            idle_tick=idle_tick,
            quick_panel=bool(quick_panel),
            dashboard_surface=bool(dashboard_surface),
        )
        self._workers[thread_id] = worker
        worker.start()

    def _schedule_conversation_title_utility_refresh(
        self, thread_id: str, *, quick_panel: bool = False
    ) -> None:
        tid = (thread_id or "").strip()
        if not tid:
            return

        def _work():
            try:
                self._maybe_apply_utility_conversation_title(
                    tid, force=False, quick_panel=quick_panel
                )
            except Exception:
                _log.debug("utility conversation title refresh failed", exc_info=True)

        Thread(target=_work, daemon=True, name="conv-title-utility").start()

    def _collect_thread_ids_needing_sidebar_title_regen(self) -> list[str]:
        """Threads whose persisted title looks like a message snippet rather than utility output."""
        from chat_storage import is_branch

        meta_all = self._conversation_meta
        if not isinstance(meta_all, dict):
            return []
        out: list[str] = []
        for tid, meta in meta_all.items():
            if not tid or not isinstance(tid, str):
                continue
            if not thread_id_allowed_in_main_conversation_sidebar(tid):
                continue
            if not isinstance(meta, dict):
                continue
            try:
                if is_branch(tid):
                    continue
            except Exception:
                continue
            if _sidebar_title_likely_message_snippet(meta.get("title") or ""):
                out.append(tid)
        return out

    def _schedule_batch_conversation_title_utility_refresh(self) -> None:
        """Recover real sidebar titles via the configured utility model (serial background batch)."""
        seed = list(getattr(self, "_repair_title_utility_seed_tids", None) or [])
        extra = self._collect_thread_ids_needing_sidebar_title_regen()
        merged: list[str] = []
        seen: set[str] = set()
        for tid in seed + extra:
            t = (tid or "").strip()
            if not t or t in seen:
                continue
            seen.add(t)
            merged.append(t)
        if not merged:
            return

        def _worker():
            applied = 0
            skips = 0
            unchanged = 0
            failures = 0
            for tid in merged:
                if not _thread_has_sidebar_title_transcript(tid):
                    skips += 1
                    continue
                try:
                    if self._maybe_apply_utility_conversation_title(tid, force=True):
                        applied += 1
                    else:
                        unchanged += 1
                except Exception:
                    failures += 1
                    _log.debug("batch utility title failed for thread", exc_info=True)
                time.sleep(0.35)
            _log.info(
                "Batch title refresh: queued=%d applied=%d unchanged=%d skip_no_transcript=%d fail=%d",
                len(merged),
                applied,
                unchanged,
                skips,
                failures,
            )

        Thread(target=_worker, daemon=True, name="conv-title-utility-batch").start()

    def _maybe_apply_utility_conversation_title(
        self, thread_id: str, *, force: bool = False, quick_panel: bool = False
    ) -> bool:
        from chat_storage import get_messages, is_branch
        from core.config import get_conversation_title_utility_milestones

        tid = (thread_id or "").strip()
        if not tid or is_branch(tid):
            return False
        if not thread_id_allowed_in_main_conversation_sidebar(tid):
            return False
        meta_all = self._conversation_meta
        if not isinstance(meta_all, dict) or tid not in meta_all:
            return False
        milestones = get_conversation_title_utility_milestones()
        # Quick Panel threads are usually one-shot Q&A, so summarize the title after the very
        # first response instead of waiting for the standard 3-turn milestone.
        is_quick = bool(quick_panel) or bool((meta_all.get(tid) or {}).get("quick_panel_origin"))
        if is_quick:
            milestones = sorted({1, *milestones})
            if quick_panel and not (meta_all.get(tid) or {}).get("quick_panel_origin"):
                meta_all[tid]["quick_panel_origin"] = True
        msgs = get_messages(tid) or []
        user_lines: list[str] = []
        for m in msgs[-48:]:
            role = (m.get("role") or "").strip()
            if role != "user":
                continue
            raw = _content_to_display_string(m.get("content"))
            one = _plain_line_for_conversation_title(role, raw)
            if not one:
                continue
            user_lines.append(f"User: {one}")
        body = "\n".join(user_lines[-28:])
        if not body.strip():
            snippet = _first_real_user_snippet_from_messages(msgs)
            if not snippet:
                _log.debug(
                    "utility conversation title skipped for thread %s (no real user text)",
                    tid,
                )
                return False
            body = f"User: {snippet}"
        if len(body) > 6000:
            body = body[-6000:]

        per = meta_all.get(tid) or {}
        next_m: int
        if force:
            next_m = max(milestones) if milestones else 15
        else:
            user_count = _count_user_messages_excluding_init(msgs)
            last_applied = 0
            try:
                last_applied = int(per.get("title_utility_milestone") or 0)
            except (TypeError, ValueError):
                last_applied = 0
            eligible = [m for m in milestones if user_count >= m and m > last_applied]
            if not eligible:
                return False
            next_m = max(eligible)

        from utility_model import utility_complete

        ut_messages = [
            {
                "role": "system",
                "content": (
                    "You name chat threads. Reply with exactly one line: a short 3–8 word title "
                    "describing the topic. No quotation marks, no 'Title:' prefix, no period at the end. "
                    "Never use bullets, 'Input:', 'transcript', or repeat User:/Assistant: labels."
                ),
            },
            {"role": "user", "content": body},
        ]
        raw = utility_complete(
            ut_messages,
            max_tokens=64,
            temperature=0.2,
            activity_operation="conversation_title",
            activity_thread_id=tid,
            surface_errors=False,
        )
        if not raw:
            _log.debug("utility conversation title empty for thread %s", tid)
            return False
        title = _sanitize_utility_conversation_title(raw)
        if not title or _sidebar_title_likely_message_snippet(title):
            fallback = _fallback_sidebar_title_from_packed_user_lines(user_lines)
            if not fallback:
                fallback = _first_real_user_snippet_from_messages(msgs)
            if fallback and not _sidebar_title_likely_message_snippet(fallback):
                _log.info(
                    "utility conversation title fallback for thread %s (raw=%r)",
                    tid,
                    (raw or "")[:160],
                )
                title = fallback
            else:
                _log.debug(
                    "utility conversation title rejected for thread %s (raw=%r sanitized=%r)",
                    tid,
                    (raw or "")[:160],
                    title,
                )
                return False
        meta_all[tid]["title"] = title
        meta_all[tid]["title_utility_milestone"] = next_m
        _save_chats_db(self._tabs, self._conversation_meta, self._live_view_event_bus)
        self._push_event({"type": "conversations_changed"}, tid)
        self._structured_bus.emit("__sidebar__", json.dumps({"type": "conversations_changed"}))
        return True

    def _do_cleanup(
        self,
        tid,
        *,
        final_state: str = "completed",
        final_summary: str = "Agent run finished",
        run_id: str = "",
    ):
        finishing_worker = self._workers.get(tid)
        effective_run_id = sanitize_run_id(
            run_id or str(getattr(finishing_worker, "run_id", "") or "")
        )

        # Release busy flags before flushing deferred kanban/council wakes. If flush
        # runs while the thread is still marked running, wake handlers re-queue the
        # pending work and the 90s fallback can fire a spurious second agent turn.
        self._run_active = False
        self._running_thread_ids.discard(tid)

        try:
            from utils.kanban_coordinator_delivery import flush_pending_coordinator_wake

            flush_pending_coordinator_wake(tid)
        except Exception:
            _log.debug("coordinator pending wake flush failed", exc_info=True)
        try:
            from utils.council_delivery import flush_pending_council_wake

            flush_pending_council_wake(tid)
        except Exception:
            _log.debug("council pending wake flush failed", exc_info=True)

        if tid in self._running_thread_ids:
            # Deferred wake started a replacement run; leave its worker/UI state intact.
            _cleanup_tags = (
                ["idle_tick"]
                if (finishing_worker and getattr(finishing_worker, "idle_tick", False))
                else ["user_initiated"]
            )
            self._push_activity(
                {
                    "source": "agent",
                    "state": final_state,
                    "summary": final_summary,
                    "job_id": tid,
                    "run_id": effective_run_id,
                    "tags": _cleanup_tags,
                },
                tid,
            )
            return

        if tid not in self._running_thread_ids and tid not in self._workers:
            return
        worker = finishing_worker or self._workers.get(tid)
        self._agent_bubble_created_threads.discard(tid)
        self._thread_stream_buffer.pop(tid, None)
        self._workers.pop(tid, None)
        self._push_js("hideThinking();", tid)
        self._push_js("setSendButtonState('send');", tid)
        self._push_js("setSpinnerVisible(false);", tid)
        self._push_event({"type": "thinking_hide"}, tid)
        self._push_event({"type": "send_button", "state": "send"}, tid)
        self._push_event({"type": "status", "text": "", "visible": False}, tid)
        self._push_event({"type": "conversations_changed"}, tid)
        # Re-fetch persisted history so the client shows the final assistant
        # bubble when live SSE missed message_add/message_update (common on
        # init welcome: agent starts before the SSE socket connects).
        self._push_event({"type": "messages_reload"}, tid)
        # Also broadcast to the sidebar channel so ContentView refreshes
        # regardless of which thread is selected
        self._structured_bus.emit("__sidebar__", json.dumps({"type": "conversations_changed"}))
        bus = self._live_view_event_bus
        if bus:
            bus.emit(tid, {"type": "done"})
        _cleanup_tags = ["idle_tick"] if (worker and getattr(worker, "idle_tick", False)) else ["user_initiated"]
        self._push_activity(
            {
                "source": "agent",
                "state": final_state,
                "summary": final_summary,
                "job_id": tid,
                "run_id": effective_run_id,
                "tags": _cleanup_tags,
            },
            tid,
        )
        _write_main_activity_state(busy=False)
        if self._mesh_manager is not None:
            self._mesh_manager.set_user_active(False)
        _schedule_save_chats_db(self)
        self._schedule_conversation_title_utility_refresh(
            tid, quick_panel=bool(getattr(worker, "quick_panel", False))
        )


# ---------------------------------------------------------------------------
# Server setup
# ---------------------------------------------------------------------------


def _mlx_context_length_from_prefs(prefs: dict) -> int | None:
    """Return llm_context_length from prefs clamped to valid range for MLX, or None."""
    val = prefs.get("llm_context_length")
    try:
        val = int(val)
    except (TypeError, ValueError):
        return None
    if 16384 <= val <= 1_048_576:
        return val
    return None


def _first_run_ready_for_chat(prefs: dict | None = None) -> bool:
    data = prefs if isinstance(prefs, dict) else _load_app_prefs()
    return bool((data or {}).get("first_run_ready_for_chat", False))


def _has_non_init_user_messages(thread_id: str) -> bool:
    try:
        from chat_storage import get_messages
        for m in (get_messages(thread_id) or []):
            if (m.get("role") or "").strip() != "user":
                continue
            content = _content_to_display_string(m.get("content")).strip().lower()
            if content and content != "init":
                return True
    except Exception:
        return False
    return False


def _count_user_messages_excluding_init(msgs: list) -> int:
    n = 0
    for m in msgs or []:
        if (m.get("role") or "").strip() != "user":
            continue
        t = _content_to_display_string(m.get("content")).strip()
        if _is_synthetic_system_user_line(t):
            continue
        n += 1
    return n


def _first_real_user_snippet_from_messages(msgs: list) -> str | None:
    """First non-init, non-automation user line suitable for a sidebar title."""
    for m in msgs or []:
        if (m.get("role") or "").strip() != "user":
            continue
        text = (_content_to_display_string(m.get("content")) or "").strip()
        if not text or _is_synthetic_system_user_line(text):
            continue
        collapsed = " ".join(text.split())
        if not collapsed:
            continue
        return (collapsed[:40] + "…") if len(collapsed) > 40 else collapsed
    return None


def _plain_line_for_conversation_title(role: str, raw_text: object) -> str | None:
    """Collapse whitespace for utility title prompting; omit assistant chain-of-thought scaffolding."""
    r = (role or "").strip().lower()
    text = (_content_to_display_string(raw_text) or "").strip()
    if not text:
        return None
    if r == "user" and _is_synthetic_system_user_line(text):
        return None
    lw = text.lower()
    if r == "assistant":
        if "here's a thinking process" in lw or "thinking process:" in lw[:120]:
            return None
        if lw.startswith(("thinking process", "thinking:", "[thinking]", "<thinking")):
            return None
        head = lw[:260]
        if "analyze user input" in head and ("**" in text[:220] or "thinking" in head[:160]):
            return None
        if "analyze the request" in head and "**" in text[:220]:
            return None
    collapsed = " ".join(text.split())
    if len(collapsed) > 500:
        collapsed = collapsed[:497] + "…"
    return collapsed


def _thread_has_sidebar_title_transcript(thread_id: str) -> bool:
    """Whether SQLite has anything we can summarize for utility ``conversation_title``."""
    tid = (thread_id or "").strip()
    if not tid:
        return False
    try:
        from chat_storage import get_messages

        for m in get_messages(tid) or []:
            role = (m.get("role") or "").strip()
            if role not in ("user", "assistant"):
                continue
            if _plain_line_for_conversation_title(role, m.get("content")):
                return True
    except Exception:
        return False
    return False


def _sidebar_title_likely_utility_prompt_echo(raw: object) -> bool:
    """True when the utility model echoed titler prompt scaffolding instead of a topic title."""
    s = (raw if isinstance(raw, str) else "").strip()
    if not s:
        return False
    low = s.lower()
    if "conversation transcript" in low:
        return True
    if re.search(r"\btranscript between\b", low):
        return True
    if low.startswith("a conversation transcript"):
        return True
    if re.match(r"^\*?\s*input\s*:", low):
        return True
    if low.startswith("user:") or low.startswith("assistant:"):
        return True
    return False


def _fallback_sidebar_title_from_packed_user_lines(user_lines: list[str]) -> str | None:
    """First meaningful user line from utility title context (``User: …`` packed rows)."""
    for packed in user_lines or []:
        row = (packed or "").strip()
        if not row.lower().startswith("user:"):
            continue
        text = row[5:].strip()
        if not text or _is_synthetic_system_user_line(text):
            continue
        if _sidebar_title_likely_utility_prompt_echo(text):
            continue
        return (text[:40] + "…") if len(text) > 40 else text
    return None


def _sidebar_title_likely_assistant_opener(raw: object) -> bool:
    """True when a title is generic assistant greeting fluff, not a conversation topic."""
    s = (raw if isinstance(raw, str) else "").strip().lower()
    if not s:
        return False
    openers = (
        "ready to dive in",
        "how can i help",
        "what can i do for you",
        "what can you do",
        "let me know if",
        "how may i assist",
        "feel free to ask",
        "happy to help",
    )
    return any(p in s for p in openers)


def _sidebar_title_likely_cot_outline_step(raw: object) -> bool:
    """True for Qwen-style numbered bold outline steps used as accidental titles."""
    s = (raw if isinstance(raw, str) else "").strip()
    if not s:
        return False
    low = s.lower()
    if re.match(r"^\d+\.\s+\*\*", s):
        if any(
            tok in low
            for tok in (
                "analyze the request",
                "analyze user input",
                "analyze the input",
                "understand the request",
                "identify the",
                "determine the",
            )
        ):
            return True
        # Lone ``1. **Foo:**`` step header with no real title body.
        if re.match(r"^\d+\.\s+\*\*[^*]{2,80}\*\*:?\s*$", s):
            return True
    return False


def _sidebar_title_likely_message_snippet(raw: object) -> bool:
    """Conservative junk-title heuristic.

    Only matches titles we are confident are bad: empty, the placeholders the repair pass
    writes, the raw search-tool ``TOPIC: … | QUERY: …`` echo, and assistant chain-of-thought
    leaks. Length / ellipsis / "Hi" / "Hey" heuristics were removed because they mis-classify
    real titles like ``Heya Callie, how's it going?`` and ``Phase 3: System Reliability Verified``
    as junk. When in doubt, leave the persisted title alone.
    """
    title = raw if isinstance(raw, str) else ""
    s = title.strip()
    if not s:
        return True
    if s in ("Recovered chat", "Untitled"):
        return True
    low = s.lower()
    if "topic:" in low and "query:" in low:
        return True
    if "thinking process" in low or "analyze user input" in low:
        return True
    if "analyze the request" in low:
        return True
    if _sidebar_title_likely_utility_prompt_echo(s):
        return True
    if _sidebar_title_likely_assistant_opener(s):
        return True
    if _is_synthetic_system_user_line(s):
        return True
    if _sidebar_title_likely_cot_outline_step(s):
        return True
    return False


def _freeze_existing_good_sidebar_titles(
    conversation_meta: dict[str, dict],
    *,
    max_milestone: int,
) -> int:
    """Mark every conversation that already has a non-junk title as utility-named.

    The per-turn regen path in ``_do_cleanup`` only fires when an eligible milestone is
    ``> title_utility_milestone``. Setting ``title_utility_milestone`` to ``max_milestone``
    therefore guarantees the persisted title will not be silently rewritten by the utility
    model on the next agent turn, restart, or batch sweep. The startup batch is also gated
    by ``_sidebar_title_likely_message_snippet`` so good titles are doubly protected.
    """
    if not isinstance(conversation_meta, dict) or max_milestone <= 0:
        return 0
    frozen = 0
    for tid, meta in conversation_meta.items():
        if not isinstance(meta, dict):
            continue
        if not thread_id_allowed_in_main_conversation_sidebar(tid):
            continue
        if _sidebar_title_likely_message_snippet(meta.get("title")):
            continue
        try:
            current_ms = int(meta.get("title_utility_milestone") or 0)
        except (TypeError, ValueError):
            current_ms = 0
        if current_ms >= max_milestone:
            continue
        meta["title_utility_milestone"] = max_milestone
        frozen += 1
    return frozen


def _unwrap_inline_thinking_title_fragment(text: str) -> str:
    """Drop same-line 'Thinking Process:' leaks that ``strip_reasoning_preamble_leak`` misses."""
    s = (text or "").strip()
    if not s:
        return ""
    low = s.lower()
    if "here's a thinking process" in low or "here is a thinking process" in low:
        return ""
    if low.startswith(("thinking process", "thinking:", "[thinking]", "<thinking")):
        if ":" in s:
            rest = s.split(":", 1)[1].strip()
            if rest and "thinking process" not in rest.lower()[:80]:
                return rest
        return ""
    if "thinking process:" in low[:120]:
        return ""
    return s


def _clear_title_utility_milestone_for_junk_sidebar_titles(
    conversation_meta: dict[str, dict],
) -> int:
    """Allow per-turn utility rename to retry after a bad title was persisted with milestone set."""
    if not isinstance(conversation_meta, dict):
        return 0
    cleared = 0
    for tid, meta in conversation_meta.items():
        if not tid or not isinstance(meta, dict):
            continue
        if not thread_id_allowed_in_main_conversation_sidebar(tid):
            continue
        if not _sidebar_title_likely_message_snippet(meta.get("title") or ""):
            continue
        if meta.pop("title_utility_milestone", None) is not None:
            cleared += 1
    return cleared


def _utility_title_piece_after_cot_strip(piece: str) -> str:
    """Strip reasoning preambles and standalone numbered outline steps from one title fragment."""
    from utils.llm_output_sanitize import (
        _strip_leading_numbered_analysis_block,
        strip_reasoning_preamble_leak,
    )

    p = (piece or "").strip()
    if not p:
        return ""
    if re.match(r"^\*?\s*input\s*:", p, re.I):
        p = re.sub(r"^\*?\s*input\s*:\s*", "", p, flags=re.I).strip()
        if not p or _sidebar_title_likely_utility_prompt_echo(p):
            return ""
    stripped = strip_reasoning_preamble_leak(p)
    unwrapped = _unwrap_inline_thinking_title_fragment(stripped or p)
    if not unwrapped:
        return ""
    if _sidebar_title_likely_cot_outline_step(unwrapped):
        remainder = _strip_leading_numbered_analysis_block(unwrapped).strip()
        if not remainder or _sidebar_title_likely_cot_outline_step(remainder):
            return ""
        unwrapped = remainder
    return unwrapped


def _sanitize_utility_conversation_title(raw: str) -> str:
    """Normalize utility-model title output; strip Qwen-style reasoning preambles."""
    text = (raw or "").strip()
    if not text:
        return ""

    candidates: list[str] = []
    for chunk in (text, *text.splitlines()):
        piece = (chunk or "").strip()
        if not piece:
            continue
        unwrapped = _utility_title_piece_after_cot_strip(piece)
        if unwrapped and unwrapped not in candidates:
            candidates.append(unwrapped)

    for s in candidates:
        s = s.split("\n", 1)[0].strip()
        s = _unwrap_inline_thinking_title_fragment(s)
        s = s.strip('\'"«»`')
        if s.lower().startswith("title:"):
            s = s[6:].strip()
        if not s or _sidebar_title_likely_message_snippet(s):
            continue
        if len(s) > 80:
            s = s[:77] + "…"
        return s
    return ""


def _start_background_events_bridge(activity_bus: ActivityBus) -> None:
    """Bridge background-agent event log (separate process) into ActivityBus."""
    events_path = os.path.join(MUGI_LOGS_DIR, "background_events.log")
    cursor_path = os.path.join(MUGI_LOGS_DIR, "background_events.cursor")

    def _event_ts_ms(ts_raw: str) -> int | None:
        """Parse background event timestamp to epoch ms (local time)."""
        s = (ts_raw or "").strip()
        if not s:
            return None
        try:
            # Expected format: "YYYY-MM-DD HH:MM:SS.mmm" (or without millis).
            return int(datetime.fromisoformat(s).timestamp() * 1000)
        except Exception:
            _log.debug("fromisoformat parse failed for %r", s, exc_info=True)
        for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
            try:
                return int(datetime.strptime(s, fmt).timestamp() * 1000)
            except Exception:
                continue
        return None

    def _apply_ts(payload: dict, ts_raw: str) -> dict:
        """Attach original event ts_ms when available."""
        ts_ms = _event_ts_ms(ts_raw)
        if ts_ms is not None:
            payload["ts_ms"] = ts_ms
        return payload

    def _clean_heartbeat_preview(raw_preview: str) -> str:
        """Keep heartbeat summaries compact and avoid surfacing internal chain-of-thought blocks."""
        preview = (raw_preview or "").strip()
        if not preview:
            return ""
        normalized = preview.upper().replace(" ", "")
        if "HEARTBEAT_OK" in normalized:
            return ""
        # Drop obvious <think> / <|channel>thought wrappers and keep only the first non-empty line.
        import re as _re
        preview = _re.sub(r"(?is)<think>.*?</think>", "", preview)
        preview = _re.sub(r"(?is)<\|channel>thought.*?<channel\|>", "", preview)
        preview = preview.replace("<think>", "").replace("</think>", "")
        preview = preview.replace("<|channel>thought", "").replace("<channel|>", "")
        preview = preview.strip()
        lines = [ln.strip() for ln in preview.splitlines() if ln.strip()]
        return (lines[0] if lines else preview)[:200]

    def _load_cursor_or_tail() -> int:
        """
        Read cursor from disk; if missing/invalid, tail from current EOF.
        This avoids replaying historical events into the Activity feed on startup.
        """
        try:
            with open(cursor_path, "r", encoding="utf-8") as f:
                raw = (f.read() or "").strip()
            pos = int(raw)
            return max(0, pos)
        except Exception:
            _log.debug("Failed to read event cursor position", exc_info=True)
        try:
            if os.path.isfile(events_path):
                return os.path.getsize(events_path)
        except Exception:
            _log.debug("Failed to get events file size", exc_info=True)
        return 0

    def _save_cursor(offset: int) -> None:
        try:
            os.makedirs(MUGI_LOGS_DIR, exist_ok=True)
            with open(cursor_path, "w", encoding="utf-8") as f:
                f.write(str(max(0, int(offset))))
        except Exception:
            _log.debug("Failed to save event cursor position", exc_info=True)

    def _map_event(entry: dict) -> dict | None:
        etype = str(entry.get("type") or "").strip().lower()
        if not etype:
            return None
        ts = str(entry.get("ts") or "")
        if etype == "curiosity":
            questions = entry.get("questions") or []
            count = len(questions) if isinstance(questions, list) else 0
            return _apply_ts({
                "source": "curiosity",
                "state": "completed",
                "summary": f"Curiosity loop completed ({count} question(s))",
                "job_id": "background_curiosity",
                "meta": {"questions": questions, "ts": ts},
            }, ts)
        if etype == "curiosity_started":
            return _apply_ts({
                "source": "curiosity",
                "state": "started",
                "summary": "Curiosity loop started",
                "job_id": "background_curiosity",
                "meta": {"ts": ts},
            }, ts)
        if etype == "curiosity_error":
            return _apply_ts({
                "source": "curiosity",
                "state": "error",
                "summary": "Curiosity loop failed",
                "job_id": "background_curiosity",
                "meta": {"error": str(entry.get("error") or ""), "ts": ts},
            }, ts)
        if etype == "research_prefetch":
            files = entry.get("files") or []
            count = len(files) if isinstance(files, list) else 0
            return _apply_ts({
                "source": "curiosity",
                "state": "completed",
                "summary": f"Research prefetch finished ({count} file(s))",
                "job_id": "background_prefetch",
                "meta": {"files": files, "ts": ts},
            }, ts)
        if etype == "research_prefetch_started":
            return _apply_ts({
                "source": "curiosity",
                "state": "started",
                "summary": "Research prefetch started",
                "job_id": "background_prefetch",
                "meta": {"ts": ts},
            }, ts)
        if etype == "research_prefetch_error":
            return _apply_ts({
                "source": "curiosity",
                "state": "error",
                "summary": "Research prefetch failed",
                "job_id": "background_prefetch",
                "meta": {"error": str(entry.get("error") or ""), "ts": ts},
            }, ts)
        if etype == "consolidation":
            preview = str(entry.get("response_preview") or "")[:200]
            return _apply_ts({
                "source": "consolidation",
                "state": "completed",
                "summary": "Memory consolidation completed" + (f": {preview}…" if preview else ""),
                "job_id": "background_consolidation",
                "meta": {"response_preview": preview, "ts": ts},
            }, ts)
        if etype == "consolidation_error":
            err = str(entry.get("error") or "")[:300]
            return _apply_ts({
                "source": "consolidation",
                "state": "error",
                "summary": "Memory consolidation failed",
                "job_id": "background_consolidation",
                "meta": {"error": err, "ts": ts},
            }, ts)
        if etype == "drive_cycle_started":
            return _apply_ts({
                "source": "drives",
                "state": "started",
                "summary": "Drive cycle started",
                "job_id": "drive_cycle",
                "meta": {"ts": ts},
            }, ts)
        if etype == "drive_cycle":
            discovered = int(entry.get("discovered") or 0)
            escalated = int(entry.get("escalated") or 0)
            expired = int(entry.get("expired") or 0)
            parts = []
            if discovered:
                parts.append(f"{discovered} discovered")
            if escalated:
                parts.append(f"{escalated} escalated")
            if expired:
                parts.append(f"{expired} expired")
            summary = "Drive cycle completed"
            if parts:
                summary += ": " + ", ".join(parts)
            return _apply_ts({
                "source": "drives",
                "state": "completed",
                "summary": summary,
                "job_id": "drive_cycle",
                "meta": {
                    "discovered": discovered,
                    "escalated": escalated,
                    "expired": expired,
                    "ts": ts,
                },
            }, ts)
        if etype == "drive_cycle_error":
            err = str(entry.get("error") or "")[:300]
            return _apply_ts({
                "source": "drives",
                "state": "error",
                "summary": "Drive cycle failed",
                "job_id": "drive_cycle",
                "meta": {"error": err, "ts": ts},
            }, ts)
        if etype.startswith("heartbeat_") and etype.endswith("_error"):
            task = etype[:-6].replace("heartbeat_", "", 1)  # e.g. consciousness, memory_maintenance
            err = str(entry.get("error") or "")[:300]
            return _apply_ts({
                "source": "heartbeat",
                "state": "error",
                "summary": f"Heartbeat ({task}) failed",
                "job_id": f"heartbeat_{task}",
                "thread_id": f"heartbeat_{task}",
                "meta": {"error": err, "task": task, "ts": ts},
            }, ts)
        if etype.startswith("heartbeat_"):
            task = etype.replace("heartbeat_", "", 1)  # e.g. consciousness, memory_maintenance, growth_check
            preview = _clean_heartbeat_preview(str(entry.get("response_preview") or ""))
            return _apply_ts({
                "source": "heartbeat",
                "state": "completed",
                "summary": f"Heartbeat ({task}) completed" + (f": {preview}…" if preview else ""),
                "job_id": f"heartbeat_{task}",
                "thread_id": f"heartbeat_{task}",
                "meta": {"response_preview": preview, "task": task, "ts": ts},
            }, ts)
        if etype == "feed_topic_match":
            matched = entry.get("matched")
            count = int(matched) if isinstance(matched, (int, float)) else 0
            return _apply_ts({
                "source": "feeds",
                "state": "completed",
                "summary": f"Feed topic match: {count} relevant item(s)" if count else "Feed topic match completed",
                "job_id": "feed_topic_match",
                "meta": {"matched": count, "ts": ts},
            }, ts)
        return None

    def _loop():
        offset = _load_cursor_or_tail()
        _save_cursor(offset)
        while True:
            try:
                if not os.path.isfile(events_path):
                    time.sleep(2.0)
                    continue
                size = os.path.getsize(events_path)
                if size < offset:
                    offset = 0
                    _save_cursor(offset)
                prev_offset = offset
                with open(events_path, "r", encoding="utf-8", errors="replace") as f:
                    f.seek(offset)
                    for raw in f:
                        line = (raw or "").strip()
                        if not line:
                            continue
                        try:
                            entry = json.loads(line)
                        except Exception:
                            continue
                        if not isinstance(entry, dict):
                            continue
                        mapped = _map_event(entry)
                        if mapped:
                            activity_bus.emit(mapped)
                    new_offset = f.tell()
                    offset = new_offset
                    if new_offset != prev_offset:
                        _save_cursor(offset)
                if offset == prev_offset:
                    # Prevent a tight spin loop when the file exists but has no
                    # new lines yet (normal idle state at EOF).
                    time.sleep(0.25)
                else:
                    # Throttle when background_events.log grows continuously (chatty
                    # runner); without this the tail loop reopens/parses as fast as the
                    # writer appends and can pin a CPU core.
                    time.sleep(0.05)
            except Exception:
                _log.debug("Error in activity event tail loop", exc_info=True)
                # Back off on repeated failures to avoid pegging a core.
                time.sleep(1.0)

    t = threading.Thread(target=_loop, daemon=True, name="activity-bg-events-bridge")
    t.start()


def _searxng_setup_port() -> int:
    """Validated SearXNG port for setup (wizard + preferences)."""
    try:
        from core.config import get_search_config

        p = int(get_search_config().get("port", 8888))
        return p if 1024 <= p <= 65535 else 8888
    except Exception:
        return 8888


def _persist_config_after_searxng_setup_success() -> None:
    """After Docker/SearXNG install succeeds, turn on local search in config (same as Settings → setup)."""
    try:
        from core import get_config, save_config, reload_config

        config = get_config()
        config.setdefault("search", {})["use_local_docker"] = True
        if not config.get("search", {}).get("port"):
            config.setdefault("search", {})["port"] = 8888
        save_config(config)
        reload_config()
        try:
            from tools.web_tools import clear_health_cache

            clear_health_cache()
        except Exception:
            _log.debug("Failed to clear web health cache after SearXNG config save", exc_info=True)
    except Exception:
        _log.debug("Failed to save SearXNG setup config", exc_info=True)


def _persist_config_after_searxng_setup_failure() -> None:
    """When local SearXNG setup fails, keep public search fallback enabled."""
    try:
        from core import get_config, save_config, reload_config

        config = get_config()
        search = config.setdefault("search", {})
        search["use_local_docker"] = False
        if not search.get("public_fallback_url"):
            search["public_fallback_url"] = "https://searx.party"
        save_config(config)
        reload_config()
        try:
            from tools.web_tools import clear_health_cache

            clear_health_cache()
        except Exception:
            _log.debug("Failed to clear web health cache after SearXNG failure config save", exc_info=True)
    except Exception:
        _log.debug("Failed to save SearXNG failure fallback config", exc_info=True)


def _build_app(delegate: HeadlessDelegate, chat_bus: ChatEventBus, html_content: str, auth_token: str):
    """Create the FastAPI app and bolt on the chat-events SSE endpoint."""
    try:
        from core.config import apply_persisted_config_migrations

        apply_persisted_config_migrations()
    except Exception:
        _log.debug("apply_persisted_config_migrations failed at startup", exc_info=True)
    temp_upload_dir = os.path.join(os.path.expanduser("~"), ".mugi", "temp_uploads")
    os.makedirs(temp_upload_dir, exist_ok=True)

    def get_voice_prefs():
        prefs = _load_app_prefs()
        return {
            "voice_insert": prefs.get("voice_insert", True),
            "voice_push_to_talk": prefs.get("voice_push_to_talk", False),
            "voice_prefer_on_device": prefs.get("voice_prefer_on_device", False),
            "supports_on_device": False,
        }

    def get_theme():
        prefs = _load_app_prefs()
        t = (prefs.get("theme") or "system").strip().lower()
        return t if t in ("light", "dark", "system") else "system"

    from duplex_session_controller import DuplexSessionController
    from mlx_audio_service import MLXAudioService

    _voice_boot_prefs = _load_app_prefs()
    try:
        _voice_idle_ttl_s = int(_voice_boot_prefs.get("voice_conversation_idle_timeout_s", 900))
    except Exception:
        _voice_idle_ttl_s = 900
    _voice_idle_ttl_s = max(60, _voice_idle_ttl_s)
    duplex_sessions = DuplexSessionController(idle_ttl_s=float(_voice_idle_ttl_s))
    duplex_metrics = {
        "sessions_started": 0,
        "sessions_resumed": 0,
        "audio_frames": 0,
        "audio_bytes": 0,
        "errors": 0,
        "interruptions": 0,
    }
    voice_session_metrics = {
        "sessions_started": 0,
        "sessions_stopped": 0,
        "interruptions": 0,
        "errors": 0,
        "evicted_same_thread": 0,
        "last_transition": "",
        "last_error": "",
    }
    mlx_audio_service = MLXAudioService()

    def _is_local_origin(url_val: str | None) -> bool:
        if not url_val:
            return True
        val = str(url_val).strip().lower()
        # Native URLSession websocket clients may send `Origin: null`.
        if val == "null":
            return True
        try:
            host = (urlparse(url_val).hostname or "").lower()
            return host in ("localhost", "127.0.0.1", "::1")
        except Exception:
            return False

    async def _auth_websocket_or_close(ws: WebSocket) -> bool:
        header_token = (ws.headers.get("x-auth-token") or "").strip()
        token = header_token
        if not token:
            token = str(ws.query_params.get("auth_token") or "").strip()
        origin = ws.headers.get("origin")
        referer = ws.headers.get("referer")
        if token != auth_token:
            _log.warning(
                "ws_auth_reject reason=token_mismatch path=%s header_token_len=%s query_token_len=%s",
                ws.url.path,
                len(header_token),
                len(str(ws.query_params.get("auth_token") or "")),
            )
            await ws.close(code=1008)
            return False
        if not _is_local_origin(origin) or not _is_local_origin(referer):
            _log.warning(
                "ws_auth_reject reason=origin path=%s origin=%s referer=%s",
                ws.url.path,
                origin,
                referer,
            )
            await ws.close(code=1008)
            return False
        return True

    fastapi_app = app_http_routes.create_fastapi_app(
        delegate, html_content, temp_upload_dir,
        get_voice_prefs=get_voice_prefs,
        get_theme=get_theme,
        live_view_event_bus=delegate._live_view_event_bus,
        activity_bus=delegate._activity_bus,
        auth_token=auth_token,
        on_user_input_start=lambda: (
            delegate._mesh_manager.set_user_active(True)
            if getattr(delegate, "_mesh_manager", None) is not None else None
        ),
    )
    # Plugin discover() runs in a background thread; the first create_fastapi_app call often
    # mounts zero /plugin/<slug> routes. Wait for initial discovery, then mount any new proxies.
    pm0 = getattr(delegate, "_plugin_manager", None)
    if pm0 is not None:
        try:
            pm0.wait_initial_discover(120.0)
        except Exception:
            _log.warning(
                "Plugin initial discovery wait failed; mounting partial plugin routes",
                exc_info=True,
            )
        app_http_routes.mount_plugin_http_routes(fastapi_app, delegate, auth_token)

    # Mesh manager + routes
    mesh_manager = MeshManager(port=delegate._http_port, delegate=delegate)
    delegate._mesh_manager = mesh_manager
    set_mesh_manager(mesh_manager)
    fastapi_app.include_router(build_mesh_router(mesh_manager))

    from mugi_backend_browser_extension_routes import register_browser_extension_routes
    register_browser_extension_routes(
        fastapi_app=fastapi_app,
        auth_token=auth_token,
        load_app_prefs=_load_app_prefs,
        save_app_prefs=lambda prefs: _save_app_prefs(prefs),
        log=_log,
    )

    @fastapi_app.middleware("http")
    async def _request_trace_middleware(request: FRequest, call_next):
        from core import debug_enabled
        noisy_info_paths = {
            "/shell/confirm/pending",
            "/user/decision/pending",
            "/app/confirm/pending",
            "/chat/stream",
            "/terminal/stream",
            "/live_view/stream",
            "/activity/stream",
            "/plugin_stream/stream",
        }
        request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
        token = _request_id_ctx.set(request_id)
        started = time.perf_counter()
        try:
            response = await call_next(request)
        except Exception:
            _log.exception("request_failed method=%s path=%s", request.method, request.url.path)
            raise
        finally:
            _request_id_ctx.reset(token)
        elapsed_ms = int((time.perf_counter() - started) * 1000)
        response.headers["X-Request-ID"] = request_id
        path = request.url.path
        status = getattr(response, "status_code", 0)
        if status >= 500:
            _log.warning(
                "request_complete method=%s path=%s status=%s duration_ms=%s",
                request.method,
                path,
                status,
                elapsed_ms,
            )
        elif status >= 400:
            _log.info(
                "request_complete method=%s path=%s status=%s duration_ms=%s",
                request.method,
                path,
                status,
                elapsed_ms,
            )
        elif (not debug_enabled()) or path in noisy_info_paths:
            _log.debug(
                "request_complete method=%s path=%s status=%s duration_ms=%s",
                request.method,
                path,
                status,
                elapsed_ms,
            )
        else:
            _log.info(
                "request_complete method=%s path=%s status=%s duration_ms=%s",
                request.method,
                path,
                status,
                elapsed_ms,
            )
        return response

    from mugi_backend_health_routes import register_health_diagnostics_routes

    register_health_diagnostics_routes(
        fastapi_app,
        delegate=delegate,
        mesh_manager=mesh_manager,
        process_start_ts=_PROCESS_START_TS,
    )

    from memory_http_routes import register_memory_http_routes
    from knowledge_http_routes import register_knowledge_http_routes
    from user_model_http_routes import register_user_model_routes

    register_memory_http_routes(fastapi_app)
    register_knowledge_http_routes(fastapi_app)
    register_user_model_routes(fastapi_app)

    # ---- Webhook hooks + system_event (see mugi_backend_hooks_routes) ----
    from mugi_backend_hooks_routes import register_hooks_routes

    register_hooks_routes(fastapi_app, delegate=delegate)

    try:
        from utils.kanban_coordinator_delivery import (
            register_coordinator_chat_reload,
            register_coordinator_wake,
        )

        def _wake_coordinator_thread(tid: str, reason: str) -> None:
            if tid in getattr(delegate, "_running_thread_ids", set()):
                from utils.kanban_coordinator_delivery import register_pending_coordinator_wake

                register_pending_coordinator_wake(tid, reason=reason or "kanban_event")
                return
            if getattr(delegate, "_run_active", False) and tid == getattr(delegate, "_current_thread_id", ""):
                from utils.kanban_coordinator_delivery import register_pending_coordinator_wake

                register_pending_coordinator_wake(tid, reason=reason or "kanban_event")
                return
            delegate._run_agent(
                "Kanban board update — summarize outcomes for the operator. "
                "Do not call implementation tools.",
                thread_id=tid,
                idle_tick=True,
                display_text="[Kanban update]",
            )

        register_coordinator_wake(_wake_coordinator_thread)
        register_coordinator_chat_reload(
            lambda tid: delegate._push_event({"type": "messages_reload"}, tid)
        )
    except Exception:
        _log.debug("coordinator wake registration failed", exc_info=True)

    try:
        from utils.council_delivery import (
            register_council_chat_reload,
            register_council_thread_state_refresh,
            register_council_wake,
        )

        def _wake_council_thread(tid: str, reason: str) -> None:
            from utils.council_delivery import register_pending_council_wake

            if tid in getattr(delegate, "_running_thread_ids", set()) or (
                getattr(delegate, "_run_active", False)
                and tid == getattr(delegate, "_current_thread_id", "")
            ):
                register_pending_council_wake(tid, reason=reason or "council_synthesis")
                return
            wake_reason = (reason or "council_synthesis").strip() or "council_synthesis"
            if wake_reason == "council_skip":
                from utils.council_triggers import COUNCIL_SKIP_LINE

                delegate._run_agent(
                    COUNCIL_SKIP_LINE,
                    thread_id=tid,
                    idle_tick=False,
                    display_text="[Skip council]",
                )
                return
            if wake_reason == "council_honest_stop":
                from utils.council_offers import build_council_honest_stop_prompt

                honest = build_council_honest_stop_prompt(tid)
                delegate._run_agent(
                    honest,
                    thread_id=tid,
                    idle_tick=True,
                    display_text="[Council convened]",
                )
                return
            if wake_reason == "council_failed":
                from utils.council_prompts import COUNCIL_FAILURE_WAKE_PROMPT

                delegate._run_agent(
                    COUNCIL_FAILURE_WAKE_PROMPT,
                    thread_id=tid,
                    idle_tick=True,
                    display_text="[Council failure]",
                )
                return
            from utils.council_prompts import COUNCIL_SYNTHESIS_WAKE_PROMPT

            delegate._run_agent(
                COUNCIL_SYNTHESIS_WAKE_PROMPT,
                thread_id=tid,
                idle_tick=True,
                display_text="[Council synthesis]",
            )

        register_council_wake(_wake_council_thread)
        register_council_chat_reload(
            lambda tid: delegate._push_event({"type": "messages_reload"}, tid)
        )
        register_council_thread_state_refresh(
            lambda tid: delegate._push_event({"type": "thread_state_reload"}, tid)
        )
        from utils.council_triggers import register_thread_running_probe

        register_thread_running_probe(
            lambda tid: tid in getattr(delegate, "_running_thread_ids", set())
            or (
                getattr(delegate, "_run_active", False)
                and tid == getattr(delegate, "_current_thread_id", "")
            )
        )
    except Exception:
        _log.debug("council wake registration failed", exc_info=True)

    from mugi_backend_email_routes import register_email_preferences_routes

    register_email_preferences_routes(fastapi_app)

    @fastapi_app.on_event("startup")
    async def _mesh_startup():
        delegate._structured_loop = asyncio.get_running_loop()
        try:
            await asyncio.to_thread(mesh_manager.start)
            await mesh_manager.ensure_worker()
            loop = asyncio.get_running_loop()
            # First pass: after discovery and HTTP have fully started. Second pass: mDNS/addresses
            # often still empty at 5s after wake; known_peers' stored address:port can succeed anyway.
            loop.call_later(
                5.0,
                lambda: asyncio.ensure_future(
                    asyncio.to_thread(mesh_manager.auto_reconnect_known_peers)
                ),
            )
            loop.call_later(
                45.0,
                lambda: asyncio.ensure_future(
                    asyncio.to_thread(mesh_manager.auto_reconnect_known_peers)
                ),
            )
            loop.call_later(15.0, lambda: mesh_manager.start_pulse())
        except Exception:
            _log.debug("Failed to schedule mesh pulse start", exc_info=True)
        try:
            from persistence import PROACTIVE_QUEUE_PATH
            from utils.proactive_queue import reconcile_stale_proactive_starts

            qpath = os.path.expanduser(PROACTIVE_QUEUE_PATH)
            await asyncio.to_thread(reconcile_stale_proactive_starts, qpath)
        except Exception:
            _log.debug("proactive start reconcile at startup failed", exc_info=True)

    @fastapi_app.on_event("shutdown")
    async def _mesh_shutdown_event():
        try:
            await asyncio.to_thread(mesh_manager.stop_pulse)
            await asyncio.to_thread(mesh_manager.stop)
        except Exception:
            _log.debug("Failed to stop mesh manager on shutdown", exc_info=True)

    # ── Chat events SSE (JS commands for the SwiftUI WebView) ─────────────

    from fastapi.exceptions import RequestValidationError

    @fastapi_app.exception_handler(RequestValidationError)
    async def _validation_handler(req: FRequest, exc: RequestValidationError):
        """For POST /scratchpad, return 400 with a clear message; otherwise preserve 422."""
        raw_errors = getattr(exc, "errors", []) or []
        errors = raw_errors() if callable(raw_errors) else (raw_errors if isinstance(raw_errors, list) else [])
        if req.method == "POST" and (req.url.path or "").rstrip("/").endswith("scratchpad"):
            msg = "Missing or invalid request body. Include title and body (and optionally thread_id)."
            if errors and isinstance(errors[0], dict):
                err_msg = (errors[0].get("msg") or "").strip()
                if err_msg:
                    msg = err_msg
            return JSONResponse(status_code=400, content={"error": msg})
        return JSONResponse(status_code=422, content={"detail": errors})

    @fastapi_app.exception_handler(Exception)
    async def _generic_exception_handler(req: FRequest, exc: Exception):
        """Return JSON for 500s; log detail server-side only (no internal leaks to clients)."""
        request_id = _request_id_ctx.get("")
        try:
            _log.error(
                "Unhandled exception on %s %s (request_id=%s): %s",
                req.method,
                req.url.path,
                request_id or "-",
                exc,
                exc_info=True,
            )
        except Exception:
            print(
                f"[mugi_backend] failed to log unhandled exception on {req.method} {req.url.path}: {exc!r}",
                file=sys.stderr,
                flush=True,
            )
        payload: dict = {"error": "internal server error"}
        if request_id:
            payload["request_id"] = request_id
        return JSONResponse(status_code=500, content=payload)

    @fastapi_app.get("/chat/events")
    async def chat_events_sse(thread_id: str = FQuery("", alias="thread_id")):
        tid = (thread_id or "").strip() or delegate._current_thread_id
        if not _thread_id_known_to_client(delegate, tid):
            return JSONResponse(content={"error": "unknown_thread"}, status_code=403)
        sub_queue = chat_bus.subscribe(tid)

        async def _gen():
            try:
                while True:
                    try:
                        js = await asyncio.wait_for(sub_queue.get(), timeout=15.0)
                    except asyncio.TimeoutError:
                        yield ": heartbeat\n\n"
                        continue
                    yield f"data: {json.dumps({'js': js})}\n\n"
            except asyncio.CancelledError:
                return
            finally:
                chat_bus.unsubscribe(tid, sub_queue)

        return StreamingResponse(
            _gen(),
            media_type="text/event-stream",
            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
        )

    @fastapi_app.post("/set_thread")
    async def set_thread(req: FRequest):
        try:
            body = await req.json()
        except Exception:
            body = {}
        tid = (body.get("thread_id") or "").strip()
        if tid:
            delegate._current_thread_id = tid
        return JSONResponse(content={"ok": True, "thread_id": delegate._current_thread_id})

    @fastapi_app.get("/conversations")
    async def list_conversations(include_archived: bool = FQuery(False, alias="include_archived")):
        def _meta_with_defaults(meta: dict | None) -> dict:
            m = dict(meta or {})
            m.setdefault("folder_id", None)
            m.setdefault("pinned", False)
            m.setdefault("archived", False)
            m.setdefault("trashed", False)
            m.setdefault("sort_order", 0)
            return m
        try:
            from chat_storage import is_branch
            branch_ids = set()
            # Collect branch thread_ids to exclude from roots list
            for tid in (delegate._conversation_meta or {}):
                if is_branch(tid):
                    branch_ids.add(tid)
        except Exception:
            branch_ids = set()
        convos = []
        for tid, meta in (delegate._conversation_meta or {}).items():
            if tid in branch_ids:
                continue
            if not thread_id_allowed_in_main_conversation_sidebar(tid):
                continue
            meta = _meta_with_defaults(meta)
            if bool(meta.get("trashed")):
                continue
            if not include_archived and bool(meta.get("archived")):
                continue
            convos.append({
                "thread_id": tid,
                "title": meta.get("title", "Untitled"),
                "created_at": meta.get("created_at", ""),
                "folder_id": meta.get("folder_id"),
                "pinned": bool(meta.get("pinned")),
                "archived": bool(meta.get("archived")),
                "sort_order": int(meta.get("sort_order", 0) or 0),
            })
        # Keep explicit grouping/order fields stable, but show newest conversations first
        # within each group (matching pre-folder behavior).
        convos.sort(key=lambda c: c.get("created_at", ""), reverse=True)
        convos.sort(
            key=lambda c: (
                c.get("folder_id") or "",
                0 if c.get("pinned") else 1,
                int(c.get("sort_order", 0) or 0),
            ),
            reverse=False,
        )
        # Native clients: HeadlessDelegate creates a fresh root thread on every backend process
        # start; they must be able to override stale UI-side `last selected` persistence with
        # this server authority without a second request.
        cur = (getattr(delegate, "_current_thread_id", None) or "").strip()
        headers = {"X-Mugi-Current-Thread-Id": cur} if cur else {}
        return JSONResponse(content=convos, headers=headers)

    @fastapi_app.get("/conversations/trashed")
    async def list_trashed_conversations():
        def _meta_with_defaults(meta: dict | None) -> dict:
            m = dict(meta or {})
            m.setdefault("folder_id", None)
            m.setdefault("pinned", False)
            m.setdefault("archived", False)
            m.setdefault("trashed", False)
            m.setdefault("sort_order", 0)
            return m
        try:
            from chat_storage import is_branch
            branch_ids = set()
            for tid in (delegate._conversation_meta or {}):
                if is_branch(tid):
                    branch_ids.add(tid)
        except Exception:
            branch_ids = set()
        convos = []
        for tid, meta in (delegate._conversation_meta or {}).items():
            if tid in branch_ids:
                continue
            if not thread_id_allowed_in_main_conversation_sidebar(tid):
                continue
            meta = _meta_with_defaults(meta)
            if not bool(meta.get("trashed")):
                continue
            convos.append({
                "thread_id": tid,
                "title": meta.get("title", "Untitled"),
                "created_at": meta.get("created_at", ""),
                "folder_id": meta.get("folder_id"),
                "pinned": bool(meta.get("pinned")),
                "archived": bool(meta.get("archived")),
                "sort_order": int(meta.get("sort_order", 0) or 0),
                "trashed": True,
            })
        convos.sort(key=lambda c: c.get("created_at", ""), reverse=True)
        convos.sort(
            key=lambda c: (
                c.get("folder_id") or "",
                0 if c.get("pinned") else 1,
                int(c.get("sort_order", 0) or 0),
            ),
            reverse=False,
        )
        cur = (getattr(delegate, "_current_thread_id", None) or "").strip()
        headers = {"X-Mugi-Current-Thread-Id": cur} if cur else {}
        return JSONResponse(content=convos, headers=headers)

    @fastapi_app.get("/conversations/folders")
    async def list_folders():
        return JSONResponse(content=_delegate_sidebar_folders(delegate))

    @fastapi_app.post("/conversations/folders")
    async def create_folder(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        name = (body.get("name") or "").strip()
        if not name:
            return JSONResponse(content={"error": "name required"}, status_code=400)
        def _mutate(data: dict) -> dict:
            folders = data.get("folders") or []
            if not isinstance(folders, list):
                folders = []
            normalized = [f for f in folders if isinstance(f, dict)]
            normalized.sort(key=lambda f: int(f.get("sort_order", 0) or 0))
            for idx, folder in enumerate(normalized):
                folder["sort_order"] = idx + 1
            new_folder = {"id": str(uuid.uuid4()), "name": name, "sort_order": 0}
            normalized.append(new_folder)
            data["folders"] = normalized
            return new_folder

        new_folder = _update_chats_db(_mutate)
        _refresh_delegate_folders(delegate)
        _schedule_save_chats_db(delegate)
        return JSONResponse(content=new_folder)

    @fastapi_app.post("/conversations/folders/reorder")
    async def reorder_folders(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        new_order = body.get("folder_ids", [])
        if not isinstance(new_order, list):
            return JSONResponse(content={"error": "invalid"}, status_code=400)

        def _mutate(data: dict) -> None:
            folders = data.get("folders") or []
            if not isinstance(folders, list):
                folders = []
            by_id = {
                str(folder.get("id") or ""): dict(folder)
                for folder in folders
                if isinstance(folder, dict) and str(folder.get("id") or "")
            }
            if not by_id:
                return
            seen = set()
            ordered: list[dict] = []
            for fid in new_order:
                key = str(fid or "").strip()
                if not key or key in seen or key not in by_id:
                    continue
                seen.add(key)
                ordered.append(by_id[key])
            for fid, folder in by_id.items():
                if fid in seen:
                    continue
                ordered.append(folder)
            for idx, folder in enumerate(ordered):
                folder["sort_order"] = idx
            data["folders"] = ordered

        _update_chats_db(_mutate)
        _refresh_delegate_folders(delegate)
        _schedule_save_chats_db(delegate)
        return JSONResponse(content={"ok": True})

    @fastapi_app.patch("/conversations/folders/{folder_id}")
    async def rename_folder(folder_id: str, request: FRequest):
        fid = (folder_id or "").strip()
        if not fid:
            return JSONResponse(content={"error": "folder_id required"}, status_code=400)
        try:
            body = await request.json()
        except Exception:
            body = {}
        name = str(body.get("name") or "").strip()
        if not name:
            return JSONResponse(content={"error": "name required"}, status_code=400)

        def _mutate(data: dict) -> dict | None:
            folders = data.get("folders") or []
            if not isinstance(folders, list):
                folders = []
            updated = None
            for idx, folder in enumerate(folders):
                if not isinstance(folder, dict):
                    continue
                if str(folder.get("id") or "") != fid:
                    continue
                new_folder = dict(folder)
                new_folder["name"] = name
                folders[idx] = new_folder
                updated = new_folder
                break
            if updated is not None:
                data["folders"] = folders
            return updated

        updated = _update_chats_db(_mutate)
        if updated is None:
            return JSONResponse(content={"error": "not found"}, status_code=404)
        _refresh_delegate_folders(delegate)
        _schedule_save_chats_db(delegate)
        return JSONResponse(content=updated)

    @fastapi_app.delete("/conversations/folders/{folder_id}")
    async def delete_folder(folder_id: str):
        fid = (folder_id or "").strip()
        if not fid:
            return JSONResponse(content={"error": "folder_id required"}, status_code=400)

        def _mutate(data: dict) -> bool:
            folders = data.get("folders") or []
            if not isinstance(folders, list):
                folders = []
            kept = [f for f in folders if isinstance(f, dict) and str(f.get("id") or "") != fid]
            if len(kept) == len(folders):
                return False
            data["folders"] = kept
            return True

        deleted = _update_chats_db(_mutate)
        if not deleted:
            return JSONResponse(content={"error": "not found"}, status_code=404)
        _refresh_delegate_folders(delegate)

        # Unfile conversations assigned to this folder.
        for tid, meta in (delegate._conversation_meta or {}).items():
            if not isinstance(meta, dict):
                continue
            if str(meta.get("folder_id") or "") == fid:
                meta["folder_id"] = None
                delegate._conversation_meta[tid] = meta

        _schedule_save_chats_db(delegate)
        return JSONResponse(content={"ok": True})

    @fastapi_app.patch("/conversations/{thread_id}/organize")
    async def organize_conversation(thread_id: str, request: FRequest):
        tid = thread_id.strip()
        if not tid:
            return JSONResponse(content={"error": "not found"}, status_code=404)
        if tid not in (delegate._conversation_meta or {}):
            # Quick Panel and other native clients can send to ad-hoc thread IDs
            # before a sidebar conversation record exists. Auto-register so
            # organizing into folders can succeed deterministically.
            delegate._conversation_meta[tid] = {
                "title": _pretty_timestamp(),
                "created_at": datetime.now().isoformat(),
                "saved_path": None,
                "folder_id": None,
                "pinned": False,
                "archived": False,
                "trashed": False,
                "sort_order": 0,
            }
            tab = delegate._tabs[0] if delegate._tabs else None
            if tab is not None:
                tids = tab.setdefault("thread_ids", [])
                if tid not in tids:
                    tids.insert(0, tid)
        try:
            body = await request.json()
        except Exception:
            body = {}
        meta = dict(delegate._conversation_meta.get(tid) or {})
        if "folder_id" in body:
            folder_id = body.get("folder_id")
            folder_val = str(folder_id).strip() if folder_id is not None else ""
            meta["folder_id"] = folder_val or None
        if "pinned" in body:
            meta["pinned"] = bool(body.get("pinned"))
        if "archived" in body:
            meta["archived"] = bool(body.get("archived"))
        if "sort_order" in body:
            try:
                meta["sort_order"] = int(body.get("sort_order"))
            except (TypeError, ValueError):
                _log.debug("PATCH conversation meta: invalid sort_order for %s", tid)
        delegate._conversation_meta[tid] = meta
        _schedule_save_chats_db(delegate)
        # Sidebar: filing from WK dashboard / Quick Panel should refresh without a manual reload.
        try:
            delegate._push_event({"type": "conversations_changed"}, tid)
            delegate._structured_bus.emit("__sidebar__", json.dumps({"type": "conversations_changed"}))
        except Exception:
            _log.debug("Failed to notify sidebar after conversation meta update", exc_info=True)
        return JSONResponse(content={"ok": True, "meta": meta})

    from mugi_backend_conversation_routes import register_conversation_routes

    register_conversation_routes(
        fastapi_app,
        delegate,
        _schedule_save_chats_db,
        _save_chats_db,
        _pretty_timestamp,
        _log,
    )

    # ── Phase 2: Preferences ──────────────────────────────────────────────

    def _get_tts_voice_data():
        """Return TTS capability data for the Swift app. Safe when voice_output is missing."""
        try:
            import voice_output
            apple_voices = [{"id": i, "name": n} for i, n in voice_output.get_english_voices()]
            builtin = list(voice_output.SUPERTONIC_BUILTIN_VOICES)
            custom = [vid for vid, _ in voice_output.list_supertonic_custom_voices()]
            supertonic_voices = builtin + custom
            chatterbox_voices = [{"id": vid, "name": name} for vid, name in voice_output.list_chatterbox_voices()]
            return {
                "supertonic_available": voice_output.supertonic_available(),
                "apple_voices": apple_voices,
                "supertonic_voices": supertonic_voices,
                "chatterbox_installed": voice_output.chatterbox_installed(),
                "chatterbox_voices": chatterbox_voices,
            }
        except Exception:
            return {
                "supertonic_available": False,
                "apple_voices": [],
                "supertonic_voices": [],
                "chatterbox_installed": False,
                "chatterbox_voices": [],
            }

    def _resolve_chatterbox_python() -> str:
        from persistence import MUGI_HOMEBREW_PREFIX, STANDALONE_PYTHON311_DIR, STANDALONE_PYTHON312_DIR

        # Prefer 3.11; fall back to 3.12 (Kraken/bootstrap often installs only python-3.12 under ~/.mugi).
        candidates = [
            os.path.join(STANDALONE_PYTHON311_DIR, "bin", "python3.11"),
            os.path.join(STANDALONE_PYTHON311_DIR, "bin", "python3"),
            os.path.join(MUGI_HOMEBREW_PREFIX, "bin", "python3.11"),
            os.path.join(STANDALONE_PYTHON312_DIR, "bin", "python3.12"),
            os.path.join(STANDALONE_PYTHON312_DIR, "bin", "python3"),
            os.path.join(MUGI_HOMEBREW_PREFIX, "bin", "python3.12"),
            os.path.join(MUGI_HOMEBREW_PREFIX, "bin", "python3"),
        ]
        for candidate in candidates:
            if os.path.isfile(candidate):
                return candidate
        raise RuntimeError(
            "No suitable Python (3.11 or 3.12) found under ~/.mugi. "
            "Install python@3.11 or python@3.12 via Mugi Homebrew (or run setup so a standalone runtime is present)."
        )

    def _resolve_chatterbox_requirements_txt() -> str | None:
        """Pip constraints file: next to this module (bundle), cwd, or MUGI_REPO_ROOT (dev)."""
        name = "requirements-chatterbox.txt"
        here = os.path.dirname(os.path.abspath(__file__))
        candidates = [os.path.join(here, name), os.path.join(os.getcwd(), name)]
        root = (os.environ.get("MUGI_REPO_ROOT") or "").strip()
        if root:
            candidates.append(os.path.join(os.path.abspath(os.path.expanduser(root)), name))
        seen: set[str] = set()
        for p in candidates:
            ap = os.path.abspath(p)
            if ap in seen:
                continue
            seen.add(ap)
            if os.path.isfile(ap):
                return ap
        return None

    def _install_chatterbox_venv(*, reinstall: bool = False) -> tuple[bool, str]:
        from persistence import CHATTERBOX_HF_HOME, CHATTERBOX_VENV_DIR, MUGI_LOGS_DIR

        req_path = _resolve_chatterbox_requirements_txt()
        if not req_path:
            bundle_hint = os.path.join(os.path.dirname(os.path.abspath(__file__)), "requirements-chatterbox.txt")
            return (
                False,
                f"Missing {bundle_hint}. For app builds, rebuild so scripts/copy_backend_bundle.sh stages "
                "requirements-chatterbox.txt into Resources/Backend.",
            )
        log_path = os.path.join(MUGI_LOGS_DIR, "chatterbox_install.log")
        venv_abs = os.path.abspath(CHATTERBOX_VENV_DIR)
        mugi_abs = os.path.abspath(os.path.expanduser("~/.mugi"))
        if not venv_abs.startswith(mugi_abs + os.sep):
            return False, "Refusing to install outside ~/.mugi"
        try:
            os.makedirs(MUGI_LOGS_DIR, exist_ok=True)
            os.makedirs(os.path.dirname(CHATTERBOX_HF_HOME), exist_ok=True)
            if os.path.isfile(os.path.join(CHATTERBOX_VENV_DIR, "bin", "python")) and not reinstall:
                return True, "Chatterbox is already installed"
            if reinstall and os.path.isdir(CHATTERBOX_VENV_DIR):
                import shutil

                shutil.rmtree(CHATTERBOX_VENV_DIR, ignore_errors=True)
            python = _resolve_chatterbox_python()
            subprocess.run([python, "-m", "venv", CHATTERBOX_VENV_DIR], check=True, capture_output=True, text=True, timeout=300)
            vpy = os.path.join(CHATTERBOX_VENV_DIR, "bin", "python")
            env = {
                "PATH": os.environ.get("PATH", ""),
                "HOME": os.environ.get("HOME", os.path.expanduser("~")),
                "PYTHONNOUSERSITE": "1",
                "HF_HOME": CHATTERBOX_HF_HOME,
            }
            for key in ("HF_TOKEN", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "PYTORCH_MPS_HIGH_WATERMARK_RATIO"):
                val = os.environ.get(key)
                if val:
                    env[key] = val
            step1 = subprocess.run(
                [vpy, "-m", "pip", "install", "--upgrade", "pip", "wheel", "setuptools>=69,<82"],
                capture_output=True,
                text=True,
                timeout=600,
                env=env,
            )
            step2 = subprocess.run(
                [vpy, "-m", "pip", "install", "-r", req_path],
                capture_output=True,
                text=True,
                timeout=3600,
                env=env,
            )
            with open(log_path, "a", encoding="utf-8") as logf:
                logf.write("\n=== chatterbox install run ===\n")
                logf.write(step1.stdout or "")
                logf.write(step1.stderr or "")
                logf.write(step2.stdout or "")
                logf.write(step2.stderr or "")
            if step1.returncode != 0:
                return False, "Failed to bootstrap pip in chatterbox venv. See ~/.mugi/logs/chatterbox_install.log"
            if step2.returncode != 0:
                return False, "Failed to install Chatterbox deps. See ~/.mugi/logs/chatterbox_install.log"
            return True, "Chatterbox installed"
        except subprocess.TimeoutExpired:
            return False, "Chatterbox installation timed out"
        except Exception as exc:
            try:
                import shutil

                if os.path.isdir(CHATTERBOX_VENV_DIR):
                    shutil.rmtree(CHATTERBOX_VENV_DIR, ignore_errors=True)
            except OSError:
                _log.debug("Chatterbox venv cleanup after install failure failed", exc_info=True)
            return False, str(exc)

    @fastapi_app.post("/setup/complete")
    async def setup_complete():
        """Mark the first-run setup wizard as complete. Sets setup.wizard_done = true in mugi_config.toml."""
        try:
            prefs = _load_app_prefs()
            if bool(prefs.get("first_run_validation_required")) and not _first_run_ready_for_chat(prefs):
                return JSONResponse(
                    content={"ok": False, "error": "First-run validation incomplete: model chat verification not ready."},
                    status_code=409,
                )
            from persistence import set_setup_wizard_done
            set_setup_wizard_done(True)
            return JSONResponse(content={"ok": True})
        except Exception as e:
            return JSONResponse(content={"error": str(e)}, status_code=500)

    @fastapi_app.get("/preferences")
    async def get_preferences():
        prefs = _load_app_prefs()
        # Ensure TTS prefs exist so Swift gets a consistent shape (fix: GET was returning nil so wrong engine was used).
        prefs.setdefault("speak_agent_replies", False)
        prefs.setdefault("use_supertonic_tts", True)  # Supertonic is the default; when key missing, use it so selection is respected.
        if not str(prefs.get("tts_engine") or "").strip():
            prefs["tts_engine"] = "supertonic" if bool(prefs.get("use_supertonic_tts", True)) else "apple"
        prefs.setdefault("supertonic_voice", "M1")
        prefs.setdefault("supertonic_speed", 1.05)
        prefs.setdefault("supertonic_total_steps", 5)
        prefs.setdefault("supertonic_lang", "en")
        prefs.setdefault("chatterbox_variant", "turbo")
        prefs.setdefault("chatterbox_voice", "")
        prefs.setdefault("chatterbox_language_id", "en")
        prefs.setdefault("chatterbox_allow_tags", False)
        prefs.setdefault("chatterbox_prewarm_on_startup", True)
        prefs.setdefault("chatterbox_keepalive_seconds", 300)
        prefs.setdefault("voice_duplex_enabled", False)
        prefs.setdefault("voice_duplex_barge_in_enabled", True)
        prefs.setdefault("voice_duplex_partial_transcripts_enabled", True)
        prefs.setdefault("voice_duplex_vad_silence_ms", 700)
        prefs.setdefault("voice_duplex_max_utterance_ms", 12000)
        prefs.setdefault("voice_duplex_forced_finalize_ms", 1500)
        prefs.setdefault("voice_duplex_punctuation_stabilization_ms", 350)
        prefs.setdefault("voice_conversation_mode_enabled", False)
        prefs.setdefault("voice_conversation_beta_badge", True)
        prefs.setdefault("voice_conversation_idle_timeout_s", 900)
        prefs.setdefault("voice_conversation_max_session_s", 3600)
        prefs.setdefault("plan_mode_enabled", False)
        prefs.setdefault("mcp_enabled", True)
        prefs.setdefault("mcp_schemas_in_tiered_parent", True)
        prefs.setdefault("memory_retrieval_profile", "conservative")
        prefs.setdefault("memory_semantic_strictness", "warn_promotion")
        prefs.setdefault("memory_semantic_search_enabled", True)
        # Appearance theme packs (SwiftUI). Value is a pack id (folder name)
        # validated in MugiApp/Mugi/Theming/ThemePackLoader.swift; the backend
        # stores whatever the client sends, so no whitelist is enforced here.
        prefs.setdefault("ui_theme_pack_light", "default")
        prefs.setdefault("ui_theme_pack_dark", "default")
        prefs.setdefault("dashboard_tile_order", [])
        prefs.setdefault("dashboard_tile_layouts", {})
        try:
            from agent_loop_config import (
                DEFAULT_AGENT_GUARD_RETRIES_TOTAL,
                DEFAULT_AGENT_MAX_TOOL_ROUNDS,
                DEFAULT_TASK_PLAN_CONTINUATION_RETRIES,
                DEFAULT_TASK_PLAN_GUARD_RETRIES_TOTAL,
            )

            prefs.setdefault("agent_max_tool_rounds", DEFAULT_AGENT_MAX_TOOL_ROUNDS)
            prefs.setdefault("agent_guard_retries_total", DEFAULT_AGENT_GUARD_RETRIES_TOTAL)
            prefs.setdefault("task_plan_guard_retries_total", DEFAULT_TASK_PLAN_GUARD_RETRIES_TOTAL)
            prefs.setdefault("task_plan_continuation_retries", DEFAULT_TASK_PLAN_CONTINUATION_RETRIES)
        except Exception:
            prefs.setdefault("agent_max_tool_rounds", 35)
            prefs.setdefault("agent_guard_retries_total", 8)
            prefs.setdefault("task_plan_guard_retries_total", 6)
            prefs.setdefault("task_plan_continuation_retries", 3)
        try:
            from core import get_config, get_search_config
            config = get_config()
            prefs["_config_debug_enabled"] = config.get("debug", {}).get("enabled", False)
            prefs["_config_evolution_enabled"] = config.get("general", {}).get("evolution_enabled", True)
            # Normalized [search] (defaults, port, use_local_docker) so Swift always gets a complete dict.
            prefs["_config_search"] = get_search_config()
            prefs["_config_messaging"] = config.get("messaging", {})
            prefs["_config_heartbeat"] = config.get("heartbeat", {})
            prefs["_config_text_evolution"] = config.get("text_evolution", {})
            prefs["_config_allowed_dirs"] = config.get("paths", {}).get("allowed_dirs", [])
            email_cfg, _email_norm_changed = prepare_email_config_for_response(config)
            prefs["_config_email"] = email_cfg
            prefs["_config_timers"] = config.get("timers", {})
            prefs["_config_outbound_webhooks"] = (config.get("hooks", {}) or {}).get("outbound", {})
            prefs["_config_file_watcher"] = config.get("file_watcher", {})
            prefs["_config_calendar_proximity"] = config.get("calendar_proximity", {})
            prefs["_config_ledger_reflection"] = config.get("ledger_reflection", {})
            from core.config import get_consolidation_config

            prefs["_config_consolidation"] = get_consolidation_config()
            prefs["_config_weather"] = dict(config.get("weather") or {})
            from core.config import (
                get_council_config,
                get_kanban_config,
                get_knowledge_config,
                get_mesh_config,
                get_subagent_config,
                get_utility_config,
            )

            prefs["_config_subagent"] = get_subagent_config()
            prefs["_config_mesh"] = get_mesh_config()
            prefs["_config_utility"] = get_utility_config()
            prefs["_config_kanban"] = get_kanban_config()
            prefs["_config_council"] = get_council_config()
            prefs["_config_knowledge"] = get_knowledge_config()
        except Exception:
            _log.debug("Failed to load config sections for preferences", exc_info=True)
        try:
            tts_data = _get_tts_voice_data()
            prefs["supertonic_available"] = tts_data["supertonic_available"]
            prefs["apple_voices"] = tts_data["apple_voices"]
            prefs["supertonic_voices"] = tts_data["supertonic_voices"]
            prefs["chatterbox_installed"] = tts_data.get("chatterbox_installed", False)
            prefs["chatterbox_voices"] = tts_data.get("chatterbox_voices", [])
        except Exception:
            prefs["supertonic_available"] = False
            prefs["apple_voices"] = []
            prefs["supertonic_voices"] = []
            prefs["chatterbox_installed"] = False
            prefs["chatterbox_voices"] = []
        return JSONResponse(content=prefs)

    @fastapi_app.post("/preferences")
    async def save_preferences(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        if not isinstance(body, dict):
            return JSONResponse(content={"error": "invalid"}, status_code=400)
        config_keys = {}
        app_keys = {}
        for k, v in body.items():
            if k.startswith("_config_"):
                config_keys[k] = v
            else:
                app_keys[k] = v
        if app_keys:
            if "tts_engine" in app_keys:
                engine = str(app_keys.get("tts_engine") or "").strip().lower()
                if engine in ("apple", "supertonic", "chatterbox"):
                    app_keys["tts_engine"] = engine
                    app_keys["use_supertonic_tts"] = engine == "supertonic"
                else:
                    app_keys.pop("tts_engine", None)
            # Normalize backend URLs so "10.0.1.111" is stored as "http://10.0.1.111"
            from agent_loop import _normalize_backend_url
            for key in ("backend_base_url", "last_remote_backend_url"):
                if key in app_keys and isinstance(app_keys[key], str) and app_keys[key].strip():
                    app_keys[key] = _normalize_backend_url(app_keys[key])
            if "dashboard_tile_order" in app_keys:
                raw_dto = app_keys.get("dashboard_tile_order")
                if raw_dto is None:
                    app_keys["dashboard_tile_order"] = []
                else:
                    coerced_order = _normalize_dashboard_tile_order(raw_dto)
                    if coerced_order is None:
                        return JSONResponse(
                            content={
                                "error": "dashboard_tile_order must be a JSON array of tile slugs (strings starting with dash_)",
                            },
                            status_code=400,
                        )
                    app_keys["dashboard_tile_order"] = coerced_order
            if "dashboard_tile_layouts" in app_keys:
                raw_l = app_keys.get("dashboard_tile_layouts")
                if raw_l is None:
                    app_keys["dashboard_tile_layouts"] = {}
                else:
                    coerced_l = _normalize_dashboard_tile_layouts(raw_l)
                    if coerced_l is None:
                        return JSONResponse(
                            content={
                                "error": "dashboard_tile_layouts must be a JSON object of slug → layout (compact, wide, feature, full, or hero for wide).",
                            },
                            status_code=400,
                        )
                    app_keys["dashboard_tile_layouts"] = coerced_l

            keys_to_merge = dict(app_keys)

            def _merge_app_prefs(prefs: dict) -> None:
                changed_model_runtime = any(
                    k in keys_to_merge
                    for k in ("mlx_model", "backend_base_url", "llm_context_length", "mlx_enabled")
                )
                if (
                    changed_model_runtime
                    and bool(prefs.get("first_run_validation_required"))
                    and "first_run_ready_for_chat" not in keys_to_merge
                ):
                    keys_to_merge["first_run_ready_for_chat"] = False
                    keys_to_merge["first_run_proactive_welcome_enabled"] = False
                    keys_to_merge["first_run_setup_state"] = "configuring"
                prefs.update(keys_to_merge)

            try:
                from utils.app_prefs_store import mutate_app_prefs

                mutate_app_prefs(_merge_app_prefs)
            except Exception:
                _log.error("POST /preferences failed to persist app_prefs", exc_info=True)
                return JSONResponse(
                    content={"error": "failed to persist preferences; check disk permissions"},
                    status_code=500,
                )
            if "dashboard_tile_order" in app_keys or "dashboard_tile_layouts" in app_keys:
                _clear_dashboard_manifest_cache()
            if "mcp_enabled" in app_keys:
                try:
                    from mcp_bridge import invalidate_discovery_cache

                    invalidate_discovery_cache()
                except Exception:
                    _log.debug(
                        "MCP discovery cache invalidation failed after mcp_enabled pref change",
                        exc_info=True,
                    )
        if config_keys:
            try:
                from core import get_config, save_config
                config = get_config()
                if "_config_debug_enabled" in config_keys:
                    debug_enabled_pref = bool(config_keys["_config_debug_enabled"])
                    config.setdefault("debug", {})["enabled"] = debug_enabled_pref
                    config.setdefault("debug", {})["trace_conversations"] = debug_enabled_pref
                if "_config_evolution_enabled" in config_keys:
                    config.setdefault("general", {})["evolution_enabled"] = bool(config_keys["_config_evolution_enabled"])
                if "_config_search" in config_keys and isinstance(config_keys["_config_search"], dict):
                    incoming = dict(config_keys["_config_search"])
                    if incoming.get("public_fallback_url", "").strip() == "https://searx.be":
                        incoming["public_fallback_url"] = "https://searx.party"
                    config["search"] = {**config.get("search", {}), **incoming}
                if "_config_messaging" in config_keys and isinstance(config_keys["_config_messaging"], dict):
                    incoming = dict(config_keys["_config_messaging"])
                    discord_incoming = (incoming.get("discord") or {})
                    if isinstance(discord_incoming, dict):
                        token = (discord_incoming.get("token") or "").strip()
                        if token:
                            try:
                                from persistence import keychain_set_discord_token
                                keychain_set_discord_token(token)
                            except Exception:
                                _log.debug("Failed to save Discord token to keychain", exc_info=True)
                    discord_merged = {**discord_incoming, "token": ""}
                    incoming = {**incoming, "discord": discord_merged}
                    config["messaging"] = {**config.get("messaging", {}), **incoming}
                if "_config_heartbeat" in config_keys and isinstance(config_keys["_config_heartbeat"], dict):
                    config["heartbeat"] = {**config.get("heartbeat", {}), **config_keys["_config_heartbeat"]}
                if "_config_text_evolution" in config_keys and isinstance(config_keys["_config_text_evolution"], dict):
                    config["text_evolution"] = {**config.get("text_evolution", {}), **config_keys["_config_text_evolution"]}
                if "_config_subagent" in config_keys and isinstance(config_keys["_config_subagent"], dict):
                    incoming_sa = dict(config_keys["_config_subagent"])
                    config["subagent"] = {**config.get("subagent", {}), **incoming_sa}
                if "_config_mesh" in config_keys and isinstance(config_keys["_config_mesh"], dict):
                    incoming_mesh = dict(config_keys["_config_mesh"])
                    config["mesh"] = {**config.get("mesh", {}), **incoming_mesh}
                if "_config_utility" in config_keys and isinstance(config_keys["_config_utility"], dict):
                    incoming_util = dict(config_keys["_config_utility"])
                    config["utility"] = {**config.get("utility", {}), **incoming_util}
                if "_config_kanban" in config_keys and isinstance(config_keys["_config_kanban"], dict):
                    # Write-allowlist a small set of operator-tunable kanban
                    # knobs. Anything else is ignored so the UI can't smuggle
                    # in unknown keys (or undo defaults the SwiftUI form does
                    # not surface). Concurrency is a nested dict; clamp ints.
                    incoming_kb = dict(config_keys["_config_kanban"])
                    prev_kb = dict(config.get("kanban") or {})
                    bool_keys = ("enabled", "mesh_eligible_agent_creation", "surface_mesh_inbound")
                    for bk in bool_keys:
                        if bk in incoming_kb:
                            prev_kb[bk] = bool(incoming_kb[bk])
                    int_keys_clamps = {
                        "tick_seconds": (1, 600),
                        "default_claim_ttl_seconds": (30, 24 * 3600),
                        "default_deadline_seconds": (0, 7 * 24 * 3600),
                        "failure_limit": (1, 50),
                        "retention_days": (1, 365),
                        "max_runs_per_task": (1, 1000),
                        "max_active_tasks_per_tenant": (1, 10_000),
                        "max_active_tasks_global": (1, 100_000),
                        "create_rate_per_minute_per_tenant": (1, 10_000),
                    }
                    for ik, (lo, hi) in int_keys_clamps.items():
                        if ik in incoming_kb:
                            try:
                                prev_kb[ik] = max(lo, min(hi, int(incoming_kb[ik])))
                            except (TypeError, ValueError):
                                continue
                    if "concurrency" in incoming_kb and isinstance(incoming_kb["concurrency"], dict):
                        prev_conc = dict(prev_kb.get("concurrency") or {})
                        for prof, val in incoming_kb["concurrency"].items():
                            try:
                                prev_conc[str(prof)] = max(0, min(64, int(val)))
                            except (TypeError, ValueError):
                                continue
                        prev_kb["concurrency"] = prev_conc
                    config["kanban"] = prev_kb
                if "_config_council" in config_keys and isinstance(config_keys["_config_council"], dict):
                    from utils.council_triggers import COUNCIL_MODES

                    incoming_cn = dict(config_keys["_config_council"])
                    prev_cn = dict(config.get("council") or {})
                    if "mode" in incoming_cn:
                        mode = str(incoming_cn.get("mode") or "off").strip().lower()
                        if mode not in COUNCIL_MODES:
                            mode = "off"
                        prev_cn["mode"] = mode
                        prev_cn["enabled"] = mode != "off"
                    if "show_transcript_in_chat" in incoming_cn:
                        prev_cn["show_transcript_in_chat"] = bool(
                            incoming_cn.get("show_transcript_in_chat")
                        )
                    int_clamps = {
                        "daily_council_cap": (0, 100),
                        "hourly_council_cap": (0, 50),
                        "council_batch_deadline_seconds": (60, 7 * 24 * 3600),
                    }
                    for key, (lo, hi) in int_clamps.items():
                        if key not in incoming_cn:
                            continue
                        try:
                            prev_cn[key] = max(lo, min(hi, int(incoming_cn[key])))
                        except (TypeError, ValueError):
                            continue
                    config["council"] = prev_cn
                if "_config_knowledge" in config_keys and isinstance(config_keys["_config_knowledge"], dict):
                    incoming_kn = dict(config_keys["_config_knowledge"])
                    prev_kn = dict(config.get("knowledge") or {})
                    bool_keys = (
                        "automation_enabled",
                        "watch_folders_enabled",
                        "heartbeat_stale_refresh_enabled",
                        "auto_refresh_stale",
                        "watch_ignore_hidden",
                    )
                    for bk in bool_keys:
                        if bk in incoming_kn:
                            prev_kn[bk] = bool(incoming_kn[bk])
                    int_keys_clamps = {
                        "heartbeat_interval_minutes": (30, 7 * 24 * 60),
                        "max_corpora_per_run": (1, 50),
                        "max_concurrent_checks": (1, 8),
                        "watch_debounce_seconds": (5, 3600),
                    }
                    for ik, (lo, hi) in int_keys_clamps.items():
                        if ik in incoming_kn:
                            try:
                                prev_kn[ik] = max(lo, min(hi, int(incoming_kn[ik])))
                            except (TypeError, ValueError):
                                continue
                    config["knowledge"] = prev_kn
                if "_config_timers" in config_keys and isinstance(config_keys["_config_timers"], dict):
                    config["timers"] = {**config.get("timers", {}), **config_keys["_config_timers"]}
                if "_config_outbound_webhooks" in config_keys and isinstance(config_keys["_config_outbound_webhooks"], dict):
                    hooks = dict(config.get("hooks", {}) or {})
                    hooks["outbound"] = {**(hooks.get("outbound") or {}), **config_keys["_config_outbound_webhooks"]}
                    config["hooks"] = hooks
                if "_config_file_watcher" in config_keys and isinstance(config_keys["_config_file_watcher"], dict):
                    config["file_watcher"] = {**config.get("file_watcher", {}), **config_keys["_config_file_watcher"]}
                if "_config_calendar_proximity" in config_keys and isinstance(config_keys["_config_calendar_proximity"], dict):
                    config["calendar_proximity"] = {**config.get("calendar_proximity", {}), **config_keys["_config_calendar_proximity"]}
                if "_config_ledger_reflection" in config_keys and isinstance(config_keys["_config_ledger_reflection"], dict):
                    config["ledger_reflection"] = {**config.get("ledger_reflection", {}), **config_keys["_config_ledger_reflection"]}
                if "_config_consolidation" in config_keys and isinstance(config_keys["_config_consolidation"], dict):
                    allowed_consolidation_keys = {
                        "personal_noticing_enabled",
                        "personal_pattern_observations_enabled",
                        "connections_journal_max_bytes",
                        "connections_preload_max_chars",
                    }
                    inc = {
                        k: v
                        for k, v in config_keys["_config_consolidation"].items()
                        if k in allowed_consolidation_keys
                    }
                    config["consolidation"] = {**config.get("consolidation", {}), **inc}
                if "_config_weather" in config_keys and isinstance(config_keys["_config_weather"], dict):
                    incw = dict(config_keys["_config_weather"])
                    wprev = dict(config.get("weather") or {})
                    if "default_location" in incw and isinstance(incw.get("default_location"), str):
                        wprev["default_location"] = incw["default_location"].strip().replace("\n", " ").replace("\r", "")[:200]
                    for k in ("default_country", "temperature_unit", "wind_speed_unit"):
                        if k in incw and isinstance(incw.get(k), str):
                            wprev[k] = incw[k].strip()[:32]
                    config["weather"] = wprev
                if "_config_allowed_dirs" in config_keys and isinstance(config_keys["_config_allowed_dirs"], list):
                    from utils.path_safety import is_path_safe_for_user_add
                    validated = []
                    for d in config_keys["_config_allowed_dirs"]:
                        if isinstance(d, str) and d.strip():
                            safe, _ = is_path_safe_for_user_add(d.strip())
                            if safe:
                                validated.append(d.strip())
                    config.setdefault("paths", {})["allowed_dirs"] = validated
                if "_config_email" in config_keys and isinstance(config_keys["_config_email"], dict):
                    normalized, password_updates, err, code = parse_email_config_payload(config_keys["_config_email"])
                    if err:
                        return JSONResponse(content={"ok": False, "error": err}, status_code=code)
                    assert normalized is not None
                    assert password_updates is not None
                    previous_email, _ = prepare_email_config_for_response(config)
                    previous_ids = {
                        str(a.get("id") or "")
                        for a in (previous_email.get("accounts") or [])
                        if str(a.get("id") or "")
                    }
                    new_ids = {
                        str(a.get("id") or "")
                        for a in (normalized.get("accounts") or [])
                        if str(a.get("id") or "")
                    }
                    config["email"] = normalized

                    try:
                        from persistence import (
                            keychain_delete_imap_password_for_account,
                            keychain_get_imap_password,
                            keychain_get_imap_password_for_account,
                            keychain_set_imap_password_for_account,
                        )
                        for account_id, password in password_updates.items():
                            keychain_set_imap_password_for_account(account_id, password)
                        # One-time transition from legacy single-password slot.
                        legacy_password = (keychain_get_imap_password() or "").strip()
                        default_id = str(normalized.get("default_account_id") or "").strip()
                        if legacy_password and default_id and not keychain_get_imap_password_for_account(default_id):
                            keychain_set_imap_password_for_account(default_id, legacy_password)
                        # Delete account-scoped secrets for removed accounts.
                        for removed_id in (previous_ids - new_ids):
                            keychain_delete_imap_password_for_account(removed_id)
                    except Exception:
                        _log.debug("Failed to update IMAP keychain entries", exc_info=True)
                save_config(config)
                apply_debug_logging_level(config)
            except Exception as exc:
                print(f"[mugi_backend] save config error: {exc}", file=sys.stderr, flush=True)
        return JSONResponse(content={"ok": True})

    # Feeds + prefs satellites: register after _gui_brew_install_lock (see below).

    # -------------------------------------------------------------------------
    # Pattern Inbox: user captures, reviewable connections, and action cards
    # -------------------------------------------------------------------------

    _CAPTURE_BODY_MAX_BYTES = 128_000
    _CAPTURE_BATCH_MAX = 50

    async def _capture_json_body(request: FRequest) -> tuple[dict[str, Any], JSONResponse | None]:
        raw = await request.body()
        if len(raw) > _CAPTURE_BODY_MAX_BYTES:
            return {}, JSONResponse(content={"ok": False, "error": "request too large"}, status_code=413)
        if not raw:
            return {}, None
        try:
            body = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw or "{}"))
        except Exception:
            return {}, JSONResponse(content={"ok": False, "error": "invalid JSON body"}, status_code=400)
        if not isinstance(body, dict):
            return {}, JSONResponse(content={"ok": False, "error": "invalid JSON body"}, status_code=400)
        return body, None

    def _capture_ids_from_body(body: dict[str, Any]) -> list[str]:
        ids = body.get("ids") or body.get("capture_ids") or []
        if isinstance(ids, str):
            ids = [ids]
        if not isinstance(ids, list):
            return []
        out: list[str] = []
        for value in ids:
            s = str(value or "").strip()
            if s and s not in out:
                out.append(s)
            if len(out) >= _CAPTURE_BATCH_MAX:
                break
        return out

    def _capture_tags_from_body(body: dict[str, Any]) -> list[str]:
        tags = body.get("tags") or body.get("add_tags") or []
        if isinstance(tags, str):
            tags = [tags]
        if not isinstance(tags, list):
            return []
        out: list[str] = []
        for value in tags:
            s = str(value or "").strip().lstrip("#")[:48]
            if s and s not in out:
                out.append(s)
            if len(out) >= 24:
                break
        return out

    def _capture_thread_from_topic(title: str) -> tuple[str | None, str | None, str | None]:
        new_tid = str(uuid.uuid4())
        created_at = datetime.now().isoformat()
        title_raw = (title or "Pattern Inbox").strip()[:60]
        title_final = title_raw.rsplit(" ", 1)[0] if len(title_raw) > 45 and " " in title_raw else title_raw
        tab = delegate._tabs[0] if delegate._tabs else None
        if tab is None:
            return None, None, None
        tab.setdefault("thread_ids", []).insert(0, new_tid)
        tab["current_thread_id"] = new_tid
        delegate._conversation_meta[new_tid] = {
            "title": title_final or "Pattern Inbox",
            "created_at": created_at,
            "saved_path": None,
            "folder_id": None,
            "pinned": False,
            "archived": False,
            "trashed": False,
            "sort_order": 0,
        }
        delegate._current_thread_id = new_tid
        _schedule_save_chats_db(delegate)
        delegate._push_event({"type": "conversations_changed"}, new_tid)
        delegate._structured_bus.emit("__sidebar__", json.dumps({"type": "conversations_changed"}))
        return new_tid, title_final or "Pattern Inbox", created_at

    @fastapi_app.get("/capture_projects")
    async def capture_projects_list(request: FRequest):
        try:
            from chat_storage import list_capture_projects
            include_archived = request.query_params.get("include_archived", "").lower() in ("1", "true", "yes")
            projects = await asyncio.to_thread(list_capture_projects, include_archived=include_archived, limit=int(request.query_params.get("limit") or 200))
            return JSONResponse(content={"ok": True, "projects": projects})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "projects": []}, status_code=500)

    @fastapi_app.post("/capture_projects")
    async def capture_projects_create(request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import create_capture_project
            project = await asyncio.to_thread(
                create_capture_project,
                str(body.get("name") or ""),
                description=str(body.get("description") or ""),
                metadata=body.get("metadata") if isinstance(body.get("metadata"), dict) else {},
            )
            return JSONResponse(content={"ok": True, "project": project, "duplicate": bool(project.get("duplicate"))})
        except ValueError as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.patch("/capture_projects/{project_id}")
    async def capture_projects_update(project_id: str, request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import update_capture_project
            project = await asyncio.to_thread(update_capture_project, project_id, **body)
            if not project:
                return JSONResponse(content={"ok": False, "error": "project not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "project": project})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/capture_projects/reorder")
    async def capture_projects_reorder(request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import reorder_capture_projects

            raw = body.get("project_ids")
            if not isinstance(raw, list):
                return JSONResponse(content={"ok": False, "error": "project_ids array required"}, status_code=400)
            ordered = [str(x) for x in raw]
            await asyncio.to_thread(reorder_capture_projects, ordered)
            return JSONResponse(content={"ok": True})
        except ValueError as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/capture_projects/{project_id}/vault_folders")
    async def capture_projects_vault_folders_create(project_id: str, request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import add_capture_project_vault_folder

            project = await asyncio.to_thread(
                add_capture_project_vault_folder,
                project_id,
                parent_path=str(body.get("parent_path") or ""),
                name=str(body.get("name") or ""),
            )
            if not project:
                return JSONResponse(content={"ok": False, "error": "project not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "project": project})
        except ValueError as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/capture_projects/{project_id}/vault_folders/move")
    async def capture_projects_vault_folders_move(project_id: str, request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import move_capture_project_vault_folder

            project = await asyncio.to_thread(
                move_capture_project_vault_folder,
                project_id,
                from_path=str(body.get("from_path") or ""),
                to_parent_path=str(body.get("to_parent_path") or ""),
            )
            if not project:
                return JSONResponse(content={"ok": False, "error": "project not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "project": project})
        except ValueError as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/capture_projects/{project_id}/vault_folders/delete")
    async def capture_projects_vault_folders_delete(project_id: str, request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import delete_capture_project_vault_folder

            project = await asyncio.to_thread(
                delete_capture_project_vault_folder,
                project_id,
                folder_path=str(body.get("folder_path") or ""),
            )
            if not project:
                return JSONResponse(content={"ok": False, "error": "project not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "project": project})
        except ValueError as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/capture_projects/{project_id}/archive")
    async def capture_projects_archive(project_id: str, request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import archive_capture_project
            archived = body.get("archived", True)
            project = await asyncio.to_thread(archive_capture_project, project_id, archived=bool(archived))
            if not project:
                return JSONResponse(content={"ok": False, "error": "project not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "project": project})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/capture_projects/{project_id}/delete")
    async def capture_projects_delete(project_id: str):
        try:
            from chat_storage import delete_capture_project

            result = await asyncio.to_thread(delete_capture_project, project_id)
            if not result:
                return JSONResponse(content={"ok": False, "error": "project not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "deleted": result})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/capture_projects/{project_id}/summary")
    async def capture_projects_summary(project_id: str):
        try:
            from chat_storage import get_capture_project_summary
            summary = await asyncio.to_thread(get_capture_project_summary, project_id)
            if not summary:
                return JSONResponse(content={"ok": False, "error": "project not found"}, status_code=404)
            return JSONResponse(content={"ok": True, **summary})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/captures")
    async def captures_create(request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        text = str(body.get("text") or body.get("body") or "").strip()
        if not text:
            return JSONResponse(content={"ok": False, "error": "text required"}, status_code=400)
        try:
            from chat_storage import create_capture
            capture = await asyncio.to_thread(
                create_capture,
                text,
                source=str(body.get("source") or "quick_panel"),
                thread_id=str(body.get("thread_id") or ""),
                tags=body.get("tags") if isinstance(body.get("tags"), list) else [],
                attachment_refs=body.get("attachment_refs") if isinstance(body.get("attachment_refs"), list) else [],
                metadata=body.get("metadata") if isinstance(body.get("metadata"), dict) else {},
                project_id=str(body.get("project_id") or ""),
                section=str(body.get("section") or ""),
                title=str(body.get("title") or ""),
            )
            return JSONResponse(content={"ok": True, "capture": capture, "duplicate": bool(capture.get("duplicate"))})
        except ValueError as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/captures")
    async def captures_list(request: FRequest):
        try:
            from chat_storage import _CAPTURE_LIST_MAX_OFFSET, list_captures

            try:
                limit = max(1, min(int(request.query_params.get("limit") or 100), 500))
                offset = max(0, min(int(request.query_params.get("offset") or 0), _CAPTURE_LIST_MAX_OFFSET))
            except (TypeError, ValueError):
                return JSONResponse(content={"ok": False, "error": "invalid limit or offset", "captures": []}, status_code=400)
            status = request.query_params.get("status") or None
            include_deleted = request.query_params.get("include_deleted", "").lower() in ("1", "true", "yes")
            batch = await asyncio.to_thread(
                list_captures,
                status,
                include_deleted=include_deleted,
                limit=limit + 1,
                offset=offset,
                project_id=request.query_params.get("project_id") or None,
                tag=request.query_params.get("tag") or None,
                unfiled=request.query_params.get("unfiled", "").lower() in ("1", "true", "yes"),
                processing_state=request.query_params.get("processing_state") or None,
                triage_state=request.query_params.get("triage_state") or None,
            )
            has_more = len(batch) > limit
            captures = batch[:limit]
            return JSONResponse(
                content={"ok": True, "captures": captures, "has_more": has_more, "offset": offset, "limit": limit}
            )
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "captures": []}, status_code=500)

    @fastapi_app.get("/captures/{capture_id}")
    async def captures_get(capture_id: str):
        try:
            from chat_storage import get_capture, list_note_backlinks, list_vault_attachments
            capture = await asyncio.to_thread(get_capture, capture_id)
            if not capture or capture.get("deleted_at"):
                return JSONResponse(content={"ok": False, "error": "capture not found"}, status_code=404)
            attachments = await asyncio.to_thread(list_vault_attachments, capture_id)
            backlinks = await asyncio.to_thread(list_note_backlinks, capture_id)
            return JSONResponse(content={"ok": True, "capture": capture, "attachments": attachments, "backlinks": backlinks})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.patch("/captures/{capture_id}")
    async def captures_update(capture_id: str, request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import update_capture
            capture = await asyncio.to_thread(update_capture, capture_id, **body)
            if not capture:
                return JSONResponse(content={"ok": False, "error": "capture not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "capture": capture})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.delete("/captures/{capture_id}")
    async def captures_delete(capture_id: str):
        try:
            from chat_storage import soft_delete_capture
            ok = await asyncio.to_thread(soft_delete_capture, capture_id)
            if not ok:
                return JSONResponse(content={"ok": False, "error": "capture not found"}, status_code=404)
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/captures/{capture_id}/restore")
    async def captures_restore(capture_id: str):
        try:
            from chat_storage import restore_capture
            capture = await asyncio.to_thread(restore_capture, capture_id)
            if not capture:
                return JSONResponse(content={"ok": False, "error": "capture not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "capture": capture})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/captures/{capture_id}/file")
    async def captures_file(capture_id: str, request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import file_capture
            project_id = str(body.get("project_id") or "").strip()
            if not project_id:
                return JSONResponse(content={"ok": False, "error": "project_id required"}, status_code=400)
            capture = await asyncio.to_thread(
                file_capture,
                capture_id,
                project_id=project_id,
                section=str(body.get("section") or ""),
                tags=_capture_tags_from_body(body),
            )
            if not capture:
                return JSONResponse(content={"ok": False, "error": "capture not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "capture": capture})
        except ValueError as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=404)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/captures/{capture_id}/unfile")
    async def captures_unfile(capture_id: str):
        try:
            from chat_storage import unfile_capture
            capture = await asyncio.to_thread(unfile_capture, capture_id)
            if not capture:
                return JSONResponse(content={"ok": False, "error": "capture not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "capture": capture})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/vault/attachments")
    async def vault_attachments_list(request: FRequest):
        try:
            from chat_storage import list_vault_attachments
            attachments = await asyncio.to_thread(
                list_vault_attachments,
                note_id=request.query_params.get("note_id") or "",
                project_id=request.query_params.get("project_id") or "",
                limit=int(request.query_params.get("limit") or 100),
            )
            return JSONResponse(content={"ok": True, "attachments": attachments})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "attachments": []}, status_code=500)

    @fastapi_app.post("/vault/attachments")
    async def vault_attachments_create(request: FRequest):
        try:
            form = await request.form()
            upload = form.get("file") or form.get("attachment")
            if upload is None or not hasattr(upload, "read"):
                return JSONResponse(content={"ok": False, "error": "file required"}, status_code=400)
            data = await upload.read()
            metadata: dict[str, Any] = {}
            raw_metadata = form.get("metadata")
            if isinstance(raw_metadata, str) and raw_metadata.strip():
                try:
                    parsed = json.loads(raw_metadata)
                    if isinstance(parsed, dict):
                        metadata = parsed
                except Exception:
                    return JSONResponse(content={"ok": False, "error": "invalid metadata"}, status_code=400)
            from chat_storage import create_vault_attachment
            attachment = await asyncio.to_thread(
                create_vault_attachment,
                data,
                mime_type=str(getattr(upload, "content_type", "") or form.get("mime_type") or "application/octet-stream"),
                filename=str(getattr(upload, "filename", "") or form.get("filename") or ""),
                note_id=str(form.get("note_id") or ""),
                project_id=str(form.get("project_id") or ""),
                metadata=metadata,
            )
            return JSONResponse(content={"ok": True, "attachment": attachment})
        except ValueError as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/vault/attachments/orphans")
    async def vault_attachments_orphans_report(request: FRequest):
        try:
            from chat_storage import _CAPTURE_LIST_MAX_OFFSET, orphan_vault_attachment_report

            try:
                limit = max(1, min(int(request.query_params.get("limit") or 200), 500))
                offset = max(0, min(int(request.query_params.get("offset") or 0), _CAPTURE_LIST_MAX_OFFSET))
            except (TypeError, ValueError):
                return JSONResponse(content={"ok": False, "error": "invalid limit or offset", "orphans": []}, status_code=400)
            result = await asyncio.to_thread(orphan_vault_attachment_report, limit=limit, offset=offset)
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "orphans": []}, status_code=500)

    @fastapi_app.post("/vault/attachments/orphans/gc")
    async def vault_attachments_orphans_gc(request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import gc_orphan_vault_attachments

            dry_run = body.get("dry_run", True)
            if isinstance(dry_run, str):
                dry_run = dry_run.lower() not in ("0", "false", "no")
            try:
                max_delete = int(body.get("max_delete") or 50)
            except (TypeError, ValueError):
                return JSONResponse(content={"ok": False, "error": "invalid max_delete"}, status_code=400)
            result = await asyncio.to_thread(
                gc_orphan_vault_attachments,
                dry_run=bool(dry_run) if dry_run is not None else True,
                max_delete=max_delete,
                include_dangling=bool(body.get("include_dangling")),
                include_deleted_note=bool(body.get("include_deleted_note")),
            )
            # Partial deletes are reported in-body (`ok` false + `errors`); reserve 500 for handler exceptions.
            return JSONResponse(content=result, status_code=200)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/vault/attachments/{attachment_id}")
    async def vault_attachments_get(attachment_id: str):
        try:
            from chat_storage import get_vault_attachment
            attachment = await asyncio.to_thread(get_vault_attachment, attachment_id, include_data=True)
            if not attachment:
                return JSONResponse(content={"ok": False, "error": "attachment not found"}, status_code=404)
            data = attachment.pop("data", b"")
            filename = (attachment.get("metadata") or {}).get("original_filename") or f"{attachment_id}.bin"
            safe_filename = re.sub(r"[^A-Za-z0-9._ -]+", "_", str(filename))[:240] or f"{attachment_id}.bin"
            return Response(
                content=data,
                media_type=attachment.get("mime_type") or "application/octet-stream",
                headers={
                    "Content-Disposition": f'inline; filename="{safe_filename}"',
                    "X-Mugi-Attachment-Id": attachment_id,
                },
            )
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/vault/attachments/{attachment_id}/link")
    async def vault_attachments_link(attachment_id: str, request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        note_id = str(body.get("note_id") or body.get("capture_id") or "").strip()
        if not note_id:
            return JSONResponse(content={"ok": False, "error": "note_id required"}, status_code=400)
        try:
            from chat_storage import link_vault_attachment
            attachment = await asyncio.to_thread(
                link_vault_attachment,
                attachment_id,
                note_id=note_id,
                project_id=str(body.get("project_id") or ""),
            )
            if not attachment:
                return JSONResponse(content={"ok": False, "error": "attachment or note not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "attachment": attachment})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.delete("/vault/attachments/{attachment_id}")
    async def vault_attachments_delete(attachment_id: str):
        try:
            from chat_storage import delete_vault_attachment

            result = await asyncio.to_thread(delete_vault_attachment, attachment_id)
            if not result:
                return JSONResponse(content={"ok": False, "error": "attachment not found"}, status_code=404)
            return JSONResponse(content=result)
        except ValueError as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/captures/process")
    async def captures_process(request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        ids = _capture_ids_from_body(body)
        try:
            from chat_storage import process_captures
            result = await asyncio.to_thread(
                process_captures,
                ids or None,
                limit=int(body.get("limit") or 25),
                project_id=str(body.get("project_id") or "") or None,
            )
            return JSONResponse(content={"ok": True, **result})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/vault/idle/process")
    async def vault_idle_process(request: FRequest):
        """Run bounded, non-destructive Vault suggestion processing.

        This is the UI/background-friendly counterpart to /captures/process:
        it only annotates notes and creates suggestion overlays.
        """
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import process_captures
            limit = max(1, min(int(body.get("limit") or 10), 25))
            result = await asyncio.to_thread(
                process_captures,
                None,
                limit=limit,
                project_id=str(body.get("project_id") or "") or None,
            )
            return JSONResponse(
                content={
                    "ok": True,
                    "processed": result.get("processed") or [],
                    "skipped": result.get("skipped") or [],
                    "connections": result.get("connections") or [],
                    "non_destructive": True,
                }
            )
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    _vault_graph_refresh_cooldown_until = 0.0
    _vault_graph_insights_cooldown_until = 0.0

    def _vault_graph_refresh_rate_ok() -> bool:
        nonlocal _vault_graph_refresh_cooldown_until
        now = time.monotonic()
        if now < _vault_graph_refresh_cooldown_until:
            return False
        _vault_graph_refresh_cooldown_until = now + 45.0
        return True

    @fastapi_app.get("/vault/graph/neighbors")
    async def vault_graph_neighbors(request: FRequest):
        note_id = str(request.query_params.get("note_id") or "").strip()
        if not note_id:
            return JSONResponse(content={"ok": False, "error": "note_id required"}, status_code=400)
        kinds_raw = str(request.query_params.get("kinds") or "").strip()
        kinds = [k.strip() for k in kinds_raw.split(",") if k.strip()] if kinds_raw else None
        try:
            from chat_storage import list_graph_neighbors

            limit = max(1, min(int(request.query_params.get("limit") or 80), 200))
            project_q = request.query_params.get("project_id")
            project_id = str(project_q).strip() if project_q is not None else None
            rows = await asyncio.to_thread(
                list_graph_neighbors,
                note_id,
                kinds=kinds,
                limit=limit,
                project_id=project_id,
            )
            return JSONResponse(content={"ok": True, "neighbors": rows})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "neighbors": []}, status_code=500)

    @fastapi_app.get("/vault/graph/mentions")
    async def vault_graph_mentions(request: FRequest):
        note_id = str(request.query_params.get("note_id") or "").strip()
        if not note_id:
            return JSONResponse(content={"ok": False, "error": "note_id required"}, status_code=400)
        try:
            from chat_storage import list_vault_graph_mentions

            limit = max(1, min(int(request.query_params.get("limit") or 40), 200))
            scan_limit = max(10, min(int(request.query_params.get("scan_limit") or 400), 800))
            include_unlinked = request.query_params.get("include_unlinked", "").lower() in ("1", "true", "yes")
            result = await asyncio.to_thread(
                list_vault_graph_mentions,
                note_id,
                limit=limit,
                scan_limit=scan_limit,
                include_unlinked=include_unlinked,
            )
            status = 200 if result.get("ok") else 400
            return JSONResponse(content=result, status_code=status)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "mentions": []}, status_code=500)

    @fastapi_app.post("/vault/graph/mentions/link")
    async def vault_graph_mention_link(request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import link_vault_unlinked_mention

            source = str(body.get("source_note_id") or "").strip()
            target = str(body.get("target_note_id") or "").strip()
            start = int(body.get("start") or -1)
            end = int(body.get("end") or -1)
            result = await asyncio.to_thread(
                link_vault_unlinked_mention,
                source,
                target,
                start,
                end,
            )
            status = 200 if result.get("ok") else 400
            return JSONResponse(content=result, status_code=status)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/vault/search")
    async def vault_search_route(request: FRequest):
        q = str(request.query_params.get("q") or "").strip()
        if not q:
            return JSONResponse(content={"ok": True, "query": "", "results": [], "scan_truncated": False})
        try:
            from chat_storage import search_vault_captures

            limit_notes = max(1, min(int(request.query_params.get("limit_notes") or 40), 80))
            hits_per = max(1, min(int(request.query_params.get("hits_per_note") or 4), 12))
            scan_limit = max(10, min(int(request.query_params.get("scan_limit") or 500), 800))
            project_q = request.query_params.get("project_id")
            project_id = str(project_q).strip() if project_q is not None and str(project_q).strip() else None
            result = await asyncio.to_thread(
                search_vault_captures,
                q,
                project_id=project_id,
                limit_notes=limit_notes,
                hits_per_note=hits_per,
                scan_limit=scan_limit,
            )
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "results": []}, status_code=500)

    @fastapi_app.get("/vault/graph/suggestions")
    async def vault_graph_suggestions(request: FRequest):
        try:
            from chat_storage import list_vault_graph_suggestions

            project_q = request.query_params.get("project_id")
            project_id = str(project_q).strip() if project_q is not None and str(project_q).strip() else None
            limit = max(1, min(int(request.query_params.get("limit") or 100), 500))
            offset = max(0, int(request.query_params.get("offset") or 0))
            rows = await asyncio.to_thread(
                list_vault_graph_suggestions,
                project_id=project_id,
                limit=limit,
                offset=offset,
            )
            return JSONResponse(content={"ok": True, "suggestions": rows})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "suggestions": []}, status_code=500)

    @fastapi_app.post("/vault/graph/suggestions/refresh")
    async def vault_graph_suggestions_refresh(request: FRequest):
        if not _vault_graph_refresh_rate_ok():
            return JSONResponse(
                content={"ok": False, "error": "rate_limited", "retry_after_sec": 45},
                status_code=429,
            )
        body, err = await _capture_json_body(request)
        if err:
            body = {}
        try:
            from chat_storage import propose_vault_similarity_edges

            project_id = str(body.get("project_id") or "").strip() or None
            limit_notes = max(2, min(int(body.get("limit_notes") or 60), 120))
            min_sim = float(body.get("min_similarity") or 0.68)
            max_edges = max(1, min(int(body.get("max_edges") or 200), 500))
            result = await asyncio.to_thread(
                propose_vault_similarity_edges,
                project_id=project_id,
                limit_notes=limit_notes,
                min_similarity=min_sim,
                max_new_edges=max_edges,
            )
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/vault/graph/reindex")
    async def vault_graph_reindex(request: FRequest):
        if not _vault_graph_refresh_rate_ok():
            return JSONResponse(
                content={"ok": False, "error": "rate_limited", "retry_after_sec": 45},
                status_code=429,
            )
        body, err = await _capture_json_body(request)
        if err:
            body = {}
        try:
            from chat_storage import reindex_capture_embeddings_batch

            project_id = str(body.get("project_id") or "").strip() or None
            limit = max(1, min(int(body.get("limit") or 80), 500))
            result = await asyncio.to_thread(
                reindex_capture_embeddings_batch,
                limit=limit,
                project_id=project_id,
            )
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/vault/graph/edges/{edge_id}/confirm")
    async def vault_graph_edge_confirm(edge_id: str):
        try:
            from chat_storage import confirm_vault_graph_edge

            row = await asyncio.to_thread(confirm_vault_graph_edge, edge_id)
            if not row:
                return JSONResponse(content={"ok": False, "error": "not found or not suggested"}, status_code=404)
            return JSONResponse(content={"ok": True, "edge": row})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/vault/graph/edges/{edge_id}/reject")
    async def vault_graph_edge_reject(edge_id: str):
        try:
            from chat_storage import reject_vault_graph_edge

            row = await asyncio.to_thread(reject_vault_graph_edge, edge_id)
            if not row:
                return JSONResponse(content={"ok": False, "error": "not found"}, status_code=404)
            return JSONResponse(content={"ok": True, **row})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/vault/graph/subgraph")
    async def vault_graph_subgraph(request: FRequest):
        focus = str(request.query_params.get("note_id") or request.query_params.get("focus_note_id") or "").strip()
        if not focus:
            return JSONResponse(content={"ok": False, "error": "note_id required"}, status_code=400)
        try:
            from chat_storage import export_vault_subgraph

            depth = max(0, min(int(request.query_params.get("depth") or 2), 4))
            max_nodes = max(1, min(int(request.query_params.get("max_nodes") or 150), 400))
            include_suggested = request.query_params.get("include_suggested", "1").lower() not in ("0", "false", "no")
            include_wikilinks = request.query_params.get("include_wikilinks", "1").lower() not in ("0", "false", "no")
            include_similarity = request.query_params.get("include_similarity", "1").lower() not in (
                "0",
                "false",
                "no",
            )
            project_q = request.query_params.get("project_id")
            project_id = str(project_q).strip() if project_q is not None and str(project_q).strip() else None
            result = await asyncio.to_thread(
                export_vault_subgraph,
                focus,
                depth=depth,
                project_id=project_id,
                include_suggested=include_suggested,
                include_wikilinks=include_wikilinks,
                include_similarity_edges=include_similarity,
                max_nodes=max_nodes,
            )
            status = 200 if result.get("ok") else 404
            return JSONResponse(content=result, status_code=status)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "nodes": [], "edges": []}, status_code=500)

    @fastapi_app.get("/vault/graph/insights")
    async def vault_graph_insights_route(request: FRequest):
        nonlocal _vault_graph_insights_cooldown_until
        focus = str(request.query_params.get("note_id") or "").strip()
        if not focus:
            return JSONResponse(content={"ok": False, "error": "note_id required"}, status_code=400)
        now = time.monotonic()
        if now < _vault_graph_insights_cooldown_until:
            ra = max(1, int(math.ceil(_vault_graph_insights_cooldown_until - now)))
            return JSONResponse(
                content={"ok": False, "error": "rate_limited", "retry_after_sec": ra},
                status_code=429,
            )
        _vault_graph_insights_cooldown_until = now + 25.0
        try:
            from chat_storage import vault_graph_insights

            mode = str(request.query_params.get("mode") or "gaps").strip().lower()
            depth = max(0, min(int(request.query_params.get("depth") or 2), 4))
            max_nodes = max(1, min(int(request.query_params.get("max_nodes") or 120), 400))
            project_q = request.query_params.get("project_id")
            project_id = str(project_q).strip() if project_q is not None and str(project_q).strip() else None
            with_ctx = request.query_params.get("with_context", "").lower() in ("1", "true", "yes")
            include_wikilinks = request.query_params.get("include_wikilinks", "1").lower() not in ("0", "false", "no")
            include_similarity = request.query_params.get("include_similarity", "1").lower() not in (
                "0",
                "false",
                "no",
            )
            result = await asyncio.to_thread(
                vault_graph_insights,
                focus,
                mode=mode,
                depth=depth,
                project_id=project_id,
                max_nodes=max_nodes,
                with_focus_context=with_ctx,
                include_wikilinks=include_wikilinks,
                include_similarity_edges=include_similarity,
            )
            status = 200 if result.get("ok") else 404
            return JSONResponse(content=result, status_code=status)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/captures/bulk")
    async def captures_bulk(request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        action = str(body.get("action") or "").strip().lower()
        ids = _capture_ids_from_body(body)
        if not ids:
            return JSONResponse(content={"ok": False, "error": "ids required"}, status_code=400)
        try:
            from chat_storage import file_capture, process_captures, soft_delete_capture, tag_capture, unfile_capture, update_capture
            if action == "process":
                result = await asyncio.to_thread(process_captures, ids, limit=len(ids), project_id=str(body.get("project_id") or "") or None)
                return JSONResponse(content={"ok": True, **result})
            if action == "file":
                project_id = str(body.get("project_id") or "").strip()
                if not project_id:
                    return JSONResponse(content={"ok": False, "error": "project_id required"}, status_code=400)
                results = []
                for cid in ids:
                    try:
                        cap = await asyncio.to_thread(file_capture, cid, project_id=project_id, section=str(body.get("section") or ""), tags=_capture_tags_from_body(body))
                        results.append({"id": cid, "ok": bool(cap), "capture": cap})
                    except Exception as exc:
                        results.append({"id": cid, "ok": False, "error": str(exc)})
                return JSONResponse(content={"ok": any(r.get("ok") for r in results), "results": results, "updated": sum(1 for r in results if r.get("ok"))})
            if action == "unfile":
                results = []
                for cid in ids:
                    cap = await asyncio.to_thread(unfile_capture, cid)
                    results.append({"id": cid, "ok": bool(cap), "capture": cap})
                return JSONResponse(content={"ok": any(r.get("ok") for r in results), "results": results, "updated": sum(1 for r in results if r.get("ok"))})
            if action in {"tag", "untag"}:
                tags = _capture_tags_from_body(body)
                results = []
                for cid in ids:
                    cap = await asyncio.to_thread(tag_capture, cid, tags=tags, mode="remove" if action == "untag" else "add")
                    results.append({"id": cid, "ok": bool(cap), "capture": cap})
                return JSONResponse(content={"ok": any(r.get("ok") for r in results), "results": results, "updated": sum(1 for r in results if r.get("ok"))})
            if action in {"archive", "restore", "delete"}:
                results = []
                for cid in ids:
                    if action == "delete":
                        ok = await asyncio.to_thread(soft_delete_capture, cid)
                        results.append({"id": cid, "ok": bool(ok)})
                    else:
                        status = "archived" if action == "archive" else "inbox"
                        cap = await asyncio.to_thread(update_capture, cid, status=status)
                        results.append({"id": cid, "ok": bool(cap), "capture": cap})
                return JSONResponse(content={"ok": any(r.get("ok") for r in results), "results": results, "updated": sum(1 for r in results if r.get("ok"))})
            return JSONResponse(content={"ok": False, "error": "unsupported action"}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/captures/{capture_id}/engage")
    async def captures_engage(capture_id: str):
        if getattr(delegate, "_run_active", False):
            return JSONResponse(content={"ok": False, "reason": "agent_busy"}, status_code=409)
        try:
            from chat_storage import get_capture, update_capture
            capture = await asyncio.to_thread(get_capture, capture_id)
            if not capture or capture.get("deleted_at"):
                return JSONResponse(content={"ok": False, "error": "capture not found"}, status_code=404)
            meta = dict(capture.get("metadata") or {})
            meta["discussed_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
            updated_capture = await asyncio.to_thread(update_capture, capture_id, metadata=meta)
            if not updated_capture:
                _log.warning(
                    "vault discuss discussed_at not persisted for capture %s",
                    capture_id[:8],
                )
            engage_capture = updated_capture or {**capture, "metadata": meta}
            try:
                from core import get_workspace_path
                from utils.vault_discuss_proposal_hook import maybe_propose_from_vault_discuss

                ws = get_workspace_path()
                hook_outcome = await asyncio.to_thread(
                    maybe_propose_from_vault_discuss, ws, engage_capture
                )
                if hook_outcome == "proposal":
                    _log.info(
                        "vault discuss proposal created for capture %s",
                        capture_id[:8],
                    )
                elif hook_outcome.startswith("skipped:") and hook_outcome not in {
                    "skipped:vault_noticing_disabled",
                    "skipped:recent_note_dedupe",
                }:
                    _log.debug(
                        "vault discuss proposal hook skipped capture=%s outcome=%s",
                        capture_id[:8],
                        hook_outcome,
                    )
            except Exception:
                _log.warning(
                    "vault discuss proposal hook failed (non-fatal)",
                    exc_info=True,
                )
            new_tid, title, created_at = _capture_thread_from_topic(capture.get("title") or capture.get("body") or "Capture")
            if not new_tid:
                return JSONResponse(content={"ok": False, "error": "no tab"}, status_code=500)
            prompt = (
                "The user chose to discuss this Vault note. Use only the bounded note context below unless you ask for more.\n\n"
                f"Note id: {capture.get('id', '')}\n"
                f"Title: {capture.get('title', '')}\n"
                f"Excerpt:\n{str(capture.get('body', ''))[:4000]}\n\n"
                "Explain why it may matter, show any useful next steps, and ask before taking external action."
            )
            delegate._run_agent(prompt, thread_id=new_tid, idle_tick=True, display_text="[Vault Note]")
            return JSONResponse(content={"ok": True, "thread_id": new_tid, "title": title, "created_at": created_at})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/capture_projects/{project_id}/engage")
    async def capture_projects_engage(project_id: str, request: FRequest):
        if getattr(delegate, "_run_active", False):
            return JSONResponse(content={"ok": False, "reason": "agent_busy"}, status_code=409)
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import get_capture_project_summary
            summary = await asyncio.to_thread(get_capture_project_summary, project_id)
            if not summary:
                return JSONResponse(content={"ok": False, "error": "project not found"}, status_code=404)
            project = summary.get("project") or {}
            requested_ids = _capture_ids_from_body(body)
            notes = summary.get("captures") or []
            if requested_ids:
                wanted = set(requested_ids)
                notes = [n for n in notes if n.get("id") in wanted]
            else:
                notes = notes[: max(1, min(int(body.get("limit") or 8), 20))]
            discussed_ts = datetime.utcnow().isoformat(timespec="seconds") + "Z"
            note_ids = [str(n.get("id") or "").strip() for n in notes if str(n.get("id") or "").strip()]
            from utils.vault_discuss_proposal_hook import (
                batch_mark_vault_notes_discussed,
                format_project_engage_wikilink_block,
                maybe_propose_from_vault_project_discuss,
            )

            try:
                from core import get_workspace_path

                ws = get_workspace_path()
                marked = await asyncio.to_thread(
                    batch_mark_vault_notes_discussed,
                    note_ids,
                    discussed_at=discussed_ts,
                )
                if note_ids and marked < len(note_ids):
                    _log.warning(
                        "vault project discussed_at partial batch project=%s marked=%s wanted=%s",
                        project_id[:8],
                        marked,
                        len(note_ids),
                    )
                hook_outcome = await asyncio.to_thread(
                    maybe_propose_from_vault_project_discuss,
                    ws,
                    project=project,
                    notes=notes,
                )
                if hook_outcome == "proposal":
                    _log.info(
                        "vault project proposal created for project %s notes_marked=%s",
                        project_id[:8],
                        marked,
                    )
                elif hook_outcome.startswith("skipped:") and hook_outcome not in {
                    "skipped:vault_noticing_disabled",
                    "skipped:recent_project_dedupe",
                }:
                    _log.debug(
                        "vault project proposal hook skipped project=%s outcome=%s",
                        project_id[:8],
                        hook_outcome,
                    )
            except Exception:
                _log.warning(
                    "vault project proposal hook failed (non-fatal)",
                    exc_info=True,
                )
            new_tid, title, created_at = _capture_thread_from_topic(project.get("name") or "Vault Project")
            if not new_tid:
                return JSONResponse(content={"ok": False, "error": "no tab"}, status_code=500)
            note_blocks = []
            for note in notes[:20]:
                note_blocks.append(
                    f"- id: {note.get('id', '')}\n"
                    f"  title: {note.get('title', '')}\n"
                    f"  excerpt: {str(note.get('body', ''))[:1200]}"
                )
            wikilink_block = format_project_engage_wikilink_block(notes[:20])
            prompt = (
                "The user chose to discuss a Vault project. Use only the bounded note excerpts below unless you ask for more.\n\n"
                f"Project: {project.get('name', '')} ({project.get('id', '')})\n"
                f"Included notes: {len(note_blocks)}\n\n"
                + "\n\n".join(note_blocks)
                + ("\n\n" + wikilink_block if wikilink_block else "\n\n")
                + "Synthesize patterns, useful next steps, and open questions. Do not imply you saw notes outside this excerpt pack."
            )
            delegate._run_agent(prompt, thread_id=new_tid, idle_tick=True, display_text="[Vault Project]")
            return JSONResponse(content={"ok": True, "thread_id": new_tid, "title": title, "created_at": created_at, "included_notes": len(note_blocks)})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/connections")
    async def connections_list(request: FRequest):
        try:
            from chat_storage import list_connections
            status = request.query_params.get("status") or None
            connections = await asyncio.to_thread(
                list_connections,
                status,
                limit=int(request.query_params.get("limit") or 100),
                project_id=request.query_params.get("project_id") or None,
            )
            return JSONResponse(content={"ok": True, "connections": connections})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "connections": []}, status_code=500)

    @fastapi_app.post("/connections/generate")
    async def connections_generate(request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import generate_capture_connections
            connections = await asyncio.to_thread(generate_capture_connections, project_id=str(body.get("project_id") or "") or None)
            return JSONResponse(content={"ok": True, "connections": connections})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.patch("/connections/{connection_id}")
    async def connections_update(connection_id: str, request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import update_connection_card
            card = await asyncio.to_thread(
                update_connection_card,
                connection_id,
                status=str(body.get("status") or ""),
                feedback=str(body.get("feedback") or ""),
            )
            if not card:
                return JSONResponse(content={"ok": False, "error": "connection not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "connection": card})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/action_cards")
    async def action_cards_list(request: FRequest):
        try:
            from chat_storage import list_action_cards
            state = request.query_params.get("state") or None
            cards = await asyncio.to_thread(
                list_action_cards,
                state,
                limit=int(request.query_params.get("limit") or 100),
                project_id=request.query_params.get("project_id") or None,
            )
            return JSONResponse(content={"ok": True, "cards": cards})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc), "cards": []}, status_code=500)

    @fastapi_app.patch("/action_cards/{action_id}")
    async def action_cards_update(action_id: str, request: FRequest):
        body, err = await _capture_json_body(request)
        if err:
            return err
        try:
            from chat_storage import update_action_card
            card = await asyncio.to_thread(
                update_action_card,
                action_id,
                state=str(body.get("state") or ""),
                failed_reason=str(body.get("failed_reason") or ""),
            )
            if not card:
                return JSONResponse(content={"ok": False, "error": "action card not found"}, status_code=404)
            return JSONResponse(content={"ok": True, "card": card})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/action_cards/{action_id}/engage")
    async def action_cards_engage(action_id: str):
        if getattr(delegate, "_run_active", False):
            return JSONResponse(content={"ok": False, "reason": "agent_busy"}, status_code=409)
        try:
            from chat_storage import get_action_card, update_action_card
            card = await asyncio.to_thread(get_action_card, action_id)
            if not card:
                return JSONResponse(content={"ok": False, "error": "action card not found"}, status_code=404)
            new_tid, title, created_at = _capture_thread_from_topic(card.get("title") or "Action card")
            if not new_tid:
                return JSONResponse(content={"ok": False, "error": "no tab"}, status_code=500)
            await asyncio.to_thread(update_action_card, action_id, state="accepted")
            prompt = (
                "The user accepted this Pattern Inbox action card.\n\n"
                f"Title: {card.get('title', '')}\n"
                f"Body: {card.get('body', '')}\n"
                f"Trust level: {card.get('trust_level', '')}\n"
                f"Action type: {card.get('action_type', '')}\n\n"
                "Explain the recommendation and take only low-risk local steps unless the user explicitly approves more."
            )
            delegate._run_agent(prompt, thread_id=new_tid, idle_tick=True, display_text="[Action card]")
            return JSONResponse(content={"ok": True, "thread_id": new_tid, "title": title, "created_at": created_at})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    # -------------------------------------------------------------------------
    # Proactive queue (bridge background queue into Swift app)
    # -------------------------------------------------------------------------

    def _proactive_queue_mutate(
        qpath: str,
        item_id: str,
        mutator,
        *,
        require_unprocessed: bool = False,
    ) -> bool:
        """Read-modify-write one proactive queue row under the file lock."""
        from utils.proactive_queue import flock_rewrite, update_entry_by_id_in_lines

        with _PROACTIVE_QUEUE_FILE_LOCK:
            with open(qpath, "r", encoding="utf-8") as f:
                lines = f.readlines()
            new_lines, updated = update_entry_by_id_in_lines(
                lines, item_id, mutator, require_unprocessed=require_unprocessed,
            )
            if not updated:
                return False
            flock_rewrite(qpath, new_lines)
            try:
                os.chmod(qpath, 0o600)
            except OSError:
                pass
        return True

    @fastapi_app.get("/proactive/pending")
    async def proactive_pending(request: FRequest):
        try:
            from persistence import PROACTIVE_QUEUE_PATH
            from utils.proactive_queue import pending_entries_from_lines

            include_notified = request.query_params.get("include_notified", "").lower() in ("1", "true", "yes")
            qpath = os.path.expanduser(PROACTIVE_QUEUE_PATH)
            if not os.path.isfile(qpath):
                return JSONResponse(content={"entries": []})
            with _PROACTIVE_QUEUE_FILE_LOCK:
                with open(qpath, "r", encoding="utf-8") as f:
                    lines = f.readlines()
            entries = pending_entries_from_lines(lines, include_notified=include_notified)
            from utils.noticing_dedupe import dedupe_observation_entries_for_display

            entries = dedupe_observation_entries_for_display(entries)
            return JSONResponse(content={"entries": entries})
        except Exception as exc:
            return JSONResponse(content={"entries": [], "error": str(exc)}, status_code=500)

    @fastapi_app.post("/proactive/mark_processed")
    async def proactive_mark_processed(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        ids = body.get("ids") if isinstance(body, dict) else []
        if not isinstance(ids, list):
            return JSONResponse(content={"ok": False, "message": "ids must be a list"}, status_code=400)
        from utils.proactive_queue import MAX_MARK_PROCESSED_IDS
        if len(ids) > MAX_MARK_PROCESSED_IDS:
            return JSONResponse(
                content={"ok": False, "message": f"ids list exceeds max ({MAX_MARK_PROCESSED_IDS})"},
                status_code=400,
            )
        ids_set = {str(i).strip() for i in ids if str(i).strip()}
        if not ids_set:
            return JSONResponse(content={"ok": True, "updated": 0})
        try:
            from persistence import PROACTIVE_QUEUE_PATH, secure_atomic_write
            from utils.proactive_queue import mark_processed_in_lines, flock_rewrite

            qpath = os.path.expanduser(PROACTIVE_QUEUE_PATH)
            if not os.path.isfile(qpath):
                return JSONResponse(content={"ok": True, "updated": 0})
            with _PROACTIVE_QUEUE_FILE_LOCK:
                with open(qpath, "r", encoding="utf-8") as f:
                    lines = f.readlines()
            new_lines, updated, blocked = mark_processed_in_lines(lines, ids_set)
            if blocked:
                return JSONResponse(
                    content={
                        "ok": False,
                        "error": "observations_require_approve_or_dismiss",
                        "blocked_ids": blocked,
                    },
                    status_code=400,
                )
            with _PROACTIVE_QUEUE_FILE_LOCK:
                flock_rewrite(qpath, new_lines)
            try:
                os.chmod(qpath, 0o600)
            except OSError:
                pass
            return JSONResponse(content={"ok": True, "updated": updated})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "message": str(exc)}, status_code=500)

    @fastapi_app.post("/proactive/mark_notified")
    async def proactive_mark_notified(request: FRequest):
        """Mark items as shown in Activity feed without setting processed.

        Items remain available for the inbox (via include_notified=true).
        """
        try:
            body = await request.json()
        except Exception:
            body = {}
        ids = body.get("ids") if isinstance(body, dict) else []
        if not isinstance(ids, list):
            return JSONResponse(content={"ok": False, "message": "ids must be a list"}, status_code=400)
        from utils.proactive_queue import MAX_MARK_PROCESSED_IDS
        if len(ids) > MAX_MARK_PROCESSED_IDS:
            return JSONResponse(
                content={"ok": False, "message": f"ids list exceeds max ({MAX_MARK_PROCESSED_IDS})"},
                status_code=400,
            )
        ids_set = {str(i).strip() for i in ids if str(i).strip()}
        if not ids_set:
            return JSONResponse(content={"ok": True, "updated": 0})
        try:
            from persistence import PROACTIVE_QUEUE_PATH
            from utils.proactive_queue import mark_notified_in_lines, flock_rewrite

            qpath = os.path.expanduser(PROACTIVE_QUEUE_PATH)
            if not os.path.isfile(qpath):
                return JSONResponse(content={"ok": True, "updated": 0})
            with _PROACTIVE_QUEUE_FILE_LOCK:
                with open(qpath, "r", encoding="utf-8") as f:
                    lines = f.readlines()
            new_lines, updated = mark_notified_in_lines(lines, ids_set)
            with _PROACTIVE_QUEUE_FILE_LOCK:
                flock_rewrite(qpath, new_lines)
            try:
                os.chmod(qpath, 0o600)
            except OSError:
                pass
            return JSONResponse(content={"ok": True, "updated": updated})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "message": str(exc)}, status_code=500)

    @fastapi_app.post("/proactive/engage")
    async def proactive_engage(request: FRequest):
        """Create a new conversation from a proactive inbox item.

        Finds the item, creates a thread, marks it processed, seeds the agent
        with a synthetic prompt, and returns the new thread metadata.
        """
        try:
            body = await request.json()
        except Exception:
            body = {}
        item_id = (body.get("item_id") or "") if isinstance(body, dict) else ""
        item_id = str(item_id).strip()
        if not item_id:
            return JSONResponse(content={"ok": False, "error": "item_id required"}, status_code=400)

        if getattr(delegate, "_run_active", False):
            return JSONResponse(content={"ok": False, "reason": "agent_busy"}, status_code=409)

        try:
            from persistence import PROACTIVE_QUEUE_PATH
            from utils.proactive_queue import (
                APPROVAL_ENGAGED,
                APPROVAL_PENDING,
                APPROVAL_STARTING,
                KIND_OBSERVATION,
                approval_payload_from_entry,
                observation_payload_from_entry,
                observation_starting_mutator,
                proactive_entry_id,
                mark_processed_in_lines,
                flock_rewrite,
                revert_observation_to_pending_mutator,
                update_entry_by_id_in_lines,
            )

            qpath = os.path.expanduser(PROACTIVE_QUEUE_PATH)
            if not os.path.isfile(qpath):
                return JSONResponse(content={"ok": False, "error": "item_not_found_or_already_processed"}, status_code=404)

            with _PROACTIVE_QUEUE_FILE_LOCK:
                with open(qpath, "r", encoding="utf-8") as f:
                    lines = f.readlines()

            matched_item = None
            matched_content = None
            for line in lines:
                raw = (line or "").strip()
                if not raw:
                    continue
                try:
                    item = json.loads(raw)
                except Exception:
                    continue
                if not isinstance(item, dict):
                    continue
                if item.get("processed"):
                    continue
                if proactive_entry_id(item) == item_id:
                    matched_item = item
                    matched_content = str(item.get("content") or "").strip()
                    break

            if not matched_item or not matched_content:
                return JSONResponse(content={"ok": False, "error": "item_not_found_or_already_processed"}, status_code=404)

            if str(matched_item.get("kind") or "") == KIND_OBSERVATION:
                if approval_payload_from_entry(matched_item).get("status") == APPROVAL_STARTING:
                    return JSONResponse(content={"ok": False, "error": "in_progress"}, status_code=409)
                if approval_payload_from_entry(matched_item).get("status") != APPROVAL_PENDING:
                    return JSONResponse(content={"ok": False, "error": "already_handled"}, status_code=409)

            observation_appr = (
                dict(approval_payload_from_entry(matched_item))
                if str(matched_item.get("kind") or "") == KIND_OBSERVATION
                else None
            )
            linked_drive_id = ""
            if str(matched_item.get("kind") or "") == KIND_OBSERVATION:
                from utils.proactive_observation_drive import drive_id_from_observation_entry

                linked_drive_id = drive_id_from_observation_entry(matched_item)

            is_observation = str(matched_item.get("kind") or "") == KIND_OBSERVATION
            if is_observation:
                from datetime import timezone

                started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")

                def _mutate_starting(item: dict) -> dict:
                    return observation_starting_mutator(
                        item,
                        prior_approval=observation_appr or {},
                        started_at=started_at,
                        matched_notified=bool(matched_item.get("notified")),
                    )

                new_lines, updated = update_entry_by_id_in_lines(
                    lines, item_id, _mutate_starting, require_unprocessed=True,
                )
            else:
                new_lines, updated, _blocked = mark_processed_in_lines(
                    lines, {item_id}, allow_observations=False,
                )
            if updated == 0:
                return JSONResponse(content={"ok": False, "error": "already_handled"}, status_code=409)

            with _PROACTIVE_QUEUE_FILE_LOCK:
                flock_rewrite(qpath, new_lines)
            try:
                os.chmod(qpath, 0o600)
            except OSError:
                pass

            new_tid = str(uuid.uuid4())
            created_at = datetime.now().isoformat()
            if str(matched_item.get("kind") or "") == KIND_OBSERVATION:
                obs_for_title = observation_payload_from_entry(matched_item)
                title_seed = str(obs_for_title.get("proposed_work") or matched_content).strip()[:40] or matched_content[:40]
            else:
                title_seed = matched_content[:40]
            title_raw = title_seed
            title = title_raw.rsplit(" ", 1)[0] if len(title_raw) > 40 and " " in title_raw else title_raw

            tab = delegate._tabs[0] if delegate._tabs else None
            if tab is None:
                def _unprocess_no_tab(item: dict) -> dict:
                    if observation_appr is not None:
                        return revert_observation_to_pending_mutator(
                            item,
                            prior_approval=observation_appr,
                            matched_notified=bool(matched_item.get("notified")),
                        )
                    item["processed"] = False
                    return item

                _proactive_queue_mutate(qpath, item_id, _unprocess_no_tab)
                return JSONResponse(content={"ok": False, "error": "no_tab"}, status_code=500)
            tab.setdefault("thread_ids", []).insert(0, new_tid)
            tab["current_thread_id"] = new_tid
            delegate._conversation_meta[new_tid] = {
                "title": title,
                "created_at": created_at,
                "saved_path": None,
                "folder_id": None,
                "pinned": False,
                "archived": False,
                "trashed": False,
                "sort_order": 0,
            }
            delegate._current_thread_id = new_tid

            _schedule_save_chats_db(delegate)

            delegate._push_event({"type": "conversations_changed"}, new_tid)
            delegate._structured_bus.emit("__sidebar__", json.dumps({"type": "conversations_changed"}))

            if is_observation:
                def _mutate_engaged_thread(item: dict) -> dict:
                    appr = approval_payload_from_entry(item)
                    item["approval"] = {**appr, "thread_id": new_tid}
                    item["thread_id"] = new_tid
                    return item

                if not _proactive_queue_mutate(qpath, item_id, _mutate_engaged_thread):
                    _log.warning(
                        "proactive/engage: queue thread_id write missed for %s",
                        item_id,
                    )

            if getattr(delegate, "_run_active", False):
                def _unprocess_busy(item: dict) -> dict:
                    if observation_appr is not None:
                        return revert_observation_to_pending_mutator(
                            item,
                            prior_approval=observation_appr,
                            matched_notified=bool(matched_item.get("notified")),
                        )
                    item["processed"] = False
                    return item

                _proactive_queue_mutate(qpath, item_id, _unprocess_busy)
                return JSONResponse(content={"ok": False, "reason": "agent_busy"}, status_code=409)

            if str(matched_item.get("kind") or "") == KIND_OBSERVATION:
                obs = observation_payload_from_entry(matched_item)
                noticed = str(obs.get("noticed") or matched_content).strip()
                proposed = str(obs.get("proposed_work") or "").strip()
                synthetic_prompt = (
                    "You previously noticed something and surfaced it for the user's attention:\n\n"
                    f"What you noticed: {noticed}\n"
                )
                if proposed:
                    synthetic_prompt += f"If approved, you would: {proposed}\n\n"
                synthetic_prompt += (
                    "The user chose to discuss this with you (not approve execution yet). "
                    "Share your thoughts naturally — what you found, why it matters, and options. "
                    "Do not repeat the above verbatim."
                )
            else:
                synthetic_prompt = (
                    "You previously noticed something and surfaced it for the user's attention:\n\n"
                    f"{matched_content}\n\n"
                    "The user has now chosen to discuss this with you. Share your thoughts on it naturally — "
                    "what you found, why it matters, and any suggestions. Do not repeat the above verbatim."
                )
            try:
                delegate._run_agent(
                    synthetic_prompt,
                    thread_id=new_tid,
                    idle_tick=True,
                    display_text="[Topic raised]",
                )
            except Exception as run_exc:
                _log.error("proactive/engage agent start failed: %s", run_exc, exc_info=True)

                def _unprocess(item: dict) -> dict:
                    if observation_appr is not None:
                        return revert_observation_to_pending_mutator(
                            item,
                            prior_approval=observation_appr,
                            matched_notified=bool(matched_item.get("notified")),
                        )
                    item["processed"] = False
                    return item

                _proactive_queue_mutate(qpath, item_id, _unprocess)
                return JSONResponse(
                    content={"ok": False, "error": "agent_start_failed"},
                    status_code=500,
                )

            if is_observation and observation_appr is not None:
                engaged_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")

                def _mutate_engaged_final(item: dict) -> dict:
                    item["approval"] = {
                        **approval_payload_from_entry(item),
                        "status": APPROVAL_ENGAGED,
                        "thread_id": new_tid,
                        "engaged_at": engaged_at,
                    }
                    item["thread_id"] = new_tid
                    item["processed"] = True
                    item["notified"] = True
                    return item

                if not _proactive_queue_mutate(qpath, item_id, _mutate_engaged_final):
                    _log.warning(
                        "proactive/engage: final engaged write missed for %s",
                        item_id,
                    )

            if observation_appr is not None and linked_drive_id:
                try:
                    from core import get_workspace_path
                    from utils.proactive_observation_drive import (
                        defer_linked_drive_after_observation_discuss,
                    )

                    ok, msg = defer_linked_drive_after_observation_discuss(
                        linked_drive_id,
                        workspace=get_workspace_path(),
                    )
                    if not ok:
                        _log.warning(
                            "proactive/engage: linked drive defer missed (drive=%s): %s",
                            linked_drive_id,
                            msg,
                        )
                except Exception:
                    _log.warning(
                        "proactive/engage: linked drive reconcile failed",
                        exc_info=True,
                    )

            return JSONResponse(content={
                "ok": True,
                "thread_id": new_tid,
                "title": title,
                "created_at": created_at,
            })
        except Exception as exc:
            _log.error("proactive/engage error: %s", exc, exc_info=True)
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    def _proactive_create_thread(
        delegate,
        title_seed: str,
        thread_id: str | None = None,
    ) -> tuple[str, str, str] | None:
        """Create a new chat thread on the primary tab. Returns (thread_id, title, created_at) or None."""
        new_tid = str(thread_id or uuid.uuid4())
        created_at = datetime.now().isoformat()
        title_raw = str(title_seed or "Observation")[:40]
        title = title_raw.rsplit(" ", 1)[0] if len(title_raw) > 40 and " " in title_raw else title_raw
        tab = delegate._tabs[0] if delegate._tabs else None
        if tab is None:
            return None
        tab.setdefault("thread_ids", []).insert(0, new_tid)
        tab["current_thread_id"] = new_tid
        delegate._conversation_meta[new_tid] = {
            "title": title,
            "created_at": created_at,
            "saved_path": None,
            "folder_id": None,
            "pinned": False,
            "archived": False,
            "trashed": False,
            "sort_order": 0,
        }
        delegate._current_thread_id = new_tid
        _schedule_save_chats_db(delegate)
        delegate._push_event({"type": "conversations_changed"}, new_tid)
        delegate._structured_bus.emit("__sidebar__", json.dumps({"type": "conversations_changed"}))
        return new_tid, title, created_at

    @fastapi_app.post("/proactive/dismiss")
    async def proactive_dismiss(request: FRequest):
        """Dismiss a pending proactive observation without executing proposed work."""
        try:
            body = await request.json()
        except Exception:
            body = {}
        item_id = str((body.get("item_id") or "") if isinstance(body, dict) else "").strip()
        if not item_id:
            return JSONResponse(content={"ok": False, "error": "item_id required"}, status_code=400)
        try:
            from datetime import timezone
            from persistence import PROACTIVE_QUEUE_PATH
            from utils.proactive_queue import (
                APPROVAL_DISMISSED,
                APPROVAL_PENDING,
                APPROVAL_STARTING,
                KIND_OBSERVATION,
                approval_payload_from_entry,
                find_unprocessed_entry_by_id,
                flock_rewrite,
                update_entry_by_id_in_lines,
            )

            qpath = os.path.expanduser(PROACTIVE_QUEUE_PATH)
            if not os.path.isfile(qpath):
                return JSONResponse(content={"ok": False, "error": "item_not_found"}, status_code=404)

            with _PROACTIVE_QUEUE_FILE_LOCK:
                with open(qpath, "r", encoding="utf-8") as f:
                    lines = f.readlines()

            matched = find_unprocessed_entry_by_id(lines, item_id)
            if not matched:
                return JSONResponse(content={"ok": False, "error": "item_not_found"}, status_code=404)
            if str(matched.get("kind") or "") != KIND_OBSERVATION:
                return JSONResponse(content={"ok": False, "error": "not_an_observation"}, status_code=400)
            appr = approval_payload_from_entry(matched)
            if appr.get("status") == APPROVAL_STARTING:
                return JSONResponse(content={"ok": False, "error": "in_progress"}, status_code=409)
            if appr.get("status") != APPROVAL_PENDING:
                return JSONResponse(content={"ok": False, "error": "already_handled"}, status_code=409)

            def _mutate(item: dict) -> dict:
                item["approval"] = {
                    **appr,
                    "status": APPROVAL_DISMISSED,
                    "approved_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
                }
                item["processed"] = True
                item["notified"] = True
                return item

            new_lines, updated = update_entry_by_id_in_lines(
                lines, item_id, _mutate, require_unprocessed=True,
            )
            if not updated:
                return JSONResponse(content={"ok": False, "error": "already_handled"}, status_code=409)

            with _PROACTIVE_QUEUE_FILE_LOCK:
                flock_rewrite(qpath, new_lines)
            try:
                os.chmod(qpath, 0o600)
            except OSError:
                pass
            try:
                from core import get_workspace_path
                from utils.proactive_observation_drive import (
                    defer_linked_drive_after_observation_dismiss,
                    drive_id_from_observation_entry,
                )

                linked_drive = drive_id_from_observation_entry(matched)
                if linked_drive:
                    ok, msg = defer_linked_drive_after_observation_dismiss(
                        linked_drive,
                        workspace=get_workspace_path(),
                    )
                    if not ok:
                        _log.warning(
                            "proactive/dismiss: linked drive defer missed (drive=%s): %s",
                            linked_drive,
                            msg,
                        )
            except Exception:
                _log.warning("proactive/dismiss: linked drive reconcile failed", exc_info=True)
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            _log.error("proactive/dismiss error: %s", exc, exc_info=True)
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/proactive/approve")
    async def proactive_approve(request: FRequest):
        """Approve a pending observation — in-thread agent run or kanban-backed durable work."""
        try:
            body = await request.json()
        except Exception:
            body = {}
        if not isinstance(body, dict):
            body = {}
        item_id = str(body.get("item_id") or "").strip()
        if not item_id:
            return JSONResponse(content={"ok": False, "error": "item_id required"}, status_code=400)

        tier_override = str(body.get("tier") or "").strip().lower()
        requested_thread = str(body.get("thread_id") or "").strip()

        try:
            from datetime import timezone
            from persistence import PROACTIVE_QUEUE_PATH
            from utils.proactive_queue import (
                APPROVAL_APPROVED,
                APPROVAL_PENDING,
                APPROVAL_STARTING,
                KIND_OBSERVATION,
                TIER_IN_THREAD,
                TIER_KANBAN,
                approval_payload_from_entry,
                find_unprocessed_entry_by_id,
                flock_rewrite,
                observation_payload_from_entry,
                observation_starting_mutator,
                revert_observation_to_pending_mutator,
                spawn_kanban_for_approved_observation,
                update_entry_by_id_in_lines,
            )

            qpath = os.path.expanduser(PROACTIVE_QUEUE_PATH)
            if not os.path.isfile(qpath):
                return JSONResponse(content={"ok": False, "error": "item_not_found"}, status_code=404)

            with _PROACTIVE_QUEUE_FILE_LOCK:
                with open(qpath, "r", encoding="utf-8") as f:
                    lines = f.readlines()

            matched = find_unprocessed_entry_by_id(lines, item_id)
            if not matched or str(matched.get("kind") or "") != KIND_OBSERVATION:
                return JSONResponse(content={"ok": False, "error": "item_not_found"}, status_code=404)

            obs = observation_payload_from_entry(matched)
            appr = approval_payload_from_entry(matched)
            if appr.get("status") == APPROVAL_STARTING:
                return JSONResponse(content={"ok": False, "error": "in_progress"}, status_code=409)
            if appr.get("status") != APPROVAL_PENDING:
                return JSONResponse(content={"ok": False, "error": "already_handled"}, status_code=409)

            tier = tier_override or str(obs.get("suggested_tier") or TIER_IN_THREAD)
            if tier not in (TIER_IN_THREAD, TIER_KANBAN):
                return JSONResponse(content={"ok": False, "error": "invalid_tier"}, status_code=400)

            noticed = str(obs.get("noticed") or matched.get("content") or "").strip()
            proposed = str(obs.get("proposed_work") or "").strip()
            if not proposed:
                return JSONResponse(content={"ok": False, "error": "missing_proposed_work"}, status_code=400)

            title_seed = proposed[:40] or noticed[:40] or "Approved work"
            title_raw = title_seed[:40]
            title = title_raw.rsplit(" ", 1)[0] if len(title_raw) > 40 and " " in title_raw else title_raw
            matched_notified = bool(matched.get("notified"))
            approved_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
            source = str(matched.get("source") or "heartbeat")
            assignee = str(obs.get("suggested_assignee") or "general").strip() or "general"
            from utils.proactive_observation_drive import (
                defer_linked_drive_after_in_thread_approve,
                defer_linked_drive_after_kanban_approve,
                drive_id_from_observation_entry,
            )

            drive_id = drive_id_from_observation_entry(matched)

            if tier == TIER_KANBAN:
                if requested_thread:
                    meta = getattr(delegate, "_conversation_meta", {}) or {}
                    if requested_thread not in meta:
                        return JSONResponse(content={"ok": False, "error": "thread_not_found"}, status_code=404)
                    status_thread_id = requested_thread
                    title = str((meta.get(status_thread_id) or {}).get("title") or title)
                    created_at = str((meta.get(status_thread_id) or {}).get("created_at") or datetime.now().isoformat())
                else:
                    status_thread_id = str(uuid.uuid4())

                def _mutate_starting_kanban(item: dict) -> dict:
                    return observation_starting_mutator(
                        item,
                        prior_approval=appr,
                        started_at=approved_at,
                        thread_id=status_thread_id,
                        matched_notified=matched_notified,
                    )

                new_lines, updated = update_entry_by_id_in_lines(
                    lines, item_id, _mutate_starting_kanban, require_unprocessed=True,
                )
                if not updated:
                    return JSONResponse(content={"ok": False, "error": "already_handled"}, status_code=409)

                with _PROACTIVE_QUEUE_FILE_LOCK:
                    flock_rewrite(qpath, new_lines)
                try:
                    os.chmod(qpath, 0o600)
                except OSError:
                    pass

                def _rollback_kanban(item: dict) -> dict:
                    return revert_observation_to_pending_mutator(
                        item,
                        prior_approval=appr,
                        matched_notified=matched_notified,
                    )

                if requested_thread:
                    tab = delegate._tabs[0] if delegate._tabs else None
                    if tab is not None:
                        tab["current_thread_id"] = status_thread_id
                    delegate._current_thread_id = status_thread_id
                else:
                    created = _proactive_create_thread(delegate, title_seed, thread_id=status_thread_id)
                    if not created:
                        _proactive_queue_mutate(qpath, item_id, _rollback_kanban)
                        return JSONResponse(content={"ok": False, "error": "no_tab"}, status_code=500)
                    status_thread_id, title, created_at = created

                try:
                    task = spawn_kanban_for_approved_observation(
                        item_id=item_id,
                        noticed=noticed,
                        proposed=proposed,
                        source=source,
                        assignee=assignee,
                        origin_thread_id=status_thread_id,
                        approved_at=approved_at,
                        drive_id=drive_id,
                    )
                except RuntimeError as exc:
                    _proactive_queue_mutate(qpath, item_id, _rollback_kanban)
                    return JSONResponse(
                        content={"ok": False, "error": "kanban_disabled", "message": str(exc)},
                        status_code=503,
                    )
                except (ValueError, PermissionError) as exc:
                    _proactive_queue_mutate(qpath, item_id, _rollback_kanban)
                    return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=400)
                except Exception as exc:
                    _log.error("proactive/approve kanban spawn failed: %s", exc, exc_info=True)
                    _proactive_queue_mutate(qpath, item_id, _rollback_kanban)
                    return JSONResponse(content={"ok": False, "error": "kanban_create_failed"}, status_code=500)

                kanban_task_id = str(task.get("id") or "").strip()
                if not kanban_task_id:
                    _proactive_queue_mutate(qpath, item_id, _rollback_kanban)
                    return JSONResponse(content={"ok": False, "error": "kanban_create_failed"}, status_code=500)

                def _set_kanban_task_id(item: dict) -> dict:
                    item["approval"] = {
                        "status": APPROVAL_APPROVED,
                        "approved_at": approved_at,
                        "kanban_task_id": kanban_task_id,
                        "thread_id": status_thread_id,
                    }
                    item["processed"] = True
                    item["notified"] = True
                    return item

                if not _proactive_queue_mutate(qpath, item_id, _set_kanban_task_id):
                    _log.error(
                        "proactive/approve: kanban task %s created but queue task_id write missed for %s; cancelling task",
                        kanban_task_id,
                        item_id,
                    )
                    try:
                        import kanban_storage as ks

                        ks.cancel_task(kanban_task_id)
                    except Exception:
                        _log.warning(
                            "proactive/approve: failed to cancel orphan kanban task %s after queue write miss",
                            kanban_task_id,
                            exc_info=True,
                        )
                    _proactive_queue_mutate(qpath, item_id, _rollback_kanban)
                    return JSONResponse(
                        content={"ok": False, "error": "kanban_queue_write_failed"},
                        status_code=500,
                    )

                if drive_id:
                    try:
                        from core import get_workspace_path

                        ok, msg = defer_linked_drive_after_kanban_approve(
                            drive_id,
                            proposed=proposed,
                            workspace=get_workspace_path(),
                        )
                        if not ok:
                            _log.warning(
                                "proactive/approve kanban: linked drive defer missed (drive=%s): %s",
                                drive_id,
                                msg,
                            )
                    except Exception:
                        _log.warning(
                            "proactive/approve kanban: linked drive reconcile failed",
                            exc_info=True,
                        )

                return JSONResponse(content={
                    "ok": True,
                    "thread_id": status_thread_id,
                    "title": title,
                    "created_at": created_at,
                    "tier": TIER_KANBAN,
                    "kanban_task_id": kanban_task_id,
                })

            if getattr(delegate, "_run_active", False):
                return JSONResponse(content={"ok": False, "reason": "agent_busy"}, status_code=409)

            if requested_thread:
                meta = getattr(delegate, "_conversation_meta", {}) or {}
                if requested_thread not in meta:
                    return JSONResponse(content={"ok": False, "error": "thread_not_found"}, status_code=404)
                thread_id = requested_thread
                title = str((meta.get(thread_id) or {}).get("title") or title)
            else:
                thread_id = str(uuid.uuid4())
                created_at = datetime.now().isoformat()

            def _mutate_starting(item: dict) -> dict:
                return observation_starting_mutator(
                    item,
                    prior_approval=appr,
                    started_at=approved_at,
                    thread_id=thread_id,
                    matched_notified=matched_notified,
                )

            new_lines, updated = update_entry_by_id_in_lines(
                lines, item_id, _mutate_starting, require_unprocessed=True,
            )
            if not updated:
                return JSONResponse(content={"ok": False, "error": "already_handled"}, status_code=409)

            with _PROACTIVE_QUEUE_FILE_LOCK:
                flock_rewrite(qpath, new_lines)
            try:
                os.chmod(qpath, 0o600)
            except OSError:
                pass

            def _rollback_in_thread(item: dict) -> dict:
                return revert_observation_to_pending_mutator(
                    item,
                    prior_approval=appr,
                    matched_notified=matched_notified,
                )

            if requested_thread:
                tab = delegate._tabs[0] if delegate._tabs else None
                if tab is not None:
                    tab["current_thread_id"] = thread_id
                delegate._current_thread_id = thread_id
                created_at = str((meta.get(thread_id) or {}).get("created_at") or datetime.now().isoformat())
            else:
                created = _proactive_create_thread(delegate, title_seed, thread_id=thread_id)
                if not created:
                    _proactive_queue_mutate(qpath, item_id, _rollback_in_thread)
                    return JSONResponse(content={"ok": False, "error": "no_tab"}, status_code=500)
                thread_id, title, created_at = created

            synthetic_prompt = (
                "The user approved your proposed work.\n\n"
                f"You noticed: {noticed}\n"
                f"Approved task: {proposed}\n\n"
                "Carry this out now using your tools as appropriate. Be concise and show what you did."
            )
            if getattr(delegate, "_run_active", False):
                _proactive_queue_mutate(qpath, item_id, _rollback_in_thread)
                return JSONResponse(content={"ok": False, "reason": "agent_busy"}, status_code=409)

            try:
                delegate._run_agent(
                    synthetic_prompt,
                    thread_id=thread_id,
                    idle_tick=False,
                    display_text="[Approved work]",
                )
            except Exception as run_exc:
                _log.error("proactive/approve agent start failed: %s", run_exc, exc_info=True)
                _proactive_queue_mutate(qpath, item_id, _rollback_in_thread)
                return JSONResponse(
                    content={"ok": False, "error": "agent_start_failed"},
                    status_code=500,
                )

            def _mutate_approved_final(item: dict) -> dict:
                item["approval"] = {
                    "status": APPROVAL_APPROVED,
                    "approved_at": approved_at,
                    "kanban_task_id": None,
                    "thread_id": thread_id,
                }
                item["processed"] = True
                item["notified"] = True
                return item

            if not _proactive_queue_mutate(qpath, item_id, _mutate_approved_final):
                _log.warning(
                    "proactive/approve in_thread: final approved write missed for %s",
                    item_id,
                )

            if drive_id:
                try:
                    from core import get_workspace_path

                    ok, msg = defer_linked_drive_after_in_thread_approve(
                        drive_id,
                        proposed=proposed,
                        workspace=get_workspace_path(),
                    )
                    if not ok:
                        _log.warning(
                            "proactive/approve in_thread: linked drive defer missed (drive=%s): %s",
                            drive_id,
                            msg,
                        )
                except Exception:
                    _log.warning(
                        "proactive/approve in_thread: linked drive reconcile failed",
                        exc_info=True,
                    )

            return JSONResponse(content={
                "ok": True,
                "thread_id": thread_id,
                "title": title,
                "created_at": created_at,
                "tier": TIER_IN_THREAD,
            })
        except Exception as exc:
            _log.error("proactive/approve error: %s", exc, exc_info=True)
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    # -------------------------------------------------------------------------
    # Text evolution: pending skill proposals
    # -------------------------------------------------------------------------

    @fastapi_app.get("/evolution/pending_skills")
    async def evolution_pending_skills():
        try:
            from evolution.pending_store import list_proposals_by_status
            proposals = list_proposals_by_status("pending")
            # Return summaries (omit full proposed_content for list view)
            items = []
            for p in proposals:
                items.append({
                    "id": p.get("id"),
                    "skill_name": p.get("skill_name"),
                    "status": p.get("status"),
                    "created_at": p.get("created_at"),
                    "metrics": p.get("metrics", {}),
                })
            return JSONResponse(content={"proposals": items, "count": len(items)})
        except Exception as exc:
            return JSONResponse(content={"proposals": [], "error": str(exc)}, status_code=500)

    @fastapi_app.get("/evolution/pending_skills/{proposal_id}")
    async def evolution_pending_skill_detail(proposal_id: str):
        try:
            from evolution.pending_store import get_proposal, is_baseline_stale
            import uuid as _uuid
            try:
                _uuid.UUID(proposal_id)
            except (ValueError, AttributeError):
                return JSONResponse(content={"error": "Invalid proposal ID"}, status_code=400)

            proposal = get_proposal(proposal_id)
            if proposal is None:
                return JSONResponse(content={"error": "Not found"}, status_code=404)

            stale = is_baseline_stale(proposal_id)

            # Read current baseline for diff
            baseline_content = ""
            try:
                from tools.skills_tools import _normalize_name, _safe_skill_path
                norm = _normalize_name(proposal["skill_name"])
                path, err = _safe_skill_path(norm, "SKILL.md")
                if path and not err and os.path.isfile(path):
                    with open(path, "r", encoding="utf-8") as f:
                        baseline_content = f.read()
            except (OSError, ImportError, KeyError, TypeError):
                _log.debug(
                    "evolution pending skill: baseline read failed for %s",
                    proposal.get("skill_name"),
                    exc_info=True,
                )

            result = dict(proposal)
            result["stale"] = bool(stale)
            result["baseline_content"] = baseline_content
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/evolution/pending_skills/{proposal_id}/accept")
    async def evolution_accept_proposal(proposal_id: str):
        try:
            import uuid as _uuid
            try:
                _uuid.UUID(proposal_id)
            except (ValueError, AttributeError):
                return JSONResponse(content={"error": "Invalid proposal ID"}, status_code=400)

            from evolution.pending_store import accept_proposal
            result = accept_proposal(proposal_id)
            return JSONResponse(content=result)
        except ValueError as exc:
            err_msg = str(exc)
            if "conflict" in err_msg.lower() or "baseline changed" in err_msg.lower():
                return JSONResponse(content={"error": err_msg}, status_code=409)
            return JSONResponse(content={"error": err_msg}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/evolution/pending_skills/{proposal_id}/reject")
    async def evolution_reject_proposal(proposal_id: str):
        try:
            import uuid as _uuid
            try:
                _uuid.UUID(proposal_id)
            except (ValueError, AttributeError):
                return JSONResponse(content={"error": "Invalid proposal ID"}, status_code=400)

            from evolution.pending_store import reject_proposal
            result = reject_proposal(proposal_id)
            return JSONResponse(content=result)
        except ValueError as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/evolution/pending_skills/reject_all")
    async def evolution_reject_all():
        try:
            from evolution.pending_store import reject_all_pending
            count = reject_all_pending()
            return JSONResponse(content={"rejected": count})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.get("/evolution/text_evolution_status")
    async def evolution_text_status():
        try:
            from evolution.skill_gepa import get_text_evolution_status
            return JSONResponse(content=get_text_evolution_status())
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.get("/skills/archived")
    async def skills_archived():
        try:
            from utils.skill_lifecycle import list_archived_workflows

            names = list_archived_workflows(limit=200)
            return JSONResponse(content={"archived": names, "count": len(names)})
        except Exception as exc:
            return JSONResponse(content={"archived": [], "count": 0, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/skills/lifecycle_status")
    async def skills_lifecycle_status():
        try:
            from utils.skill_lifecycle import get_skill_lifecycle_status

            return JSONResponse(content=get_skill_lifecycle_status())
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    # -------------------------------------------------------------------------
    # Idle in-chat tick (Phase 2: required foreground continuity)
    # -------------------------------------------------------------------------

    def _record_idle_tick_ledger(
        *,
        thread_id: str | None,
        status: str,
        skip_reason: str | None = None,
        duration_ms: int = 0,
        error_summary: str | None = None,
    ) -> None:
        try:
            from utils.task_ledger import record_run as _ledger_record
            _ledger_record(
                kind="idle_tick",
                task_name="idle_tick",
                thread_id=thread_id,
                status=status,
                skip_reason=skip_reason,
                duration_ms=duration_ms,
                error_summary=error_summary,
            )
        except Exception:
            _log.debug("idle_tick ledger record failed", exc_info=True)

    @fastapi_app.post("/idle_tick")
    async def idle_tick(request: FRequest):
        """Trigger an idle continuity tick for the foreground thread.

        Called by Swift when the main chat thread has been idle beyond the threshold.
        Checks: pref enabled, agent_plane.enabled, hot-thread guard (recent real user
        message vs ``idle_tick_hot_thread_minutes``, defaulting to 2× idle minutes),
        rate limits, pending mesh task waits,
        single-flight claim, no active run, no pending attachments.
        Does NOT bump MAIN_USER_INPUT_TS.
        """
        from utils.idle_tick import (
            acquire_idle_claim,
            build_idle_tick_message,
            check_rate_limit,
            get_idle_tick_config,
            is_idle_tick_enabled,
            pending_mesh_blocks_idle_tick,
            record_idle_tick_fired,
            release_idle_claim,
            thread_is_too_hot_for_idle_tick,
        )

        import time as _time
        _t0 = _time.monotonic()

        prefs = _load_app_prefs()
        if not is_idle_tick_enabled(prefs):
            _record_idle_tick_ledger(thread_id=None, status="skipped", skip_reason="disabled_by_user")
            return JSONResponse(content={"ok": False, "reason": "disabled_by_user"})

        tick_cfg = get_idle_tick_config()
        if not tick_cfg.get("enabled", True):
            _record_idle_tick_ledger(thread_id=None, status="skipped", skip_reason="disabled_agent_plane")
            return JSONResponse(content={"ok": False, "reason": "disabled_agent_plane"})

        try:
            body = await request.json()
        except Exception:
            body = {}
        thread_id = (body.get("thread_id") or "").strip() or delegate._current_thread_id

        if not thread_id:
            _record_idle_tick_ledger(thread_id=None, status="skipped", skip_reason="no_thread")
            return JSONResponse(content={"ok": False, "reason": "no_thread"})

        if thread_id in getattr(delegate, "_running_thread_ids", set()):
            _record_idle_tick_ledger(thread_id=thread_id, status="skipped", skip_reason="thread_running")
            return JSONResponse(content={"ok": False, "reason": "thread_running"})

        if getattr(delegate, "_run_active", False):
            _record_idle_tick_ledger(thread_id=thread_id, status="skipped", skip_reason="agent_busy")
            return JSONResponse(content={"ok": False, "reason": "agent_busy"})

        if getattr(delegate, "_attached_files", None):
            _record_idle_tick_ledger(thread_id=thread_id, status="skipped", skip_reason="pending_attachments")
            return JSONResponse(content={"ok": False, "reason": "pending_attachments"})

        hot_minutes = float(tick_cfg.get("hot_thread_minutes", 0))
        too_hot, hot_reason = thread_is_too_hot_for_idle_tick(thread_id, hot_thread_minutes=hot_minutes)
        if too_hot:
            _record_idle_tick_ledger(thread_id=thread_id, status="skipped", skip_reason=hot_reason)
            return JSONResponse(content={"ok": False, "reason": hot_reason})

        mesh_block, mesh_reason = pending_mesh_blocks_idle_tick()
        if mesh_block:
            _record_idle_tick_ledger(thread_id=thread_id, status="skipped", skip_reason=mesh_reason)
            return JSONResponse(content={"ok": False, "reason": mesh_reason})

        try:
            from utils.council_delivery import (
                council_synthesis_already_answered,
                synthesis_delivery_pending,
            )
            from utils.council_orchestrator import probe_council_active

            if council_synthesis_already_answered(thread_id):
                _record_idle_tick_ledger(
                    thread_id=thread_id,
                    status="skipped",
                    skip_reason="council_synthesis_answered",
                )
                return JSONResponse(
                    content={"ok": False, "reason": "council_synthesis_answered"},
                )
            if synthesis_delivery_pending(thread_id):
                _record_idle_tick_ledger(
                    thread_id=thread_id,
                    status="skipped",
                    skip_reason="council_awaiting_synthesis",
                )
                return JSONResponse(
                    content={"ok": False, "reason": "council_awaiting_synthesis"},
                )
            if probe_council_active(thread_id):
                _record_idle_tick_ledger(
                    thread_id=thread_id,
                    status="skipped",
                    skip_reason="council_active",
                )
                return JSONResponse(content={"ok": False, "reason": "council_active"})
        except Exception:
            _log.warning("idle_tick council gate check failed", exc_info=True)
            _record_idle_tick_ledger(
                thread_id=thread_id,
                status="skipped",
                skip_reason="council_gate_unavailable",
            )
            return JSONResponse(content={"ok": False, "reason": "council_gate_unavailable"})

        allowed, rate_reason = check_rate_limit(
            thread_id,
            max_per_day=tick_cfg.get("max_per_day", 24),
            cooldown_seconds=tick_cfg.get("cooldown_seconds", 300),
        )
        if not allowed:
            _record_idle_tick_ledger(thread_id=thread_id, status="skipped", skip_reason=rate_reason)
            return JSONResponse(content={"ok": False, "reason": rate_reason})

        owner = f"idle_tick:{os.getpid()}"
        if not acquire_idle_claim(owner):
            _record_idle_tick_ledger(thread_id=thread_id, status="skipped", skip_reason="claim_held")
            return JSONResponse(content={"ok": False, "reason": "claim_held"})

        _err: str | None = None
        try:
            record_idle_tick_fired(thread_id)

            _lifecycle_summary = ""
            _curator_summary = ""
            try:
                from utils.skill_lifecycle import apply_automatic_skill_transitions

                _lc = apply_automatic_skill_transitions(
                    app_prefs=prefs,
                    from_idle_tick=True,
                )
                if _lc.get("ok"):
                    _archived = int(len(_lc.get("archived") or []))
                    _stale = int(len(_lc.get("stale") or []))
                    _reactivated = int(len(_lc.get("reactivated") or []))
                    _rejected = int(_lc.get("pending_rejected") or 0)
                    if any((_archived, _stale, _reactivated, _rejected)):
                        _lifecycle_summary = (
                            f"Lifecycle update: archived={_archived}, stale={_stale}, "
                            f"reactivated={_reactivated}, pending_rejected={_rejected}."
                        )
            except Exception:
                _log.debug("idle_tick skill lifecycle hook failed", exc_info=True)

            try:
                from skill_curator import maybe_run_skill_curator

                _cur = maybe_run_skill_curator(app_prefs=prefs, trigger="idle_tick")
                if _cur.get("ok"):
                    _curator_summary = "Skills curator ran in the background; changes/log were recorded."
            except Exception:
                _log.debug("idle_tick skill curator hook failed", exc_info=True)

            idle_message = build_idle_tick_message()
            if _lifecycle_summary:
                idle_message = f"{idle_message}\n\n{_lifecycle_summary}"
            if _curator_summary:
                idle_message = f"{idle_message}\n\n{_curator_summary}"
            try:
                from utils.task_plan_reconcile import (
                    assess_plan_staleness,
                    format_idle_tick_plan_nudge,
                    stale_activity_summary,
                )
                from utils.task_plan_activity import emit_task_plan_stale_activity

                _tp_assess = assess_plan_staleness(thread_id)
                if _tp_assess and _tp_assess.is_stale:
                    _tp_nudge = format_idle_tick_plan_nudge(_tp_assess)
                    if _tp_nudge:
                        idle_message = f"{idle_message}\n\n{_tp_nudge}"
                    _tp_sum, _tp_meta = stale_activity_summary(_tp_assess)
                    emit_task_plan_stale_activity(
                        thread_id=thread_id,
                        summary=_tp_sum,
                        meta=_tp_meta,
                    )
            except Exception:
                _log.debug("idle_tick task_plan staleness hook failed", exc_info=True)

            delegate._run_agent(
                idle_message,
                thread_id=thread_id,
                research_mode=False,
                include_cli=False,
                has_media_attachment=False,
                display_text="[Idle continuity]",
                idle_tick=True,
            )
        except Exception as e:
            _err = str(e)
            raise
        finally:
            release_idle_claim(owner)
            _dur_ms = int((_time.monotonic() - _t0) * 1000)
            if _err is None:
                _record_idle_tick_ledger(thread_id=thread_id, status="ok", duration_ms=_dur_ms)
            else:
                _record_idle_tick_ledger(
                    thread_id=thread_id,
                    status="error",
                    duration_ms=_dur_ms,
                    error_summary=_err[:200],
                )

        return JSONResponse(content={"ok": True, "thread_id": thread_id})

    @fastapi_app.get("/idle_tick/status")
    async def idle_tick_status():
        """Return idle tick config and rate state for Swift UI."""
        from utils.idle_tick import get_idle_tick_config, is_idle_tick_enabled
        prefs = _load_app_prefs()
        cfg = get_idle_tick_config()
        return JSONResponse(content={
            "enabled": is_idle_tick_enabled(prefs),
            "agent_plane_enabled": cfg.get("enabled", True),
            "idle_minutes": cfg.get("idle_minutes", 5.0),
            "hot_thread_minutes": cfg.get("hot_thread_minutes", 10.0),
            "max_per_day": cfg.get("max_per_day", 24),
            "cooldown_seconds": cfg.get("cooldown_seconds", 300),
        })

    # Slice 7a: TTS + HTTP duplex/cancel (inject prefs, delegate, duplex state).
    from mugi_backend_voice_tts_routes import register_voice_tts_routes
    register_voice_tts_routes(
        fastapi_app,
        load_app_prefs=lambda: _load_app_prefs(),
        delegate=delegate,
        duplex_sessions=duplex_sessions,
        duplex_metrics=duplex_metrics,
    )

    # Slice 6: LM Studio / MLX / utility_model prefs (inject load/save as lambdas for test patches).
    from mugi_backend_llmster_prefs_routes import register_llmster_preferences_routes
    register_llmster_preferences_routes(
        fastapi_app,
        load_app_prefs=lambda: _load_app_prefs(),
        save_app_prefs=lambda p: _save_app_prefs(p),
    )
    from mugi_backend_mlx_prefs_routes import register_mlx_preferences_routes
    register_mlx_preferences_routes(
        fastapi_app,
        load_app_prefs=lambda: _load_app_prefs(),
        save_app_prefs=lambda p: _save_app_prefs(p),
        mlx_context_length_from_prefs=_mlx_context_length_from_prefs,
        first_run_ready_for_chat=_first_run_ready_for_chat,
    )
    from mugi_backend_utility_model_prefs_routes import register_utility_model_preferences_routes
    register_utility_model_preferences_routes(
        fastapi_app,
        log=_log,
    )

    # ------------------------------------------------------------------
    # Fine-tuning routes
    # ------------------------------------------------------------------

    @fastapi_app.post("/fine-tune/start")
    async def fine_tune_start_route(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        adapter_name = (body.get("adapter_name") or "").strip()
        if not adapter_name:
            return JSONResponse(content={"ok": False, "message": "adapter_name required"}, status_code=400)
        prefs = _load_app_prefs()
        model_path = (body.get("model_path") or prefs.get("mlx_model") or "").strip()
        if not model_path:
            return JSONResponse(content={"ok": False, "message": "No MLX model selected"}, status_code=400)
        thread_ids = body.get("thread_ids")
        try:
            iters = int(body.get("iters", prefs.get("fine_tune_default_iters", 200)))
            learning_rate = float(body.get("learning_rate", prefs.get("fine_tune_default_lr", 1e-5)))
            num_layers = int(body.get("num_layers", prefs.get("fine_tune_default_layers", 4)))
        except (TypeError, ValueError):
            return JSONResponse(
                content={"ok": False, "message": "Invalid fine-tune hyperparameters."},
                status_code=400,
            )
        try:
            from fine_tuner import run_fine_tune
        except Exception:
            return JSONResponse(content={"ok": False, "message": "fine_tuner not available"}, status_code=500)
        loop = asyncio.get_running_loop()
        try:
            result = await loop.run_in_executor(None, lambda: run_fine_tune(
                model_path=model_path,
                adapter_name=adapter_name,
                thread_ids=thread_ids,
                iters=iters,
                learning_rate=learning_rate,
                num_layers=num_layers,
            ))
            code = int(result.get("status_code") or 0) if isinstance(result, dict) else 0
            if isinstance(result, dict) and result.get("ok") is False:
                return JSONResponse(content=result, status_code=code or 500)
            return JSONResponse(content=result)
        except Exception as e:
            return JSONResponse(content={"ok": False, "message": str(e)}, status_code=500)

    @fastapi_app.get("/fine-tune/status")
    async def fine_tune_status_route():
        try:
            from fine_tuner import get_fine_tune_status
            return JSONResponse(content=get_fine_tune_status())
        except Exception as exc:
            return JSONResponse(content={"running": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/fine-tune/cancel")
    async def fine_tune_cancel_route():
        try:
            from fine_tuner import cancel_fine_tune
            result = cancel_fine_tune()
            if isinstance(result, dict) and result.get("ok") is False:
                return JSONResponse(content=result, status_code=500)
            return JSONResponse(content=result)
        except Exception:
            return JSONResponse(content={"ok": False, "message": "fine_tuner not available"}, status_code=500)

    @fastapi_app.get("/fine-tune/adapters")
    async def fine_tune_adapters_route():
        error = ""
        try:
            from app_mlx import mlx_list_adapters
            adapters = mlx_list_adapters()
        except Exception as exc:
            adapters = []
            error = str(exc)
        return JSONResponse(content={"adapters": adapters, "error": error})

    @fastapi_app.post("/fine-tune/apply")
    async def fine_tune_apply_route(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        adapter_name = (body.get("adapter_name") or "").strip()
        prefs = _load_app_prefs()
        prefs["mlx_active_adapter"] = adapter_name
        _save_app_prefs(prefs)
        return JSONResponse(content={"ok": True, "message": f"Active adapter set to '{adapter_name}'" if adapter_name else "Adapter cleared (base model)."})

    @fastapi_app.delete("/fine-tune/adapters/{name}")
    async def fine_tune_delete_adapter_route(name: str):
        try:
            from app_mlx import mlx_delete_adapter
            ok, msg = mlx_delete_adapter(name)
            prefs = _load_app_prefs()
            if ok and prefs.get("mlx_active_adapter") == name:
                prefs["mlx_active_adapter"] = ""
                _save_app_prefs(prefs)
            return JSONResponse(content={"ok": ok, "message": msg})
        except Exception as e:
            return JSONResponse(content={"ok": False, "message": str(e)})

    @fastapi_app.get("/fine-tune/tool-adapter/plan")
    async def tool_adapter_plan_route(request: FRequest):
        model_path = (request.query_params.get("model_path") or "").strip()
        try:
            from tool_adapter_trainer import resolve_tool_adapter_plan
        except Exception:
            return JSONResponse(
                content={"ok": False, "error": "tool_adapter_trainer not available"},
                status_code=500,
            )
        plan = resolve_tool_adapter_plan(model_path or None)
        status = 200 if plan.get("ok") else 400
        return JSONResponse(content=plan, status_code=status)

    @fastapi_app.post("/fine-tune/tool-adapter/start")
    async def tool_adapter_start_route(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        try:
            from tool_adapter_trainer import run_tool_adapter_train
        except Exception:
            return JSONResponse(
                content={"ok": False, "message": "tool_adapter_trainer not available"},
                status_code=500,
            )
        def _optional_int(key: str) -> int | None:
            raw = body.get(key)
            if raw is None:
                return None
            try:
                return int(raw)
            except (TypeError, ValueError):
                return None

        def _optional_float(key: str) -> float | None:
            raw = body.get(key)
            if raw is None:
                return None
            try:
                return float(raw)
            except (TypeError, ValueError):
                return None

        loop = asyncio.get_running_loop()
        try:
            result = await loop.run_in_executor(
                None,
                lambda: run_tool_adapter_train(
                    model_path=(body.get("model_path") or "").strip() or None,
                    overwrite=bool(body.get("overwrite")),
                    apply_when_finished=body.get("apply_when_finished", True) is not False,
                    iters=_optional_int("iters"),
                    num_layers=_optional_int("num_layers"),
                    learning_rate=_optional_float("learning_rate"),
                    batch_size=_optional_int("batch_size"),
                ),
            )
            code = int(result.get("status_code") or 0) if isinstance(result, dict) else 0
            if isinstance(result, dict) and result.get("ok") is False:
                return JSONResponse(content=result, status_code=code or 500)
            return JSONResponse(content=result, status_code=200)
        except Exception as e:
            return JSONResponse(content={"ok": False, "message": str(e)}, status_code=500)

    @fastapi_app.get("/software/chatterbox/status")
    async def software_chatterbox_status():
        from persistence import CHATTERBOX_HF_HOME, CHATTERBOX_VENV_DIR, MUGI_LOGS_DIR

        try:
            import voice_output

            installed = voice_output.chatterbox_installed()
            chatterbox_voices = [{"id": vid, "name": name} for vid, name in voice_output.list_chatterbox_voices()]
            voice_count = len(chatterbox_voices)
            py = os.path.join(CHATTERBOX_VENV_DIR, "bin", "python")
        except Exception:
            installed = False
            chatterbox_voices = []
            voice_count = 0
            py = ""
        return JSONResponse(
            content={
                "installed": installed,
                "venv_path": CHATTERBOX_VENV_DIR,
                "python_path": py if installed else "",
                "hf_home": CHATTERBOX_HF_HOME,
                "hf_home_exists": os.path.isdir(CHATTERBOX_HF_HOME),
                "voice_count": voice_count,
                "chatterbox_voices": chatterbox_voices,
                "installing": _software_chatterbox_install_lock.locked(),
                "install_log_path": os.path.join(MUGI_LOGS_DIR, "chatterbox_install.log"),
            }
        )

    @fastapi_app.post("/software/chatterbox/install")
    async def software_chatterbox_install(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        reinstall = bool((body or {}).get("reinstall"))
        try:
            async with _software_chatterbox_install_lock:
                ok, msg = await asyncio.to_thread(_install_chatterbox_venv, reinstall=reinstall)
        except Exception as exc:
            _log.exception("software_chatterbox_install unexpected error")
            return JSONResponse(content={"ok": False, "message": str(exc)}, status_code=200)
        return JSONResponse(content={"ok": ok, "message": msg}, status_code=200)

    @fastapi_app.post("/software/chatterbox/remove")
    async def software_chatterbox_remove(request: FRequest):
        from persistence import CHATTERBOX_HF_HOME, CHATTERBOX_VENV_DIR

        try:
            body = await request.json()
        except Exception:
            body = {}
        clear_cache = bool(body.get("clear_cache"))

        def _remove() -> tuple[bool, str]:
            try:
                import shutil

                if os.path.isdir(CHATTERBOX_VENV_DIR):
                    shutil.rmtree(CHATTERBOX_VENV_DIR)
                if clear_cache and os.path.isdir(CHATTERBOX_HF_HOME):
                    shutil.rmtree(CHATTERBOX_HF_HOME)
                prefs = _load_app_prefs()
                if str(prefs.get("tts_engine") or "").strip().lower() == "chatterbox":
                    try:
                        import voice_output

                        supertonic_ok = bool(voice_output.supertonic_available())
                    except Exception:
                        supertonic_ok = False
                    prefs["tts_engine"] = "supertonic" if supertonic_ok else "apple"
                    prefs["use_supertonic_tts"] = prefs["tts_engine"] == "supertonic"
                    _save_app_prefs(prefs)
                return True, "Chatterbox removed"
            except Exception as exc:
                return False, str(exc)

        ok, msg = await asyncio.get_running_loop().run_in_executor(None, _remove)
        code = 200 if ok else 500
        return JSONResponse(content={"ok": ok, "message": msg}, status_code=code)

    # -- Hyperframes (HTML-to-video) optional software routes ------------------

    @fastapi_app.get("/software/hyperframes/status")
    async def software_hyperframes_status():
        from utils.hyperframes_manager import hyperframes_status_dict

        status = await asyncio.to_thread(hyperframes_status_dict)
        status["installing"] = _software_hyperframes_install_lock.locked()
        return JSONResponse(content=status)

    @fastapi_app.post("/software/hyperframes/install")
    async def software_hyperframes_install(request: FRequest):
        from utils.hyperframes_manager import install_hyperframes

        try:
            body = await request.json()
        except Exception:
            body = {}
        reinstall = bool((body or {}).get("reinstall"))
        if _software_hyperframes_install_lock.locked():
            return JSONResponse(
                content={"ok": False, "message": "Install already in progress", "doctor": None},
                status_code=200,
            )
        try:
            async with _software_hyperframes_install_lock:
                ok, msg, doctor = await asyncio.to_thread(
                    install_hyperframes, reinstall=reinstall,
                )
        except Exception as exc:
            _log.exception("software_hyperframes_install unexpected error")
            return JSONResponse(
                content={"ok": False, "message": str(exc), "doctor": None},
                status_code=500,
            )
        status = 200 if ok else 500
        return JSONResponse(content={"ok": ok, "message": msg, "doctor": doctor}, status_code=status)

    @fastapi_app.post("/software/hyperframes/remove")
    async def software_hyperframes_remove(request: FRequest):
        from utils.hyperframes_manager import remove_hyperframes

        try:
            body = await request.json()
        except Exception:
            body = {}
        clear_cache = bool((body or {}).get("clear_cache"))
        try:
            ok, msg = await asyncio.to_thread(remove_hyperframes, clear_cache=clear_cache)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "message": str(exc)}, status_code=500)
        status = 200 if ok else 500
        return JSONResponse(content={"ok": ok, "message": msg}, status_code=status)

    @fastapi_app.post("/software/hyperframes/doctor")
    async def software_hyperframes_doctor():
        from utils.hyperframes_manager import doctor_hyperframes

        try:
            result = await asyncio.to_thread(doctor_hyperframes)
        except Exception as exc:
            result = {"ok": False, "error": str(exc)}
        return JSONResponse(content=result)

    def _plugins_for_api(pm, *, dashboard_only: bool = False):
        """Build plugin list for API from get_all_discovered + approval state."""
        if pm is None:
            return []
        try:
            from plugin_security import (
                PLUGIN_KIND_DASHBOARD,
                effective_plugin_settings,
                get_plugin_approval_state,
                get_plugin_settings_values,
            )
            route_errors = getattr(delegate, "_plugin_route_mount_errors", {}) or {}
            out = []
            for slug, manifest, _hash, is_loaded, error in pm.get_all_discovered():
                if dashboard_only:
                    if manifest.kind != PLUGIN_KIND_DASHBOARD:
                        continue
                elif manifest.kind == PLUGIN_KIND_DASHBOARD:
                    continue
                state = get_plugin_approval_state(slug)
                enabled = state.get("enabled", False)
                schema = pm.get_settings_schema(slug)
                stored = get_plugin_settings_values(slug, schema=schema)
                settings_effective = effective_plugin_settings(schema, stored)
                row = {
                    "slug": slug,
                    "name": manifest.name,
                    "version": manifest.version,
                    "author": manifest.author,
                    "enabled": enabled,
                    "load_error": str(error or ""),
                    "load_warnings": list(pm.get_load_warnings(slug)),
                    "mount_error": str(route_errors.get(slug) or ""),
                    "settings": settings_effective,
                }
                try:
                    row["runtime"] = pm.get_runtime_diagnostics(slug)
                except Exception:
                    row["runtime"] = {}
                if schema is not None:
                    row["settings_schema"] = schema
                out.append(row)
            return out
        except Exception:
            _log.warning("_plugins_for_api failed", exc_info=True)
            raise

    def _dashboard_request_allowed(request: FRequest) -> bool:
        t = request.headers.get("x-auth-token") or request.cookies.get("mugi_auth")
        if t != auth_token:
            return False
        # Valid token + TCP from loopback is enough for this localhost-only surface.
        # (Referer/Origin checks below break WKWebView: it may send Referer: about:blank
        # for fetches, which is not a trusted http://localhost URL → 403 and no save.)
        try:
            ch = (request.client.host if request.client else "") or ""
            if ch in ("127.0.0.1", "localhost", "::1"):
                return True
            if ch:
                import ipaddress

                if ipaddress.ip_address(ch).is_loopback:
                    return True
        except (ValueError, AttributeError):
            pass  # non-IP client host; fall through to Origin/Referer checks
        origin = (request.headers.get("origin") or "").strip()
        referer = (request.headers.get("referer") or "").strip()
        if referer in ("about:blank", "about:srcdoc"):
            referer = ""
        if origin in ("null", "about:blank"):
            origin = ""
        if origin and not app_http_routes.is_trusted_localhost_http_url(origin):
            return False
        if referer and not app_http_routes.is_trusted_localhost_http_url(referer):
            return False
        return True

    def _require_dashboard_slug(pm, slug: str) -> JSONResponse | None:
        from plugin_security import PLUGIN_KIND_DASHBOARD

        s = str(slug or "").strip()
        if not s.startswith("dash_"):
            return JSONResponse(content={"error": "invalid dashboard plugin slug"}, status_code=400)
        entry = pm._discovered_manifests.get(s)
        if not entry:
            return JSONResponse(content={"error": "not found"}, status_code=404)
        if entry[0].kind != PLUGIN_KIND_DASHBOARD:
            return JSONResponse(content={"error": "not a dashboard plugin"}, status_code=400)
        return None

    # Tile merge: module ``_merge_first_party_dashboard_tiles`` + ``BUNDLED_FIRST_PARTY_DASHBOARD_TILES``.
    # The dashboard UI is the native SwiftUI DashboardView; the WK web shell was retired (Plan 3).

    def _bundled_dashboard_feed(slug: str) -> dict:
        from dashboard_bundled.feeds import (
            feed_calendar,
            feed_kanban,
            feed_memory_note,
            feed_news,
            feed_reminders,
            feed_stocks,
            feed_system_vitals,
            feed_weather,
        )

        mapping = {
            "dash_calendar": feed_calendar,
            "dash_kanban": feed_kanban,
            "dash_markets": feed_stocks,
            "dash_memory": feed_memory_note,
            "dash_news": feed_news,
            "dash_reminders": feed_reminders,
            "dash_system": feed_system_vitals,
            "dash_weather": feed_weather,
        }
        fn = mapping.get(str(slug or ""))
        if fn is None:
            return {"version": 1, "kind": "error", "state": "error", "title": "Dashboard", "message": "Unknown bundled tile."}
        return fn()

    @fastapi_app.get("/dashboard/manifest")
    async def dashboard_manifest(request: FRequest):
        global _dash_manifest_cache
        if not _dashboard_request_allowed(request):
            return JSONResponse(content={"error": "forbidden"}, status_code=403)
        pm = delegate._plugin_manager
        if pm is None:
            return JSONResponse(
                content=_with_dashboard_user_prefs_applied(
                    _merge_first_party_dashboard_tiles({"tiles": [], "plugins": []})
                )
            )
        try:
            base = str(request.base_url).rstrip("/")
        except Exception:
            base = f"http://127.0.0.1:{int(getattr(delegate, '_http_port', 8321) or 8321)}"
        now = time.monotonic()
        with _dash_manifest_cache_lock:
            if _dash_manifest_cache is not None:
                ts, cached = _dash_manifest_cache
                if (now - ts) < _DASHBOARD_MANIFEST_TTL_S:
                    return JSONResponse(content=_with_dashboard_user_prefs_applied(cached))
        content = _merge_first_party_dashboard_tiles(pm.get_dashboard_manifest(base))
        with _dash_manifest_cache_lock:
            _dash_manifest_cache = (time.monotonic(), copy.deepcopy(content))
        return JSONResponse(content=_with_dashboard_user_prefs_applied(content))

    @fastapi_app.get("/dashboard/bundled/{slug}")
    async def dashboard_bundled_tile(slug: str, request: FRequest):
        if not _dashboard_request_allowed(request):
            return JSONResponse(content={"error": "forbidden"}, status_code=403)
        if slug in _BUNDLED_FIRST_PARTY_DASH_SLUGS and not _bundled_first_party_dashboard_tile_enabled(slug):
            return JSONResponse(
                content={
                    "version": 1,
                    "kind": "error",
                    "state": "error",
                    "title": "Dashboard",
                    "message": "Plugin disabled in Settings → Dashboard.",
                },
                status_code=404,
            )
        loop = asyncio.get_running_loop()
        payload = await loop.run_in_executor(None, lambda: _bundled_dashboard_feed(slug))
        return JSONResponse(content=payload)

    @fastapi_app.post("/dashboard/weather-location")
    async def dashboard_set_weather_location(request: FRequest):
        """Set [weather] default_location from the dashboard (no manual TOML edit). Returns fresh feed_weather() JSON."""
        if not _dashboard_request_allowed(request):
            return JSONResponse(content={"error": "forbidden"}, status_code=403)
        try:
            body = await request.json()
        except Exception:
            body = {}
        if not isinstance(body, dict):
            return JSONResponse(content={"error": "invalid body"}, status_code=400)
        loc = body.get("default_location")
        if loc is not None and not isinstance(loc, str):
            return JSONResponse(content={"error": "default_location must be a string"}, status_code=400)
        loc = (loc or "").strip().replace("\n", " ").replace("\r", "")[:200]
        try:
            from core import get_config, reload_config, save_config

            config = get_config()
            config.setdefault("weather", {})["default_location"] = loc
            save_config(config)
            reload_config()
        except Exception as e:
            return JSONResponse(content={"error": str(e)}, status_code=500)
        try:
            from dashboard_bundled.feeds import clear_weather_feed_cache, feed_weather

            clear_weather_feed_cache()
            loop = asyncio.get_running_loop()
            payload = await loop.run_in_executor(None, feed_weather)
            return JSONResponse(content=payload)
        except Exception as e:
            _log.warning("dashboard_set_weather_location feed refresh failed", exc_info=True)
            return JSONResponse(
                content={"ok": False, "reload_only": True, "message": str(e)},
                status_code=502,
            )

    # Yahoo-style tickers: AAPL, BRK.B, BTC-USD, ^GSPC, EURUSD=X.
    _MARKET_SYMBOL_RE = re.compile(r"^[A-Za-z0-9.\-^=]{1,12}$")

    @fastapi_app.post("/dashboard/market-symbols")
    async def dashboard_set_market_symbols(request: FRequest):
        """Set [dashboard] stock_symbols from the Markets tile (max 10). Returns fresh feed_stocks() JSON."""
        if not _dashboard_request_allowed(request):
            return JSONResponse(content={"error": "forbidden"}, status_code=403)
        try:
            body = await request.json()
        except Exception:
            body = {}
        if not isinstance(body, dict):
            return JSONResponse(content={"error": "invalid body"}, status_code=400)
        raw = body.get("symbols")
        if isinstance(raw, str):
            raw = [x.strip() for x in raw.replace(";", ",").split(",")]
        if not isinstance(raw, list):
            return JSONResponse(content={"error": "symbols must be a list or comma string"}, status_code=400)
        from dashboard_bundled.feeds import MARKET_SYMBOLS_MAX

        symbols: list[str] = []
        for item in raw:
            s = str(item or "").strip().upper()
            if not s:
                continue
            if not _MARKET_SYMBOL_RE.match(s):
                return JSONResponse(content={"error": f"invalid symbol: {s[:24]}"}, status_code=400)
            if s not in symbols:
                symbols.append(s)
        if not symbols:
            return JSONResponse(content={"error": "at least one symbol required"}, status_code=400)
        if len(symbols) > MARKET_SYMBOLS_MAX:
            return JSONResponse(
                content={"error": f"at most {MARKET_SYMBOLS_MAX} symbols allowed"}, status_code=400
            )
        try:
            from core import get_config, reload_config, save_config

            config = get_config()
            config.setdefault("dashboard", {})["stock_symbols"] = ",".join(symbols)
            save_config(config)
            reload_config()
        except Exception as e:
            return JSONResponse(content={"error": str(e)}, status_code=500)
        try:
            from dashboard_bundled.feeds import feed_stocks

            loop = asyncio.get_running_loop()
            payload = await loop.run_in_executor(None, feed_stocks)
            return JSONResponse(content=payload)
        except Exception as e:
            _log.warning("dashboard_set_market_symbols feed refresh failed", exc_info=True)
            return JSONResponse(
                content={"ok": False, "reload_only": True, "message": str(e)},
                status_code=502,
            )

    _MCP_CONFIG_MAX_BYTES = 512 * 1024
    _MCP_DISCOVER_TIMEOUT_SECONDS = 120.0
    _MCP_HEALTH_RATE_LIMIT_COUNT = 8
    _MCP_HEALTH_RATE_LIMIT_WINDOW_SECONDS = 60.0
    _mcp_health_rate_limit: dict[str, deque[float]] = {}
    _mcp_health_rate_limit_lock = threading.Lock()
    _GUI_BREW_FORMULA_RE = re.compile(r"^[A-Za-z0-9@._+\-]+$")
    _GUI_BREW_TAP_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
    _gui_brew_install_lock = asyncio.Lock()

    # Feeds + SearXNG/Discord/advanced/mugi_brew/dashboard-plugins: need brew/MCP guard locals above.
    from mugi_backend_preferences_satellite_routes import register_preferences_satellite_routes

    register_preferences_satellite_routes(
        fastapi_app,
        delegate=delegate,
        load_app_prefs=lambda: _load_app_prefs(),
        persist_searxng_setup_success=_persist_config_after_searxng_setup_success,
        persist_searxng_setup_failure=_persist_config_after_searxng_setup_failure,
        plugins_for_api=_plugins_for_api,
        max_json_body_bytes=_MCP_CONFIG_MAX_BYTES,
        gui_brew_formula_re=_GUI_BREW_FORMULA_RE,
        gui_brew_tap_re=_GUI_BREW_TAP_RE,
        gui_brew_install_lock=_gui_brew_install_lock,
        log=_log,
    )

    _software_chatterbox_install_lock = asyncio.Lock()
    _software_hyperframes_install_lock = asyncio.Lock()
    # /preferences/mcp/*: same MCP byte cap + health rate-limit dict/lock as satellite (see stanza above).
    from mugi_backend_mcp_prefs_routes import register_mcp_preferences_routes

    register_mcp_preferences_routes(
        fastapi_app,
        load_app_prefs=lambda: _load_app_prefs(),
        log=_log,
        max_config_bytes=_MCP_CONFIG_MAX_BYTES,
        discover_timeout_seconds=_MCP_DISCOVER_TIMEOUT_SECONDS,
        health_rate_limit_count=_MCP_HEALTH_RATE_LIMIT_COUNT,
        health_rate_limit_window_seconds=_MCP_HEALTH_RATE_LIMIT_WINDOW_SECONDS,
        health_rate_limit_state=_mcp_health_rate_limit,
        health_rate_limit_lock=_mcp_health_rate_limit_lock,
    )

    # ── Knowledge Base routes ─────────────────────────────────────────────
    _kb_rate_lock = threading.Lock()
    _kb_rate_window: dict[str, list[float]] = {"digest": [], "refresh": [], "export": []}

    def _kb_allow(action: str, *, limit: int, window_seconds: float) -> bool:
        now = time.time()
        with _kb_rate_lock:
            entries = _kb_rate_window.setdefault(action, [])
            entries[:] = [ts for ts in entries if (now - ts) < window_seconds]
            if len(entries) >= limit:
                return False
            entries.append(now)
            return True

    @fastapi_app.post("/knowledge/digest")
    async def knowledge_digest(req: FRequest):
        if not _kb_allow("digest", limit=6, window_seconds=60.0):
            return JSONResponse(
                content={"ok": False, "error": "knowledge digest rate-limited", "retry_after_seconds": 60},
                status_code=429,
            )
        raw = await req.body()
        if len(raw) > 64_000:
            return JSONResponse(content={"ok": False, "error": "request too large"}, status_code=413)
        try:
            body = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw or "{}"))
        except Exception:
            return JSONResponse(content={"ok": False, "error": "invalid JSON body"}, status_code=400)
        if not isinstance(body, dict):
            return JSONResponse(content={"ok": False, "error": "invalid JSON body"}, status_code=400)
        source_path = str(body.get("path") or "").strip()
        label = str(body.get("label") or "").strip()
        if not source_path or not label:
            return JSONResponse(content={"ok": False, "error": "path and label are required"}, status_code=400)
        try:
            from tools.knowledge_tools import normalize_ingest_profile, start_digest_job

            profile = normalize_ingest_profile(
                str(body.get("ingest_profile") or "docs"),
                source_path=source_path,
            )
            result = await asyncio.to_thread(
                start_digest_job,
                source_path,
                label,
                update=False,
                ingest_profile=profile,
            )
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)
        if not result.get("ok"):
            err_text = str(result.get("error") or "").lower()
            if "digest already running" in err_text:
                return JSONResponse(
                    content={**result, "ok": True, "status": "running", "already_running": True},
                    status_code=200,
                )
            if "too many concurrent digests" in err_text:
                return JSONResponse(content={**result, "retry_after_seconds": 30}, status_code=429)
            return JSONResponse(content=result, status_code=409)
        return JSONResponse(content=result)

    @fastapi_app.get("/knowledge/list")
    async def knowledge_list():
        try:
            from tools.knowledge_tools import list_knowledge_corpora

            # Read-only: a sidebar refresh must not rewrite manifests on the GET path.
            corpora = await asyncio.to_thread(list_knowledge_corpora, repair=False)
            return JSONResponse(content={"ok": True, "corpora": corpora})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/knowledge/{slug}/upgrade_summaries")
    async def knowledge_upgrade_summaries(slug: str):
        if not _kb_allow("refresh", limit=3, window_seconds=300.0):
            return JSONResponse(
                content={"ok": False, "error": "summary regeneration rate-limited"},
                status_code=429,
            )
        try:
            from tools.knowledge_tools import start_upgrade_summaries_job

            result = await asyncio.to_thread(start_upgrade_summaries_job, slug)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)
        if not result.get("ok"):
            return JSONResponse(content=result, status_code=400)
        return JSONResponse(content=result)

    @fastapi_app.post("/knowledge/{slug}/refresh")
    async def knowledge_refresh(slug: str, req: FRequest):
        if not _kb_allow("refresh", limit=10, window_seconds=60.0):
            return JSONResponse(content={"ok": False, "error": "knowledge refresh rate-limited"}, status_code=429)
        stale_only = False
        try:
            raw = await req.body()
            if raw:
                body = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw))
                if isinstance(body, dict):
                    stale_only = str(body.get("stale_only") or "").lower() in ("1", "true", "yes")
        except Exception:
            stale_only = False
        try:
            from tools.knowledge_tools import refresh_digest_job

            result = await asyncio.to_thread(refresh_digest_job, slug, stale_only=stale_only)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)
        if not result.get("ok"):
            return JSONResponse(content=result, status_code=400)
        return JSONResponse(content=result)

    @fastapi_app.delete("/knowledge/{slug}")
    async def knowledge_delete(slug: str):
        try:
            from tools.knowledge_tools import delete_corpus

            removed = await asyncio.to_thread(delete_corpus, slug)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)
        if not removed:
            return JSONResponse(content={"ok": False, "error": "Corpus not found"}, status_code=404)
        return JSONResponse(content={"ok": True})

    @fastapi_app.get("/knowledge/{slug}/status")
    async def knowledge_status(slug: str):
        try:
            from tools.knowledge_tools import get_knowledge_status

            # Read-only: a status poll must never stop an ingest or rewrite a manifest.
            # Zombie cleanup runs from the heartbeat staleness sweep (reconcile_jobs=True).
            result = await asyncio.to_thread(get_knowledge_status, slug, reconcile_jobs=False)
            return JSONResponse(content={"ok": True, **result})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    @fastapi_app.post("/knowledge/{slug}/pause")
    async def knowledge_pause(slug: str):
        if not _kb_allow("pause", limit=30, window_seconds=60.0):
            return JSONResponse(content={"ok": False, "error": "knowledge pause rate-limited"}, status_code=429)
        try:
            from tools.knowledge_tools import pause_digest_job

            result = await asyncio.to_thread(pause_digest_job, slug)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)
        if not result.get("ok"):
            return JSONResponse(content=result, status_code=400)
        return JSONResponse(content=result)

    @fastapi_app.post("/knowledge/{slug}/check_staleness")
    async def knowledge_check_staleness(slug: str):
        try:
            from tools.knowledge_tools import check_corpus_staleness

            result = await asyncio.to_thread(check_corpus_staleness, slug)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)
        if not result.get("ok"):
            return JSONResponse(content=result, status_code=404)
        return JSONResponse(content=result)

    @fastapi_app.get("/knowledge/export")
    async def knowledge_export():
        if not _kb_allow("export", limit=3, window_seconds=60.0):
            return JSONResponse(content={"ok": False, "error": "knowledge export rate-limited"}, status_code=429)
        try:
            import io
            import zipfile

            from tools.knowledge_tools import _knowledge_root  # type: ignore

            root = await asyncio.to_thread(_knowledge_root)
            buffer = io.BytesIO()
            with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf:
                for entry in os.listdir(root):
                    corpus_dir = os.path.join(root, entry)
                    if not os.path.isdir(corpus_dir):
                        continue
                    for rel in ("manifest.json", "index.md", "synthesis.md"):
                        p = os.path.join(corpus_dir, rel)
                        if os.path.isfile(p):
                            zf.write(p, arcname=os.path.join(entry, rel))
                    src_dir = os.path.join(corpus_dir, "sources")
                    if os.path.isdir(src_dir):
                        for name in os.listdir(src_dir):
                            p = os.path.join(src_dir, name)
                            if os.path.isfile(p):
                                zf.write(p, arcname=os.path.join(entry, "sources", name))
            payload = buffer.getvalue()
            headers = {"Content-Disposition": f'attachment; filename="knowledge_export_{int(time.time())}.zip"'}
            return Response(content=payload, media_type="application/zip", headers=headers)
        except Exception as exc:
            return JSONResponse(content={"ok": False, "error": str(exc)}, status_code=500)

    # ── Phase 3a: Terminal stream SSE ─────────────────────────────────────

    _terminal_subscribers: set[asyncio.Queue] = set()
    _terminal_lock = threading.Lock()
    _terminal_drain_started = False

    def _start_terminal_drain():
        nonlocal _terminal_drain_started
        if _terminal_drain_started:
            return
        _terminal_drain_started = True
        import app_terminal
        src_q = app_terminal.get_queue()

        def _drain():
            while True:
                try:
                    line = src_q.get(timeout=1.0)
                except Empty:
                    continue
                with _terminal_lock:
                    dead = []
                    for sub in _terminal_subscribers:
                        try:
                            sub.put_nowait(line)
                        except asyncio.QueueFull:
                            dead.append(sub)
                    for d in dead:
                        _terminal_subscribers.discard(d)

        Thread(target=_drain, daemon=True).start()

    @fastapi_app.get("/terminal/stream")
    async def terminal_stream():
        _start_terminal_drain()
        sub: asyncio.Queue = asyncio.Queue(maxsize=4096)
        with _terminal_lock:
            _terminal_subscribers.add(sub)

        async def _gen():
            try:
                while True:
                    try:
                        line = await asyncio.wait_for(sub.get(), timeout=5.0)
                        yield f"data: {json.dumps({'type': 'line', 'text': line})}\n\n"
                    except asyncio.TimeoutError:
                        yield ": heartbeat\n\n"
            except asyncio.CancelledError:
                return
            finally:
                with _terminal_lock:
                    _terminal_subscribers.discard(sub)

        return StreamingResponse(
            _gen(),
            media_type="text/event-stream",
            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
        )

    # ── Phase 3c: Scratchpad CRUD ─────────────────────────────────────────

    def _scratchpad_tab_id(tab_id: str = "", thread_id: str = "") -> str:
        """Resolve to tab_id from either tab_id or thread_id (thread_id takes precedence for Swift client)."""
        thread_id = (thread_id or "").strip()
        tab_id = (tab_id or "").strip()
        if thread_id:
            for tab in delegate._tabs or []:
                if thread_id in (tab.get("thread_ids") or []):
                    return tab.get("tab_id", "") or ""
        if tab_id:
            return tab_id
        if delegate._tabs:
            return delegate._tabs[0].get("tab_id", "") or ""
        return ""

    @fastapi_app.get("/scratchpads")
    async def list_scratchpads(
        tab_id: str = FQuery("", alias="tab_id"),
        thread_id: str = FQuery("", alias="thread_id"),
    ):
        tid = _scratchpad_tab_id(tab_id=tab_id, thread_id=thread_id)
        try:
            from chat_storage import get_scratchpads
            pads = get_scratchpads(tid) or []
        except Exception:
            pads = []
        return JSONResponse(content=pads)

    @fastapi_app.get("/scratchpad/{sp_id}")
    async def get_scratchpad(
        sp_id: str,
        tab_id: str = FQuery("", alias="tab_id"),
        thread_id: str = FQuery("", alias="thread_id"),
    ):
        from utils.llm_token_normalize import normalize_uuid_token

        sp_id = normalize_uuid_token((sp_id or "").strip())
        tid = _scratchpad_tab_id(tab_id=tab_id, thread_id=thread_id)
        try:
            from chat_storage import get_scratchpads
            pads = get_scratchpads(tid) or []
            for p in pads:
                if normalize_uuid_token(str(p.get("id", ""))) == sp_id:
                    return JSONResponse(content=p)
            return JSONResponse(content={"error": "not found"}, status_code=404)
        except Exception:
            _log.warning("Failed to get scratchpad %s", sp_id, exc_info=True)
            return JSONResponse(content={"error": "internal error"}, status_code=500)

    @fastapi_app.post("/scratchpad")
    async def create_scratchpad(req: FRequest):
        try:
            raw = await req.body()
            body = json.loads(raw) if raw else {}
        except Exception:
            body = {}
        if not isinstance(body, dict):
            body = {}
        try:
            tab_id = _scratchpad_tab_id(
                tab_id=(body.get("tab_id") or "").strip(),
                thread_id=(body.get("thread_id") or "").strip(),
            )
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)
        if not tab_id:
            return JSONResponse(
                content={"error": "No tab or thread context; try selecting a conversation first."},
                status_code=400,
            )
        title = body.get("title", "Untitled")
        text_body = body.get("body", "")
        try:
            from chat_storage import create_scratchpad
            sp_id = create_scratchpad(tab_id, title, text_body)
            return JSONResponse(content={"id": sp_id, "title": title, "body": text_body})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.put("/scratchpad/{sp_id}")
    async def update_scratchpad(sp_id: str, request: FRequest):
        from utils.llm_token_normalize import normalize_uuid_token

        sp_id = normalize_uuid_token((sp_id or "").strip())
        try:
            body = await request.json()
        except Exception:
            body = {}
        tab_id = _scratchpad_tab_id(
            tab_id=(body.get("tab_id") or "").strip(),
            thread_id=(body.get("thread_id") or "").strip(),
        )
        title = body.get("title", "")
        text_body = body.get("body", "")
        try:
            from chat_storage import update_scratchpad as update_existing_scratchpad
            updated = update_existing_scratchpad(sp_id, tab_id, title, text_body)
            if not updated:
                return JSONResponse(content={"error": "not found"}, status_code=404)
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.delete("/scratchpad/{sp_id}")
    async def delete_scratchpad_route(sp_id: str):
        from utils.llm_token_normalize import normalize_uuid_token

        sp_id = normalize_uuid_token((sp_id or "").strip())
        try:
            from chat_storage import delete_scratchpad
            delete_scratchpad(sp_id)
            return JSONResponse(content={"ok": True})
        except Exception:
            _log.warning("Failed to delete scratchpad %s", sp_id, exc_info=True)
            return JSONResponse(content={"error": "internal error"}, status_code=500)

    # ── Phase 4: Structured chat stream SSE ───────────────────────────────

    # ── SSE Event Contract (chat/stream) ──────────────────────────────
    # Event types and their payload shapes (all wrapped in {"type": …}):
    #
    #   thinking_show      — {}                              Spinner on
    #   thinking_hide      — {}                              Spinner off
    #   message_add        — {text, is_user, timestamp,      New chat bubble
    #                         bubble_type?, image_urls?}
    #   chunk              — {text}                          Streaming LLM token
    #   message_update     — {text}                          Replace last agent bubble text
    #   tool_start         — {tool_name, tool_call_id,       Tool execution begins
    #                         summary?}
    #   tool_result        — {tool_name, tool_call_id,       Tool execution done
    #                         success, summary?}
    #   image_add          — {image_urls}                    Append images to last bubble
    #   display_window     — {url, content_type, title}      Open media in separate window
    #   context_usage      — {estimated_tokens,              Context window telemetry
    #                         context_limit,
    #                         messages_count,
    #                         summarization_trigger_tokens}
    #   memory_turn_summary — {thread_id, …}                Memory inspector delta
    #   knowledge_turn_summary — {thread_id, …}               Knowledge inspector delta
    #   conversations_changed — {}                           Sidebar should reload
    #   status             — {text, visible}                 Live activity row (AgentActivityRow)
    #   send_button        — {state}                         "send" | "stop"
    #   input_clear        — {}                              Clear compose box
    #   messages_reload    — {}                              Client should re-fetch history
    #   system_event       — {text, source?, timestamp?}     System notification bubble
    #   btw_echo           — {text, is_user, timestamp}      /btw user echo
    #   btw_pending        — {text, timestamp}               /btw thinking
    #   btw_result         — {text, is_user, timestamp}      /btw answer
    #   duplex_user_partial — {text}                         Live STT partial transcript
    #   duplex_user_final  — {text, timestamp}               Committed STT utterance
    #   duplex_agent_state — {state}                         Duplex agent lifecycle
    #   duplex_interrupted — {}                              Voice interruption
    #
    # Mirror: MugiApp/Mugi/Chat/ChatViewModel.swift processEvent()
    # ──────────────────────────────────────────────────────────────────
    @fastapi_app.get("/chat/stream")
    async def chat_stream_sse(thread_id: str = FQuery("", alias="thread_id")):
        """Structured event stream for native SwiftUI chat (Phase 4).

        Instead of JS snippets, emits typed JSON events that the native
        ChatViewModel can interpret directly.
        """
        tid = (thread_id or "").strip() or delegate._current_thread_id
        sub_queue = delegate._structured_bus.subscribe(tid)

        async def _gen():
            try:
                while True:
                    try:
                        evt = await asyncio.wait_for(sub_queue.get(), timeout=15.0)
                    except asyncio.TimeoutError:
                        yield ": heartbeat\n\n"
                        continue
                    # evt is already a JSON string from _push_event; emit directly
                    yield f"data: {evt}\n\n"
            except asyncio.CancelledError:
                return
            finally:
                delegate._structured_bus.unsubscribe(tid, sub_queue)

        return StreamingResponse(
            _gen(),
            media_type="text/event-stream",
            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
        )

    @fastapi_app.get("/chat/thread_state")
    async def chat_thread_state(thread_id: str = FQuery("", alias="thread_id")):
        """Return whether an agent is currently running for this thread (so the client can restore UI state when switching back)."""
        tid = (thread_id or "").strip() or delegate._current_thread_id
        running = tid in getattr(delegate, "_running_thread_ids", set())
        coordinator_mode = False
        coordinator_batch_id = ""
        coordinator_open_tasks = 0
        council_active = False
        council_batch_id = ""
        council_seat_in_progress = ""
        council_awaiting_synthesis = False
        council_failed = False
        council_failure_summary = ""
        council_failure_batch_id = ""
        coordinator_blocked_tasks = 0
        council_pending_offer: dict = {}
        try:
            from utils.kanban_coordinator import (
                count_coordinator_blocked_tasks,
                probe_board_active,
                resolve_coordinator_batch_id,
                tasks_for_active_batch,
                TERMINAL_BOARD_STATUSES,
            )

            if tid and probe_board_active(tid):
                coordinator_mode = True
                coordinator_batch_id = resolve_coordinator_batch_id(tid)
                tasks = tasks_for_active_batch(tid)
                coordinator_open_tasks = sum(
                    1 for t in tasks if (t.get("status") or "") not in TERMINAL_BOARD_STATUSES
                )
                coordinator_blocked_tasks = count_coordinator_blocked_tasks(tid)
        except Exception:
            _log.debug("chat_thread_state coordinator probe failed", exc_info=True)
        try:
            from utils.council_delivery import (
                probe_council_thread_failure_summary,
                synthesis_delivery_pending,
            )
            from utils.council_orchestrator import (
                council_seat_in_progress_for_batch,
                probe_council_active,
                probe_council_batch_id,
                probe_council_open_batch_id,
            )

            if tid:
                open_batch_id = probe_council_open_batch_id(tid)
                council_awaiting_synthesis = synthesis_delivery_pending(tid)
                if open_batch_id:
                    council_active = True
                    council_batch_id = open_batch_id
                    council_seat_in_progress = council_seat_in_progress_for_batch(
                        tid,
                        open_batch_id,
                    )
                elif council_awaiting_synthesis:
                    council_active = True
                    council_batch_id = probe_council_batch_id(tid)
                council_failed, council_failure_summary, council_failure_batch_id = (
                    probe_council_thread_failure_summary(tid)
                )
                if council_active:
                    council_failed = False
                    council_failure_summary = ""
                    council_failure_batch_id = ""
        except Exception:
            _log.debug("chat_thread_state council probe failed", exc_info=True)
        try:
            from utils.council_offers import get_pending_offer_for_thread

            if tid:
                row = get_pending_offer_for_thread(tid)
                if row:
                    frame = str(row.get("council_frame") or "")
                    council_pending_offer = {
                        "offer_id": str(row.get("offer_id") or ""),
                        "rationale": str(row.get("rationale") or ""),
                        "frame_preview": frame[:240],
                    }
        except Exception:
            _log.debug("chat_thread_state council offer probe failed", exc_info=True)
        return JSONResponse(
            content={
                "running": running,
                "coordinator_mode": coordinator_mode,
                "coordinator_batch_id": coordinator_batch_id,
                "coordinator_open_tasks": coordinator_open_tasks,
                "council_active": council_active,
                "council_batch_id": council_batch_id,
                "council_seat_in_progress": council_seat_in_progress,
                "council_awaiting_synthesis": council_awaiting_synthesis,
                "council_failed": council_failed,
                "council_failure_summary": council_failure_summary,
                "council_failure_batch_id": council_failure_batch_id,
                "coordinator_blocked_tasks": coordinator_blocked_tasks,
                "council_pending_offer": council_pending_offer,
            }
        )

    @fastapi_app.post("/council/approve_offer")
    async def council_approve_offer(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        if not isinstance(body, dict):
            return JSONResponse(content={"ok": False, "error": "invalid"}, status_code=400)
        thread_id = str(body.get("thread_id") or "").strip()
        offer_id = str(body.get("offer_id") or "").strip()
        if not thread_id or not offer_id:
            return JSONResponse(
                content={"ok": False, "error": "thread_id and offer_id required"},
                status_code=400,
            )
        if getattr(delegate, "_run_active", False) and thread_id == getattr(
            delegate, "_current_thread_id", ""
        ):
            return JSONResponse(content={"ok": False, "error": "agent_busy"}, status_code=409)
        if thread_id in getattr(delegate, "_running_thread_ids", set()):
            return JSONResponse(content={"ok": False, "error": "agent_busy"}, status_code=409)

        from utils.council_offers import (
            approve_pending_offer,
            build_council_honest_stop_prompt,
            deliver_council_spawn_activation,
            schedule_council_honest_stop_wake,
        )

        result = approve_pending_offer(thread_id=thread_id, offer_id=offer_id)
        status = int(result.pop("status", 200))
        if not result.get("ok"):
            try:
                delegate._push_event({"type": "messages_reload"}, thread_id)
                delegate._push_event({"type": "thread_state_reload"}, thread_id)
            except Exception:
                _log.debug("council approve failure reload failed", exc_info=True)
            return JSONResponse(content=result, status_code=status)

        msg = str(result.get("message") or "")
        if not deliver_council_spawn_activation(thread_id, message=msg):
            _log.warning("council approve activation bubble missing for %s", thread_id)
        try:
            delegate._push_event({"type": "messages_reload"}, thread_id)
            delegate._push_event({"type": "thread_state_reload"}, thread_id)
        except Exception:
            _log.debug("council approve reload failed", exc_info=True)

        wake_deferred = False
        honest_stop_pending = False
        thread_busy = (
            thread_id in getattr(delegate, "_running_thread_ids", set())
            or (
                getattr(delegate, "_run_active", False)
                and thread_id == getattr(delegate, "_current_thread_id", "")
            )
        )
        if thread_busy:
            schedule_council_honest_stop_wake(thread_id)
            wake_deferred = True
        else:
            try:
                honest = build_council_honest_stop_prompt(thread_id)
                delegate._run_agent(
                    honest,
                    thread_id=thread_id,
                    idle_tick=True,
                    display_text="[Council convened]",
                )
            except Exception as run_exc:
                _log.error("council/approve_offer wake failed: %s", run_exc, exc_info=True)
                schedule_council_honest_stop_wake(thread_id)
                wake_deferred = True
                honest_stop_pending = True

        return JSONResponse(
            content={
                "ok": True,
                "thread_id": thread_id,
                "council_batch_id": result.get("council_batch_id"),
                "honest_stop": not wake_deferred,
                "wake_deferred": wake_deferred,
                "honest_stop_pending": honest_stop_pending,
            }
        )

    @fastapi_app.post("/council/dismiss_offer")
    async def council_dismiss_offer(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        if not isinstance(body, dict):
            return JSONResponse(content={"ok": False, "error": "invalid"}, status_code=400)
        thread_id = str(body.get("thread_id") or "").strip()
        offer_id = str(body.get("offer_id") or "").strip()
        if not thread_id or not offer_id:
            return JSONResponse(
                content={"ok": False, "error": "thread_id and offer_id required"},
                status_code=400,
            )

        from utils.council_offers import dismiss_pending_offer, schedule_council_skip_wake
        from utils.council_triggers import COUNCIL_SKIP_LINE

        result = dismiss_pending_offer(thread_id=thread_id, offer_id=offer_id)
        status = int(result.pop("status", 200))
        if not result.get("ok"):
            return JSONResponse(content=result, status_code=status)

        try:
            delegate._push_event({"type": "messages_reload"}, thread_id)
            delegate._push_event({"type": "thread_state_reload"}, thread_id)
        except Exception:
            _log.debug("council dismiss reload failed", exc_info=True)

        thread_busy = (
            thread_id in getattr(delegate, "_running_thread_ids", set())
            or (
                getattr(delegate, "_run_active", False)
                and thread_id == getattr(delegate, "_current_thread_id", "")
            )
        )
        if thread_busy:
            schedule_council_skip_wake(thread_id)
            return JSONResponse(
                content={"ok": True, "thread_id": thread_id, "deferred_wake": True},
            )

        try:
            delegate._run_agent(
                COUNCIL_SKIP_LINE,
                thread_id=thread_id,
                idle_tick=False,
                display_text="[Skip council]",
            )
        except Exception as run_exc:
            _log.error("council/dismiss_offer wake failed: %s", run_exc, exc_info=True)
            schedule_council_skip_wake(thread_id)
            return JSONResponse(
                content={
                    "ok": True,
                    "thread_id": thread_id,
                    "deferred_wake": True,
                    "wake_error": "agent_start_failed",
                },
            )

        return JSONResponse(content={"ok": True, "thread_id": thread_id})

    @fastapi_app.post("/council/stop")
    async def council_stop(request: FRequest):
        if not _dashboard_request_allowed(request):
            return JSONResponse(content={"ok": False, "error": "forbidden"}, status_code=403)
        try:
            body = await request.json()
        except Exception:
            body = {}
        if not isinstance(body, dict):
            return JSONResponse(content={"ok": False, "error": "invalid"}, status_code=400)
        thread_id = str(body.get("thread_id") or "").strip()
        if not thread_id:
            return JSONResponse(
                content={"ok": False, "error": "thread_id required"},
                status_code=400,
            )
        from utils.council_orchestrator import stop_council_batch

        result = stop_council_batch(thread_id=thread_id, reason="operator_stop")
        status = int(result.pop("status", 200))
        if not result.get("ok"):
            return JSONResponse(content=result, status_code=status)
        try:
            delegate._push_event({"type": "messages_reload"}, thread_id)
            delegate._push_event({"type": "thread_state_reload"}, thread_id)
        except Exception:
            _log.debug("council stop reload failed", exc_info=True)
        return JSONResponse(content=result)

    @fastapi_app.get("/council/transcript")
    async def council_transcript(request: FRequest):
        if not _dashboard_request_allowed(request):
            return JSONResponse(content={"ok": False, "error": "forbidden"}, status_code=403)
        thread_id = str(request.query_params.get("thread_id") or "").strip()
        batch_id = str(request.query_params.get("batch_id") or "").strip()
        if not thread_id or not batch_id:
            return JSONResponse(
                content={"ok": False, "error": "thread_id and batch_id required"},
                status_code=400,
            )
        from utils.council_orchestrator import build_council_transcript

        try:
            transcript = build_council_transcript(thread_id=thread_id, batch_id=batch_id)
        except ValueError as exc:
            return JSONResponse(
                content={"ok": False, "error": str(exc)},
                status_code=404,
            )
        except Exception:
            _log.debug("council transcript build failed", exc_info=True)
            return JSONResponse(
                content={"ok": False, "error": "transcript_unavailable"},
                status_code=500,
            )
        return JSONResponse(content={"ok": True, "transcript": transcript})


    # Slice 7b: Voice session + duplex WebSockets (inject prefs, delegate, metrics, auth).
    from mugi_backend_voice_duplex_routes import register_voice_duplex_routes
    register_voice_duplex_routes(
        fastapi_app,
        delegate=delegate,
        load_app_prefs=lambda: _load_app_prefs(),
        log=_log,
        duplex_sessions=duplex_sessions,
        duplex_metrics=duplex_metrics,
        voice_session_metrics=voice_session_metrics,
        mlx_audio_service=mlx_audio_service,
        auth_websocket=_auth_websocket_or_close,
    )

    @fastapi_app.get("/plugin_stream/stream")
    async def plugin_stream_sse(thread_id: str = FQuery("", alias="thread_id")):
        """Structured plugin stream SSE for native realtime dashboards."""
        tid = (thread_id or "").strip() or delegate._current_thread_id
        sub_queue = delegate._plugin_stream_bus.subscribe(tid)
        async def _gen():
            try:
                while True:
                    try:
                        evt = await asyncio.wait_for(sub_queue.get(), timeout=15.0)
                    except asyncio.TimeoutError:
                        yield ": heartbeat\n\n"
                        continue
                    yield f"data: {evt}\n\n"
            except asyncio.CancelledError:
                return
            finally:
                delegate._plugin_stream_bus.unsubscribe(tid, sub_queue)

        return StreamingResponse(
            _gen(),
            media_type="text/event-stream",
            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
        )

    @fastapi_app.post("/plugin_stream/control")
    async def plugin_stream_control(req: FRequest):
        """Reverse channel for dashboard controls (UI -> backend -> plugin)."""
        from tools.job_stream_schema import validate_control_value, find_control_def

        try:
            body = await req.json()
        except Exception:
            body = {}
        if not isinstance(body, dict):
            body = {}
        plugin_id = str(body.get("plugin_id") or "").strip()
        from utils.llm_token_normalize import normalize_stream_id_token

        stream_id = normalize_stream_id_token(str(body.get("stream_id") or "").strip())
        control_id = str(body.get("control_id") or "").strip()
        action_id = str(body.get("action_id") or "").strip() or None
        event_id = str(body.get("event_id") or "").strip() or None
        value = body.get("value")
        if not plugin_id or not stream_id or not control_id:
            return JSONResponse(
                content={"ok": False, "status": "rejected", "message": "Missing plugin_id, stream_id, or control_id."},
                status_code=400,
            )
        meta = delegate.get_plugin_stream_meta(stream_id)
        if not meta:
            return JSONResponse(
                content={"ok": False, "status": "rejected", "message": "Unknown or stale stream_id."},
                status_code=404,
            )
        expected_plugin_id = str(meta.get("plugin_id") or "").strip()
        if expected_plugin_id and expected_plugin_id != plugin_id:
            return JSONResponse(
                content={"ok": False, "status": "rejected", "message": "plugin_id does not match stream owner."},
                status_code=400,
            )
        tid = str(body.get("thread_id") or "").strip() or str(meta.get("thread_id") or "").strip() or delegate._current_thread_id
        ts_raw = body.get("timestamp_ms")
        try:
            ts_ms = int(ts_raw) if ts_raw is not None else int(time.time() * 1000)
        except Exception:
            ts_ms = int(time.time() * 1000)

        cdef = find_control_def(meta.get("controls"), control_id)
        if cdef is None:
            result = {"ok": False, "status": "rejected", "message": f"Control '{control_id}' is not registered for stream."}
        else:
            valid, coerced_value, _cdef, err_msg, ctype = validate_control_value(cdef, value, action_id=action_id)
            if not valid:
                result = {"ok": False, "status": "rejected", "message": err_msg}
            else:
                # Built-in tools use plugin_stream_controls shared state.
                # Third-party plugins go through PluginManager.dispatch_control_event.
                _BUILTIN_STREAM_IDS = {
                    "network_traffic_monitor",
                    "background_tool",
                    "research_job",
                    "media_transcode",
                    "shell_job",
                    "file_download",
                    "ocr_job",
                    "collector_feed",
                    "collector_watch",
                }
                if expected_plugin_id in _BUILTIN_STREAM_IDS:
                    from tools.plugin_stream_controls import set_control_value as _sc_set, trigger_action as _sc_trigger
                    if ctype == "button":
                        action_key = str(cdef.get("action_id") or action_id or control_id).strip()
                        if expected_plugin_id == "background_tool":
                            from subagent_registry import get_subagent_registry
                            stop_info = get_subagent_registry().stop(stream_id)
                            if "error" in stop_info and "handle_id" not in stop_info:
                                result = {"ok": False, "status": "rejected", "message": stop_info["error"]}
                            else:
                                result = None
                        else:
                            _sc_trigger(stream_id, action_key)
                            result = None
                    else:
                        _sc_set(stream_id, control_id, coerced_value)
                        result = None
                    if result is None:
                        result = {
                            "ok": True,
                            "status": "applied",
                            "message": "Control applied.",
                            "plugin_id": expected_plugin_id,
                            "stream_id": stream_id,
                            "control_id": control_id,
                            "value": coerced_value,
                            "action_id": action_id,
                        }
                else:
                    pm = getattr(delegate, "_plugin_manager", None)
                    if pm is None:
                        return JSONResponse(
                            content={"ok": False, "status": "error", "message": "Plugin manager unavailable."},
                            status_code=500,
                        )
                    result = pm.dispatch_control_event(
                        plugin_id=plugin_id,
                        stream_id=stream_id,
                        control_id=control_id,
                        value=coerced_value,
                        action_id=action_id,
                        thread_id=tid,
                        event_id=event_id,
                        timestamp_ms=ts_ms,
                    )
        ack_status = str(result.get("status") or "rejected")
        ack_message = str(result.get("message") or "")
        ack_data = {
            "control_id": control_id,
            "status": ack_status,
            "message": ack_message,
            "value": result.get("value"),
            "action_id": action_id,
            "event_id": event_id,
        }
        # Let plugins request client actions: open_url (open in system browser), copy_text (copy to clipboard)
        # Sanitize so we never open javascript:/file:/data: or put unsafe content in the clipboard
        _open_url = _sanitize_plugin_open_url(result.get("open_url"))
        if _open_url is not None:
            ack_data["open_url"] = _open_url
        _copy_text = _sanitize_plugin_copy_text(result.get("copy_text"))
        if _copy_text is not None:
            ack_data["copy_text"] = _copy_text
        ack_event = {
            "event": "control_ack",
            "plugin_id": expected_plugin_id or plugin_id,
            "stream_id": stream_id,
            "thread_id": tid,
            "data": ack_data,
        }
        delegate._push_plugin_stream_event(ack_event, tid)
        status_code = 200 if bool(result.get("ok")) else 400
        return JSONResponse(content=result, status_code=status_code)

    @fastapi_app.get("/chat/messages")
    async def chat_messages(thread_id: str = FQuery("", alias="thread_id")):
        tid = (thread_id or "").strip() or delegate._current_thread_id
        try:
            from chat_storage import get_messages
            msgs = get_messages(tid) or []
            result = []
            for m in msgs:
                raw_content = m.get("content")
                content = _content_to_display_string(raw_content, max_length=12000)
                image_urls = _content_image_urls(raw_content) if isinstance(raw_content, list) else []
                file_attachments = _content_file_attachments(raw_content) if isinstance(raw_content, list) else []
                ts = m.get("timestamp", "") or ""
                result.append({
                    "role": m.get("role", ""),
                    "content": content,
                    "image_urls": image_urls,
                    "file_attachments": file_attachments,
                    "timestamp": ts,
                })
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc), "thread_id": tid}, status_code=500)

    @fastapi_app.get("/chat/context_usage")
    async def chat_context_usage(thread_id: str = FQuery("", alias="thread_id")):
        tid = (thread_id or "").strip() or delegate._current_thread_id
        try:
            from operator_helpers import estimate_context_for_thread
            payload = estimate_context_for_thread(tid)
            payload["thread_id"] = tid
            return JSONResponse(content=payload)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    # ── Phase 5: Export / Backup / Encryption key ───────────────────────

    @fastapi_app.get("/conversations/{thread_id}/export")
    async def export_conversation(thread_id: str):
        tid = thread_id.strip()
        try:
            from chat_storage import get_messages
            msgs = get_messages(tid) or []
            result = []
            for m in msgs:
                role = m.get("role", "")
                content = _content_to_display_string(m.get("content"))
                if role == "tool":
                    try:
                        meta = json.loads(content)
                        if isinstance(meta, dict):
                            tname = str(meta.get("tool_name") or "tool")
                            preview = str(meta.get("result_preview") or "")
                            content = f"[Tool: {tname}] {preview}".strip()
                    except json.JSONDecodeError:
                        pass  # export raw tool content when not JSON-shaped
                result.append({"role": m.get("role", ""), "content": content})
            title = (delegate._conversation_meta or {}).get(tid, {}).get("title", "Untitled")
            return JSONResponse(content={"thread_id": tid, "title": title, "messages": result})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.get("/conversations/export/all")
    async def export_all_conversations():
        try:
            from chat_storage import get_messages
            all_convos = []
            for tid, meta in (delegate._conversation_meta or {}).items():
                msgs = get_messages(tid) or []
                result = []
                for m in msgs:
                    role = m.get("role", "")
                    content = _content_to_display_string(m.get("content"))
                    if role == "tool":
                        try:
                            tool_meta = json.loads(content)
                            if isinstance(tool_meta, dict):
                                tname = str(tool_meta.get("tool_name") or "tool")
                                preview = str(tool_meta.get("result_preview") or "")
                                content = f"[Tool: {tname}] {preview}".strip()
                        except json.JSONDecodeError:
                            pass  # export raw tool content when not JSON-shaped
                    result.append({"role": m.get("role", ""), "content": content})
                all_convos.append({
                    "thread_id": tid,
                    "title": meta.get("title", "Untitled"),
                    "messages": result,
                })
            return JSONResponse(content=all_convos)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/backup/create")
    async def backup_create(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        dest_path = (body.get("path") or "").strip()
        if not dest_path:
            return JSONResponse(content={"error": "path required"}, status_code=400)
        try:
            import shutil
            from chat_storage import DB_PATH
            dest_file = os.path.join(dest_path, "chats_backup.db")
            shutil.copy2(DB_PATH, dest_file)
            chats_db_path = os.path.join(CHATS_DB_DIR, _CHATS_DB_FILENAME)
            if os.path.exists(chats_db_path):
                shutil.copy2(chats_db_path, os.path.join(dest_path, "chats_db_backup.json"))
            return JSONResponse(content={"ok": True, "path": dest_file})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/agent/export")
    async def agent_export(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        dest_path = (body.get("path") or "").strip()
        if not dest_path:
            return JSONResponse(content={"error": "path required"}, status_code=400)
        try:
            from migration_bundle import create_bundle, AGENT_BUNDLE_TYPE

            result = create_bundle(
                bundle_type=AGENT_BUNDLE_TYPE,
                destination_zip_path=dest_path,
                include_encryption_key=False,
            )
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/migration/export")
    async def migration_export(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        dest_path = (body.get("path") or "").strip()
        include_key = bool(body.get("include_encryption_key", False))
        if not dest_path:
            return JSONResponse(content={"error": "path required"}, status_code=400)
        try:
            from migration_bundle import create_bundle, MIGRATION_BUNDLE_TYPE

            result = create_bundle(
                bundle_type=MIGRATION_BUNDLE_TYPE,
                destination_zip_path=dest_path,
                include_encryption_key=include_key,
            )
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/agent/import")
    async def agent_import(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        src_path = (body.get("path") or "").strip()
        confirm_overwrite = bool(body.get("confirm_overwrite", False))
        if not src_path:
            return JSONResponse(content={"error": "path required"}, status_code=400)
        try:
            from migration_bundle import import_bundle, AGENT_BUNDLE_TYPE

            result = import_bundle(
                expected_bundle_type=AGENT_BUNDLE_TYPE,
                bundle_zip_path=src_path,
                confirm_overwrite=confirm_overwrite,
            )
            if result.get("requires_overwrite_confirmation"):
                return JSONResponse(content=result, status_code=409)
            try:
                if delegate._plugin_manager is not None:
                    delegate._plugin_manager.reload_all()
                    _clear_dashboard_manifest_cache()
            except Exception:
                _log.debug("Failed to reload plugins after plugin import", exc_info=True)
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/migration/import")
    async def migration_import(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        src_path = (body.get("path") or "").strip()
        confirm_overwrite = bool(body.get("confirm_overwrite", False))
        if not src_path:
            return JSONResponse(content={"error": "path required"}, status_code=400)
        try:
            from migration_bundle import import_bundle, MIGRATION_BUNDLE_TYPE

            result = import_bundle(
                expected_bundle_type=MIGRATION_BUNDLE_TYPE,
                bundle_zip_path=src_path,
                confirm_overwrite=confirm_overwrite,
            )
            if result.get("requires_overwrite_confirmation"):
                return JSONResponse(content=result, status_code=409)
            try:
                if delegate._plugin_manager is not None:
                    delegate._plugin_manager.reload_all()
                    _clear_dashboard_manifest_cache()
            except Exception:
                _log.debug("Failed to reload plugins after migration import", exc_info=True)
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/backup/restore")
    async def backup_restore(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        src_path = (body.get("path") or "").strip()
        if not src_path:
            return JSONResponse(content={"error": "path required"}, status_code=400)
        try:
            import shutil
            from chat_storage import DB_PATH, verify_backup_readable
            backup_db = src_path
            if os.path.isdir(src_path):
                candidate = os.path.join(src_path, "chats_backup.db")
                if os.path.exists(candidate):
                    backup_db = candidate
                else:
                    for f in os.listdir(src_path):
                        if f.endswith(".db"):
                            backup_db = os.path.join(src_path, f)
                            break
            if not os.path.isfile(backup_db):
                return JSONResponse(content={"error": "backup file not found"}, status_code=404)
            if not verify_backup_readable(backup_db):
                return JSONResponse(content={"error": "backup not readable with current encryption key"}, status_code=400)
            shutil.copy2(backup_db, DB_PATH)
            # Chat history restore invalidates derived search.db rows; force rebuild.
            try:
                import session_search_db as _search_idx

                if os.path.exists(_search_idx.SEARCH_DB_PATH):
                    os.remove(_search_idx.SEARCH_DB_PATH)
                _search_idx.ensure_index_exists()
            except Exception:
                _log.warning(
                    "backup restore succeeded but search index rebuild failed",
                    exc_info=True,
                )
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.get("/encryption/key/export")
    async def export_encryption_key():
        try:
            from chat_storage import export_encryption_key as _export_key
            key = _export_key()
            return JSONResponse(content={"key": key})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/encryption/key/import")
    async def import_encryption_key(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        key_b64 = (body.get("key") or "").strip()
        if not key_b64:
            return JSONResponse(content={"error": "key required"}, status_code=400)
        try:
            from chat_storage import import_encryption_key as _import_key
            _import_key(key_b64)
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    # ── Phase 5: Background Agent endpoints ───────────────────────────

    @fastapi_app.post("/background-agent/{action}")
    async def background_agent_action(action: str):
        try:
            import app_background_agent as bga
            actions_map = {
                "install": bga._install_background_agent,
                "uninstall": bga._uninstall_background_agent,
                "start": bga._launchctl_bootstrap,
                "stop": bga._launchctl_bootout,
                "restart": bga._restart_background_agent,
            }
            fn = actions_map.get(action)
            if fn is None:
                return JSONResponse(content={"error": "unknown action"}, status_code=400)
            ok, msg = fn()
            if action == "stop":
                bga.set_background_agent_desired_running(False)
            if not ok:
                return JSONResponse(content={"error": msg}, status_code=500)
            return JSONResponse(content={"ok": True, "message": msg})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.get("/background-agent/status")
    async def background_agent_status():
        try:
            import app_background_agent as bga
            running = bga._background_agent_ping()
            return JSONResponse(content={"running": running, "error": ""})
        except Exception as exc:
            return JSONResponse(content={"running": False, "error": str(exc)}, status_code=500)

    @fastapi_app.get("/background-agent/log")
    async def background_agent_log():
        """Return background agent log content (last ~100KB for large files)."""
        try:
            log_path = os.path.join(MUGI_LOGS_DIR, "background.log")
            if not os.path.isfile(log_path):
                return JSONResponse(content={"content": "", "path": log_path, "truncated": False})
            max_chars = 100_000
            with open(log_path, "r", encoding="utf-8", errors="replace") as f:
                content = f.read()
            truncated = len(content) > max_chars
            if truncated:
                content = content[-max_chars:]
                content = "... [earlier content truncated]\n\n" + content
            return JSONResponse(content={"content": content, "path": log_path, "truncated": truncated})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    # ── Phase 5: App confirmation endpoints ─────────────────────────

    @fastapi_app.get("/app/confirm/pending")
    async def app_confirm_pending():
        try:
            from tools.system import get_pending_shell_confirm_from_file
            pending_shell = get_pending_shell_confirm_from_file()
            if pending_shell:
                return JSONResponse(
                    content={
                        "type": "shell",
                        "command": pending_shell[0],
                        "request_id": pending_shell[1],
                    }
                )
            from user_decision_ipc import get_pending_user_decision_from_file
            pending_decision = get_pending_user_decision_from_file()
            if pending_decision:
                return JSONResponse(
                    content={
                        "type": "user_decision",
                        "request_id": pending_decision.get("request_id"),
                        "kind": pending_decision.get("kind"),
                        "context": pending_decision.get("context") if isinstance(pending_decision.get("context"), dict) else {},
                        "source": pending_decision.get("source") or "",
                        "created_ms": int(pending_decision.get("created_ms") or 0),
                    }
                )
            return JSONResponse(content={})
        except Exception:
            _log.warning("app_confirm_pending IPC failed", exc_info=True)
            return JSONResponse(content={"error": "confirm_ipc_failed"}, status_code=500)

    @fastapi_app.post("/app/confirm/respond")
    async def app_confirm_respond(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        request_id = (body.get("request_id") or "").strip()
        choice = (body.get("choice") or "").strip()
        kind = (body.get("kind") or "").strip()
        resp_type = (body.get("type") or "").strip()
        if not request_id or not choice or not resp_type:
            return JSONResponse(content={"error": "request_id, choice, and type are required"}, status_code=400)
        try:
            if resp_type == "shell":
                from tools.system import complete_shell_confirm_file
                complete_shell_confirm_file(request_id, choice)
                return JSONResponse(content={"ok": True})
            if resp_type == "user_decision":
                from user_decision_ipc import complete_user_decision_file
                ok = complete_user_decision_file(request_id, choice, kind=kind or None)
                if not ok:
                    return JSONResponse(content={"error": "invalid request_id/kind/choice"}, status_code=400)
                return JSONResponse(content={"ok": True})
            return JSONResponse(content={"error": "unknown confirm type"}, status_code=400)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/app/shutdown-cleanup")
    async def app_shutdown_cleanup():
        """Host calls this before SIGTERM so Colima, MLX, LaunchAgent, etc. stop draining battery."""
        try:
            from mugi_shutdown_cleanup import run_mugi_shutdown_cleanup

            summary = await asyncio.to_thread(run_mugi_shutdown_cleanup, reason="http")
            return JSONResponse(content={"ok": True, "summary": summary})
        except Exception as exc:
            _log.debug("app/shutdown-cleanup failed", exc_info=True)
            return JSONResponse(
                content={"ok": False, "error": str(exc)[:400]},
                status_code=500,
            )

    # ── Backward-compatible shell/user-decision endpoints ─────────────

    @fastapi_app.get("/shell/confirm/pending")
    async def shell_confirm_pending():
        try:
            from tools.system import get_pending_shell_confirm_from_file
            pending = get_pending_shell_confirm_from_file()
            if pending:
                return JSONResponse(content={"command": pending[0], "request_id": pending[1]})
            return JSONResponse(content={})
        except Exception:
            _log.warning("shell_confirm_pending IPC failed", exc_info=True)
            return JSONResponse(content={"error": "confirm_ipc_failed"}, status_code=500)

    @fastapi_app.post("/shell/confirm/respond")
    async def shell_confirm_respond(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        request_id = (body.get("request_id") or "").strip()
        choice = (body.get("choice") or "").strip()
        if not request_id or not choice:
            return JSONResponse(content={"error": "request_id and choice required"}, status_code=400)
        try:
            from tools.system import complete_shell_confirm_file
            complete_shell_confirm_file(request_id, choice)
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.get("/user/decision/pending")
    async def user_decision_pending():
        try:
            from user_decision_ipc import get_pending_user_decision_from_file
            pending = get_pending_user_decision_from_file()
            if not pending:
                return JSONResponse(content={})
            return JSONResponse(
                content={
                    "request_id": pending.get("request_id"),
                    "kind": pending.get("kind"),
                    "context": pending.get("context") if isinstance(pending.get("context"), dict) else {},
                    "source": pending.get("source") or "",
                    "created_ms": int(pending.get("created_ms") or 0),
                }
            )
        except Exception:
            _log.warning("user_decision_pending IPC failed", exc_info=True)
            return JSONResponse(content={"error": "confirm_ipc_failed"}, status_code=500)

    @fastapi_app.post("/user/decision/respond")
    async def user_decision_respond(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        request_id = (body.get("request_id") or "").strip()
        choice = (body.get("choice") or "").strip()
        kind = (body.get("kind") or "").strip()
        if not request_id or not choice or not kind:
            return JSONResponse(content={"error": "request_id, choice, and kind required"}, status_code=400)
        try:
            from user_decision_ipc import complete_user_decision_file
            ok = complete_user_decision_file(request_id, choice, kind=kind)
            if not ok:
                return JSONResponse(content={"error": "invalid request_id/kind/choice"}, status_code=400)
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    # ── Phase 5: Summarize + Search endpoints ─────────────────────────

    @fastapi_app.post("/conversations/{thread_id}/summarize")
    async def summarize_conversation(thread_id: str):
        tid = thread_id.strip()
        if not tid:
            return JSONResponse(content={"error": "thread_id required"}, status_code=400)

        def _do_summarize():
            old_tid = delegate._current_thread_id
            delegate._current_thread_id = tid
            try:
                delegate.onSend_text_(None, "Please summarize our conversation so far.")
            finally:
                delegate._current_thread_id = old_tid

        delegate._main_thread_queue.put(_do_summarize)
        return JSONResponse(content={"ok": True})

    @fastapi_app.get("/conversations/search")
    async def search_conversations(q: str = FQuery("", alias="q")):
        query = (q or "").strip().lower()
        if not query:
            return JSONResponse(content=[])
        try:
            from chat_storage import get_messages, get_parent_of_branch
            results = []
            for tid, meta in (delegate._conversation_meta or {}).items():
                msgs = get_messages(tid) or []
                for i, m in enumerate(msgs):
                    content = _content_to_display_string(m.get("content")).strip()
                    if query in content.lower():
                        snippet = content[:200]
                        item = {
                            "thread_id": tid,
                            "title": meta.get("title", "Untitled"),
                            "message_index": i,
                            "role": m.get("role", ""),
                            "snippet": snippet,
                        }
                        parent = get_parent_of_branch(tid)
                        if parent:
                            item["parent_thread_id"] = parent
                        results.append(item)
                        if len(results) >= 50:
                            break
                if len(results) >= 50:
                    break
            return JSONResponse(content=results)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    # ── Phase 5: Setup wizard orchestration ───────────────────────────

    # In-memory setup state for /setup/status polling (module-level singleton via closure)
    _setup_state = {
        "step": "idle",
        "message": "",
        "error": "",
        "error_code": "",
        "progress": [],
        "running": False,
        "retry_running": False,
    }

    @fastapi_app.get("/setup/status")
    async def setup_status():
        return JSONResponse(content=_setup_state)

    @fastapi_app.post("/setup/run")
    async def setup_run(
        background_tasks: BackgroundTasks,
        include_kraken: bool = FQuery(True),
        body: dict | None = Body(default=None),
    ):
        """Run optional setup steps after backend is ready."""
        import asyncio
        known_steps = ("searxng", "kraken", "playwright", "utility_model")
        run_plan = {
            "searxng": True,
            "kraken": bool(include_kraken),
            "playwright": True,
            "utility_model": True,
        }
        if body is not None:
            if not isinstance(body, dict):
                return JSONResponse(content={"ok": False, "error": "Invalid setup plan body."}, status_code=400)
            if "steps" in body:
                raw_steps = body.get("steps")
                if not isinstance(raw_steps, list) or any(not isinstance(s, str) for s in raw_steps):
                    return JSONResponse(content={"ok": False, "error": "steps must be a list of step ids."}, status_code=400)
                normalized_steps = [s.strip() for s in raw_steps if isinstance(s, str) and s.strip()]
                unknown = sorted(set(normalized_steps) - set(known_steps))
                if unknown:
                    return JSONResponse(
                        content={"ok": False, "error": f"Unknown setup step(s): {', '.join(unknown)}"},
                        status_code=400,
                    )
                selected = set(normalized_steps)
                run_plan = {k: (k in selected) for k in known_steps}

            bool_flag_map = {
                "run_searxng": "searxng",
                "include_kraken": "kraken",
                "run_playwright": "playwright",
                "run_utility_model": "utility_model",
            }
            for body_key, step_name in bool_flag_map.items():
                if body_key in body:
                    flag = body.get(body_key)
                    if not isinstance(flag, bool):
                        return JSONResponse(
                            content={"ok": False, "error": f"{body_key} must be a boolean."},
                            status_code=400,
                        )
                    run_plan[step_name] = flag

        if _setup_state.get("running"):
            return JSONResponse(content={"ok": True, "running": True, "message": "Setup already running."})
        _setup_state["running"] = True
        _setup_state["step"] = "queued"
        _setup_state["message"] = "Starting setup..."
        _setup_state["error"] = ""
        _setup_state["error_code"] = ""
        _setup_state["progress"] = []

        async def _run():
            try:
                if run_plan["searxng"]:
                    _setup_state["step"] = "searxng"
                    _setup_state["message"] = "Setting up local search (SearXNG)..."
                    try:
                        from tools.searxng_docker import run_setup as searxng_run_setup

                        port = _searxng_setup_port()
                        ok, msg = await asyncio.get_running_loop().run_in_executor(
                            None, lambda p=port: searxng_run_setup(p)
                        )
                        if ok:
                            _persist_config_after_searxng_setup_success()
                            _setup_state["progress"].append("searxng")
                        else:
                            _persist_config_after_searxng_setup_failure()
                            _setup_state["error"] = f"searxng: {msg}"
                    except Exception as e:
                        _persist_config_after_searxng_setup_failure()
                        _setup_state["error"] = f"searxng: {e}"
                else:
                    _setup_state["progress"].append("searxng_skipped")

                if run_plan["kraken"]:
                    if not _setup_state["error"]:
                        _setup_state["step"] = "kraken"
                        _setup_state["message"] = "Setting up OCR (Kraken)..."
                        try:
                            from tools.ocr_backends.kraken import ensure_kraken_venv
                            ok, msg = await asyncio.get_running_loop().run_in_executor(None, ensure_kraken_venv)
                            if ok:
                                _setup_state["progress"].append("kraken")
                            else:
                                _setup_state["error"] = f"kraken: {msg}"
                        except Exception as e:
                            _setup_state["error"] = f"kraken: {e}"
                else:
                    _setup_state["progress"].append("kraken_skipped")

                if run_plan["playwright"]:
                    if not _setup_state["error"]:
                        _setup_state["step"] = "playwright"
                        _setup_state["message"] = "Setting up browser automation (Playwright)..."
                        try:
                            from tools.playwright_screenshot import install_playwright
                            ok, msg = await asyncio.get_running_loop().run_in_executor(None, install_playwright)
                            if ok:
                                _setup_state["progress"].append("playwright")
                            else:
                                _setup_state["error"] = f"playwright: {msg}"
                        except Exception as e:
                            _setup_state["error"] = f"playwright: {e}"
                else:
                    _setup_state["progress"].append("playwright_skipped")

                if run_plan["utility_model"]:
                    if not _setup_state["error"]:
                        _setup_state["step"] = "utility_model"
                        _setup_state["message"] = "Downloading utility model for parallel processing..."
                        try:
                            from utility_model import ensure_downloaded

                            def _on_progress(msg: str):
                                _setup_state["message"] = msg

                            ok, msg = await asyncio.get_running_loop().run_in_executor(
                                None, lambda: ensure_downloaded(on_progress=_on_progress)
                            )
                            if ok:
                                _setup_state["progress"].append("utility_model")
                            else:
                                _setup_state["error"] = f"utility_model: {msg}"
                        except Exception as e:
                            _setup_state["error"] = f"utility_model: {e}"
                else:
                    _setup_state["progress"].append("utility_model_skipped")

                if not _setup_state["error"]:
                    _setup_state["step"] = "done"
                    _setup_state["message"] = "Setup complete."
                    _setup_state["error_code"] = ""
                else:
                    _setup_state["step"] = "failed"
            finally:
                _setup_state["running"] = False

        background_tasks.add_task(_run)
        return JSONResponse(content={"ok": True, "running": True})

    @fastapi_app.post("/setup/skip_step")
    async def setup_skip_step(step: str = ""):
        """Mark a setup step as skipped so the wizard can advance."""
        order = ["searxng", "kraken", "playwright", "utility_model", "done"]
        known = frozenset({"searxng", "kraken", "playwright", "utility_model"})
        s = (step or "").strip()
        if s and s not in known:
            return JSONResponse(content={"ok": False, "error": "Unknown step."}, status_code=400)
        if s and s not in _setup_state["progress"]:
            _setup_state["progress"].append(f"{s}_skipped")
        if _setup_state["step"] == s:
            # Advance to next step
            idx = order.index(s)
            if idx + 1 < len(order):
                _setup_state["step"] = order[idx + 1]
                _setup_state["message"] = ""
                _setup_state["error"] = ""
                _setup_state["error_code"] = ""
            else:
                _setup_state["step"] = "done"
                _setup_state["message"] = "Setup complete."
        return JSONResponse(content={"ok": True})

    @fastapi_app.post("/setup/retry_step")
    async def setup_retry_step(background_tasks: BackgroundTasks, step: str = ""):
        """Re-run a single failed setup step."""
        import asyncio
        s = (step or "").strip()
        if s not in ("searxng", "kraken", "playwright", "utility_model"):
            return JSONResponse(content={"ok": False, "error": "Unknown step"}, status_code=400)
        if _setup_state.get("retry_running"):
            return JSONResponse(
                content={"ok": False, "running": True, "message": "A step retry is already in progress."},
                status_code=409,
            )

        _setup_state["error"] = ""
        _setup_state["error_code"] = ""
        _setup_state["retry_running"] = True

        async def _retry():
            _setup_state["step"] = s
            _setup_state["message"] = f"Retrying {s}…"
            try:
                if s == "searxng":
                    from tools.searxng_docker import run_setup as searxng_run_setup

                    port = _searxng_setup_port()
                    ok, msg = await asyncio.get_running_loop().run_in_executor(
                        None, lambda p=port: searxng_run_setup(p)
                    )
                elif s == "kraken":
                    from tools.ocr_backends.kraken import ensure_kraken_venv
                    ok, msg = await asyncio.get_running_loop().run_in_executor(None, ensure_kraken_venv)
                elif s == "playwright":
                    from tools.playwright_screenshot import install_playwright
                    ok, msg = await asyncio.get_running_loop().run_in_executor(None, install_playwright)
                else:
                    from utility_model import ensure_downloaded
                    ok, msg = await asyncio.get_running_loop().run_in_executor(None, ensure_downloaded)

                if ok:
                    if s == "searxng":
                        _persist_config_after_searxng_setup_success()
                    if s not in _setup_state["progress"]:
                        _setup_state["progress"].append(s)
                    skipped_key = f"{s}_skipped"
                    if skipped_key in _setup_state["progress"]:
                        _setup_state["progress"].remove(skipped_key)
                    _setup_state["error"] = ""
                    _setup_state["error_code"] = ""
                else:
                    if s == "searxng":
                        _persist_config_after_searxng_setup_failure()
                    _setup_state["error"] = f"{s}: {msg}"
            except Exception as e:
                if s == "searxng":
                    _persist_config_after_searxng_setup_failure()
                _setup_state["error"] = f"{s}: {e}"
            finally:
                _setup_state["retry_running"] = False

        background_tasks.add_task(_retry)
        return JSONResponse(content={"ok": True})

    @fastapi_app.get("/setup/command_line_tools/status")
    async def setup_command_line_tools_status():
        try:
            from tools.searxng_docker import command_line_tools_installed
            installed = bool(command_line_tools_installed())
            return JSONResponse(content={
                "ok": True,
                "installed": installed,
                "message": "Apple Command Line Tools are installed." if installed else "Apple Command Line Tools are not installed yet.",
            })
        except Exception as exc:
            return JSONResponse(content={"ok": False, "installed": False, "message": str(exc)}, status_code=500)

    @fastapi_app.post("/setup/command_line_tools/install")
    async def setup_command_line_tools_install():
        try:
            from tools.searxng_docker import trigger_command_line_tools_install, command_line_tools_installed
            ok, msg = trigger_command_line_tools_install()
            installed = bool(command_line_tools_installed())
            return JSONResponse(content={"ok": ok, "installed": installed, "message": msg})
        except Exception as exc:
            return JSONResponse(content={"ok": False, "installed": False, "message": str(exc)}, status_code=500)

    @fastapi_app.post("/setup/rerun")
    async def setup_rerun():
        """Remove venv + optional components; preserve workspace/personality/chat data.
        Caller should restart the app after this returns."""
        try:
            import shutil
            from core import get_config, save_config
            mugi_dir = os.path.expanduser("~/.mugi")
            for sub in ("venv", "kraken-venv", "playwright-venv", "mlx-venv", "mlx-utility-venv"):
                p = os.path.join(mugi_dir, sub)
                if os.path.isdir(p):
                    shutil.rmtree(p, ignore_errors=True)
            # Reset wizard_done so setup wizard shows again on next launch
            config = get_config()
            config.setdefault("setup", {})["wizard_done"] = False
            save_config(config)
            # Clear runtime LLM selections that can become stale after venv cleanup.
            prefs = _load_app_prefs()
            for key in (
                "mlx_model",
                "mlx_active_adapter",
                "backend_base_url",
                "last_remote_backend_url",
                "llm_context_length",
            ):
                prefs.pop(key, None)
            prefs["llm_setup_complete"] = False
            prefs["mlx_enabled"] = False
            prefs["first_run_validation_required"] = False
            prefs["first_run_ready_for_chat"] = False
            prefs["first_run_proactive_welcome_enabled"] = False
            prefs["first_run_setup_state"] = "not_started"
            _save_app_prefs(prefs)
            legacy_marker = os.path.join(mugi_dir, ".setup_wizard_done")
            if os.path.isfile(legacy_marker):
                os.remove(legacy_marker)
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/setup/full_reset")
    async def setup_full_reset():
        """Move workspace to Trash; remove all venvs and config. Personality files go to Trash for recovery.
        Caller should restart the app after this returns."""
        try:
            import shutil
            from datetime import datetime as _dt
            mugi_dir = os.path.expanduser("~/.mugi")
            trash_dir = os.path.expanduser("~/.Trash")
            ts = _dt.now().strftime("%Y-%m-%d_%H-%M-%S")

            # Move workspace to Trash
            workspace = os.path.join(mugi_dir, "workspace")
            if os.path.isdir(workspace):
                dest = os.path.join(trash_dir, f"mugi_workspace_backup_{ts}")
                shutil.move(workspace, dest)

            # Move core persisted files to Trash.
            file_backups = (
                ("mugi_config.toml", f"mugi_config_backup_{ts}.toml"),
                ("app_prefs.json", f"mugi_app_prefs_backup_{ts}.json"),
                ("chats.db", f"mugi_chats_db_backup_{ts}"),
                (".setup_wizard_done", f"mugi_setup_wizard_done_backup_{ts}"),
                (".setup_progress.json", f"mugi_setup_progress_backup_{ts}.json"),
                (".setup_orchestrator.json", f"mugi_setup_orchestrator_backup_{ts}.json"),
                (".setup.lock", f"mugi_setup_lock_backup_{ts}"),
            )
            for source_name, backup_name in file_backups:
                source_path = os.path.join(mugi_dir, source_name)
                if os.path.isfile(source_path):
                    shutil.move(source_path, os.path.join(trash_dir, backup_name))

            # Remove venvs and components (not Python itself — no need to re-download)
            for sub in (
                "venv",
                "kraken-venv",
                "playwright-venv",
                "mlx-venv",
                "mlx-utility-venv",
                "searxng",
                "mlx",
                "logs",
                "launchd_logs",
                "plugins",
                "cli-venvs",
            ):
                p = os.path.join(mugi_dir, sub)
                if os.path.isdir(p):
                    shutil.rmtree(p, ignore_errors=True)

            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    # ── Phase 5: Onboarding status ────────────────────────────────────

    @fastapi_app.get("/workspace/profile")
    async def workspace_profile_get():
        try:
            from core import get_workspace_path
            from utils.profile_markdown import load_profile_from_workspace

            profile = load_profile_from_workspace(get_workspace_path())
            return JSONResponse(content=profile)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.put("/workspace/profile")
    async def workspace_profile_put(request: FRequest):
        try:
            from core import get_workspace_path
            from utils.profile_markdown import save_profile_to_workspace

            body = await request.json()
            if not isinstance(body, dict):
                return JSONResponse(content={"error": "invalid body"}, status_code=400)
            save_profile_to_workspace(get_workspace_path(), body)
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.get("/onboarding/status")
    async def onboarding_status():
        try:
            from core import get_config
            config = get_config()
            ob = config.get("onboarding") or {}
            done = bool(ob.get("done", False))
            enabled = bool(ob.get("enabled", True))
            wt = ob.get("welcome_tour_completed")
            welcome_done = bool(wt) if isinstance(wt, bool) else done
            # Swift sheet is the welcome tour only; personality reset clears done but must not re-force the tour.
            needed = enabled and not welcome_done
            return JSONResponse(
                content={
                    "done": done,
                    "needed": needed,
                    "welcome_tour_completed": welcome_done,
                }
            )
        except Exception as e:
            return JSONResponse(content={"done": False, "needed": True, "welcome_tour_completed": False})

    @fastapi_app.post("/onboarding/complete")
    async def onboarding_complete():
        try:
            from core import get_config, save_config
            config = get_config()
            ob = config.setdefault("onboarding", {})
            ob["done"] = True
            ob["welcome_tour_completed"] = True
            save_config(config)
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    @fastapi_app.post("/onboarding/reset")
    async def onboarding_reset():
        try:
            from core import get_config, save_config
            from persistence import reset_personality_files
            config = get_config()
            ob = config.setdefault("onboarding", {})
            ob["done"] = False
            # User already ran the app; do not show the Swift welcome tour on every launch after a personality reset.
            ob["welcome_tour_completed"] = True
            reset_personality_files(config)
            save_config(config)
            return JSONResponse(content={"ok": True})
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

    def _agent_onboarding_needed():
        """True when core personality files are missing/empty and agent onboarding is enabled and not done (mirrors PyObjC)."""
        try:
            from core import get_config
            config = get_config()
            workspace_path = (config.get("general") or {}).get("workspace_path")
            files = (config.get("personality_files") or {}).get("files", [])
            if not workspace_path or not isinstance(files, list) or not files:
                return False
            workspace_path = os.path.expanduser(workspace_path)
            all_missing_or_empty = True
            for filename in files:
                if not filename or not isinstance(filename, str):
                    continue
                if filename.startswith("/") or filename.startswith("~"):
                    full_path = os.path.expanduser(filename)
                else:
                    full_path = os.path.join(workspace_path, filename.strip())
                if os.path.isfile(full_path):
                    try:
                        if os.path.getsize(full_path) >= 10:
                            with open(full_path, "r", encoding="utf-8") as f:
                                if f.read().strip():
                                    all_missing_or_empty = False
                                    break
                    except Exception:
                        _log.debug("Failed to check personality file %s", full_path, exc_info=True)
            onboarding_enabled = config.get("onboarding", {}).get("enabled", True)
            onboarding_done = config.get("onboarding", {}).get("done", False)
            return bool(all_missing_or_empty and onboarding_enabled and not onboarding_done)
        except Exception:
            return False

    @fastapi_app.get("/onboarding/agent_status")
    async def onboarding_agent_status():
        return JSONResponse(content={"agent_onboarding_needed": _agent_onboarding_needed()})

    @fastapi_app.post("/conversations/{thread_id}/trigger_agent_welcome")
    async def trigger_agent_welcome(thread_id: str):
        """Send invisible 'init' to the agent for an empty conversation so it posts the first welcome (mirrors PyObjC _triggerProactiveWelcome_)."""
        tid = (thread_id or "").strip()
        if not tid:
            return JSONResponse(content={"error": "thread_id required"}, status_code=400)
        prefs = _load_app_prefs()
        first_run_required = bool(prefs.get("first_run_validation_required", False))
        welcome_enabled = bool(prefs.get("first_run_proactive_welcome_enabled", True))
        if first_run_required and not welcome_enabled:
            return JSONResponse(content={"ok": True, "triggered": False, "reason": "proactive_welcome_disabled"})
        if first_run_required and not _first_run_ready_for_chat(prefs):
            failures = int(prefs.get("first_run_init_failures", 0) or 0)
            if failures >= 3:
                return JSONResponse(content={"ok": True, "triggered": False, "reason": "startup_circuit_breaker"})
            prefs["first_run_init_failures"] = failures + 1
            _save_app_prefs(prefs)
            return JSONResponse(content={"ok": True, "triggered": False, "reason": "chat_not_ready"})
        if delegate._run_active or tid in getattr(delegate, "_running_thread_ids", set()):
            return JSONResponse(content={"ok": True, "triggered": False})
        try:
            from chat_storage import get_messages
            msgs = get_messages(tid) or []
            if msgs:
                return JSONResponse(content={"ok": True, "triggered": False})
        except Exception:
            return JSONResponse(content={"error": "could not load thread"}, status_code=500)
        old_tid = delegate._current_thread_id
        delegate._current_thread_id = tid
        try:
            delegate._run_agent("init", thread_id=tid)
        finally:
            delegate._current_thread_id = old_tid
        prefs["first_run_init_failures"] = 0
        _save_app_prefs(prefs)
        return JSONResponse(content={"ok": True, "triggered": True})

    from mugi_backend_plugin_routes import register_plugin_routes
    register_plugin_routes(
        fastapi_app=fastapi_app,
        delegate=delegate,
        app_http_routes=app_http_routes,
        auth_token=auth_token,
        plugins_for_api=_plugins_for_api,
        clear_dashboard_manifest_cache=_clear_dashboard_manifest_cache,
        require_dashboard_slug=_require_dashboard_slug,
    )

    @fastapi_app.post("/scheduled_jobs/{job_id}/run")
    async def scheduled_job_run_now(job_id: str):
        """Used by Activity \"Retry\": schedule the job for the next cron tick."""
        import asyncio as _asyncio
        from cron_scheduler import run_job_now

        try:
            status = await _asyncio.to_thread(run_job_now, job_id)
        except Exception:
            _log.warning("scheduled_job_run_now failed for %s", job_id, exc_info=True)
            return JSONResponse(status_code=500, content={"error": "internal error"})
        if status == "scheduled":
            return JSONResponse(content={"ok": True, "status": "scheduled"})
        if status == "inactive":
            return JSONResponse(status_code=409, content={"error": "job is not active"})
        return JSONResponse(status_code=404, content={"error": "job not found"})

    return fastapi_app


# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------

def main():
    # Own process group (PGID == PID) so the macOS host can `kill(-pgid, SIGTERM)` and reach MCP/plugin
    # subprocesses that have reparented to PID 1 — PPID walks alone miss those.
    try:
        if hasattr(os, "setpgrp"):
            os.setpgrp()
    except OSError:
        pass
    _setup_structured_logging()
    print("[mugi] main() started", file=sys.stderr, flush=True)
    print("MUGI_STARTING", flush=True)

    # --- Freshness sync: keep ~/.mugi/launchd_backend in lockstep with the
    # source tree so the background runner (mugi_runner.py via launchd) never
    # runs stale code after a git pull / code change. Also invalidates any
    # __pycache__ bytecode that predates the source file — prevents a partial-
    # write pyc from masking a fix on the next import.
    try:
        _here = os.path.dirname(os.path.abspath(__file__))
        _staging = os.path.join(os.path.expanduser("~"), ".mugi", "launchd_backend")
        _sync_script = os.path.join(_here, "scripts", "copy_backend_bundle.sh")
        if os.path.isfile(_sync_script) and os.path.isdir(_staging):
            import subprocess as _sp
            _sp.run(["bash", _sync_script, _here, _staging],
                    capture_output=True, timeout=30)
        _pycache = os.path.join(_here, "__pycache__")
        if os.path.isdir(_pycache):
            for _pyc in os.listdir(_pycache):
                if not _pyc.endswith(".pyc"):
                    continue
                _pyc_path = os.path.join(_pycache, _pyc)
                _mod_name = _pyc.split(".")[0]
                _src_path = os.path.join(_here, _mod_name + ".py")
                if os.path.isfile(_src_path):
                    if os.path.getmtime(_pyc_path) < os.path.getmtime(_src_path):
                        os.remove(_pyc_path)
    except Exception:
        pass  # best-effort; never block startup

    # Single-instance lock — MUST happen before any other init that opens
    # ~/.mugi/chats_db.json. Two backends racing the same file is the documented
    # root cause of "all my chats turned into Untitled" data loss. The fd is
    # parked on a module attribute so a hard exit (sys.exit / os._exit) still
    # releases the kernel-side lock when the process tears down.
    _instance_lock_fd = _acquire_single_instance_lock()
    if _instance_lock_fd is None:
        sys.exit(2)
    globals()["_MUGI_INSTANCE_LOCK_FD"] = _instance_lock_fd

    # Parent-watch — exit cleanly when the launching Mugi.app process dies. The
    # Swift host sets MUGI_PARENT_PID to its own PID; without this watch a
    # SIGKILL'd Mugi.app leaves us reparented to launchd as a ghost backend.
    try:
        _parent_pid_env = os.environ.get("MUGI_PARENT_PID")
        if _parent_pid_env:
            _start_parent_watch(int(_parent_pid_env))
    except (TypeError, ValueError):
        _log.debug("ignoring invalid MUGI_PARENT_PID=%r", os.environ.get("MUGI_PARENT_PID"))
    # Ensure Homebrew bins (poppler, etc.) are findable by subprocesses (e.g. pdf2image → pdftoppm). Prefer Mugi Homebrew.
    _path = os.environ.get("PATH", "")
    try:
        from tools.mugi_brew_bootstrap import get_mugi_homebrew_bin_dir
        mugi_bin = get_mugi_homebrew_bin_dir()
        if mugi_bin and os.path.isdir(mugi_bin) and mugi_bin not in _path:
            _path = mugi_bin + os.pathsep + _path
    except Exception:
        _log.debug("Failed to add Mugi Homebrew bin to PATH", exc_info=True)
    for prefix in ("/opt/homebrew/bin", "/usr/local/bin"):
        if prefix and prefix not in _path:
            _path = prefix + os.pathsep + _path
    os.environ["PATH"] = _path
    # Port from env (Swift app) or --port; env avoids "unknown option --port" when Process mangles argv on macOS
    port = 0
    if os.environ.get("MUGI_BACKEND_PORT"):
        try:
            port = int(os.environ["MUGI_BACKEND_PORT"])
        except ValueError:
            pass
    if port == 0:
        parser = argparse.ArgumentParser(description="Mugi headless backend for SwiftUI")
        parser.add_argument("--port", type=int, default=0, help="Port to bind (0 = random)")
        args = parser.parse_args()
        port = args.port
    if port == 0:
        import socket
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.bind(("", 0))
        port = sock.getsockname()[1]
        sock.close()

    try:
        from chat_storage import ensure_encryption_ready
        ensure_encryption_ready()
    except Exception:
        _log.debug("Failed to initialize chat encryption", exc_info=True)
    try:
        from persistence import load_agent_config
        config = load_agent_config()  # triggers one-time API-key migration into Keychain if needed
        apply_debug_logging_level(config)
    except Exception:
        _log.debug("Failed to load agent config during startup", exc_info=True)
    # Clear agent/network error log on each app launch
    os.makedirs(MUGI_LOGS_DIR, exist_ok=True)
    try:
        with open(AGENT_NETWORK_LOG_PATH, "w", encoding="utf-8") as _f:
            pass
    except OSError:
        pass

    # Delete working-buffer file(s) on shutdown (not trash — rm) so conversation summaries are not left on disk.
    def _delete_working_buffers_on_exit():
        try:
            from core import get_workspace_path
            workspace = get_workspace_path()
            memory_dir = os.path.join(workspace, "memory")
            if not os.path.isdir(memory_dir):
                return
            for name in os.listdir(memory_dir):
                if name.startswith("working-buffer") and name.endswith(".md"):
                    path = os.path.join(memory_dir, name)
                    try:
                        os.remove(path)
                    except OSError:
                        pass
        except Exception:
            _log.debug("Failed to reset working buffers", exc_info=True)

    def _purge_temp_uploads():
        """Remove all files from temp_uploads. Safe to call on startup (crash recovery) and shutdown."""
        try:
            import attachment_registry as _ar
            _ar.purge()
        except Exception:
            _log.debug("Failed to purge attachment registry", exc_info=True)
        _dir = os.path.join(os.path.expanduser("~"), ".mugi", "temp_uploads")
        if not os.path.isdir(_dir):
            return
        for name in os.listdir(_dir):
            try:
                os.remove(os.path.join(_dir, name))
            except OSError:
                pass

    _purge_temp_uploads()  # clean up leftovers from a previous crash
    try:
        from fine_tuner import sweep_stale_training_data
        sweep_stale_training_data()
    except Exception:
        _log.debug("Failed to sweep stale training data", exc_info=True)
    try:
        from mlx_training_run import get_training_status
        get_training_status(reconcile=True)
    except Exception:
        _log.debug("Failed to reconcile MLX training status", exc_info=True)
    # Recover SearXNG container if use_local_docker is enabled (handles reboot/sleep/Colima exit).
    def _recover_searxng_container():
        try:
            import time as _time
            from core.config import get_search_config
            from tools.searxng_docker import ensure_colima_running, ensure_searxng_container_running, _run_docker
            cfg = get_search_config()
            if not cfg.get("use_local_docker"):
                return
            port = int(cfg.get("port", 8888))
            if port < 1024 or port > 65535:
                port = 8888
            ok, err = ensure_colima_running()
            if not ok:
                print(f"[mugi] Colima/SearXNG recovery: Colima could not start: {err[:500]}", file=sys.stderr, flush=True)
                return
            # Wait for the Docker socket to become ready (Colima may have just started)
            for _attempt in range(10):
                ok, _ = _run_docker(["info"], timeout=15)
                if ok:
                    break
                _time.sleep(2)
            if not ok:
                print("[mugi] Colima/SearXNG recovery: Docker daemon not ready after Colima start.", file=sys.stderr, flush=True)
                return
            ok, err = ensure_searxng_container_running(port)
            if not ok:
                print(f"[mugi] Colima/SearXNG recovery: SearXNG container could not start: {err[:500]}", file=sys.stderr, flush=True)
        except Exception as e:
            print(f"[mugi] Colima/SearXNG recovery failed: {e}", file=sys.stderr, flush=True)

    def _auto_start_mlx_if_enabled():
        try:
            prefs = _load_app_prefs()
            if not prefs.get("mlx_enabled"):
                return
            model_path = (prefs.get("mlx_model") or "").strip()
            if not model_path:
                return
            context_length = _mlx_context_length_from_prefs(prefs)
            from app_mlx import (
                mlx_venv_exists,
                mlx_server_running,
                mlx_prepare_venv_runtime,
                start_mlx_server,
                stop_mlx_server,
            )
            if not mlx_venv_exists():
                return
            runtime_needs_restart = False
            try:
                if callable(mlx_prepare_venv_runtime):
                    runtime_needs_restart = bool(mlx_prepare_venv_runtime())
            except Exception:
                _log.debug("mlx_prepare_venv_runtime during autostart failed", exc_info=True)
            _running = mlx_server_running()
            _log.debug("MLX autostart: running=%s restart=%s model=%s ctx=%s",
                        _running, runtime_needs_restart, model_path[-80:], context_length)
            if _running:
                if runtime_needs_restart:
                    stop_mlx_server()
                else:
                    return
            ok, msg = start_mlx_server(model_path, context_length=context_length)
            if ok:
                if bool(prefs.get("mlx_warm_start_on_launch", True)):
                    _log.debug("MLX warm-start on launch: %s", msg)
            else:
                # Don't swallow the failure — the UI infers a stall from server_running
                # staying false, but the reason must be diagnosable from the logs.
                _log.warning("MLX auto-start failed: %s", msg)
        except Exception:
            _log.warning("Failed to auto-start MLX server", exc_info=True)

    def _auto_warm_chatterbox_if_enabled():
        try:
            prefs = _load_app_prefs()
            if not bool(prefs.get("chatterbox_prewarm_on_startup", True)):
                return
            tts_engine = str(prefs.get("tts_engine") or "").strip().lower()
            if tts_engine != "chatterbox":
                return
            keepalive_s = int(prefs.get("chatterbox_keepalive_seconds", 300) or 300)
            keepalive_s = max(30, min(3600, keepalive_s))
            variant = str(prefs.get("chatterbox_variant") or "turbo").strip().lower() or "turbo"
            import voice_output

            voice_output.start_chatterbox_keepalive(interval_s=keepalive_s, variant=variant)
            voice_output.prewarm_chatterbox(variant=variant, timeout_s=180.0)
        except Exception:
            _log.debug("Failed to auto-warm Chatterbox worker", exc_info=True)
    threading.Thread(target=_recover_searxng_container, daemon=True).start()
    # SwiftUI is the primary UI; legacy _push_js HTML hooks are deprecated for new features.
    threading.Thread(target=_auto_start_mlx_if_enabled, daemon=True).start()
    threading.Thread(target=_auto_warm_chatterbox_if_enabled, daemon=True, name="chatterbox-prewarm").start()

    def _reload_bg_agent_if_desired():
        try:
            import app_background_agent as bga
            bga.reload_background_agent_if_desired()
        except Exception as e:
            print(f"[mugi] Background agent reload-if-desired failed: {e}", file=sys.stderr, flush=True)

    threading.Thread(target=_reload_bg_agent_if_desired, daemon=True, name="bg-agent-reload").start()

    # Start cron scheduler daemon for scheduled tasks.
    try:
        from cron_scheduler import start_scheduler as _start_cron
        _start_cron()
    except Exception as _cron_err:
        print(f"[mugi] Cron scheduler failed to start: {_cron_err}", file=sys.stderr, flush=True)

    # Start kanban dispatcher daemon (durable cross-profile task board).
    # Must run AFTER chat encryption is initialized — the storage layer
    # reads/writes Fernet ciphertext on every claim.
    try:
        from chat_storage import ensure_encryption_ready as _ensure_enc
        _ensure_enc()
        from utils.kanban_dispatcher import start_dispatcher as _start_kanban
        _start_kanban()
    except Exception as _kanban_err:
        print(f"[mugi] Kanban dispatcher failed to start: {_kanban_err}", file=sys.stderr, flush=True)

    # Ensure session search index exists (background rebuild if needed).
    try:
        from session_search_db import ensure_index_exists as _ensure_search
        _ensure_search()
    except Exception:
        _log.debug("Failed to ensure session search index", exc_info=True)
    atexit.register(_delete_working_buffers_on_exit)
    atexit.register(_purge_temp_uploads)
    # Drain pending chats_db writes BEFORE other cleanup runs (atexit is LIFO, so
    # registering this LAST among the data-persistence handlers makes it run FIRST
    # on shutdown — while the executor is still alive and the file system is sane).
    atexit.register(_drain_chats_db_writes)
    try:
        from utility_model import unload as _unload_utility_model
        atexit.register(_unload_utility_model)
    except Exception:
        _log.debug("Failed to register utility model atexit unload", exc_info=True)

    def _shutdown_cleanup_atexit():
        try:
            from mugi_shutdown_cleanup import run_mugi_shutdown_cleanup
            run_mugi_shutdown_cleanup(reason="atexit")
        except Exception:
            _log.debug("atexit shutdown cleanup failed", exc_info=True)

    atexit.register(_shutdown_cleanup_atexit)

    print("[mugi] Initializing agent...", file=sys.stderr, flush=True)
    aiagent.agent = aiagent._create_agent(aiagent.MemorySaver())

    chat_bus = ChatEventBus()
    live_view_bus = LiveViewEventBus()
    activity_bus = ActivityBus()
    live_view_bus.load_from_disk()
    _start_background_events_bridge(activity_bus)

    delegate = HeadlessDelegate(chat_bus, live_view_bus, port, activity_bus=activity_bus)
    set_activity_emitter(activity_bus.emit)
    set_system_event_emitter(delegate._push_event)
    try:
        from tools.recents_tools import set_recents_provider

        def _recents_provider(limit: int):
            with delegate._recents_lock:
                items = list(delegate._recents_items)
            return items[: max(0, int(limit or 0))] if limit else items

        set_recents_provider(_recents_provider)
    except Exception:
        _log.debug("Failed to install recents provider", exc_info=True)
    try:
        from utils.task_plan import set_task_plan_event_sink
        set_task_plan_event_sink(delegate._push_event)
    except Exception:
        _log.debug("Failed to register task plan event sink", exc_info=True)
    try:
        from utils.kanban_events import set_kanban_event_sink
        set_kanban_event_sink(delegate._push_event)
    except Exception:
        _log.debug("Failed to register kanban event sink", exc_info=True)

    def _proactive_jsonl_poller():
        """Poll proactive_queue.jsonl and bridge new entries to system_event SSE.
        READ-ONLY: never modifies the JSONL file to avoid data loss races.
        """
        from collections import OrderedDict
        from persistence import PROACTIVE_QUEUE_PATH
        from utils.proactive_queue import proactive_entry_id, sanitize_system_event_text, is_entry_expired
        seen_ids: OrderedDict[str, None] = OrderedDict()
        _SEEN_IDS_MAX = 500
        qpath = os.path.expanduser(PROACTIVE_QUEUE_PATH)

        try:
            if os.path.isfile(qpath):
                with open(qpath, "r", encoding="utf-8") as f:
                    for line in f:
                        line = line.strip()
                        if not line:
                            continue
                        try:
                            entry = json.loads(line)
                        except json.JSONDecodeError:
                            continue
                        if not isinstance(entry, dict):
                            continue
                        seen_ids[proactive_entry_id(entry)] = None
        except OSError:
            _log.debug("Proactive queue bootstrap read failed", exc_info=True)

        while True:
            try:
                time.sleep(10)
                if not os.path.isfile(qpath):
                    continue
                with _PROACTIVE_QUEUE_FILE_LOCK:
                    with open(qpath, "r", encoding="utf-8") as f:
                        lines = f.readlines()
                for line in lines:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        entry = json.loads(line)
                    except json.JSONDecodeError:
                        continue
                    if not isinstance(entry, dict):
                        continue
                    if entry.get("processed") or entry.get("notified"):
                        continue
                    if is_entry_expired(entry):
                        continue
                    eid = proactive_entry_id(entry)
                    if eid in seen_ids:
                        continue
                    seen_ids[eid] = None
                    content = sanitize_system_event_text(entry.get("content") or "", max_chars=500)
                    if not content:
                        continue
                    tid = delegate._current_thread_id
                    try:
                        delegate._push_event(
                            {"type": "system_event", "text": content,
                             "source": str(entry.get("source") or "heartbeat")},
                            tid,
                        )
                    except Exception:
                        _log.debug("Failed to push proactive system_event", exc_info=True)
                while len(seen_ids) > _SEEN_IDS_MAX:
                    seen_ids.popitem(last=False)
            except Exception as e:
                _log.debug("Proactive JSONL poller error: %s", e)
                time.sleep(30)

    threading.Thread(
        target=_proactive_jsonl_poller, daemon=True,
        name="proactive-jsonl-poller",
    ).start()

    html_content = aiagent.get_chat_html()
    # Token from parent (e.g. Swift) via env so we never print it to stdout.
    # MUGI_AUTH_TOKEN must always be set by the launcher. In standalone/dev mode,
    # generate one and write it to a mode-0600 file rather than printing to stdout.
    auth_token = os.environ.get("MUGI_AUTH_TOKEN")
    if not auth_token:
        auth_token = generate_secure_token()
        import tempfile
        token_fd, token_path = tempfile.mkstemp(prefix="mugi_token_", suffix=".txt")
        try:
            os.write(token_fd, auth_token.encode())
        finally:
            os.close(token_fd)
        os.chmod(token_path, 0o600)
        ready_line = f"MUGI_READY:{port}:file:{token_path}"
    else:
        ready_line = f"MUGI_READY:{port}"

    app = _build_app(delegate, chat_bus, html_content, auth_token)

    # Start the invisible evolution scheduler (GRPO training when idle + AC power + enough data).
    try:
        from evolution.scheduler import start_evolution_scheduler
        start_evolution_scheduler()
    except Exception:
        _log.debug("Failed to start evolution scheduler", exc_info=True)

    # Run uvicorn in a daemon thread so we can probe the port and only print
    # MUGI_READY once the HTTP server is actually accepting connections.
    # This ensures the Swift app doesn't call GET /preferences before the
    # FastAPI server is listening (which previously caused applyStartupMLXContext
    # to bail out immediately and skip the MLX auto-start wait loop).
    # Bind on all interfaces so Mesh peers on LAN can reach /mesh/* routes.
    _uvicorn_config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="warning")
    _uvicorn_server = uvicorn.Server(_uvicorn_config)

    _server_thread = threading.Thread(target=_uvicorn_server.run, daemon=True)
    _server_thread.start()

    # Probe until the port is accepting connections (up to 30 s).
    import socket as _socket
    for _probe_attempt in range(300):
        try:
            with _socket.create_connection(("127.0.0.1", port), timeout=0.1):
                break
        except OSError:
            time.sleep(0.1)

    # Now the server is listening — safe to signal the Swift host.
    # Emit the diagnostic banner first; the existing parser keys on ``MUGI_READY:``
    # so the extra ``MUGI_BACKEND_INFO:`` line is ignored by older Swift builds and
    # parsed by the new one.
    _emit_backend_banner()
    print(ready_line, flush=True)

    def _on_shutdown_signal(signum, _frame):
        _log.info("shutdown signal %s received; beginning teardown", signum)
        # Hard guarantee: once shutdown begins, this process WILL exit. The
        # June 2026 zombie incident: cleanup ran, but uvicorn's graceful
        # shutdown waited forever on open SSE connections — port closed,
        # dispatcher stopped, process alive. Swift never restarted it because
        # the PID survived, and the kanban board stalled. The long fuse covers
        # the worst-case cleanup (colima stop has a 120s timeout).
        try:
            from mugi_shutdown_cleanup import arm_forced_exit_watchdog
            arm_forced_exit_watchdog(180.0, reason=f"signal:{signum}")
        except Exception:
            _log.debug("failed to arm shutdown watchdog", exc_info=True)
        # Drain chats_db writes FIRST so a trash / rename / organize that was queued
        # in the last few hundred ms is durable on disk before anything else tears down.
        try:
            _drain_chats_db_writes()
        except Exception:
            _log.debug("chats_db drain on signal failed", exc_info=True)
        try:
            from mugi_shutdown_cleanup import run_mugi_shutdown_cleanup
            run_mugi_shutdown_cleanup(reason=f"signal:{signum}")
        except Exception:
            _log.debug("signal shutdown cleanup failed", exc_info=True)
        try:
            # force_exit makes uvicorn cancel in-flight requests (long-lived
            # SSE streams from the Swift host) instead of waiting for them to
            # close — they never will, since the client keeps them open.
            _uvicorn_server.force_exit = True
            _uvicorn_server.should_exit = True
        except Exception:
            # Uvicorn may already be torn down during signal handling.
            _log.debug("uvicorn should_exit on signal failed", exc_info=True)
        # Cleanup is done and the server has been told to exit; anything still
        # holding the process open now is a leak, not work worth waiting for.
        try:
            from mugi_shutdown_cleanup import arm_forced_exit_watchdog
            arm_forced_exit_watchdog(15.0, reason=f"signal:{signum}:post-cleanup")
        except Exception:
            _log.debug("failed to arm post-cleanup watchdog", exc_info=True)

    for _sig in (signal.SIGTERM, signal.SIGINT):
        try:
            signal.signal(_sig, _on_shutdown_signal)
        except (OSError, ValueError, RuntimeError):
            pass

    _server_thread.join()


if __name__ == "__main__":
    main()
