#!/usr/bin/env python3
"""OpenClaw Model Manager - GTK4/Adwaita GUI for managing AI models and agents."""

import json
import os
import subprocess
import sys
import threading
import urllib.request
import urllib.error
from pathlib import Path

import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk, Gdk, GLib, Pango  # noqa: E402

# ── Constants ──────────────────────────────────────────────────────────────────

OPENCLAW_CONFIG = Path.home() / ".openclaw" / "openclaw.json"
OPENCLAW_ENV = Path.home() / ".openclaw" / ".env"
OPENCLAW_GATEWAY_ENV = Path.home() / ".openclaw" / "gateway.systemd.env"
LMSTUDIO_URL = "http://127.0.0.1:1234/v1/models"
LMSTUDIO_ANTHROPIC_BASE = "http://127.0.0.1:1234"
# Both LM Studio servers are managed: "vllm" = LM Studio on this PC, "buildpc" =
# LM Studio on the Main PC (tailscale). Their baseUrls are read live from
# openclaw.json (models.providers.<id>.baseUrl) so an IP change needs no app
# edit; the per-provider maxTokens matches the existing entries' convention.
LMSTUDIO_PROVIDERS = {"vllm": "Local", "buildpc": "Main PC"}
LMSTUDIO_PROVIDER_MAXTOK = {"vllm": 8192, "buildpc": 32768}
# DeepSeek ships an Anthropic-compatible endpoint, so the Claude Code CLI (which
# only speaks the Anthropic protocol) can be pointed straight at it.
DEEPSEEK_ANTHROPIC_BASE = "https://api.deepseek.com/anthropic"
DEEPSEEK_DEFAULT_MODEL = "deepseek-v4-pro"
GATEWAY_SERVICE = "openclaw-gateway.service"
SYSTEMD_DROPIN_DIR = Path.home() / ".config" / "systemd" / "user" / f"{GATEWAY_SERVICE}.d"
SYSTEMD_DROPIN_FILE = SYSTEMD_DROPIN_DIR / "60-subagent-routing.conf"
MAX_BACKUPS = 5

# Hermes Agent (separate Python-based agent with its own systemd service)
HERMES_CONFIG = Path.home() / ".hermes" / "config.yaml"
HERMES_GATEWAY_SERVICE = "hermes-gateway.service"

# ── Background services we can start / stop / restart / update ──────────────────
# These are the long-running pieces of the OpenClaw stack, each a `systemctl
# --user` unit. (Rocket.Chat was retired 2026-06-11 — DisPatch is the chat now.)
EMAIL_SERVICE = "openclaw-email.service"
# "DisPatch" — the self-hosted chat UI that replaced Rocket.Chat as the daily
# driver (the unit kept its original name when the app was renamed).
DISPATCH_SERVICE = "local-chat.service"

# Image-tool secrets (CIVITAI keys). Loaded by optional EnvironmentFile drop-ins
# on the gateway (95-imagetool-env.conf) and DisPatch (imagetool.conf) — surfaced
# read-only in the status panel so a missing file is visible at a glance.
IMAGETOOL_ENV = Path.home() / ".openclaw" / "secrets" / "imagetool.env"

# Update toolchain locations. Path.home() resolves the node/openclaw paths the
# systemd units use (works on atomic OSes where /home is a /var/home symlink).
NODE_BIN = Path.home() / ".hermes" / "node" / "bin" / "node"
OPENCLAW_ENTRY = Path.home() / ".hermes" / "node" / "lib" / "node_modules" / "openclaw" / "dist" / "index.js"
HERMES_BIN = Path.home() / ".local" / "bin" / "hermes"
UV_BIN = Path.home() / ".local" / "bin" / "uv"
EMAIL_SOURCE_DIR = Path.home() / "openclaw-email"

SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"

# Declarative description of each controllable service. The UI builds one
# collapsible row per entry whose unit file actually exists on this machine.
#   update_kind: dispatch key for Updates.run().
#   op_timeout: seconds for a start/stop/restart of this unit.
#   reset_failed_after_stop: gateway/hermes land in `failed` on stop (SIGKILL / exit 1);
#                            clearing it keeps the status honest (cosmetic only).
SERVICES = [
    {
        "key": "hermes",
        "name": "Hermes Agent",
        "unit": HERMES_GATEWAY_SERVICE,
        "health": "systemd",
        "op_timeout": 240,
        "update_kind": "hermes",
        "reset_failed_after_stop": True,
        "stop_note": "Stops the Hermes Agent gateway.",
        "update_blurb": "Runs `hermes update` (git pull + reinstall), then restarts.",
    },
    {
        "key": "gateway",
        "name": "OpenClaw Gateway",
        "unit": GATEWAY_SERVICE,
        "health": "systemd",
        "op_timeout": 90,
        "update_kind": "openclaw",
        "reset_failed_after_stop": True,
        "stop_note": ("Stops the OpenClaw gateway — all bots go offline. DisPatch "
                      "runs in its own unit and keeps serving (without agent replies)."),
        "update_blurb": "Runs `openclaw update`, then restarts the gateway.",
    },
    {
        "key": "email",
        "name": "OpenClaw Email Agent",
        "unit": EMAIL_SERVICE,
        "health": "systemd",
        "op_timeout": 90,
        "update_kind": "email",
        "reset_failed_after_stop": False,
        "stop_note": "Stops the email agent (the approval UI on :47767 goes offline).",
        "update_blurb": "Runs `uv tool upgrade openclaw-email`, then restarts.",
    },
    {
        "key": "dispatch",
        "name": "DisPatch (chat)",
        "unit": DISPATCH_SERVICE,
        "health": "systemd",
        "op_timeout": 60,
        "update_kind": None,  # runs from local source (~/_ChatApp) — nothing to pull
        "reset_failed_after_stop": False,
        "stop_note": "Stops the DisPatch chat UI (port :8765 goes offline).",
        "update_blurb": "",
    },
]

# ── Color-coded "named default" model slots ─────────────────────────────────────
# UI-only annotations: which model wears which colour, and the editable default
# model for each named role. Stored SEPARATELY from openclaw.json so the gateway
# never sees these manager-only keys. Selecting a slot's model anywhere in the UI
# tints that picker with the slot's colour (left accent bar + faint row tint).
SLOTS_FILE = Path.home() / ".openclaw" / ".model-manager-slots.json"
DEFAULT_SLOTS = [
    {"key": "api_main",    "name": "API Main",              "color": "#9141ac",
     "model": "deepseek/deepseek-v4-pro"},
    {"key": "api_backup",  "name": "API Backup",            "color": "#3584e4",
     "model": "deepseek/deepseek-v4-flash"},
    {"key": "local_main",  "name": "Local LM Studio Main",  "color": "#2ec27e",
     "model": "vllm/trohrbaugh-qwen3.5-122b-a10b-heretic-i1"},
    {"key": "remote_main", "name": "Remote LM Studio Main", "color": "#e66100",
     "model": "buildpc/trohrbaugh-qwen3.5-122b-a10b-heretic-i1"},
]

# Two default "profiles" built from the four slots. An agent assigned to a profile
# can set its primary/backup to "[Default]" and inherit that profile's models.
PROFILE_API = "api"
PROFILE_LOCAL = "local"
PROFILE_LABELS = {PROFILE_API: "API", PROFILE_LOCAL: "Local"}
PROFILE_ORDER = [PROFILE_API, PROFILE_LOCAL]
# profile -> (primary slot key, backup slot key)
PROFILE_SLOTS = {
    PROFILE_API:   ("api_main", "api_backup"),
    PROFILE_LOCAL: ("local_main", "remote_main"),
}
# Sentinel ids carried inside a model ComboRow's raw item list (like "(None)").
DEFAULT_PRIMARY = "__default_primary__"
DEFAULT_BACKUP = "__default_backup__"


def atomic_write_private(target: Path, content: str, fallback_mode=0o600):
    """Atomically replace `target` with `content`, preserving its permission
    bits. The temp file is created 0600 and chmodded to the original file's
    mode BEFORE any content is written, so a save never flips a secret-bearing
    file (openclaw.json holds gateway.auth.token; ~/.hermes/config.yaml holds
    api_keys) to the umask default 0644 — not even transiently via the temp
    file. New files get `fallback_mode` (0600 unless the caller says otherwise).
    """
    try:
        mode = target.stat().st_mode & 0o7777
    except OSError:
        mode = fallback_mode
    tmp = target.with_suffix(target.suffix + ".tmp")
    fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    try:
        os.fchmod(fd, mode)  # set final perms while the file is still empty
        os.write(fd, content.encode("utf-8"))
        os.fsync(fd)
    finally:
        os.close(fd)
    tmp.replace(target)


class SlotConfig:
    """Load/save manager-owned metadata: the four colour-coded model slots and a
    tiny per-agent "intent" map (which profile, and whether primary/backup inherit
    the profile default). Stored in SLOTS_FILE — never in openclaw.json. Reads both
    the legacy bare-list format and the current {slots, agentProfiles} object."""

    @staticmethod
    def _read_raw():
        try:
            return json.loads(SLOTS_FILE.read_text())
        except Exception:
            return None

    @staticmethod
    def load():
        slots = [dict(s) for s in DEFAULT_SLOTS]
        raw = SlotConfig._read_raw()
        saved_slots = raw if isinstance(raw, list) else (
            raw.get("slots") if isinstance(raw, dict) else None)
        if isinstance(saved_slots, list):
            by_key = {s.get("key"): s for s in saved_slots if isinstance(s, dict)}
            for s in slots:
                u = by_key.get(s["key"])
                if not u:
                    continue
                if "model" in u:
                    s["model"] = u["model"]
                if u.get("color"):
                    s["color"] = u["color"]
        return slots

    @staticmethod
    def load_agent_profiles():
        raw = SlotConfig._read_raw()
        if isinstance(raw, dict) and isinstance(raw.get("agentProfiles"), dict):
            out = {}
            for aid, rec in raw["agentProfiles"].items():
                if isinstance(rec, dict):
                    out[aid] = {
                        "profile": rec.get("profile", PROFILE_API),
                        "primary": rec.get("primary", "explicit"),
                        "backup": rec.get("backup", "explicit"),
                    }
            return out
        return {}

    @staticmethod
    def save(slots, agent_profiles=None):
        data = {"slots": slots, "agentProfiles": agent_profiles or {}}
        atomic_write_private(SLOTS_FILE, json.dumps(data, indent=2) + "\n",
                             fallback_mode=0o644)  # no secrets in the slots file

SUBAGENT_MODE_LOCAL = "local"
SUBAGENT_MODE_CLOUD = "cloud"
SUBAGENT_MODE_DEEPSEEK = "deepseek"
SUBAGENT_MODE_DISABLED = "disabled"
SUBAGENT_MODE_LABELS = {
    SUBAGENT_MODE_LOCAL: "Use LM Studio  —  free, runs on this PC",
    SUBAGENT_MODE_DEEPSEEK: "Use DeepSeek  —  paid API, via DeepSeek's Anthropic-compatible endpoint",
    SUBAGENT_MODE_CLOUD: "Use Anthropic Cloud  —  paid, needs API credits",
    SUBAGENT_MODE_DISABLED: "Turn off  —  block all coding-helper calls",
}
SUBAGENT_MODE_SHORT = {
    SUBAGENT_MODE_LOCAL: "Local",
    SUBAGENT_MODE_DEEPSEEK: "DeepSeek",
    SUBAGENT_MODE_CLOUD: "Cloud",
    SUBAGENT_MODE_DISABLED: "Off",
}
SUBAGENT_MODE_ORDER = [SUBAGENT_MODE_LOCAL, SUBAGENT_MODE_DEEPSEEK,
                      SUBAGENT_MODE_CLOUD, SUBAGENT_MODE_DISABLED]

ANTHROPIC_MODELS = [
    {"id": "claude-opus-4-8", "display": "Claude Opus 4.8", "cost": "$5/$25 per M tokens"},
    {"id": "claude-sonnet-4-6", "display": "Claude Sonnet 4.6", "cost": "$3/$15 per M tokens"},
    {"id": "claude-haiku-4-5", "display": "Claude Haiku 4.5", "cost": "$1/$5 per M tokens"},
]

# Friendly display names for known models. The machine tag — (Local) = LM Studio
# on this PC, (Main PC) = the buildpc desktop, (Cloud) = billed API — and the build
# token (i1 / v2 / Heretic / Uncensored / MTP) are ALWAYS kept so no two distinct
# ids collapse to the same line-1 name (the near-duplicate trap that made it easy to
# assign the wrong model — e.g. the two local 122B heretics, or Local vs Main PC).
DISPLAY_NAMES = {
    # ── Cloud (DeepSeek) ──
    "deepseek/deepseek-v4-flash": "DeepSeek V4 Flash (Cloud)",
    "deepseek/deepseek-v4-pro": "DeepSeek V4 Pro (Cloud)",
    "anthropic/claude-opus-4-8": "Claude Opus 4.8 (Cloud)",
    "anthropic/claude-opus-4-6": "Claude Opus 4.6 (Cloud)",
    "anthropic/claude-sonnet-4-6": "Claude Sonnet 4.6 (Cloud)",
    "anthropic/claude-haiku-4-5": "Claude Haiku 4.5 (Cloud)",
    "anthropic/claude-haiku-4-5-20251001": "Claude Haiku 4.5 (Cloud)",

    # ── Local — LM Studio on this PC (vllm/) ──
    "vllm/google/gemma-4-31b": "Gemma 4 31B (Local)",
    "vllm/google/gemma-4-26b-a4b": "Gemma 4 26B-A4B (Local)",
    "vllm/google/gemma-4-e4b": "Gemma 4 E4B (Local)",
    "google/gemma-4-31b": "Gemma 4 31B (Local)",
    "google/gemma-4-26b-a4b": "Gemma 4 26B-A4B (Local)",
    "vllm/qwen/qwen3.6-35b-a3b": "Qwen 3.6 35B-A3B (Local)",
    "vllm/qwen3.6-27b": "Qwen 3.6 27B (Local)",
    "vllm/qwen3.6-27b-mtp": "Qwen 3.6 27B MTP (Local)",
    # The two distinct local 122B heretics — i1 vs v2 kept explicit:
    "vllm/trohrbaugh-qwen3.5-122b-a10b-heretic-i1": "Qwen3.5 122B Heretic i1 (Local)",
    "vllm/qwen3.5-122b-a10b-heretic-v2-i1": "Qwen3.5 122B Heretic v2 (Local)",
    # Doxy's local uncensored fallback (was a cryptic raw id):
    "vllm/qwen3.6-27b-uncensored-heretic-v2-native-mtp-preserved":
        "Qwen 3.6 27B Uncensored Heretic v2 MTP (Local)",

    # ── Main PC (buildpc/) ──
    "buildpc/google/gemma-4-31b": "Gemma 4 31B (Main PC)",
    "buildpc/trohrbaugh-qwen3.5-122b-a10b-heretic-i1": "Qwen3.5 122B Heretic i1 (Main PC)",
    "buildpc/nemotron-3-super-120b-a12b": "Nemotron 3 Super 120B (Main PC)",
    "buildpc/qwen3.6-27b-mtp": "Qwen 3.6 27B MTP (Main PC)",
    "buildpc/qwen3.6-27b-uncensored-heretic-v2-native-mtp-preserved":
        "Qwen 3.6 27B Uncensored Heretic v2 MTP (Main PC)",
}


def friendly_name(model_id):
    """Return a friendly display name for a model ID. Unmapped ids still get a
    machine tag from their provider prefix so the user can always tell where a
    model runs (Local / Main PC / Cloud)."""
    if model_id in DISPLAY_NAMES:
        return DISPLAY_NAMES[model_id]
    parts = model_id.split("/")
    suffix = {"vllm": " (Local)", "buildpc": " (Main PC)",
              "deepseek": " (Cloud)", "anthropic": " (Cloud)",
              "openai": " (Cloud)", "openrouter": " (Cloud)"}.get(parts[0], "")
    base = "/".join(parts[1:]) if len(parts) >= 2 else model_id
    return f"{base}{suffix}"


# Badge colours for where a model actually runs.
WHERE_COLORS = {"Local": "#2ec27e", "Remote": "#e66100", "Cloud": "#3584e4",
                "Off": "#9a9996", "—": "#9a9996", "?": "#9a9996"}


def classify_model(model_id):
    """Where does this model run? → 'Local' (LM Studio on this PC), 'Remote'
    (the Main PC / buildpc), 'Cloud' (billed API), or '—' (unset/inherit)."""
    if not model_id:
        return "—"
    if model_id.startswith("vllm/"):
        return "Local"
    if model_id.startswith("buildpc/"):
        return "Remote"
    if model_id.startswith(("deepseek/", "anthropic/", "openai/", "openrouter/",
                            "google/", "xai/", "mistral/", "groq/")):
        return "Cloud"
    m = model_id.lower()
    if any(t in m for t in ("deepseek", "claude", "gpt-", "gemini", "grok",
                            "sonnet", "opus", "haiku", "o1-", "o3-")):
        return "Cloud"
    if "/" not in model_id:
        return "Local"  # a bare id resolves through the local LM Studio endpoint
    return "?"


# ── Config Manager ─────────────────────────────────────────────────────────────

class ConfigManager:
    def __init__(self, path: Path):
        self.path = path
        self.data = {}
        self._original = ""
        self._mtime = 0.0

    @staticmethod
    def _dumps(data):
        # ensure_ascii=False keeps emoji/UTF-8 in agent identities byte-identical
        # across a load→save round-trip (instead of re-escaping to \uXXXX), so an
        # unmodified save is a true no-op for the gateway's file watcher.
        return json.dumps(data, indent=2, ensure_ascii=False)

    def load(self):
        raw = self.path.read_text(encoding="utf-8")
        self.data = json.loads(raw)
        self._original = self._dumps(self.data)
        self._mtime = self.path.stat().st_mtime

    def external_change_detected(self):
        """True if the on-disk file's mtime moved since we loaded it — the
        gateway rewrites openclaw.json itself (identity reconcile, `openclaw
        update`), so the GUI must ask before clobbering such an edit."""
        try:
            return self.path.exists() and self.path.stat().st_mtime != self._mtime
        except OSError:
            return False

    def save(self):
        """Back up the current on-disk file, then write our data.

        The current file is copied to .bak before we overwrite it, so any edits
        made externally since we loaded land in the rotated backups rather than
        being silently discarded.
        """
        import shutil
        # Detect an external rewrite (gateway reconcile / `openclaw update`) since
        # we loaded. We still proceed — the pre-write .bak below preserves the
        # on-disk version — but record it so the caller can warn the user that
        # their save overrode an external change rather than doing so silently.
        self.clobbered_external = False
        try:
            if self.path.exists() and self.path.stat().st_mtime != self._mtime:
                self.clobbered_external = True
        except OSError:
            pass
        # Rotate oldest-first so no move clobbers a backup that hasn't shifted
        # yet:  .bak.4→.bak.5,  .bak.3→.bak.4,  …,  .bak.1→.bak.2,  .bak→.bak.1
        for i in range(MAX_BACKUPS, 0, -1):
            src = self.path.with_suffix(".json.bak" if i == 1 else f".json.bak.{i - 1}")
            dst = self.path.with_suffix(f".json.bak.{i}")
            if src.exists():
                src.replace(dst)
        if self.path.exists():
            shutil.copy2(self.path, self.path.with_suffix(".json.bak"))
        new_json = self._dumps(self.data) + "\n"
        # Validate what we are about to write parses back to the same data —
        # never hand the live gateway an unreadable file.
        if json.loads(new_json) != self.data:
            raise RuntimeError("serialized config failed validation; aborting save")
        # Atomic write so a crash/kill mid-save can't leave the live gateway
        # config truncated (matches SlotConfig/SubAgentRouting/Hermes writers).
        # Perm-preserving: openclaw.json carries gateway.auth.token and must
        # stay 0600 (a bare write_text temp would be created umask-wide).
        atomic_write_private(self.path, new_json)
        self._original = self._dumps(self.data)
        self._mtime = self.path.stat().st_mtime

    def is_dirty(self):
        return self._dumps(self.data) != self._original

    def get_agents(self):
        return self.data.get("agents", {}).get("list", [])

    def get_default_model(self):
        return self.data.get("agents", {}).get("defaults", {}).get("model", {})

    def get_model_catalog(self):
        return self.data.get("agents", {}).get("defaults", {}).get("models", {})

    def get_configured_model_ids(self):
        return list(self.get_model_catalog().keys())

    def get_provider_model_detail(self, model_id):
        """Return the provider's advertised detail dict for a (prefixed) catalog
        id — name, reasoning, input modalities, cost, contextWindow, maxTokens —
        or {} if the provider/model isn't described in the OpenClaw config."""
        if not model_id or "/" not in model_id:
            return {}
        provider, raw_id = model_id.split("/", 1)
        try:
            prov = self.data.get("models", {}).get("providers", {}).get(provider, {})
            for m in prov.get("models", []):
                if isinstance(m, dict) and m.get("id") == raw_id:
                    return m
        except Exception:
            pass
        return {}

    def model_exists(self, model_id):
        """True if a prefixed model id is real: present in the agents.defaults.models
        catalog OR advertised in its provider's models list. Used to refuse writing
        a model id that doesn't exist on its provider (a stale [Default] slot once
        pointed agents at vllm/google/gemma-4-31b-qat, which the vllm provider
        never had — a save would have put agents on a dead model)."""
        if not model_id or "/" not in model_id:
            return False
        if model_id in self.get_model_catalog():
            return True
        return bool(self.get_provider_model_detail(model_id))

    def plugin_enabled(self, plugin_id):
        """Effective plugin state under OpenClaw's strict-allowlist semantics:
        a NON-EMPTY plugins.allow gates everything — entries.<id>.enabled=true is
        inert unless the id is ALSO in allow. This is read-only; the manager never
        writes plugins.* (re-enabling a plugin is a deliberate manual act)."""
        plugins = self.data.get("plugins", {})
        entry = plugins.get("entries", {}).get(plugin_id)
        enabled = bool(entry.get("enabled")) if isinstance(entry, dict) else False
        allow = plugins.get("allow")
        if isinstance(allow, list) and allow:
            return enabled and plugin_id in allow
        return enabled

    def get_agent_primary(self, agent):
        m = agent.get("model", "")
        if isinstance(m, dict):
            return m.get("primary", "")
        return m

    def get_agent_fallbacks(self, agent):
        m = agent.get("model", "")
        if isinstance(m, dict):
            return m.get("fallbacks", [])
        return []

    def set_agent_model(self, agent_id, primary, fallback=None):
        for agent in self.get_agents():
            if agent.get("id") == agent_id:
                # The UI edits only the first fallback slot. Preserve any extra
                # fallbacks the agent already had so editing one agent doesn't
                # silently drop another agent's additional fallbacks on save.
                tail = self.get_agent_fallbacks(agent)[1:]
                fallbacks = ([fallback] if fallback else []) + tail
                if fallbacks:
                    agent["model"] = {"primary": primary, "fallbacks": fallbacks}
                else:
                    agent["model"] = primary
                return

    def set_default_model(self, primary, fallback=None):
        defaults = self.data.setdefault("agents", {}).setdefault("defaults", {})
        existing = defaults.get("model", {})
        existing_fbs = existing.get("fallbacks", []) if isinstance(existing, dict) else []
        tail = existing_fbs[1:]
        defaults["model"] = {"primary": primary, "fallbacks": ([fallback] if fallback else []) + tail}

    def get_subagents_model(self):
        """Model for OpenClaw-spawned subagents (agents.defaults.subagents.model).
        Empty string means 'inherit each agent's own model' (the default)."""
        sa = self.data.get("agents", {}).get("defaults", {}).get("subagents", {})
        m = sa.get("model")
        if isinstance(m, dict):
            return m.get("primary", "")
        return m or ""

    def set_subagents_model(self, primary):
        sa = self.data.setdefault("agents", {}).setdefault("defaults", {}).setdefault("subagents", {})
        if not primary:
            sa.pop("model", None)
        else:
            sa["model"] = primary

    def add_lmstudio_model(self, raw_id, provider="vllm", reasoning=True,
                           max_tokens=8192):
        """Add an LM Studio model to the catalog + the provider's model list.

        reasoning defaults to True: a local model that emits reasoning_content
        while configured reasoning:false gets false-stalled and aborted by the
        gateway (the 2026-06-09 Doxy/Build_PC incident). reasoning:true is
        harmless for models that never emit it, so it is the safe default —
        the add dialog lets the user switch it off for known non-reasoners.
        """
        prefixed_id = f"{provider}/{raw_id}"
        catalog = self.data.setdefault("agents", {}).setdefault("defaults", {}).setdefault("models", {})
        if prefixed_id not in catalog:
            catalog[prefixed_id] = {}
        providers = self.data.setdefault("models", {}).setdefault("providers", {})
        prov = providers.setdefault(provider, {})
        models_list = prov.setdefault("models", [])
        existing_ids = {m.get("id") for m in models_list if isinstance(m, dict)}
        if raw_id not in existing_ids:
            models_list.append({
                "id": raw_id, "name": raw_id, "reasoning": bool(reasoning),
                "input": ["text"],
                "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
                "contextWindow": 128000, "maxTokens": max_tokens,
            })

    def add_anthropic_model(self, model_id):
        prefixed_id = f"anthropic/{model_id}"
        catalog = self.data.setdefault("agents", {}).setdefault("defaults", {}).setdefault("models", {})
        if prefixed_id not in catalog:
            catalog[prefixed_id] = {"params": {"cacheRetention": "short", "thinking": "adaptive"}}

    def strip_model_from_fallbacks(self, model_id):
        """Remove model_id from every agent's fallback list (including the hidden
        tail the UI doesn't surface). Returns the names of agents touched."""
        touched = []
        for agent in self.get_agents():
            fbs = self.get_agent_fallbacks(agent)
            if model_id in fbs:
                new_fbs = [f for f in fbs if f != model_id]
                primary = self.get_agent_primary(agent)
                if new_fbs:
                    agent["model"] = {"primary": primary, "fallbacks": new_fbs}
                else:
                    agent["model"] = primary
                touched.append(agent.get("name") or agent.get("id", "?"))
        return touched

    def remove_model(self, model_id):
        catalog = self.get_model_catalog()
        catalog.pop(model_id, None)
        # Also drop the provider's model entry for LM Studio providers (both the
        # local "vllm" server and the Main-PC "buildpc" one) so no orphan
        # definitions pile up. Cloud providers (deepseek/...) keep their entries —
        # those define the model itself, not a discovered local copy.
        provider, _, raw_id = model_id.partition("/")
        if raw_id and provider in LMSTUDIO_PROVIDERS:
            prov = self.data.get("models", {}).get("providers", {}).get(provider, {})
            models_list = prov.get("models")
            if isinstance(models_list, list):
                prov["models"] = [m for m in models_list
                                  if not (isinstance(m, dict) and m.get("id") == raw_id)]


# ── Sub-Agent CLI Routing (systemd drop-in) ────────────────────────────────────

class SubAgentRouting:
    """Manages a systemd user drop-in that overrides the ANTHROPIC_* env vars
    seen by the gateway and any sub-processes it spawns (notably the
    Claude Code CLI invoked for coding tasks). Without this, the CLI inherits
    ANTHROPIC_API_KEY from the user environment and bills against Anthropic
    even when the chat agent itself is running on a local model.
    """

    @staticmethod
    def read():
        """Return {'mode': str, 'model': str|None} describing current routing.
        Modes: 'local' (LM Studio), 'cloud' (default Anthropic), 'disabled'.
        """
        if not SYSTEMD_DROPIN_FILE.exists():
            return {"mode": SUBAGENT_MODE_CLOUD, "model": None}
        env = {}
        try:
            for line in SYSTEMD_DROPIN_FILE.read_text().splitlines():
                line = line.strip()
                if line.startswith('Environment="') and line.endswith('"'):
                    kv = line[len('Environment="'):-1]
                    if "=" in kv:
                        k, v = kv.split("=", 1)
                        env[k] = v
        except Exception:
            return {"mode": SUBAGENT_MODE_CLOUD, "model": None}
        base = env.get("ANTHROPIC_BASE_URL", "")
        model = env.get("ANTHROPIC_MODEL")
        if base.startswith("http://127.0.0.1:1234"):
            return {"mode": SUBAGENT_MODE_LOCAL, "model": model}
        if "api.deepseek.com" in base:
            return {"mode": SUBAGENT_MODE_DEEPSEEK, "model": model}
        if "blocked" in base or env.get("ANTHROPIC_AUTH_TOKEN") == "disabled":
            return {"mode": SUBAGENT_MODE_DISABLED, "model": None}
        return {"mode": SUBAGENT_MODE_CLOUD, "model": model}

    @staticmethod
    def _atomic_write(content):
        SYSTEMD_DROPIN_DIR.mkdir(parents=True, exist_ok=True)
        # systemd needs to read this drop-in but it never holds a secret
        # (DeepSeek mode deliberately writes $$DEEPSEEK_API_KEY, not the key),
        # so a fresh file may stay world-readable like any unit file.
        atomic_write_private(SYSTEMD_DROPIN_FILE, content, fallback_mode=0o644)

    @staticmethod
    def write_local(model_id):
        if not model_id:
            raise ValueError("Local mode needs a model name")
        # Sanitize: model_id goes inside Environment="ANTHROPIC_MODEL=..." so reject
        # anything that could break out of that quoted value (newlines or unescaped quotes).
        if "\n" in model_id or '"' in model_id:
            raise ValueError("Model name contains invalid characters")
        content = (
            "[Service]\n"
            "# Managed by openclaw-model-manager.\n"
            "# Routes Claude Code CLI sub-processes to LM Studio's Anthropic-compat endpoint.\n"
            f'Environment="ANTHROPIC_BASE_URL={LMSTUDIO_ANTHROPIC_BASE}"\n'
            'Environment="ANTHROPIC_AUTH_TOKEN=lm-studio"\n'
            'Environment="ANTHROPIC_API_KEY=lm-studio"\n'
            f'Environment="ANTHROPIC_MODEL={model_id}"\n'
        )
        SubAgentRouting._atomic_write(content)

    @staticmethod
    def _gateway_canonical_execstart():
        """The gateway's ExecStart command from its *canonical* unit file, ignoring
        drop-ins, so DeepSeek mode wraps the real command and never double-wraps a
        previous DeepSeek override. Falls back to the known default on any error."""
        default = f"{NODE_BIN} {OPENCLAW_ENTRY} gateway --port 18789"
        try:
            frag = subprocess.run(
                ["systemctl", "--user", "show", "-p", "FragmentPath", "--value",
                 GATEWAY_SERVICE],
                capture_output=True, text=True, timeout=10).stdout.strip()
            if frag and Path(frag).exists():
                for line in Path(frag).read_text().splitlines():
                    s = line.strip()
                    if not s.startswith("ExecStart="):
                        continue
                    cmd = s[len("ExecStart="):].strip()
                    while cmd[:1] in ("-", "+", "@", "!", ":"):
                        cmd = cmd[1:].strip()
                    # Accept the real gateway invocation; reject only our own
                    # DeepSeek wrapper so we never double-wrap. Keyed on "index.js"
                    # (robust to a future update changing the port/path/quoting)
                    # instead of the old quote/bash heuristic, which silently fell
                    # back to a hard-coded command if the unit ever gained a quote.
                    if cmd and "$$DEEPSEEK_API_KEY" not in cmd and "index.js" in cmd:
                        return cmd
        except Exception:
            pass
        return default

    @staticmethod
    def write_deepseek(model_id):
        """Route the Claude Code CLI to DeepSeek's Anthropic-compatible endpoint.
        The DeepSeek key is never written here: "$$DEEPSEEK_API_KEY" is systemd's
        escape for a literal "$DEEPSEEK_API_KEY", which bash expands at runtime from
        the gateway EnvironmentFile, so the secret stays out of the unit and argv."""
        model_id = model_id or DEEPSEEK_DEFAULT_MODEL
        if "\n" in model_id or '"' in model_id or "'" in model_id:
            raise ValueError("Model name contains invalid characters")
        execcmd = SubAgentRouting._gateway_canonical_execstart()
        content = (
            "[Service]\n"
            "# Managed by openclaw-model-manager  (Coding Helper -> DeepSeek mode).\n"
            "# Routes the Claude Code CLI sub-processes to DeepSeek's Anthropic-compatible\n"
            "# endpoint. The key is NOT copied here: $$DEEPSEEK_API_KEY is systemd's escape\n"
            "# for a literal $DEEPSEEK_API_KEY, which bash expands at runtime from the\n"
            "# gateway EnvironmentFile -- the secret never enters the unit or the argv.\n"
            "# If a future `openclaw update` changes the gateway command, re-select\n"
            "# DeepSeek here to re-wrap the new ExecStart.\n"
            f'Environment="ANTHROPIC_BASE_URL={DEEPSEEK_ANTHROPIC_BASE}"\n'
            f'Environment="ANTHROPIC_MODEL={model_id}"\n'
            "ExecStart=\n"
            "ExecStart=/usr/bin/bash -c 'export ANTHROPIC_AUTH_TOKEN=\"$$DEEPSEEK_API_KEY\"; "
            "export ANTHROPIC_API_KEY=\"$$DEEPSEEK_API_KEY\"; "
            f"exec {execcmd}'\n"
        )
        SubAgentRouting._atomic_write(content)

    @staticmethod
    def write_disabled():
        content = (
            "[Service]\n"
            "# Managed by openclaw-model-manager.\n"
            "# Blocks Claude Code CLI sub-processes from reaching any backend.\n"
            'Environment="ANTHROPIC_BASE_URL=http://127.0.0.1:9/blocked"\n'
            'Environment="ANTHROPIC_AUTH_TOKEN=disabled"\n'
            'Environment="ANTHROPIC_API_KEY=disabled"\n'
        )
        SubAgentRouting._atomic_write(content)

    @staticmethod
    def clear():
        if SYSTEMD_DROPIN_FILE.exists():
            SYSTEMD_DROPIN_FILE.unlink()

    @staticmethod
    def daemon_reload():
        result = subprocess.run(["systemctl", "--user", "daemon-reload"],
                                capture_output=True, text=True, timeout=10)
        if result.returncode != 0:
            raise RuntimeError(f"systemd daemon-reload failed: {result.stderr.strip() or 'unknown error'}")
        return result

    @staticmethod
    def test_local_model(model_id, timeout=15):
        """Send a real Anthropic-shaped request to LM Studio. Returns
        (ok: bool, message: str). A non-network error (e.g., model not loaded)
        still shows that the redirect works, so we report the upstream message."""
        try:
            req = urllib.request.Request(
                f"{LMSTUDIO_ANTHROPIC_BASE}/v1/messages",
                method="POST",
                headers={
                    "content-type": "application/json",
                    "x-api-key": "lm-studio",
                    "anthropic-version": "2023-06-01",
                },
                data=json.dumps({
                    "model": model_id,
                    "max_tokens": 8,
                    "messages": [{"role": "user", "content": "ping"}],
                }).encode(),
            )
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                body = json.loads(resp.read())
            if body.get("type") == "message":
                served = body.get("model") or model_id
                return True, f"Round-trip OK. LM Studio answered as model: {served}"
            return False, f"Unexpected response shape: {body!r}"
        except urllib.error.HTTPError as e:
            try:
                detail = e.read().decode("utf-8", "replace")[:200]
            except Exception:
                detail = ""
            return False, f"HTTP {e.code} from LM Studio. {detail}".strip()
        except Exception as e:
            return False, f"Could not reach LM Studio at {LMSTUDIO_ANTHROPIC_BASE}: {e}"

    @staticmethod
    def _read_env_key(name):
        """Read a key value (e.g. DEEPSEEK_API_KEY) from the gateway EnvironmentFile
        or ~/.openclaw/.env, falling back to this process's own environment. Used
        only to authenticate the 'Test connection' probe; never displayed."""
        for path in (OPENCLAW_GATEWAY_ENV, OPENCLAW_ENV):
            try:
                for line in Path(path).read_text().splitlines():
                    line = line.strip()
                    if line.startswith(name + "="):
                        v = line[len(name) + 1:].strip()
                        if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
                            v = v[1:-1]
                        if v:
                            return v
            except Exception:
                pass
        return os.environ.get(name)

    @staticmethod
    def test_deepseek_model(model_id, timeout=20):
        """Send a real Anthropic-shaped request to DeepSeek's Anthropic-compatible
        endpoint, authenticating with DEEPSEEK_API_KEY. Returns (ok, message)."""
        key = SubAgentRouting._read_env_key("DEEPSEEK_API_KEY")
        if not key:
            return False, ("DEEPSEEK_API_KEY not found in gateway.systemd.env, "
                           "~/.openclaw/.env, or the environment.")
        try:
            req = urllib.request.Request(
                f"{DEEPSEEK_ANTHROPIC_BASE}/v1/messages",
                method="POST",
                headers={
                    "content-type": "application/json",
                    "x-api-key": key,
                    "anthropic-version": "2023-06-01",
                },
                data=json.dumps({
                    "model": model_id,
                    "max_tokens": 16,
                    "messages": [{"role": "user", "content": "ping"}],
                }).encode(),
            )
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                body = json.loads(resp.read())
            if body.get("type") == "message":
                served = body.get("model") or model_id
                return True, f"Round-trip OK. DeepSeek answered as model: {served}"
            return False, f"Unexpected response shape: {body!r}"
        except urllib.error.HTTPError as e:
            try:
                detail = e.read().decode("utf-8", "replace")[:200]
            except Exception:
                detail = ""
            return False, f"HTTP {e.code} from DeepSeek. {detail}".strip()
        except Exception as e:
            return False, f"Could not reach DeepSeek at {DEEPSEEK_ANTHROPIC_BASE}: {e}"


# ── Hermes Agent Config ────────────────────────────────────────────────────────

class HermesConfig:
    """Read/write the `model.default:` field in ~/.hermes/config.yaml.

    We avoid a PyYAML dependency by doing a targeted line edit. Hermes' config
    has a top-level `model:` block; `default:` is a single scalar inside it.
    Other model.* fields (provider, base_url, api_key) are left untouched.
    """

    @staticmethod
    def exists():
        return HERMES_CONFIG.exists()

    @staticmethod
    def read_default_model():
        if not HERMES_CONFIG.exists():
            return None
        in_model_block = False
        for line in HERMES_CONFIG.read_text().splitlines():
            if line.startswith("model:"):
                in_model_block = True
                continue
            if in_model_block:
                # Top-level key ends the block.
                if line and not line[0].isspace():
                    break
                stripped = line.strip()
                if stripped.startswith("default:"):
                    val = stripped[len("default:"):].strip()
                    # Strip optional surrounding quotes
                    if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
                        val = val[1:-1]
                    return val or None
        return None

    @staticmethod
    def read_provider():
        """Return the `provider:` scalar inside the top-level model: block, or None."""
        if not HERMES_CONFIG.exists():
            return None
        in_model_block = False
        for line in HERMES_CONFIG.read_text().splitlines():
            if line.startswith("model:"):
                in_model_block = True
                continue
            if in_model_block:
                if line and not line[0].isspace():
                    break
                stripped = line.strip()
                if stripped.startswith("provider:"):
                    val = stripped[len("provider:"):].strip()
                    if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
                        val = val[1:-1]
                    return val or None
        return None

    @staticmethod
    def write_default_model(model_id):
        if not model_id or "\n" in model_id or "'" in model_id:
            raise ValueError("Invalid model id")
        if not HERMES_CONFIG.exists():
            raise FileNotFoundError(f"Hermes config not found at {HERMES_CONFIG}")

        # Backup
        bak = HERMES_CONFIG.with_suffix(".yaml.bak")
        import shutil
        shutil.copy2(HERMES_CONFIG, bak)

        lines = HERMES_CONFIG.read_text().splitlines(keepends=True)
        out = []
        in_model_block = False
        replaced = False
        for line in lines:
            if not replaced and line.startswith("model:"):
                in_model_block = True
                out.append(line)
                continue
            if in_model_block and not replaced:
                if line and not line[0].isspace() and line.strip():
                    # Left the model block without finding default — insert it
                    out.append(f"  default: '{model_id}'\n")
                    replaced = True
                    out.append(line)
                    continue
                stripped = line.strip()
                if stripped.startswith("default:"):
                    # Preserve indentation of the original line
                    indent = line[: len(line) - len(line.lstrip())]
                    out.append(f"{indent}default: '{model_id}'\n")
                    replaced = True
                    continue
            out.append(line)
        # If `model:` was the final block at EOF with no `default:` key, the
        # loop ends still inside the block having inserted nothing — synthesize
        # the default line now rather than failing the save.
        if in_model_block and not replaced:
            out.append(f"  default: '{model_id}'\n")
            replaced = True
        if not replaced:
            raise RuntimeError("Could not locate model.default in hermes config.yaml")

        # Atomic + perm-preserving write: config.yaml holds 17 api_keys and
        # must stay 0600 even transiently.
        atomic_write_private(HERMES_CONFIG, "".join(out))


class HermesService:
    @staticmethod
    def unit_installed():
        unit_path = Path.home() / ".config" / "systemd" / "user" / HERMES_GATEWAY_SERVICE
        return unit_path.exists()

    @staticmethod
    def is_running():
        try:
            r = subprocess.run(
                ["systemctl", "--user", "is-active", "--quiet", HERMES_GATEWAY_SERVICE],
                capture_output=True, timeout=5,
            )
            return r.returncode == 0
        except Exception:
            return False

    @staticmethod
    def restart():
        return subprocess.run(
            ["systemctl", "--user", "restart", HERMES_GATEWAY_SERVICE],
            capture_output=True, text=True, timeout=30,
        )


# ── Service management (start / stop / restart / update) ────────────────────────

def _service_env():
    """Environment for update subprocesses. Prepends the tool dirs the services
    themselves use so `openclaw update` finds npm, `podman-compose` finds podman,
    `hermes`/`uv` resolve, etc. — the GUI's own PATH may be narrower."""
    env = dict(os.environ)
    extra = [
        str(Path.home() / ".hermes" / "node" / "bin"),
        str(Path.home() / ".local" / "bin"),
        "/usr/local/bin", "/usr/bin", "/bin",
    ]
    cur = env.get("PATH", "")
    env["PATH"] = ":".join(extra) + ((":" + cur) if cur else "")
    env.setdefault("HOME", str(Path.home()))
    return env


def unit_exists(unit):
    """True if a `systemctl --user` unit file is installed for this user."""
    return (SYSTEMD_USER_DIR / unit).exists()


class Systemd:
    """Thin wrapper over `systemctl --user` for a single unit."""

    @staticmethod
    def _run(args, timeout):
        return subprocess.run(["systemctl", "--user", *args],
                              capture_output=True, text=True, timeout=timeout)

    @staticmethod
    def is_active(unit):
        try:
            return Systemd._run(["is-active", "--quiet", unit], timeout=5).returncode == 0
        except Exception:
            return False

    @staticmethod
    def start(unit, timeout=120):
        return Systemd._run(["start", unit], timeout=timeout)

    @staticmethod
    def stop(unit, timeout=240):
        return Systemd._run(["stop", unit], timeout=timeout)

    @staticmethod
    def restart(unit, timeout=240):
        return Systemd._run(["restart", unit], timeout=timeout)

    @staticmethod
    def reset_failed(unit):
        try:
            return Systemd._run(["reset-failed", unit], timeout=10)
        except Exception:
            return None


# NOTE (2026-06-11): the old gateway_config_stale() check was removed. The
# current gateway build DOES hot-reload openclaw.json (chokidar watcher,
# hybrid mode; the model paths the manager edits — agents.defaults.model[s],
# agents.list, models.providers — are classified "hot"). The old "config
# changed — restart to apply" warning was wrong and trained the user to do
# needless gateway restarts. Restarts ARE still required for systemd drop-in
# changes (Coding Helper routing), which the save flow handles.
# (The Health class that probed Rocket.Chat on :3000 was removed with RC's
# retirement on 2026-06-11 — DisPatch is the chat now.)


class Updates:
    """Native 'update to latest' for each service. Each returns a list of
    (label, CompletedProcess) steps so the UI can show exactly what ran."""

    @staticmethod
    def run(kind, svc):
        if kind == "openclaw":
            return Updates._openclaw()
        if kind == "hermes":
            return Updates._hermes(svc)
        if kind == "email":
            return Updates._email(svc)
        raise ValueError(f"Unknown update kind: {kind}")

    @staticmethod
    def _proc(cmd, timeout, cwd=None):
        return subprocess.run(cmd, capture_output=True, text=True,
                              timeout=timeout, env=_service_env(),
                              cwd=str(cwd) if cwd else None)

    @staticmethod
    def _openclaw():
        # `openclaw update` updates the npm install and restarts the gateway itself.
        return [("openclaw update --yes", Updates._proc(
            [str(NODE_BIN), str(OPENCLAW_ENTRY), "update", "--yes"], timeout=1800))]

    @staticmethod
    def _hermes(svc):
        steps = [("hermes update --yes", Updates._proc(
            [str(HERMES_BIN), "update", "--yes"], timeout=1800))]
        if steps[-1][1].returncode == 0:
            # The running gateway holds the old code; restart to pick up the pull.
            steps.append(("restart " + svc["unit"],
                          Systemd.restart(svc["unit"], timeout=svc["op_timeout"])))
        return steps

    @staticmethod
    def _email(svc):
        steps = [("uv tool upgrade openclaw-email", Updates._proc(
            [str(UV_BIN), "tool", "upgrade", "openclaw-email"], timeout=900))]
        if steps[-1][1].returncode == 0:
            steps.append(("restart " + svc["unit"],
                          Systemd.restart(svc["unit"], timeout=svc["op_timeout"])))
        return steps


# ── Scheduled (cron) model spinups ──────────────────────────────────────────────

class CronJobs:
    """Talks to the gateway's own cron scheduler (`openclaw cron …`). Every cron
    job is an agent turn, so each one spins up a model on a schedule — this is the
    biggest 'models running on their own' surface and it lives in the gateway, not
    systemd."""

    @staticmethod
    def _cli(*args, timeout=30):
        return subprocess.run(
            [str(NODE_BIN), str(OPENCLAW_ENTRY), "cron", *args],
            capture_output=True, text=True, timeout=timeout, env=_service_env())

    @staticmethod
    def list():
        """Return the list of job dicts, or None if the gateway/cron is unreachable."""
        try:
            r = CronJobs._cli("list", "--all", "--json")
            out = r.stdout or ""
            i = out.find("{")
            if i < 0:
                return None
            return json.loads(out[i:]).get("jobs", [])
        except Exception:
            return None

    @staticmethod
    def set_enabled(job_id, enabled):
        return CronJobs._cli("enable" if enabled else "disable", job_id, timeout=30)

    @staticmethod
    def run(job_id):
        return CronJobs._cli("run", job_id, timeout=30)


def cron_job_agent_id(job):
    """The agent a cron job runs as. Current gateway builds put it at the job's
    top-level `agentId`; older shapes used `payload.agentId` or encoded it in
    `sessionKey` ("agent:<id>:…", sometimes lower-cased). Returns "" if none."""
    aid = job.get("agentId") or (job.get("payload", {}) or {}).get("agentId")
    if aid:
        return aid
    sk = job.get("sessionKey", "") or ""
    if sk.startswith("agent:"):
        parts = sk.split(":")
        if len(parts) > 1:
            return parts[1]
    return ""


# ── Model Discovery ────────────────────────────────────────────────────────────

class ModelDiscovery:
    @staticmethod
    def fetch_lmstudio_models(url=LMSTUDIO_URL, timeout=3):
        try:
            req = urllib.request.Request(url, method="GET")
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                data = json.loads(resp.read())
                return [m["id"] for m in data.get("data", [])]
        except Exception:
            return None

    @staticmethod
    def check_anthropic_key():
        key = ModelDiscovery.get_anthropic_key()
        if not key or key == "sk-ant-YOUR_KEY_HERE":
            return None
        try:
            # GET /v1/models validates the key without spending tokens or
            # depending on a specific (possibly-retired) model id.
            req = urllib.request.Request(
                "https://api.anthropic.com/v1/models", method="GET",
                headers={"x-api-key": key, "anthropic-version": "2023-06-01"},
            )
            with urllib.request.urlopen(req, timeout=5):
                return True
        except urllib.error.HTTPError as e:
            # Only auth failures are authoritative "invalid". Anything else
            # (rate limit, transient 5xx) is unknown rather than "valid".
            if e.code in (401, 403):
                return False
            return None
        except Exception:
            return None

    @staticmethod
    def get_anthropic_key():
        try:
            for line in OPENCLAW_ENV.read_text().splitlines():
                line = line.strip()
                if line.startswith("ANTHROPIC_API_KEY="):
                    return line.split("=", 1)[1].strip().strip('"').strip("'")
        except Exception:
            pass
        return None

    @staticmethod
    def check_gateway():
        try:
            r = subprocess.run(["systemctl", "--user", "is-active", "--quiet", GATEWAY_SERVICE],
                               capture_output=True, timeout=5)
            return r.returncode == 0
        except Exception:
            return False

    @staticmethod
    def check_lmstudio():
        return ModelDiscovery.fetch_lmstudio_models() is not None

    @staticmethod
    def restart_gateway():
        return subprocess.run(["systemctl", "--user", "restart", GATEWAY_SERVICE],
                              capture_output=True, text=True, timeout=30)


# ── Application ────────────────────────────────────────────────────────────────

class ModelManagerApp(Adw.Application):
    def __init__(self):
        super().__init__(application_id="ai.omla.openclaw.model-manager")
        self.connect("activate", self._on_activate)

    def _on_activate(self, app):
        win = ModelManagerWindow(application=app)
        win.present()


class ModelManagerWindow(Adw.ApplicationWindow):
    def __init__(self, **kwargs):
        super().__init__(**kwargs, title="OpenClaw Model Manager", default_width=1040, default_height=840)

        self.config = ConfigManager(OPENCLAW_CONFIG)
        self._combos = {}
        self._default_combos = None
        self._bulk_primary_combo = None
        self._bulk_fallback_combo = None
        self._status_rows = {}
        self._catalog_group = None
        self._model_ids = []
        self._subagent_saved = SubAgentRouting.read()
        self._subagent_pending = dict(self._subagent_saved)
        self._subagent_mode_combo = None
        self._subagent_model_combo = None
        self._subagent_status_row = None
        self._subagent_test_btn = None
        self._subagent_discard_btn = None
        self._subagent_group = None

        # Hermes Agent state (separate agent, separate config + systemd service)
        self._hermes_available = HermesConfig.exists()
        self._hermes_provider = HermesConfig.read_provider() if self._hermes_available else None
        self._hermes_saved_model = HermesConfig.read_default_model() if self._hermes_available else None
        self._hermes_pending_model = self._hermes_saved_model
        self._hermes_model_combo = None
        self._hermes_status_subrow = None
        self._hermes_discard_btn = None

        # Controllable background services — keep only those actually installed.
        self._services = [s for s in SERVICES if unit_exists(s["unit"])]
        self._service_rows = {}
        self._service_busy = set()

        # Colour-coded named default-model slots (manager-owned, UI only).
        self._slots_saved = SlotConfig.load()
        self._slots_pending = [dict(s) for s in self._slots_saved]
        self._slot_combos = {}
        self._profile_combos = {}          # slot key -> ComboRow in the Model Defaults panel
        self._slot_css_provider = None
        self._columns = None
        self._breakpoint = None

        # Per-agent profile/[Default] intent (manager-owned, UI only).
        self._dead_default_warned = set()   # (agent_id, which, model_id) already toasted
        self._agent_profiles_saved = SlotConfig.load_agent_profiles()
        self._agent_profiles_pending = {k: dict(v) for k, v in self._agent_profiles_saved.items()}
        self._agent_profile_dropdowns = {}  # agent_id -> profile Gtk.DropDown
        self._subagents_combo = None        # agents.defaults.subagents.model picker
        self._default_combos = None         # (primary, fallback) for agents.defaults.model
        self._default_had_primary = False   # was a default primary set on load?
        self._default_touched = False       # did the user edit the default combos?

        # Model Usage overview (read-only inventory) state.
        self._usage_container = None
        self._usage_cron_expander = None
        self._usage_gen = 0

        try:
            self.config.load()
        except Exception as e:
            self._show_error_page(f"Failed to load config:\n{e}")
            return

        self._agent_count_at_load = len(self.config.get_agents())
        self._install_slot_css()
        self._build_ui()
        self._refresh_all_status()

    def _show_error_page(self, msg):
        page = Adw.StatusPage(title="Configuration Error", description=msg, icon_name="dialog-error-symbolic")
        self.set_content(page)

    def _build_ui(self):
        self._toast_overlay = Adw.ToastOverlay()
        self.set_content(self._toast_overlay)

        main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self._toast_overlay.set_child(main_box)

        # Header bar
        header = Adw.HeaderBar()
        self._save_btn = Gtk.Button(label="Save & Apply")
        self._save_btn.add_css_class("suggested-action")
        self._save_btn.set_sensitive(False)
        self._save_btn.connect("clicked", self._on_save)
        header.pack_end(self._save_btn)
        main_box.append(header)

        # Banner for unsaved changes
        self._banner = Adw.Banner(title="You have unsaved changes", revealed=False)
        self._banner.set_button_label("Save")
        self._banner.connect("button-clicked", self._on_save)
        main_box.append(self._banner)

        # Scrollable content
        scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
        main_box.append(scroll)

        content = Adw.Clamp(maximum_size=1200, tightening_threshold=900,
                            margin_top=12, margin_bottom=24, margin_start=12, margin_end=12)
        scroll.set_child(content)

        page_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
        content.set_child(page_box)

        # Full-width "see all the models" inventory across the whole stack.
        page_box.append(self._build_model_usage_section())

        # Two columns: Systems (left) and Model Defaults + agents (right). A
        # breakpoint flips the box to vertical (stacked) when the window is narrow.
        columns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=24)
        columns.set_homogeneous(True)
        self._columns = columns
        page_box.append(columns)

        left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18, hexpand=True,
                       valign=Gtk.Align.START)
        right = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18, hexpand=True,
                        valign=Gtk.Align.START)
        columns.append(left)
        columns.append(right)

        # LEFT — Systems (services) + connectivity indicators
        if self._services:
            left.append(self._build_services_section())
        left.append(self._build_status_section())

        # RIGHT — model defaults (profiles), agents, then the rest
        right.append(self._build_profiles_section())
        right.append(self._build_agents_section())
        right.append(self._build_bulk_section())
        right.append(self._build_defaults_section())
        right.append(self._build_subagent_section())
        if self._hermes_available:
            right.append(self._build_hermes_section())
        right.append(self._build_catalog_section())

        # Stack the columns on narrow windows.
        self._install_columns_breakpoint()

        # Bottom save button (always visible)
        bottom_save = Gtk.Button(label="Save & Apply Changes")
        bottom_save.add_css_class("suggested-action")
        bottom_save.add_css_class("pill")
        bottom_save.set_halign(Gtk.Align.CENTER)
        bottom_save.set_margin_top(12)
        bottom_save.set_margin_bottom(12)
        bottom_save.connect("clicked", self._on_save)
        self._bottom_save_btn = bottom_save
        page_box.append(bottom_save)

    def _install_columns_breakpoint(self):
        """Flip the two columns to a single stacked column on narrow windows.
        Idempotent across _build_ui rebuilds (removes any prior breakpoint)."""
        try:
            if self._breakpoint is not None:
                self.remove_breakpoint(self._breakpoint)
        except Exception:
            pass
        self._breakpoint = None
        try:
            bp = Adw.Breakpoint.new(Adw.BreakpointCondition.parse("max-width: 860px"))
            bp.add_setter(self._columns, "orientation", Gtk.Orientation.VERTICAL)
            bp.add_setter(self._columns, "homogeneous", False)
            self.add_breakpoint(bp)
            self._breakpoint = bp
        except Exception:
            # If breakpoints aren't available, leave the two-column layout as-is.
            pass

    # ── Model Usage Overview (read-only "see all the models") ──

    def _badge(self, model_id, override=None):
        text = override or classify_model(model_id)
        color = WHERE_COLORS.get(text, "#9a9996")
        lbl = Gtk.Label(valign=Gtk.Align.CENTER)
        lbl.set_use_markup(True)
        lbl.set_markup(f'<span foreground="{color}"><b>{text}</b></span>')
        return lbl

    def _usage_row(self, title, model_id, subtitle=None, badge_override=None):
        row = Adw.ActionRow(title=title)
        if subtitle:
            row.set_subtitle(subtitle)
        if model_id:
            name = friendly_name(model_id) if "/" in model_id else model_id
            ml = Gtk.Label(label=name, valign=Gtk.Align.CENTER, xalign=1)
            ml.add_css_class("dim-label")
            ml.set_max_width_chars(30)
            ml.set_ellipsize(Pango.EllipsizeMode.MIDDLE)
            row.add_suffix(ml)
        row.add_suffix(self._badge(model_id, badge_override))
        return row

    def _build_model_usage_section(self):
        self._usage_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        self._populate_model_usage()
        return self._usage_container

    def _populate_model_usage(self):
        self._usage_gen += 1
        box = self._usage_container
        # Clear previous content.
        child = box.get_first_child()
        while child is not None:
            nxt = child.get_next_sibling()
            box.remove(child)
            child = nxt

        group = Adw.PreferencesGroup(
            title="Model Usage — everywhere a model runs",
            description=("Read-only inventory across the whole OpenClaw stack, each with "
                         "a Local / Remote / Cloud badge, so nothing calls a model unseen. "
                         "Scheduled (cron) jobs are the main hidden source."),
        )
        refresh = Gtk.Button(icon_name="view-refresh-symbolic", valign=Gtk.Align.CENTER,
                             tooltip_text="Refresh inventory")
        refresh.add_css_class("flat")
        refresh.connect("clicked", lambda b: self._populate_model_usage())
        group.set_header_suffix(refresh)

        # 1) OpenClaw roster agents
        agents = self.config.get_agents()
        ag = Adw.ExpanderRow(title="OpenClaw agents", subtitle=f"{len(agents)} roster agents")
        for agent in agents:
            ag.add_row(self._usage_row(agent.get("name") or agent.get("id", "?"),
                                       self.config.get_agent_primary(agent)))
        group.add(ag)

        # 2) Gateway defaults + helpers
        core = Adw.ExpanderRow(title="Gateway defaults and helpers")
        dm = self.config.get_default_model()
        dprim = dm.get("primary", "") if isinstance(dm, dict) else dm
        core.add_row(self._usage_row("Default model", dprim,
                                     subtitle="new agents / global fallback"))
        sam = self.config.get_subagents_model()
        core.add_row(self._usage_row(
            "Spawned subagents", sam,
            subtitle=None if sam else "inherit each agent's model",
            badge_override=None if sam else "Inherit"))
        sub = SubAgentRouting.read()
        if sub["mode"] == SUBAGENT_MODE_LOCAL:
            core.add_row(self._usage_row("Claude Code CLI (coding helper)",
                                         sub.get("model") or "?",
                                         subtitle="routed to LM Studio"))
        elif sub["mode"] == SUBAGENT_MODE_DEEPSEEK:
            core.add_row(self._usage_row("Claude Code CLI (coding helper)",
                                         sub.get("model") or DEEPSEEK_DEFAULT_MODEL,
                                         subtitle="routed to DeepSeek (api.deepseek.com)"))
        elif sub["mode"] == SUBAGENT_MODE_DISABLED:
            core.add_row(self._usage_row("Claude Code CLI (coding helper)", None,
                                         subtitle="disabled", badge_override="Off"))
        else:
            core.add_row(self._usage_row("Claude Code CLI (coding helper)", "anthropic/",
                                         subtitle="Anthropic cloud (billed)"))
        group.add(core)

        # 3) Hermes
        if self._hermes_available:
            h = Adw.ExpanderRow(title="Hermes Agent")
            h.add_row(self._usage_row("Default model", HermesConfig.read_default_model(),
                                      subtitle=f"provider: {self._hermes_provider or '?'}"))
            group.add(h)

        # 4) Email agent
        if unit_exists(EMAIL_SERVICE):
            e = Adw.ExpanderRow(title="OpenClaw Email Agent")
            for label, mid, sub2, override in self._email_models():
                e.add_row(self._usage_row(label, mid, subtitle=sub2, badge_override=override))
            group.add(e)

        # 5) Scheduled (cron) — the big hidden surface, loaded async.
        self._usage_cron_expander = Adw.ExpanderRow(
            title="Scheduled jobs (cron)", subtitle="loading…")
        group.add(self._usage_cron_expander)

        box.append(group)
        self._load_cron_async(self._usage_gen)

    def _email_models(self):
        """Best-effort read of the email agent's model knobs. Returns
        [(label, model_id, subtitle, badge_override)]."""
        chat, emb, heavy_url, heavy_model = (
            "qwen/qwen3.6-35b-a3b", "text-embedding-nomic-embed-text-v1.5", "", "")
        cfg = Path.home() / ".config" / "openclaw-email" / "config.toml"
        try:
            import tomllib
            with open(cfg, "rb") as f:
                d = tomllib.load(f)
            llm = d.get("llm", {})
            oc = d.get("openclaw", {})
            chat = llm.get("chat_model", chat)
            emb = llm.get("embedding_model", emb)
            heavy_url = oc.get("heavy_lift_base_url", "") or ""
            heavy_model = oc.get("heavy_lift_model", "") or ""
        except Exception:
            pass
        rows = [
            ("Chat · classify / draft", chat, "local LM Studio", "Local"),
            ("Embeddings (RAG)", emb, "local · 768-dim", "Local"),
        ]
        if heavy_url and heavy_model:
            rows.append(("Heavy-lift", heavy_model, f"→ {heavy_url}", "Cloud"))
        else:
            rows.append(("Heavy-lift (cloud)", None, "off", "Off"))
        return rows

    def _cron_effective_model(self, job):
        """(model_id, pinned) for a cron job — its pinned model, else the model of
        the agent it runs as, else the gateway default."""
        payload = job.get("payload", {}) or {}
        pinned = payload.get("model")
        if pinned:
            return pinned, True
        aid = cron_job_agent_id(job)
        agent = self._agent_by_id(aid) if aid else None
        if agent:
            return self.config.get_agent_primary(agent), False
        dm = self.config.get_default_model()
        return (dm.get("primary", "") if isinstance(dm, dict) else dm), False

    def _load_cron_async(self, gen):
        def work():
            jobs = CronJobs.list()
            GLib.idle_add(self._fill_cron, jobs, gen)
        threading.Thread(target=work, daemon=True).start()

    def _fill_cron(self, jobs, gen):
        # Ignore stale loads from a previous populate/refresh.
        if gen != self._usage_gen or self._usage_cron_expander is None:
            return
        exp = self._usage_cron_expander
        if jobs is None:
            exp.set_subtitle("gateway not reachable — start the gateway to see cron jobs")
            exp.add_row(Adw.ActionRow(title="Cron unavailable",
                                      subtitle="openclaw-gateway is not responding"))
            return
        cloud = sum(1 for j in jobs
                    if classify_model(self._cron_effective_model(j)[0]) == "Cloud")
        exp.set_subtitle(f"{len(jobs)} jobs · {cloud} run on Cloud")
        for job in jobs:
            model, pinned = self._cron_effective_model(job)
            status = job.get("status", "?")
            sched = (job.get("schedule", {}) or {}).get("expr", "?")
            tz = (job.get("schedule", {}) or {}).get("tz", "")
            sub = f"{sched}{(' ' + tz) if tz else ''} · last: {status}"
            if not pinned:
                sub += " · inherits"
            # A pinned cron model MUST be provider-prefixed (vllm/…, deepseek/…):
            # the gateway normalizes a bare id to deepseek/<id> and the allowlist
            # then rejects it ("payload.model … rejected"), erroring the job.
            bad_pin = bool(pinned and model and "/" not in model)
            if bad_pin:
                sub += "  ⚠ bare model id — needs a provider/ prefix (vllm/…, deepseek/…)"
            row = Adw.ActionRow(title=job.get("name", job.get("id", "?")), subtitle=sub)
            if status == "error" or bad_pin:
                row.add_prefix(Gtk.Image(icon_name="dialog-warning-symbolic"))
            name = friendly_name(model) if model and "/" in model else (model or "—")
            ml = Gtk.Label(label=name, valign=Gtk.Align.CENTER, xalign=1)
            ml.add_css_class("dim-label")
            ml.set_max_width_chars(26)
            ml.set_ellipsize(Pango.EllipsizeMode.MIDDLE)
            row.add_suffix(ml)
            row.add_suffix(self._badge(model))
            run_btn = Gtk.Button(icon_name="media-playback-start-symbolic",
                                 valign=Gtk.Align.CENTER, tooltip_text="Run now")
            run_btn.add_css_class("flat")
            run_btn.connect("clicked", self._on_cron_run, job.get("id"), job.get("name", "job"))
            row.add_suffix(run_btn)
            sw = Gtk.Switch(active=bool(job.get("enabled", True)), valign=Gtk.Align.CENTER,
                            tooltip_text="Enable / disable this scheduled job")
            sw.connect("state-set", self._on_cron_toggle, job.get("id"))
            row.add_suffix(sw)
            exp.add_row(row)

    def _on_cron_toggle(self, _switch, want_on, job_id):
        def work():
            r = CronJobs.set_enabled(job_id, want_on)
            ok = r.returncode == 0
            msg = (f"Cron job {'enabled' if want_on else 'disabled'}" if ok
                   else f"Toggle failed: {(r.stderr or r.stdout or '').strip()[:80]}")
            GLib.idle_add(self._show_toast, msg)
        threading.Thread(target=work, daemon=True).start()
        return False  # let the switch reflect the new state

    def _on_cron_run(self, _btn, job_id, name):
        self._show_toast(f"Running “{name}”…")
        def work():
            r = CronJobs.run(job_id)
            ok = r.returncode == 0
            GLib.idle_add(self._show_toast,
                          f"“{name}” triggered" if ok else f"“{name}” run failed")
        threading.Thread(target=work, daemon=True).start()

    # ── Status Section ──

    def _build_status_section(self):
        # Connectivity / routing indicators (read-only). The startable services
        # (gateway, Hermes, DisPatch, email) live in the Services group below,
        # which carries their own status plus Start/Stop/Restart/Update controls.
        group = Adw.PreferencesGroup(title="Connectivity and Routing")

        status_items = [
            ("lmstudio", "LM Studio"),
            ("anthropic", "Anthropic API"),
            ("subagent", "Sub-Agent CLI Routing"),
            ("imagetool", "Image-tool secrets (imagetool.env)"),
        ]
        for key, label in status_items:
            row = Adw.ActionRow(title=label)
            status_label = Gtk.Label(label="Checking...", css_classes=["dim-label"])
            status_icon = Gtk.Image(icon_name="content-loading-symbolic", pixel_size=16)
            row.add_suffix(status_icon)
            row.add_suffix(status_label)
            group.add(row)
            self._status_rows[key] = (row, status_icon, status_label)

        # Refresh button inside the status group
        refresh_row = Adw.ActionRow(title="Refresh Status", subtitle="Check service connectivity")
        refresh_row.set_activatable(True)
        refresh_row.add_prefix(Gtk.Image(icon_name="view-refresh-symbolic", pixel_size=16))
        refresh_row.connect("activated", lambda r: self._refresh_all_status())
        group.add(refresh_row)

        return group

    def _refresh_all_status(self):
        """Refresh both the connectivity indicators and the service-control rows."""
        self._refresh_status_async()
        self._refresh_services_status_async()

    def _refresh_status_async(self):
        # Update labels to "Checking..."
        for key in self._status_rows:
            _, icon, label = self._status_rows[key]
            label.set_label("Checking...")
            icon.set_from_icon_name("content-loading-symbolic")

        # The anthropic plugin is gated by the strict plugins.allow allowlist.
        # When it's deliberately disabled (no working vision/anthropic key — the
        # image tool intentionally fails fast), don't probe the API at all: a
        # "Invalid Key" error would just invite someone to "fix" an intentional
        # state. Re-enabling is a manual openclaw.json edit, never a manager default.
        ant_plugin_on = self.config.plugin_enabled("anthropic")

        def check():
            try:
                lms = ModelDiscovery.check_lmstudio()
                ant = ModelDiscovery.check_anthropic_key() if ant_plugin_on else None
                sub = SubAgentRouting.read()
                img = IMAGETOOL_ENV.exists()
                GLib.idle_add(self._update_status, lms, ant, sub, ant_plugin_on, img)
            except Exception as e:
                GLib.idle_add(self._show_toast, f"Status check failed: {e}")
        threading.Thread(target=check, daemon=True).start()

    def _update_status(self, lms, ant, sub, ant_plugin_on=True, img=None):
        if sub["mode"] == SUBAGENT_MODE_LOCAL:
            sub_ok, sub_text = True, f"Local: {sub['model'] or '?'}"
        elif sub["mode"] == SUBAGENT_MODE_DEEPSEEK:
            sub_ok, sub_text = True, f"DeepSeek: {sub['model'] or '?'}"
        elif sub["mode"] == SUBAGENT_MODE_DISABLED:
            sub_ok, sub_text = None, "Disabled"
        else:
            sub_ok, sub_text = None, "Anthropic Cloud (billed)"
        if not ant_plugin_on:
            # Intentional state: plugin disabled + dropped from plugins.allow
            # because there is no working vision/anthropic key.
            ant_state = (None, "Plugin disabled (intentional)")
        else:
            ant_state = (ant, "Valid" if ant else ("No Key" if ant is None else "Invalid Key"))
        rows = [
            ("lmstudio", (lms, "Running" if lms else "Not Running")),
            ("anthropic", ant_state),
            ("subagent", (sub_ok, sub_text)),
            ("imagetool", (bool(img) or None,
                           "Present (gateway + DisPatch load it)" if img
                           else "Missing — image feeds will lack CIVITAI keys")),
        ]
        for key, (ok, label_text) in rows:
            _, icon, label = self._status_rows[key]
            label.set_use_markup(True)
            if ok:
                icon.set_from_icon_name("checkbox-checked-symbolic")
                label.set_markup(f'<span foreground="#26a269"><b>{label_text}</b></span>')
            elif ok is None:
                icon.set_from_icon_name("dialog-warning-symbolic")
                label.set_markup(f'<span foreground="#e5a50a"><b>{label_text}</b></span>')
            else:
                icon.set_from_icon_name("dialog-error-symbolic")
                label.set_markup(f'<span foreground="#c01c28"><b>{label_text}</b></span>')

    # ── Services Section (start / stop / restart / update) ──

    def _build_services_section(self):
        group = Adw.PreferencesGroup(
            title="Systems",
            description=("Status and controls per service. The icons are start, stop, "
                         "restart, and update."),
        )
        self._service_rows = {}
        # (key, icon, tooltip, extra-css). Order = how they appear left→right.
        actions = [
            ("start",   "media-playback-start-symbolic",     "Start",            None),
            ("stop",    "media-playback-stop-symbolic",      "Stop",             "destructive-action"),
            ("restart", "view-refresh-symbolic",             "Restart",          None),
            ("update",  "software-update-available-symbolic", "Update to latest", None),
        ]
        for svc in self._services:
            row = Adw.ActionRow(title=svc["name"], subtitle="Checking…")
            icon = Gtk.Image(icon_name="content-loading-symbolic", pixel_size=16)
            row.add_prefix(icon)
            btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2,
                              valign=Gtk.Align.CENTER)
            widgets = {"row": row, "icon": icon}
            for key, icon_name, tip, extra in actions:
                if key == "update" and not svc.get("update_kind"):
                    continue  # e.g. DisPatch runs from local source — no updater
                b = Gtk.Button(icon_name=icon_name, valign=Gtk.Align.CENTER, tooltip_text=tip)
                b.add_css_class("flat")
                if extra:
                    b.add_css_class(extra)
                # Reuse the existing safety-bearing handlers verbatim.
                if key == "update":
                    b.connect("clicked", self._on_service_update, svc)
                else:
                    b.connect("clicked", self._on_service_power, svc, key)
                btn_box.append(b)
                widgets[key] = b
            row.add_suffix(btn_box)
            group.add(row)
            self._service_rows[svc["key"]] = widgets
        return group

    def _refresh_services_status_async(self):
        if not self._service_rows:
            return False
        for key, w in self._service_rows.items():
            if key in self._service_busy:
                continue
            w["icon"].set_from_icon_name("content-loading-symbolic")
            w["row"].set_subtitle("Checking…")
        services = list(self._services)

        def check():
            results = {}
            for svc in services:
                if svc["key"] in self._service_busy:
                    continue
                results[svc["key"]] = Systemd.is_active(svc["unit"])
            GLib.idle_add(self._apply_services_status, results)
        threading.Thread(target=check, daemon=True).start()
        return False  # safe to use as a GLib.timeout_add callback

    def _apply_services_status(self, results):
        for key, active in results.items():
            if key in self._service_busy:
                continue
            w = self._service_rows.get(key)
            if not w:
                continue
            if active:
                self._paint_service_row(key, "ok", "Running")
            else:
                self._paint_service_row(key, "off", "Stopped")
            # Can't Start what's up; can't Stop/Restart what's down.
            w["start"].set_sensitive(not active)
            w["stop"].set_sensitive(active)
            w["restart"].set_sensitive(active)
            if "update" in w:
                w["update"].set_sensitive(True)

    def _paint_service_row(self, key, level, text):
        w = self._service_rows.get(key)
        if not w:
            return
        icon = {"ok": "emblem-ok-symbolic",
                "warn": "dialog-warning-symbolic",
                "off": "media-playback-stop-symbolic"}.get(level, "content-loading-symbolic")
        w["icon"].set_from_icon_name(icon)
        for cls in ("svcdot-ok", "svcdot-warn", "svcdot-off"):
            w["icon"].remove_css_class(cls)
        cls = {"ok": "svcdot-ok", "warn": "svcdot-warn", "off": "svcdot-off"}.get(level)
        if cls:
            w["icon"].add_css_class(cls)
        w["row"].set_subtitle(text)

    def _set_service_busy(self, key, busy, msg=None):
        w = self._service_rows.get(key)
        if not w:
            return
        if busy:
            self._service_busy.add(key)
        else:
            self._service_busy.discard(key)
        for b in ("start", "stop", "restart", "update"):
            if b in w:
                w[b].set_sensitive(not busy)
        if busy:
            w["icon"].set_from_icon_name("content-loading-symbolic")
            if msg is not None:
                w["row"].set_subtitle(msg)

    # ── Power handlers ──

    def _on_service_power(self, _btn, svc, action):
        if action == "stop":
            self._confirm_then(
                f"Stop {svc['name']}?",
                svc.get("stop_note", f"This stops {svc['name']}."),
                "Stop",
                lambda: self._do_service_power(svc, "stop"),
                destructive=True,
            )
            return
        self._do_service_power(svc, action)

    def _do_service_power(self, svc, action):
        key = svc["key"]
        verb = {"start": "Starting", "stop": "Stopping", "restart": "Restarting"}[action]
        self._set_service_busy(key, True, f"{verb}…")
        unit = svc["unit"]
        timeout = svc.get("op_timeout", 120)
        fn = {"start": Systemd.start, "stop": Systemd.stop, "restart": Systemd.restart}[action]

        def run():
            try:
                r = fn(unit, timeout=timeout)
                err = (r.stderr or r.stdout or "").strip()
                # Gateway has StartLimitBurst — clear the limiter and retry once.
                if r.returncode != 0 and action in ("start", "restart") and (
                        "too quickly" in err.lower() or "start-limit" in err.lower()):
                    Systemd.reset_failed(unit)
                    r = fn(unit, timeout=timeout)
                    err = (r.stderr or r.stdout or "").strip()
                if action == "stop" and svc.get("reset_failed_after_stop"):
                    # gateway/hermes land in `failed` on SIGKILL / exit 1 — cosmetic.
                    Systemd.reset_failed(unit)
                GLib.idle_add(self._on_service_power_done, svc, action,
                              r.returncode == 0, err)
            except subprocess.TimeoutExpired:
                GLib.idle_add(self._on_service_power_done, svc, action, False,
                              f"Timed out after {timeout}s")
            except Exception as e:
                GLib.idle_add(self._on_service_power_done, svc, action, False, str(e))
        threading.Thread(target=run, daemon=True).start()

    def _on_service_power_done(self, svc, action, ok, err):
        self._set_service_busy(svc["key"], False)
        if ok:
            self._show_toast(f"{svc['name']}: {action} OK")
        else:
            self._show_output_dialog(f"{svc['name']}: {action} failed",
                                     err or "Unknown error.", None)
        # Brief settle before repainting.
        GLib.timeout_add_seconds(2, self._refresh_services_status_async)

    # ── Update handlers ──

    def _on_service_update(self, _btn, svc):
        self._confirm_then(
            f"Update {svc['name']}?",
            self._update_confirm_text(svc),
            "Update now",
            lambda: self._do_service_update(svc),
            destructive=False,
        )

    def _update_confirm_text(self, svc):
        kind = svc["update_kind"]
        if kind == "openclaw":
            return ("Runs `openclaw update` (updates the npm install) and restarts the "
                    "gateway. This can take several minutes.\n\n"
                    "After it finishes, double-check plugins.allow in openclaw.json — "
                    "OpenClaw updates have silently disabled allowlisted plugins before.")
        if kind == "hermes":
            return ("Runs `hermes update`: pulls the latest from git and reinstalls "
                    "dependencies, then restarts Hermes. Uncommitted local changes are "
                    "auto-stashed. This can take several minutes.")
        if kind == "email":
            return ("Runs `uv tool upgrade openclaw-email`, then restarts the email "
                    "agent. The agent is built from your local source, so this "
                    "re-resolves its recorded package.")
        return f"Update {svc['name']} to the latest version?"

    def _do_service_update(self, svc):
        key = svc["key"]
        self._set_service_busy(key, True, "Updating… (this can take several minutes)")
        self._show_toast(f"Updating {svc['name']}…")

        def run():
            try:
                steps = Updates.run(svc["update_kind"], svc)
                GLib.idle_add(self._on_service_update_done, svc, steps, None)
            except subprocess.TimeoutExpired:
                GLib.idle_add(self._on_service_update_done, svc, [],
                              "The update timed out. It may still be running in the "
                              "background — re-check status shortly.")
            except FileNotFoundError as e:
                GLib.idle_add(self._on_service_update_done, svc, [], f"Tool not found: {e}")
            except Exception as e:
                GLib.idle_add(self._on_service_update_done, svc, [], str(e))
        threading.Thread(target=run, daemon=True).start()

    def _on_service_update_done(self, svc, steps, error):
        self._set_service_busy(svc["key"], False)
        if error:
            self._show_output_dialog(f"{svc['name']} update failed", error, None)
            GLib.timeout_add_seconds(2, self._refresh_services_status_async)
            return
        ok = bool(steps) and all(p.returncode == 0 for _, p in steps)
        detail = self._format_steps_detail(steps)
        if ok:
            summary = f"{svc['name']} updated successfully."
            if svc["update_kind"] == "openclaw":
                summary += ("\n\nHeads-up: OpenClaw updates have previously disabled "
                            "plugins and dropped them from plugins.allow. If something "
                            "stops responding, diff openclaw.json against its "
                            ".pre-update backup.")
                # `openclaw update` rewrites openclaw.json on disk, so our in-memory
                # copy is now stale. Reload it (unless the user has unsaved edits we
                # would otherwise clobber) so a later save doesn't revert the update.
                if not self.config.is_dirty():
                    try:
                        self.config.load()
                        self._agent_count_at_load = len(self.config.get_agents())
                    except Exception as e:
                        summary += f"\n\n(Could not reload config after update: {e})"
                else:
                    summary += ("\n\nNote: you have unsaved changes, so the on-disk "
                                "config was NOT reloaded — saving now will overwrite the "
                                "update's changes. Discard your edits and reopen to pick "
                                "up the update.")
        else:
            summary = f"{svc['name']} update finished with errors — see details below."
        self._show_output_dialog(f"{svc['name']} update", summary, detail)
        self._refresh_all_status()

    def _format_steps_detail(self, steps):
        chunks = []
        for label, p in steps:
            out = (p.stdout or "")
            if p.stderr:
                out = (out + "\n" + p.stderr) if out else p.stderr
            out = out.strip()
            if len(out) > 1500:
                out = "…(truncated)…\n" + out[-1500:]
            chunks.append(f"$ {label}   (exit {p.returncode})\n{out or '(no output)'}")
        return "\n\n".join(chunks)

    # ── Shared dialog helpers ──

    def _confirm_then(self, heading, body, yes_label, on_yes, destructive=False):
        dialog = Adw.AlertDialog(heading=heading, body=body)
        dialog.add_response("cancel", "Cancel")
        dialog.add_response("ok", yes_label)
        dialog.set_response_appearance(
            "ok",
            Adw.ResponseAppearance.DESTRUCTIVE if destructive else Adw.ResponseAppearance.SUGGESTED,
        )
        dialog.set_default_response("cancel" if destructive else "ok")
        dialog.connect("response", lambda d, r: on_yes() if r == "ok" else None)
        dialog.present(self)

    def _show_output_dialog(self, heading, summary, detail):
        dialog = Adw.AlertDialog(heading=heading, body=summary)
        if detail:
            scroller = Gtk.ScrolledWindow(
                min_content_height=160, max_content_height=320,
                propagate_natural_height=True,
                hscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
            )
            lbl = Gtk.Label(label=detail, xalign=0, yalign=0, wrap=True, selectable=True,
                            margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
            lbl.add_css_class("monospace")
            scroller.set_child(lbl)
            dialog.set_extra_child(scroller)
        dialog.add_response("close", "Close")
        dialog.set_default_response("close")
        dialog.present(self)

    # ── Bulk "Set All" Section ──

    def _build_bulk_section(self):
        # Ensure the model list is current — this section is built before the
        # agents section, which is where _model_ids is otherwise populated.
        self._model_ids = self.config.get_configured_model_ids()

        group = Adw.PreferencesGroup(
            title="Set Models for All Agents",
            description=(
                "Pick a model and apply it to every agent at once. This just "
                "fills in the selections below — review and tweak any agent, "
                "then Save and Apply."
            ),
        )

        primary_combo = self._make_model_combo("Primary model for all agents", None)
        primary_combo.connect("notify::selected", lambda c, p: self._apply_slot_tint(c))
        apply_primary = Gtk.Button(label="Apply to all", valign=Gtk.Align.CENTER)
        apply_primary.add_css_class("flat")
        apply_primary.connect("clicked", self._on_apply_all_primary)
        primary_combo.add_suffix(apply_primary)
        group.add(primary_combo)
        self._bulk_primary_combo = primary_combo

        fallback_combo = self._make_model_combo(
            "Secondary (fallback) model for all agents", None, allow_none=True
        )
        fallback_combo.connect("notify::selected", lambda c, p: self._apply_slot_tint(c))
        apply_fallback = Gtk.Button(label="Apply to all", valign=Gtk.Align.CENTER)
        apply_fallback.add_css_class("flat")
        apply_fallback.connect("clicked", self._on_apply_all_fallback)
        fallback_combo.add_suffix(apply_fallback)
        group.add(fallback_combo)
        self._bulk_fallback_combo = fallback_combo

        return group

    def _set_combo_to_value(self, combo, value):
        """Select `value` (or None → "(None)") in a model ComboRow. Returns True
        if a matching item was found and selected."""
        items = getattr(combo, "_raw_items", [])
        target = "(None)" if value is None else value
        for i, mid in enumerate(items):
            if mid == target:
                combo.set_selected(i)
                return True
        return False

    def _on_apply_all_primary(self, _btn):
        value = self._get_combo_value(self._bulk_primary_combo)
        if not value:
            self._show_toast("Pick a primary model first")
            return
        count = skipped = 0
        for primary_combo, _fallback_combo in self._combos.values():
            # Don't clobber an agent the user deliberately pinned to [Default]
            # (inherit-from-profile) — that would silently convert it to explicit.
            if self._get_combo_value(primary_combo) == DEFAULT_PRIMARY:
                skipped += 1
                continue
            if self._set_combo_to_value(primary_combo, value):
                count += 1
        if self._default_combos and self._set_combo_to_value(self._default_combos[0], value):
            count += 1
        # set_selected fires notify::selected, which applies to config and updates
        # dirty state — but only when the value actually changed. Force a sync so
        # the banner appears even if every agent already matched.
        self._apply_combos_to_config()
        self._update_dirty_state()
        msg = f"Set primary to {friendly_name(value)} on {count} agent(s) — review and Save"
        if skipped:
            msg += f"  ({skipped} left on [Default])"
        self._show_toast(msg)

    def _on_apply_all_fallback(self, _btn):
        combo = self._bulk_fallback_combo
        idx = combo.get_selected()
        if idx < 0 or idx >= len(combo._raw_items):
            return
        target = combo._raw_items[idx]
        value = None if target == "(None)" else target
        count = skipped = 0
        for _primary_combo, fallback_combo in self._combos.values():
            if self._get_combo_value(fallback_combo) == DEFAULT_BACKUP:
                skipped += 1
                continue
            if self._set_combo_to_value(fallback_combo, value):
                count += 1
        if self._default_combos and self._set_combo_to_value(self._default_combos[1], value):
            count += 1
        self._apply_combos_to_config()
        self._update_dirty_state()
        label = friendly_name(value) if value else "None"
        msg = f"Set fallback to {label} on {count} agent(s) — review and Save"
        if skipped:
            msg += f"  ({skipped} left on [Default])"
        self._show_toast(msg)

    # ── Agents Section ──

    def _build_agents_section(self):
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        self._model_ids = self.config.get_configured_model_ids()
        self._agent_profile_dropdowns = {}

        for agent in self.config.get_agents():
            agent_id = agent.get("id", "?")
            agent_name = agent.get("name", agent_id)
            is_default = agent.get("default", False)
            title = f"{agent_name}" + (" (default)" if is_default else "")

            group = Adw.PreferencesGroup(title=title)

            primary = self.config.get_agent_primary(agent)
            fallbacks = self.config.get_agent_fallbacks(agent)
            fallback = fallbacks[0] if fallbacks else None

            # Decide the agent's profile + whether each slot inherits ("[Default]").
            prof, prim_default, back_default = self._reconcile_agent(agent_id, primary, fallback)

            # Profile dropdown (API | Local) in the group header — "on the side".
            dropdown = Gtk.DropDown.new_from_strings([PROFILE_LABELS[p] for p in PROFILE_ORDER])
            dropdown.set_valign(Gtk.Align.CENTER)
            dropdown.set_tooltip_text("Which profile “[Default]” inherits from")
            # Guard against a corrupted/hand-edited slots file carrying an unknown
            # profile value — index() would raise and abort the whole UI build.
            dropdown.set_selected(PROFILE_ORDER.index(prof) if prof in PROFILE_ORDER else 0)
            dropdown.connect("notify::selected", self._on_agent_profile_changed, agent_id)
            group.set_header_suffix(dropdown)
            self._agent_profile_dropdowns[agent_id] = dropdown

            primary_combo = self._make_model_combo(
                "Primary model", DEFAULT_PRIMARY if prim_default else primary,
                default_sentinel=DEFAULT_PRIMARY)
            primary_combo._profile_agent_id = agent_id
            primary_combo.connect("notify::selected", self._on_combo_changed)
            group.add(primary_combo)

            fallback_combo = self._make_model_combo(
                "Fallback model", DEFAULT_BACKUP if back_default else fallback,
                allow_none=True, default_sentinel=DEFAULT_BACKUP)
            fallback_combo._profile_agent_id = agent_id
            fallback_combo.connect("notify::selected", self._on_combo_changed)
            group.add(fallback_combo)

            cost = self._get_cost_label(primary)
            if cost:
                info_row = Adw.ActionRow(title="Estimated cost", subtitle=cost)
                info_row.add_prefix(Gtk.Image(icon_name="dialog-information-symbolic"))
                group.add(info_row)

            self._combos[agent_id] = (primary_combo, fallback_combo)
            # Re-tint now that the agent_id + dropdown exist (sentinel tint needs them).
            self._apply_slot_tint(primary_combo)
            self._apply_slot_tint(fallback_combo)
            box.append(group)

        return box

    def _reconcile_agent(self, agent_id, primary, fallback):
        """Return (profile, primary_is_default, backup_is_default) for rendering.
        Config is the source of truth; a stored intent only turns a matching value
        into "[Default]". With no record we never auto-pick [Default] (avoids
        mislabeling agents that merely happen to use a slot's model)."""
        rec = self._agent_profiles_pending.get(agent_id)
        if rec:
            # A stored intent is AUTHORITATIVE: "default" stays [Default] (and
            # re-resolves to the current slot on save), "explicit" stays the
            # agent's own value. We do NOT re-derive intent from value-equality —
            # doing so let a slot edit silently move an explicitly-pinned agent, or
            # an explicit pick that merely coincided with a slot revert to inherit.
            prof = rec.get("profile", PROFILE_API)
            prim_default = rec.get("primary") == "default"
            back_default = rec.get("backup") == "default"
            return prof, prim_default, back_default
        # Explicit-biased inference: choose the profile whose primary matches.
        if primary and primary == self._profile_primary_model(PROFILE_LOCAL):
            prof = PROFILE_LOCAL
        else:
            prof = PROFILE_API
        return prof, False, False

    def _on_agent_profile_changed(self, _dropdown, _pspec, agent_id):
        combos = self._combos.get(agent_id)
        if combos:
            for c in combos:
                self._apply_slot_tint(c)
        self._apply_combos_to_config()
        self._update_dirty_state()

    # ── Defaults Section ──

    def _build_defaults_section(self):
        group = Adw.PreferencesGroup(title="Default Model", description="Used for new agents and global fallback")

        default = self.config.get_default_model()
        primary = default.get("primary", "") if isinstance(default, dict) else default
        fallbacks = default.get("fallbacks", []) if isinstance(default, dict) else []
        fallback = fallbacks[0] if fallbacks else None

        self._default_had_primary = bool(primary)

        primary_combo = self._make_model_combo("Primary model", primary)
        primary_combo.connect("notify::selected", self._on_default_combo_changed)
        group.add(primary_combo)

        fallback_combo = self._make_model_combo("Fallback model", fallback, allow_none=True)
        fallback_combo.connect("notify::selected", self._on_default_combo_changed)
        group.add(fallback_combo)

        self._default_combos = (primary_combo, fallback_combo)
        return group

    def _on_default_combo_changed(self, combo, _pspec):
        # The user actually touched the global default — only now may we write it
        # (so an empty default doesn't get the catalog's first model on an
        # unrelated agent edit).
        self._default_touched = True
        self._apply_combos_to_config()
        self._apply_slot_tint(combo)
        self._update_dirty_state()

    # ── Model Defaults Section (colour-coded API / Local profiles) ──

    def _build_profiles_section(self):
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
        self._slot_combos = {}
        known = self._all_known_model_ids()
        slot_by_key = {s["key"]: s for s in self._slots_pending}
        profile_meta = [
            (PROFILE_API, "API Defaults",
             "Cloud-API models. Agents on the API profile inherit these wherever "
             "their primary or backup is set to “[Default]”."),
            (PROFILE_LOCAL, "Local Defaults",
             "Local and remote LM Studio models. Agents on the Local profile inherit "
             "these for “[Default]”."),
        ]
        for prof, title, desc in profile_meta:
            group = Adw.PreferencesGroup(title=title, description=desc)
            pk, bk = PROFILE_SLOTS[prof]
            for role, skey in (("Primary", pk), ("Backup", bk)):
                s = slot_by_key.get(skey)
                if not s:
                    continue
                combo = self._make_model_combo(
                    f"{role} · {s['name']}", s.get("model"),
                    allow_none=True, items_override=known)
                combo.add_prefix(self._make_color_swatch(s))
                combo.connect("notify::selected", self._on_slot_combo_changed, s["key"])
                group.add(combo)
                self._slot_combos[s["key"]] = combo
            box.append(group)
        return box

    def _slot_model(self, slot_key):
        for s in self._slots_pending:
            if s["key"] == slot_key:
                return s.get("model")
        return None

    def _profile_primary_model(self, prof):
        return self._slot_model(PROFILE_SLOTS[prof][0])

    def _profile_backup_model(self, prof):
        return self._slot_model(PROFILE_SLOTS[prof][1])

    def _agent_profile(self, agent_id):
        """The profile (api/local) currently shown for an agent — from its dropdown
        if built, else its saved/inferred record, else API."""
        dd = self._agent_profile_dropdowns.get(agent_id)
        if dd is not None:
            idx = dd.get_selected()
            if 0 <= idx < len(PROFILE_ORDER):
                return PROFILE_ORDER[idx]
        rec = self._agent_profiles_pending.get(agent_id)
        return rec.get("profile", PROFILE_API) if rec else PROFILE_API

    def _agent_by_id(self, agent_id):
        for a in self.config.get_agents():
            if a.get("id") == agent_id:
                return a
        # Cron sessionKeys lower-case the agent id (agent:ds_brain:… for DS_Brain)
        # — fall back to a case-insensitive match so those jobs resolve correctly.
        if agent_id:
            low = agent_id.lower()
            for a in self.config.get_agents():
                if (a.get("id") or "").lower() == low:
                    return a
        return None

    def _all_known_model_ids(self):
        """Catalog model ids plus every provider-advertised id (prefixed), so a
        slot can point at a model that isn't in the catalog yet (e.g. a buildpc/*)."""
        ids = list(self.config.get_configured_model_ids())
        providers = self.config.data.get("models", {}).get("providers", {})
        for prov, pv in providers.items():
            if not isinstance(pv, dict):
                continue
            for m in pv.get("models", []):
                if isinstance(m, dict) and "id" in m:
                    full = f"{prov}/{m['id']}"
                    if full not in ids:
                        ids.append(full)
        return ids

    def _make_color_swatch(self, slot):
        sw = Gtk.Box(valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
        sw.set_size_request(18, 18)
        sw.add_css_class(f"slotswatch-{slot['key']}")
        sw.set_tooltip_text(f"{slot['name']} — {slot['color']}")
        return sw

    def _on_slot_combo_changed(self, combo, _pspec, key):
        val = self._get_combo_value(combo)
        for s in self._slots_pending:
            if s["key"] == key:
                s["model"] = val
                break
        # Agents inheriting this slot via "[Default]" must re-resolve into config…
        self._apply_combos_to_config()
        # …and the model→colour map changed, so re-tint every picker.
        self._retint_all_combos()
        self._update_dirty_state()

    def _all_model_combos(self):
        for pc, fc in self._combos.values():
            yield pc
            yield fc
        if self._default_combos:
            yield from self._default_combos
        if self._bulk_primary_combo:
            yield self._bulk_primary_combo
        if self._bulk_fallback_combo:
            yield self._bulk_fallback_combo
        if self._subagents_combo:
            yield self._subagents_combo
        for c in self._slot_combos.values():
            yield c

    def _retint_all_combos(self):
        for combo in self._all_model_combos():
            self._apply_slot_tint(combo)

    def _apply_slot_tint(self, combo):
        """Add the matching slot's CSS class to a model ComboRow (and clear any
        stale ones). A "[Default]" sentinel tints by the owning agent's profile
        colour; any other value tints by the slot whose model it equals."""
        if combo is None:
            return
        for s in self._slots_pending:
            combo.remove_css_class(f"slottint-{s['key']}")
        val = self._get_combo_value(combo)
        if not val:
            return
        if val in (DEFAULT_PRIMARY, DEFAULT_BACKUP):
            agent_id = getattr(combo, "_profile_agent_id", None)
            if agent_id is None:
                return
            pk, bk = PROFILE_SLOTS[self._agent_profile(agent_id)]
            skey = pk if val == DEFAULT_PRIMARY else bk
            combo.add_css_class(f"slottint-{skey}")
            return
        for s in self._slots_pending:
            if s.get("model") == val:
                combo.add_css_class(f"slottint-{s['key']}")
                break

    def _slots_is_dirty(self):
        return self._slots_pending != self._slots_saved

    def _agent_profiles_is_dirty(self):
        return self._agent_profiles_pending != self._agent_profiles_saved

    def _build_slot_css(self):
        parts = [
            ".svcdot-ok { color: #2ec27e; }",
            ".svcdot-warn { color: #e5a50a; }",
            ".svcdot-off { color: #c0bfbc; }",
        ]
        for s in self._slots_pending:
            k, c = s["key"], s["color"]
            parts.append(
                f".slottint-{k} {{ border-left: 4px solid {c}; "
                f"background-color: alpha({c}, 0.12); }}")
            parts.append(
                f".slotswatch-{k} {{ background-color: {c}; border-radius: 4px; }}")
        return "\n".join(parts)

    def _install_slot_css(self):
        css = self._build_slot_css()
        provider = Gtk.CssProvider()
        loaded = False
        for attempt in (
            lambda: provider.load_from_string(css),        # GTK 4.12+
            lambda: provider.load_from_data(css.encode()),  # older PyGObject
            lambda: provider.load_from_data(css, -1),
        ):
            try:
                attempt()
                loaded = True
                break
            except Exception:
                continue
        display = Gdk.Display.get_default()
        if loaded and display is not None:
            Gtk.StyleContext.add_provider_for_display(
                display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        self._slot_css_provider = provider

    # ── Sub-Agent CLI Routing Section ──

    def _build_subagent_section(self):
        group = Adw.PreferencesGroup(
            title="Coding Helper",
            description=(
                "When OpenClaw needs to write code, it can call out to the "
                "“Claude Code” command-line tool. Choose where those requests go. "
                "Pick Local to keep everything on this PC and avoid Anthropic "
                "billing. Note: every option except “Anthropic Cloud” writes a "
                "systemd drop-in that overrides ANTHROPIC_* for everything the "
                "gateway spawns (saving here is the explicit opt-in)."
            ),
        )
        self._subagent_group = group

        mode_labels = [SUBAGENT_MODE_LABELS[m] for m in SUBAGENT_MODE_ORDER]
        mode_list = Gtk.StringList.new(mode_labels)
        mode_combo = Adw.ComboRow(title="Send requests to", model=mode_list)
        mode_combo._raw_items = list(SUBAGENT_MODE_ORDER)
        try:
            current_idx = SUBAGENT_MODE_ORDER.index(self._subagent_pending["mode"])
        except ValueError:
            current_idx = SUBAGENT_MODE_ORDER.index(SUBAGENT_MODE_CLOUD)
        mode_combo.set_selected(current_idx)
        mode_combo.connect("notify::selected", self._on_subagent_mode_changed)
        group.add(mode_combo)
        self._subagent_mode_combo = mode_combo

        # Model picker — sensitive in Local and DeepSeek modes. The item list depends
        # on the selected mode (LM Studio's live models vs DeepSeek's configured
        # models). Always include the currently-pending choice so it never silently
        # disappears if LM Studio is briefly down.
        ids = self._subagent_model_choices(self._subagent_pending["mode"])
        pending_model = self._subagent_pending.get("model")
        if pending_model and pending_model not in ids:
            ids.insert(0, pending_model)
        model_list = Gtk.StringList.new(ids)
        model_combo = Adw.ComboRow(title="Helper model", model=model_list)
        model_combo._raw_items = list(ids)
        if pending_model in ids:
            model_combo.set_selected(ids.index(pending_model))
        model_combo.connect("notify::selected", self._on_subagent_model_changed)
        group.add(model_combo)
        self._subagent_model_combo = model_combo

        # Live status row: explains what the picked model+mode means right now
        self._subagent_status_row = Adw.ActionRow(title="Status", subtitle="Checking…")
        self._subagent_status_row.add_prefix(Gtk.Image(icon_name="content-loading-symbolic"))
        group.add(self._subagent_status_row)

        # Action row with Test + Discard buttons
        action_row = Adw.ActionRow(title="Verify the route works")
        test_btn = Gtk.Button(label="Test connection", valign=Gtk.Align.CENTER)
        test_btn.add_css_class("flat")
        test_btn.connect("clicked", self._on_subagent_test)
        action_row.add_suffix(test_btn)
        discard_btn = Gtk.Button(label="Discard", valign=Gtk.Align.CENTER, tooltip_text="Revert pending changes in this section")
        discard_btn.add_css_class("flat")
        discard_btn.connect("clicked", self._on_subagent_discard)
        action_row.add_suffix(discard_btn)
        group.add(action_row)
        self._subagent_test_btn = test_btn
        self._subagent_discard_btn = discard_btn

        # A SEPARATE knob from the Claude-CLI routing above: the model OpenClaw uses
        # for its OWN spawned subagents (agents.defaults.subagents.model). Leaving it
        # at "(None)" means each spawned subagent inherits its parent agent's model.
        known = self._all_known_model_ids()
        sub_model = self.config.get_subagents_model() or None
        subagents_combo = self._make_model_combo(
            "Spawned-subagent model   ·   (None) = inherit each agent's",
            sub_model, allow_none=True, items_override=known)
        subagents_combo.connect("notify::selected", self._on_subagents_combo_changed)
        group.add(subagents_combo)
        self._subagents_combo = subagents_combo

        self._update_subagent_sensitivity()
        self._refresh_subagent_status_row_async()
        return group

    def _on_subagents_combo_changed(self, combo, _pspec):
        val = self._get_combo_value(combo)
        self.config.set_subagents_model(val or "")
        self._apply_slot_tint(combo)
        self._update_dirty_state()

    def _available_local_model_ids(self):
        """Raw model ids (no vllm/ prefix) — what LM Studio expects in requests."""
        ids = []
        for full_id in self.config.get_configured_model_ids():
            if full_id.startswith("vllm/"):
                ids.append(full_id[len("vllm/"):])
        live = ModelDiscovery.fetch_lmstudio_models() or []
        for m in live:
            if m not in ids:
                ids.append(m)
        return ids

    def _subagent_model_choices(self, mode):
        """Model ids to offer the coding-helper picker for a given mode."""
        if mode == SUBAGENT_MODE_DEEPSEEK:
            ids = self._provider_model_ids("deepseek")
            return ids or [DEEPSEEK_DEFAULT_MODEL]
        ids = self._available_local_model_ids()
        return ids or ["qwen3.6-27b"]

    def _repopulate_subagent_model_combo(self):
        """Rebuild the helper-model combo's item list to match the pending mode,
        preserving the pending selection where possible."""
        combo = self._subagent_model_combo
        if combo is None:
            return
        ids = self._subagent_model_choices(self._subagent_pending["mode"])
        pending = self._subagent_pending.get("model")
        if pending and pending not in ids:
            ids.insert(0, pending)
        combo.set_model(Gtk.StringList.new(ids))
        combo._raw_items = list(ids)
        if pending in ids:
            combo.set_selected(ids.index(pending))
        elif ids:
            combo.set_selected(0)

    def _update_subagent_sensitivity(self):
        if not self._subagent_model_combo:
            return
        # Local and DeepSeek modes both pick a model and support a connection test.
        picks_model = self._subagent_pending["mode"] in (
            SUBAGENT_MODE_LOCAL, SUBAGENT_MODE_DEEPSEEK)
        self._subagent_model_combo.set_sensitive(picks_model)
        if self._subagent_test_btn is not None:
            # Anthropic Cloud / Off have no quick round-trip to offer here.
            self._subagent_test_btn.set_sensitive(picks_model)
        if self._subagent_discard_btn is not None:
            self._subagent_discard_btn.set_sensitive(self._subagent_is_dirty())

    def _on_subagent_mode_changed(self, combo, _pspec):
        idx = combo.get_selected()
        if 0 <= idx < len(combo._raw_items):
            self._subagent_pending["mode"] = combo._raw_items[idx]
        mode = self._subagent_pending["mode"]
        if mode in (SUBAGENT_MODE_LOCAL, SUBAGENT_MODE_DEEPSEEK):
            # Make sure the pending model is valid for the new mode's catalog,
            # then refresh the picker's item list to match.
            choices = self._subagent_model_choices(mode)
            if self._subagent_pending.get("model") not in choices:
                self._subagent_pending["model"] = choices[0] if choices else None
            self._repopulate_subagent_model_combo()
        self._update_subagent_sensitivity()
        self._refresh_subagent_status_row_async()
        self._update_dirty_state()

    def _on_subagent_model_changed(self, combo, _pspec):
        idx = combo.get_selected()
        if 0 <= idx < len(combo._raw_items):
            self._subagent_pending["model"] = combo._raw_items[idx]
        self._refresh_subagent_status_row_async()
        self._update_dirty_state()

    def _on_subagent_discard(self, _btn):
        self._subagent_pending = dict(self._subagent_saved)
        # Reset combos to saved state
        if self._subagent_mode_combo is not None:
            try:
                idx = self._subagent_mode_combo._raw_items.index(self._subagent_saved["mode"])
                self._subagent_mode_combo.set_selected(idx)
            except (ValueError, AttributeError):
                pass
        if self._subagent_model_combo is not None:
            saved_model = self._subagent_saved.get("model")
            if saved_model in self._subagent_model_combo._raw_items:
                self._subagent_model_combo.set_selected(self._subagent_model_combo._raw_items.index(saved_model))
        self._update_subagent_sensitivity()
        self._refresh_subagent_status_row_async()
        self._update_dirty_state()
        self._show_toast("Discarded pending coding-helper changes")

    def _on_subagent_test(self, _btn):
        mode = self._subagent_pending["mode"]
        if mode not in (SUBAGENT_MODE_LOCAL, SUBAGENT_MODE_DEEPSEEK):
            self._show_toast("Test is available for Local and DeepSeek modes")
            return
        model = self._subagent_pending.get("model")
        if not model:
            self._show_toast("Pick a model first")
            return
        self._subagent_test_btn.set_sensitive(False)
        self._subagent_test_btn.set_label("Testing…")

        def run():
            try:
                if mode == SUBAGENT_MODE_DEEPSEEK:
                    ok, msg = SubAgentRouting.test_deepseek_model(model)
                else:
                    ok, msg = SubAgentRouting.test_local_model(model)
                GLib.idle_add(self._on_subagent_test_done, ok, msg)
            except Exception as e:
                GLib.idle_add(self._on_subagent_test_done, False, str(e))
        threading.Thread(target=run, daemon=True).start()

    def _on_subagent_test_done(self, ok, msg):
        self._subagent_test_btn.set_label("Test connection")
        self._update_subagent_sensitivity()
        heading = "Connection OK" if ok else "Connection failed"
        dialog = Adw.AlertDialog(heading=heading, body=msg)
        dialog.add_response("close", "Close")
        dialog.set_default_response("close")
        dialog.present(self)

    def _refresh_subagent_status_row_async(self):
        if not getattr(self, "_subagent_status_row", None):
            return
        row = self._subagent_status_row
        row.set_subtitle("Checking…")
        mode = self._subagent_pending["mode"]
        if mode == SUBAGENT_MODE_CLOUD:
            # Cloud mode = remove the routing drop-in and let the CLI use the
            # ambient ANTHROPIC_API_KEY. Flag honestly when that key is dead.
            if not self.config.plugin_enabled("anthropic"):
                self._set_subagent_status_row(
                    "error",
                    "Coding Helper would send requests to Anthropic, but there is no "
                    "working Anthropic key on this system (the anthropic plugin is "
                    "intentionally disabled) — these calls will fail. Pick Local or "
                    "DeepSeek instead.",
                )
            else:
                self._set_subagent_status_row(
                    "warning",
                    "Coding Helper sends requests to Anthropic. Charges your API key.",
                )
            return
        if mode == SUBAGENT_MODE_DISABLED:
            self._set_subagent_status_row(
                "warning",
                "Coding-helper calls will fail loudly. Use this if you want to be sure no Cloud requests can leak out.",
            )
            return
        if mode == SUBAGENT_MODE_DEEPSEEK:
            model = self._subagent_pending.get("model") or DEEPSEEK_DEFAULT_MODEL
            has_key = bool(SubAgentRouting._read_env_key("DEEPSEEK_API_KEY"))
            if has_key:
                self._set_subagent_status_row(
                    "ok",
                    f"Coding Helper routes to DeepSeek ({model}) via api.deepseek.com "
                    "(Anthropic-compatible). Billed to your DeepSeek key. "
                    "Click Test connection to confirm.",
                )
            else:
                self._set_subagent_status_row(
                    "error",
                    "DeepSeek selected, but DEEPSEEK_API_KEY was not found in "
                    "gateway.systemd.env or ~/.openclaw/.env. Add it before saving.",
                )
            return
        # Local mode — needs LM Studio reachable and the chosen model loaded
        model = self._subagent_pending.get("model") or "(none selected)"

        def check():
            live = ModelDiscovery.fetch_lmstudio_models()
            GLib.idle_add(self._update_subagent_status_row_local, live, model)
        threading.Thread(target=check, daemon=True).start()

    def _update_subagent_status_row_local(self, live, model):
        if live is None:
            self._set_subagent_status_row(
                "error",
                "LM Studio is not running. Start LM Studio (and load a model) before saving.",
            )
            return
        if model not in live:
            self._set_subagent_status_row(
                "warning",
                f"LM Studio is up, but “{model}” is not currently loaded. "
                f"Load it in LM Studio, or pick from: {', '.join(live[:5])}{'…' if len(live) > 5 else ''}",
            )
            return
        self._set_subagent_status_row(
            "ok",
            f"Ready. LM Studio is serving “{model}”. Click Test connection to confirm.",
        )

    def _set_subagent_status_row(self, level, text):
        row = self._subagent_status_row
        # ActionRow stores prefixes as children; rebuild prefix icon by replacing the row.
        # Simpler: just update subtitle and CSS classes; icon is decorative here.
        row.set_subtitle(text)
        for cls in ("success", "warning", "error"):
            row.remove_css_class(cls)
        if level == "ok":
            row.add_css_class("success")
        elif level == "warning":
            row.add_css_class("warning")
        elif level == "error":
            row.add_css_class("error")

    def _subagent_is_dirty(self):
        return self._subagent_pending != self._subagent_saved

    def _apply_subagent_routing(self):
        mode = self._subagent_pending["mode"]
        if mode == SUBAGENT_MODE_LOCAL:
            model = self._subagent_pending.get("model")
            if not model:
                raise ValueError("Pick a local model before saving in Local mode.")
            SubAgentRouting.write_local(model)
        elif mode == SUBAGENT_MODE_DEEPSEEK:
            model = self._subagent_pending.get("model") or DEEPSEEK_DEFAULT_MODEL
            SubAgentRouting.write_deepseek(model)
        elif mode == SUBAGENT_MODE_DISABLED:
            SubAgentRouting.write_disabled()
        else:
            SubAgentRouting.clear()
        # The drop-in file is now written; record it as saved BEFORE the reload so
        # a daemon-reload failure doesn't strand the UI as permanently dirty with
        # the file already changed on disk.
        self._subagent_saved = dict(self._subagent_pending)
        SubAgentRouting.daemon_reload()

    # ── Hermes Agent Section ──

    def _build_hermes_section(self):
        provider = (self._hermes_provider or "lmstudio").lower()
        is_cloud = provider in ("deepseek", "anthropic", "openai", "openrouter")
        group = Adw.PreferencesGroup(
            title="Hermes Agent",
            description=(
                "Pick the model the Hermes Agent uses. Hermes is a separate "
                "agent with its own systemd service (hermes-gateway.service); "
                "saving here updates ~/.hermes/config.yaml and restarts it. "
                f"Current provider: {provider}."
            ),
        )

        # Offer models appropriate to Hermes' configured provider. For a cloud
        # provider (e.g. DeepSeek) list that provider's models from the OpenClaw
        # config; for a local provider list LM Studio's live models. Always keep
        # the saved model so it never silently disappears.
        if is_cloud:
            model_ids = self._provider_model_ids(provider)
        else:
            model_ids = list(ModelDiscovery.fetch_lmstudio_models() or [])
        saved = self._hermes_saved_model
        if saved and saved not in model_ids:
            model_ids.insert(0, saved)
        if not model_ids:
            model_ids = [saved or "qwen/qwen3.6-35b-a3b"]

        model_list = Gtk.StringList.new(model_ids)
        model_combo = Adw.ComboRow(title="Default model", model=model_list)
        model_combo._raw_items = list(model_ids)
        if self._hermes_pending_model in model_ids:
            model_combo.set_selected(model_ids.index(self._hermes_pending_model))
        model_combo.connect("notify::selected", self._on_hermes_model_changed)
        group.add(model_combo)
        self._hermes_model_combo = model_combo

        status_row = Adw.ActionRow(title="Status", subtitle="Checking…")
        group.add(status_row)
        self._hermes_status_subrow = status_row

        action_row = Adw.ActionRow(title="Apply changes")
        discard_btn = Gtk.Button(label="Discard", valign=Gtk.Align.CENTER,
                                 tooltip_text="Revert pending Hermes change")
        discard_btn.add_css_class("flat")
        discard_btn.connect("clicked", self._on_hermes_discard)
        action_row.add_suffix(discard_btn)
        group.add(action_row)
        self._hermes_discard_btn = discard_btn

        self._refresh_hermes_status_row()
        self._update_hermes_sensitivity()
        return group

    def _provider_model_ids(self, provider):
        """Model ids advertised by a provider in the OpenClaw config (no prefix)."""
        try:
            prov = self.config.data.get("models", {}).get("providers", {}).get(provider, {})
            return [m["id"] for m in prov.get("models", []) if isinstance(m, dict) and "id" in m]
        except Exception:
            return []

    def _on_hermes_model_changed(self, combo, _pspec):
        idx = combo.get_selected()
        if 0 <= idx < len(combo._raw_items):
            self._hermes_pending_model = combo._raw_items[idx]
        self._refresh_hermes_status_row()
        self._update_hermes_sensitivity()
        self._update_dirty_state()

    def _on_hermes_discard(self, _btn):
        self._hermes_pending_model = self._hermes_saved_model
        if self._hermes_model_combo and self._hermes_saved_model in self._hermes_model_combo._raw_items:
            self._hermes_model_combo.set_selected(
                self._hermes_model_combo._raw_items.index(self._hermes_saved_model)
            )
        self._refresh_hermes_status_row()
        self._update_hermes_sensitivity()
        self._update_dirty_state()
        self._show_toast("Discarded pending Hermes change")

    def _refresh_hermes_status_row(self):
        if not self._hermes_status_subrow:
            return
        row = self._hermes_status_subrow
        for cls in ("success", "warning", "error"):
            row.remove_css_class(cls)
        pending = self._hermes_pending_model or "(unset)"
        provider = (self._hermes_provider or "lmstudio").lower()
        if provider in ("deepseek", "anthropic", "openai", "openrouter"):
            # Cloud provider: nothing to load locally — just confirm the choice.
            row.set_subtitle(
                f"Hermes uses {provider} (cloud). Model: “{pending}”. "
                f"Save to apply and restart Hermes."
            )
            row.add_css_class("success")
            return
        # Fire check in background to avoid blocking GTK main thread
        row.set_subtitle(f"Checking LM Studio for model {pending}…")
        threading.Thread(target=lambda: self._hermes_check_lmstudio_async(provider, pending), daemon=True).start()

    def _hermes_check_lmstudio_async(self, provider, pending):
        try:
            live = ModelDiscovery.fetch_lmstudio_models()
            GLib.idle_add(self._hermes_update_status_from_check, live, provider, pending)
        except Exception as e:
            GLib.idle_add(self._hermes_update_status_from_check, None, provider, pending)

    def _hermes_update_status_from_check(self, live, provider, pending):
        if not self._hermes_status_subrow:
            return
        row = self._hermes_status_subrow
        for cls in ("success", "warning", "error"):
            row.remove_css_class(cls)
        if live is None:
            row.set_subtitle(f"LM Studio not reachable. Pending model: {pending}")
            row.add_css_class("warning")
            return
        if pending not in live:
            row.set_subtitle(
                f"“{pending}” is not currently loaded in LM Studio. "
                f"Saving will still update the config."
            )
            row.add_css_class("warning")
            return
        row.set_subtitle(f"LM Studio is serving “{pending}”. Save to apply and restart Hermes.")
        row.add_css_class("success")

    def _update_hermes_sensitivity(self):
        if self._hermes_discard_btn is not None:
            self._hermes_discard_btn.set_sensitive(self._hermes_is_dirty())

    def _hermes_is_dirty(self):
        if not self._hermes_available:
            return False
        return self._hermes_pending_model != self._hermes_saved_model

    def _apply_hermes_change(self):
        if not self._hermes_pending_model:
            raise ValueError("Pick a Hermes model before saving.")
        HermesConfig.write_default_model(self._hermes_pending_model)
        self._hermes_saved_model = self._hermes_pending_model

    # ── Catalog Section ──

    def _build_catalog_section(self):
        self._catalog_group = Adw.PreferencesGroup(title="Model Catalog",
                                                    description="Available models for agent assignment")
        self._rebuild_catalog_rows()

        btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, halign=Gtk.Align.CENTER, margin_top=8)

        add_lms_btn = Gtk.Button(label="+ Add LM Studio Model")
        add_lms_btn.add_css_class("pill")
        add_lms_btn.connect("clicked", self._on_add_lmstudio)
        btn_box.append(add_lms_btn)

        add_ant_btn = Gtk.Button(label="+ Add Anthropic Model")
        add_ant_btn.add_css_class("pill")
        add_ant_btn.connect("clicked", self._on_add_anthropic)
        btn_box.append(add_ant_btn)

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        outer.append(self._catalog_group)
        outer.append(btn_box)
        return outer

    def _rebuild_catalog_rows(self):
        catalog = self.config.get_model_catalog()
        for model_id, params in catalog.items():
            cost = self._get_cost_label(model_id)
            if model_id.startswith("vllm/"):
                source = "Local"
            elif model_id.startswith("buildpc/"):
                source = "Main PC"
            else:
                source = "Cloud"
            subtitle_parts = []
            if source == "Local":
                subtitle_parts.append("Free / Local GPU")
            elif cost:
                subtitle_parts.append(cost)
            else:
                subtitle_parts.append("Cloud")

            p = params if isinstance(params, dict) else {}
            inner_params = p.get("params", {})
            if inner_params.get("cacheRetention"):
                subtitle_parts.append(f"Cache: {inner_params['cacheRetention']}")
            if inner_params.get("thinking"):
                subtitle_parts.append(f"Thinking: {inner_params['thinking']}")

            # Pull in any details the provider advertises for this model.
            detail = self.config.get_provider_model_detail(model_id)
            detail_parts = []
            adv_name = detail.get("name")
            if adv_name and adv_name != model_id.split("/", 1)[-1]:
                detail_parts.append(GLib.markup_escape_text(adv_name))
            ctx = detail.get("contextWindow")
            if ctx:
                detail_parts.append(f"Context: {self._fmt_tokens(ctx)}")
            mx = detail.get("maxTokens")
            if mx:
                detail_parts.append(f"Max out: {self._fmt_tokens(mx)}")
            if "reasoning" in detail:
                detail_parts.append("Reasoning" if detail.get("reasoning") else "No reasoning")
            inputs = detail.get("input")
            if isinstance(inputs, list) and inputs:
                detail_parts.append("In: " + "/".join(inputs))

            display = friendly_name(model_id)
            # Line 1: friendly name. Line 2: the exact model id (the actual model
            # name). Line 3 (if the provider describes it): advertised name +
            # context window / max tokens / reasoning / input modalities.
            subtitle_lines = [" | ".join(subtitle_parts), GLib.markup_escape_text(model_id)]
            if detail_parts:
                subtitle_lines.append(" | ".join(detail_parts))
            row = Adw.ActionRow(title=display,
                                subtitle="\n".join(subtitle_lines))
            # Let the multi-line subtitle render fully instead of being ellipsised.
            try:
                row.set_subtitle_lines(len(subtitle_lines))
            except Exception:
                pass
            # Show the full model ID as a tooltip
            row.set_tooltip_text(model_id)

            if source in ("Local", "Main PC"):
                row.add_prefix(Gtk.Image(icon_name="computer-symbolic", pixel_size=16))
            else:
                row.add_prefix(Gtk.Image(icon_name="network-server-symbolic", pixel_size=16))

            remove_btn = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Remove model")
            remove_btn.add_css_class("flat")
            remove_btn.connect("clicked", self._on_remove_model, model_id)
            row.add_suffix(remove_btn)

            self._catalog_group.add(row)

    # ── Combo Helpers ──

    def _make_model_combo(self, title, current_value, allow_none=False,
                          items_override=None, default_sentinel=None):
        """Create a ComboRow with full model names displayed. If default_sentinel
        is given (DEFAULT_PRIMARY/DEFAULT_BACKUP), a "[Default]" entry is prepended
        that the save path resolves to the agent's profile model."""
        items = list(self._model_ids if items_override is None else items_override)
        if allow_none:
            items.insert(0, "(None)")
        if default_sentinel:
            items.insert(0, default_sentinel)
        # Preserve a model the agent already references but that isn't in the
        # catalog (e.g. a buildpc/* fallback). Without this the combo would show
        # the first entry instead, and saving would silently rewrite the agent.
        if current_value and current_value not in items:
            items.append(current_value)

        # Build display strings. Real models render on TWO lines — line 1 the
        # friendly name, line 2 the exact provider/id — split apart in the factory
        # bind. Sentinels stay single-line.
        display_items = []
        for item in items:
            if item == "(None)":
                display_items.append("(None)")
            elif item in (DEFAULT_PRIMARY, DEFAULT_BACKUP):
                display_items.append("[Default]")
            else:
                display_items.append(f"{friendly_name(item)}\n{item}")

        string_list = Gtk.StringList.new(display_items)
        combo = Adw.ComboRow(title=title, model=string_list)

        # Use a custom factory so text doesn't get ellipsized
        factory = Gtk.SignalListItemFactory()
        factory.connect("setup", self._on_combo_factory_setup)
        factory.connect("bind", self._on_combo_factory_bind)
        combo.set_factory(factory)

        # Also set a list factory for the dropdown popup
        list_factory = Gtk.SignalListItemFactory()
        list_factory.connect("setup", self._on_combo_factory_setup)
        list_factory.connect("bind", self._on_combo_factory_bind)
        combo.set_list_factory(list_factory)

        # Store the raw IDs so we can look them up
        combo._raw_items = items

        selected = 0
        if current_value:
            for i, mid in enumerate(items):
                if mid == current_value:
                    selected = i
                    break
        elif allow_none:
            selected = 0

        combo.set_selected(selected)
        self._apply_slot_tint(combo)
        return combo

    def _on_combo_factory_setup(self, factory, list_item):
        label = Gtk.Label(xalign=0, ellipsize=Pango.EllipsizeMode.NONE, wrap=True)
        list_item.set_child(label)

    def _on_combo_factory_bind(self, factory, list_item):
        label = list_item.get_child()
        item = list_item.get_item()
        if not item:
            return
        text = item.get_string()
        if "\n" in text:
            # Two-line model entry: bold-ish friendly name on top, dimmed exact id
            # underneath, so near-identical models stay distinguishable.
            name, _, mid = text.partition("\n")
            label.set_markup(
                f"{GLib.markup_escape_text(name)}\n"
                f"<small><span alpha='55%'>{GLib.markup_escape_text(mid)}</span></small>")
        else:
            label.set_label(text)

    def _get_combo_value(self, combo):
        idx = combo.get_selected()
        items = combo._raw_items
        if idx < 0 or idx >= len(items):
            return None
        val = items[idx]
        return None if val == "(None)" else val

    def _fmt_tokens(self, n):
        """Compact token count: 128000 -> '128K', 1000000 -> '1M'."""
        try:
            n = int(n)
        except (TypeError, ValueError):
            return str(n)
        if n >= 1_000_000:
            v = n / 1_000_000
            return (f"{v:.1f}".rstrip("0").rstrip(".")) + "M"
        if n >= 1000:
            v = n / 1000
            return (f"{v:.1f}".rstrip("0").rstrip(".")) + "K"
        return str(n)

    def _get_cost_label(self, model_id):
        if not model_id:
            return None
        if "opus" in model_id:
            return "$5 / $25 per M tokens (input/output)"
        elif "sonnet-4-6" in model_id:
            return "$3 / $15 per M tokens (input/output)"
        elif "haiku" in model_id:
            return "$1 / $5 per M tokens (input/output)"
        elif "deepseek-v4-pro" in model_id:
            return "$0.44 / $0.87 per M tokens (input/output)"
        elif "deepseek-v4-flash" in model_id:
            return "$0.14 / $0.28 per M tokens (input/output)"
        elif model_id.startswith("buildpc/"):
            return "Free (your main PC)"
        elif model_id.startswith("vllm/"):
            return "Free (local)"
        return None

    # ── Event Handlers ──

    def _on_combo_changed(self, combo, _pspec):
        self._apply_combos_to_config()
        self._apply_slot_tint(combo)
        self._update_dirty_state()

    def _apply_combos_to_config(self):
        # Rebuilt from current UI state each call; only meaningful intents kept.
        new_profiles = {}
        for agent_id, (pc, fc) in self._combos.items():
            prof = self._agent_profile(agent_id)
            raw_primary = self._get_combo_value(pc)
            raw_fallback = self._get_combo_value(fc)
            prim_def = raw_primary == DEFAULT_PRIMARY
            back_def = raw_fallback == DEFAULT_BACKUP
            primary = self._profile_primary_model(prof) if prim_def else raw_primary
            fallback = self._profile_backup_model(prof) if back_def else raw_fallback
            # A "[Default]" primary that resolves to an empty slot must never blank
            # the agent — keep its existing primary so a concurrent fallback edit
            # still lands instead of being dropped with the whole row.
            if prim_def and not primary:
                primary = self.config.get_agent_primary(self._agent_by_id(agent_id) or {})
            # Safety guard: never write a model id that doesn't exist on its
            # provider. If a "[Default]" slot resolves to a dead id (stale slots
            # file vs a reconciled roster), keep the agent's current on-disk
            # value and warn instead of silently reverting it onto a ghost model.
            if prim_def and primary and not self.config.model_exists(primary):
                self._warn_dead_default(agent_id, "primary", primary)
                primary = self.config.get_agent_primary(self._agent_by_id(agent_id) or {})
            if back_def and fallback and not self.config.model_exists(fallback):
                self._warn_dead_default(agent_id, "fallback", fallback)
                cur_fbs = self.config.get_agent_fallbacks(self._agent_by_id(agent_id) or {})
                fallback = cur_fbs[0] if cur_fbs else None
            if primary:
                self.config.set_agent_model(agent_id, primary, fallback)
            # Persist intent only when it carries real signal: an inherited slot, or
            # a profile the user actually chose (already saved). A merely *inferred*
            # non-API profile is not persisted, so it can't spuriously mark dirty.
            if prim_def or back_def or agent_id in self._agent_profiles_saved:
                new_profiles[agent_id] = {
                    "profile": prof,
                    "primary": "default" if prim_def else "explicit",
                    "backup": "default" if back_def else "explicit",
                }
        self._agent_profiles_pending = new_profiles

        if self._default_combos and (self._default_had_primary or self._default_touched):
            pc, fc = self._default_combos
            primary = self._get_combo_value(pc)
            fallback = self._get_combo_value(fc)
            if primary and primary not in (DEFAULT_PRIMARY, DEFAULT_BACKUP):
                self.config.set_default_model(primary, fallback)

    def _warn_dead_default(self, agent_id, which, model_id):
        """Toast (once per agent/slot/model) that a [Default] slot resolves to a
        model id its provider doesn't have, so the agent's current value was kept."""
        key = (agent_id, which, model_id)
        if key in self._dead_default_warned:
            return
        self._dead_default_warned.add(key)
        msg = (f"“[Default]” {which} for {agent_id} points at {model_id}, which "
               f"doesn't exist on its provider — keeping the agent's current "
               f"model. Fix the Model Defaults slot.")
        print(f"WARNING: {msg}", file=sys.stderr)
        try:
            self._show_toast(msg)
        except Exception:
            pass  # headless / UI not built yet — the stderr line still lands

    def _update_dirty_state(self):
        dirty = (self.config.is_dirty() or self._subagent_is_dirty()
                 or self._hermes_is_dirty() or self._slots_is_dirty()
                 or self._agent_profiles_is_dirty())
        self._banner.set_revealed(dirty)
        self._save_btn.set_sensitive(dirty)
        self._bottom_save_btn.set_sensitive(dirty)

    def _on_save(self, *args):
        config_dirty = self.config.is_dirty()
        sub_dirty = self._subagent_is_dirty()
        hermes_dirty = self._hermes_is_dirty()
        slots_dirty = self._slots_is_dirty() or self._agent_profiles_is_dirty()
        if not config_dirty and not sub_dirty and not hermes_dirty and not slots_dirty:
            return

        # Pre-flight: warn if user is saving Local mode but LM Studio is unreachable
        if sub_dirty and self._subagent_pending["mode"] == SUBAGENT_MODE_LOCAL:
            if ModelDiscovery.fetch_lmstudio_models() is None:
                dialog = Adw.AlertDialog(
                    heading="LM Studio is not running",
                    body=("You picked Local mode, but LM Studio isn't reachable at "
                          "127.0.0.1:1234. The setting will be saved, but coding-helper "
                          "calls will fail until you start LM Studio.\n\n"
                          "Save anyway?"),
                )
                dialog.add_response("cancel", "Cancel")
                dialog.add_response("save", "Save anyway")
                dialog.set_response_appearance("save", Adw.ResponseAppearance.DESTRUCTIVE)
                dialog.set_default_response("cancel")
                dialog.connect("response", lambda d, r: self._continue_save(config_dirty, sub_dirty, hermes_dirty, slots_dirty) if r == "save" else None)
                dialog.present(self)
                return

        self._continue_save(config_dirty, sub_dirty, hermes_dirty, slots_dirty)

    def _reload_from_disk(self):
        """Discard all pending edits and rebuild the UI from the current on-disk
        state (used by the external-edit conflict dialog's Reload choice)."""
        try:
            self.config.load()
        except Exception as e:
            self._show_toast(f"Reload failed — nothing was changed: {e}")
            return
        self._agent_count_at_load = len(self.config.get_agents())
        self._slots_saved = SlotConfig.load()
        self._slots_pending = [dict(s) for s in self._slots_saved]
        self._agent_profiles_saved = SlotConfig.load_agent_profiles()
        self._agent_profiles_pending = {k: dict(v)
                                        for k, v in self._agent_profiles_saved.items()}
        self._combos = {}
        self._default_touched = False
        self._build_ui()
        self._refresh_all_status()
        self._show_toast("Reloaded openclaw.json from disk — your edits were discarded")

    def _continue_save(self, config_dirty, sub_dirty, hermes_dirty=False, slots_dirty=False,
                       force_overwrite=False):
        # External-edit guard: if openclaw.json changed on disk since we loaded
        # it (gateway identity reconcile, `openclaw update`, hand edit), ask
        # BEFORE writing instead of clobbering with only a post-hoc toast.
        # (The post-`openclaw update` auto-reload path handles the clean case
        # already; this catches everything it can't.)
        if config_dirty and not force_overwrite and self.config.external_change_detected():
            dialog = Adw.AlertDialog(
                heading="openclaw.json changed on disk",
                body=("Something else (the gateway's identity reconcile, an "
                      "openclaw update, or a hand edit) has modified "
                      "openclaw.json since you opened it.\n\n"
                      "Overwriting will discard that external change (a copy "
                      "is kept in openclaw.json.bak). Reloading will discard "
                      "YOUR unsaved edits and show the file as it is now."),
            )
            dialog.add_response("cancel", "Cancel")
            dialog.add_response("reload", "Reload (discard my edits)")
            dialog.add_response("overwrite", "Overwrite anyway")
            dialog.set_response_appearance("overwrite", Adw.ResponseAppearance.DESTRUCTIVE)
            dialog.set_default_response("cancel")
            dialog.set_close_response("cancel")

            def _on_conflict(_d, resp):
                if resp == "overwrite":
                    self._continue_save(config_dirty, sub_dirty, hermes_dirty,
                                        slots_dirty, force_overwrite=True)
                elif resp == "reload":
                    self._reload_from_disk()
                # "cancel": keep pending edits, write nothing.

            dialog.connect("response", _on_conflict)
            dialog.present(self)
            return
        # Run each step independently and remember which applied. A later step's
        # failure must NOT swallow the restart offer for the steps that DO need a
        # restart (Coding-Helper drop-in / Hermes). openclaw.json itself needs no
        # restart: the gateway hot-reloads it (chokidar; model paths are "hot").
        errors = []
        config_saved = False
        if config_dirty:
            try:
                if len(self.config.get_agents()) != self._agent_count_at_load:
                    raise RuntimeError("agent list changed unexpectedly")
                self.config.save()
                config_saved = True
                if getattr(self.config, "clobbered_external", False):
                    self._show_toast("Saved — note: openclaw.json had changed on disk "
                                     "since you opened it; the prior version is in "
                                     "openclaw.json.bak")
            except Exception as e:
                errors.append(f"config: {e}")
        if sub_dirty:
            try:
                self._apply_subagent_routing()
            except Exception as e:
                errors.append(f"coding-helper: {e}")
                sub_dirty = False
        if hermes_dirty:
            try:
                self._apply_hermes_change()
            except Exception as e:
                errors.append(f"Hermes: {e}")
                hermes_dirty = False
        if self._slots_is_dirty() or self._agent_profiles_is_dirty():
            try:
                SlotConfig.save(self._slots_pending, self._agent_profiles_pending)
                self._slots_saved = [dict(s) for s in self._slots_pending]
                self._agent_profiles_saved = {k: dict(v)
                                              for k, v in self._agent_profiles_pending.items()}
            except Exception as e:
                errors.append(f"model defaults: {e}")

        if errors:
            self._show_toast("Some changes failed to save — " + "; ".join(errors))

        self._update_dirty_state()
        self._update_subagent_sensitivity()
        self._update_hermes_sensitivity()

        # Decide which restarts to offer. openclaw.json changes apply LIVE (the
        # gateway hot-reloads the file; model paths are "hot") — only the
        # Coding-Helper systemd drop-in (Environment=/ExecStart) and Hermes'
        # config.yaml genuinely require a restart. Slot/colour edits are
        # manager-only metadata, so a slots-only save needs no restart either.
        openclaw_needs_restart = sub_dirty
        hermes_needs_restart = hermes_dirty
        if not openclaw_needs_restart and not hermes_needs_restart:
            if not errors:
                self._show_toast("Saved — gateway hot-reloads openclaw.json, changes are live"
                                 if config_saved else "Saved.")
            return

        if openclaw_needs_restart and hermes_needs_restart:
            heading = "Restart services?"
            body = ("OpenClaw and Hermes both need to restart to apply changes.\n"
                    "This briefly interrupts any active conversations.")
            yes_label = "Restart both"
        elif hermes_needs_restart:
            heading = "Restart Hermes?"
            body = ("Hermes Agent needs to restart to pick up the new model.\n"
                    "This briefly interrupts any active Hermes conversations.")
            yes_label = "Restart Hermes"
        else:
            heading = "Restart required"
            body = ("OpenClaw needs to restart for the new coding-helper routing "
                    "to take effect (it changes the gateway's systemd environment). "
                    "This briefly interrupts any active conversations.")
            yes_label = "Restart now"

        dialog = Adw.AlertDialog(heading=heading, body=body)
        dialog.add_response("no", "Not Now")
        dialog.add_response("yes", yes_label)
        dialog.set_response_appearance("yes", Adw.ResponseAppearance.SUGGESTED)
        dialog.set_default_response("yes")
        dialog.connect("response", self._on_restart_response,
                       openclaw_needs_restart, hermes_needs_restart)
        dialog.present(self)

    def _on_restart_response(self, dialog, response, openclaw_needed=True, hermes_needed=False):
        if response != "yes":
            hint = []
            if openclaw_needed:
                hint.append("systemctl --user restart openclaw-gateway")
            if hermes_needed:
                hint.append("systemctl --user restart hermes-gateway")
            self._show_toast("Saved. Run: " + " && ".join(hint) if hint else "Saved.")
            return

        targets = []
        if openclaw_needed:
            targets.append("OpenClaw")
        if hermes_needed:
            targets.append("Hermes")
        self._show_toast(f"Restarting {' and '.join(targets)}…")

        def do_restart():
            results = []
            if openclaw_needed:
                results.append(("OpenClaw", ModelDiscovery.restart_gateway()))
            if hermes_needed:
                results.append(("Hermes", HermesService.restart()))
            GLib.idle_add(self._on_restart_done_multi, results)
        threading.Thread(target=do_restart, daemon=True).start()

    def _on_restart_done_multi(self, results):
        failures = [(name, r.stderr.strip() or f"exit {r.returncode}")
                    for name, r in results if r.returncode != 0]
        if failures:
            msg = "; ".join(f"{n}: {e}" for n, e in failures)
            self._show_toast(f"Restart failed: {msg}")
        else:
            names = ", ".join(n for n, _ in results)
            self._show_toast(f"{names} restarted successfully")
        GLib.timeout_add_seconds(3, self._refresh_all_status)

    def _lmstudio_servers(self):
        """(provider, label, models-url) for each LM Studio server described in
        openclaw.json — baseUrl read live so an IP change needs no app edit."""
        servers = []
        providers = self.config.data.get("models", {}).get("providers", {})
        for provider, label in LMSTUDIO_PROVIDERS.items():
            base = (providers.get(provider) or {}).get("baseUrl")
            if not base and provider == "vllm":
                base = LMSTUDIO_ANTHROPIC_BASE + "/v1"
            if base:
                servers.append((provider, label, base.rstrip("/") + "/models"))
        return servers

    def _on_add_lmstudio(self, btn):
        servers = self._lmstudio_servers()

        def fetch():
            try:
                found, down = [], []
                for provider, label, url in servers:
                    models = ModelDiscovery.fetch_lmstudio_models(url, timeout=5)
                    if models is None:
                        down.append(label)
                        continue
                    found.extend((provider, label, m) for m in models)
                GLib.idle_add(self._show_lmstudio_dialog, found, down)
            except Exception as e:
                GLib.idle_add(self._show_toast, f"Failed to fetch models: {e}")
        threading.Thread(target=fetch, daemon=True).start()

    def _show_lmstudio_dialog(self, found, down):
        if not found:
            self._show_toast("No LM Studio server is reachable"
                             if down else "No models found")
            return

        existing = set(self.config.get_configured_model_ids())
        available = [(p, lbl, m) for p, lbl, m in found
                     if f"{p}/{m}" not in existing]
        if not available:
            self._show_toast("All discovered LM Studio models are already added")
            return

        body = "Select a model to add to OpenClaw:"
        if down:
            body += ("\n\n(" + " and ".join(down)
                     + (" servers are" if len(down) > 1 else " server is")
                     + " unreachable — only the listed source was scanned.)")
        dialog = Adw.AlertDialog(heading="Add LM Studio Model", body=body)
        dialog.add_response("cancel", "Cancel")
        dialog.add_response("add", "Add")
        dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)

        list_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE, css_classes=["boxed-list"])
        for provider, label, m in available:
            display = friendly_name(f"{provider}/{m}")
            row = Adw.ActionRow(title=display, subtitle=f"Free / {label} GPU  [{m}]")
            row.add_prefix(Gtk.Image(icon_name="computer-symbolic"))
            row._model_id = m
            row._provider = provider
            list_box.append(row)
        list_box.select_row(list_box.get_row_at_index(0))

        # Reasoning defaults ON: a reasoning model saved with reasoning:false
        # gets false-stalled and aborted by the gateway (Jun-9 incident);
        # reasoning:true is harmless for models that never emit it.
        reason_switch = Adw.SwitchRow(
            title="Reasoning model",
            subtitle=("Leave ON unless the model never thinks out loud "
                      "(e.g. gemma/nemotron) — OFF on a thinking model makes "
                      "the gateway abort it as stalled."),
            active=True)
        reason_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE,
                                 css_classes=["boxed-list"], margin_top=8)
        reason_box.append(reason_switch)

        scroll = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER,
                                    propagate_natural_height=True,
                                    max_content_height=420, child=list_box)
        extra = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        extra.append(scroll)
        extra.append(reason_box)
        dialog.set_extra_child(extra)
        dialog.connect("response", self._on_lmstudio_response, list_box, reason_switch)
        dialog.present(self)

    def _on_lmstudio_response(self, dialog, response, list_box, reason_switch):
        if response != "add":
            return
        selected = list_box.get_selected_row()
        if selected is None:
            return
        model_id = selected._model_id
        provider = selected._provider
        self.config.add_lmstudio_model(
            model_id, provider=provider,
            reasoning=reason_switch.get_active(),
            max_tokens=LMSTUDIO_PROVIDER_MAXTOK.get(provider, 8192))
        self._refresh_after_catalog_change()
        self._show_toast(f"Added {provider}/{model_id}")

    def _on_add_anthropic(self, btn):
        existing = set(self.config.get_configured_model_ids())
        available = [m for m in ANTHROPIC_MODELS if f"anthropic/{m['id']}" not in existing]

        if not available:
            self._show_toast("All Anthropic models are already added")
            return

        body = "Select a Claude model to add:"
        if not self.config.plugin_enabled("anthropic"):
            body = ("The anthropic plugin is currently DISABLED and not in the "
                    "plugins.allow allowlist (intentional — there is no working "
                    "Anthropic/vision key on this system, and the image tool is "
                    "meant to fail fast).\n\n"
                    "Adding a model here only puts it in the catalog; it will NOT "
                    "work, and this app will NOT re-enable the plugin for you. "
                    "Re-enabling requires deliberately editing plugins.allow and "
                    "plugins.entries.anthropic in openclaw.json.\n\n"
                    "Select a Claude model to add anyway:")
        dialog = Adw.AlertDialog(heading="Add Anthropic Model", body=body)
        dialog.add_response("cancel", "Cancel")
        dialog.add_response("add", "Add")
        dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)

        list_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE, css_classes=["boxed-list"])
        for m in available:
            row = Adw.ActionRow(title=m["display"], subtitle=m["cost"])
            row.add_prefix(Gtk.Image(icon_name="network-server-symbolic"))
            row._model_id = m["id"]
            list_box.append(row)
        list_box.select_row(list_box.get_row_at_index(0))

        dialog.set_extra_child(list_box)
        dialog.connect("response", self._on_anthropic_response, list_box)
        dialog.present(self)

    def _on_anthropic_response(self, dialog, response, list_box):
        if response != "add":
            return
        selected = list_box.get_selected_row()
        if selected is None:
            return
        model_id = selected._model_id
        self.config.add_anthropic_model(model_id)
        self._refresh_after_catalog_change()
        self._show_toast(f"Added anthropic/{model_id}")

    def _on_remove_model(self, btn, model_id):
        primary_users = []
        fallback_users = []
        for agent in self.config.get_agents():
            name = agent.get("name") or agent.get("id", "?")
            if self.config.get_agent_primary(agent) == model_id:
                primary_users.append(name)
            if model_id in self.config.get_agent_fallbacks(agent):
                fallback_users.append(name)

        # A model used as a *primary* can't be pulled out from under an agent —
        # that would break it. A model used only as a fallback (often a hidden
        # extra fallback the UI's single slot doesn't surface) can be removed by
        # stripping it from those agents' fallback lists first.
        if primary_users:
            self._show_toast(f"Can't remove: primary model for {', '.join(primary_users)}")
            return

        body = f"Remove {friendly_name(model_id)} from the catalog?"
        if fallback_users:
            uniq = ", ".join(dict.fromkeys(fallback_users))
            body += (f"\n\nIt's currently a fallback for {uniq}. Removing it will "
                     f"also drop it from {'their' if len(fallback_users) > 1 else 'its'} fallback list.")
        dialog = Adw.AlertDialog(heading="Remove Model?", body=body)
        dialog.add_response("cancel", "Cancel")
        dialog.add_response("remove", "Remove")
        dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE)
        dialog.connect("response", self._on_confirm_remove, model_id)
        dialog.present(self)

    def _on_confirm_remove(self, dialog, response, model_id):
        if response != "remove":
            return
        touched = self.config.strip_model_from_fallbacks(model_id)
        self.config.remove_model(model_id)
        self._refresh_after_catalog_change()
        if touched:
            self._show_toast(f"Removed {friendly_name(model_id)} (dropped from fallbacks: {', '.join(dict.fromkeys(touched))})")
        else:
            self._show_toast(f"Removed {friendly_name(model_id)}")

    def _refresh_after_catalog_change(self):
        if self.config.is_dirty() or self._subagent_is_dirty() or self._hermes_is_dirty():
            self._show_toast("Catalog updated — your pending selections are kept")
        self._model_ids = self.config.get_configured_model_ids()
        self._combos.clear()
        self._status_rows.clear()
        self._service_rows.clear()
        self._service_busy.clear()
        self._slot_combos.clear()
        self._profile_combos.clear()
        self._agent_profile_dropdowns.clear()
        self._subagents_combo = None
        self._usage_container = None
        self._usage_cron_expander = None
        self._build_ui()
        self._update_dirty_state()
        self._refresh_all_status()

    def _show_toast(self, msg):
        toast = Adw.Toast(title=msg, timeout=3)
        self._toast_overlay.add_toast(toast)


# ── Entry Point ────────────────────────────────────────────────────────────────

def main():
    app = ModelManagerApp()
    app.run(sys.argv)


if __name__ == "__main__":
    main()
