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

import json
import os
import re
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 local-inference servers are managed: "vllm" = an OpenAI-compatible server on
# this PC, "buildpc" = an OpenAI-compatible server on a second PC (any llama.cpp-,
# vLLM- or LM Studio-style server on :1234 works; some serve the long
# <publisher>/<repo>/<file-stem> model-id form). 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

# Optional secondary agent gateway: a separate agent with its own systemd unit
# and its own config file. EDIT ME to match your own second gateway (or leave
# it — the section only renders when the config file actually exists).
SECONDARY_CONFIG = Path.home() / ".secondary-agent" / "config.yaml"
SECONDARY_GATEWAY_SERVICE = "secondary-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. EDIT ME: these are placeholder unit names — rows only render
# for unit files that actually exist, so wrong entries are harmless.
EMAIL_SERVICE = "mailforge.service"
# The self-hosted chat UI the agents post into.
CHAT_SERVICE = "chat.service"

# 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).
# EDIT ME if your OpenClaw node install lives somewhere else.
OPENCLAW_NODE_ROOT = Path.home() / ".openclaw-node"
NODE_BIN = OPENCLAW_NODE_ROOT / "node" / "bin" / "node"
OPENCLAW_ENTRY = OPENCLAW_NODE_ROOT / "node" / "lib" / "node_modules" / "openclaw" / "dist" / "index.js"
SECONDARY_BIN = Path.home() / ".local" / "bin" / "secondary-agent"
UV_BIN = Path.home() / ".local" / "bin" / "uv"
EMAIL_SOURCE_DIR = Path.home() / "mailforge"

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: routing key for Updates.run().
#   op_timeout: seconds for a start/stop/restart of this unit.
#   reset_failed_after_stop: some gateways land in `failed` on stop (SIGKILL / exit 1);
#                            clearing it keeps the status honest (cosmetic only).
SERVICES = [
    {
        "key": "secondary",
        "name": "Secondary Agent",
        "unit": SECONDARY_GATEWAY_SERVICE,
        "health": "systemd",
        "op_timeout": 240,
        "update_kind": "secondary",
        "reset_failed_after_stop": True,
        "stop_note": "Stops the secondary agent gateway.",
        "update_blurb": "Runs the secondary agent's own updater (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. The chat UI "
                      "runs in its own unit and keeps serving (without agent replies)."),
        "update_blurb": "Runs `openclaw update`, then restarts the gateway.",
    },
    {
        "key": "email",
        "name": "MailForge",
        "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 mailforge`, then restarts.",
    },
    {
        "key": "chat",
        "name": "Chat UI",
        "unit": CHAT_SERVICE,
        "health": "systemd",
        "op_timeout": 60,
        "update_kind": None,  # runs from a local source directory — nothing to pull
        "reset_failed_after_stop": False,
        "stop_note": "Stops the chat UI service (its web port 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; a gateway config may hold
    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 GatewayEnv:
    """Read/write single keys in the gateway EnvironmentFile
    (~/.openclaw/gateway.systemd.env).

    This is where a cloud provider's API key lives. openclaw.json only ever
    stores the "${VAR}" template — the secret itself stays in this 0600 file and
    reaches the gateway through the unit's `EnvironmentFile=` line. Nothing is
    written to openclaw.json, to argv, or to the journal.

    The unit's OPENCLAW_SERVICE_MANAGED_ENV_KEYS list is deliberately NOT
    touched. Per OpenClaw's own source (daemon/service-managed-env.ts) that list
    only "tracks managed service environment keys across reinstall and repair
    flows" — `EnvironmentFile=` loads every key in this file regardless, so a new
    key works without being listed. Pinning the list from a drop-in would shadow
    whatever the installer later manages, which is how entries get silently
    dropped across an update.
    """

    # Values that need no quoting in a systemd EnvironmentFile. API keys are
    # normally in this set; anything else gets quoted+escaped below.
    _BARE_VALUE = re.compile(r"^[A-Za-z0-9_\-./:+=~]+$")
    _VALID_NAME = re.compile(r"^[A-Z_][A-Z0-9_]*$")

    @staticmethod
    def read_key(name):
        """Return the value of `name` from the gateway EnvironmentFile, or None.
        Used to decide whether a key still needs to be supplied; never displayed."""
        try:
            for line in OPENCLAW_GATEWAY_ENV.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 None

    @staticmethod
    def set_key(name, value):
        """Set name=value in the gateway EnvironmentFile, preserving every other
        line, and back the file up first. Creates it 0600 if absent.

        A duplicate definition of `name` is collapsed to one: systemd applies the
        last assignment, so leaving a stale later line would silently win over
        the value we just wrote.
        """
        if not GatewayEnv._VALID_NAME.match(name or ""):
            raise ValueError(f"invalid environment variable name: {name!r}")
        value = (value or "").strip()
        if not value:
            raise ValueError("refusing to write an empty API key")
        if "\n" in value or "\r" in value:
            raise ValueError("API key contains a line break")
        if GatewayEnv._BARE_VALUE.match(value):
            rendered = f"{name}={value}"
        else:
            escaped = value.replace("\\", "\\\\").replace('"', '\\"')
            rendered = f'{name}="{escaped}"'

        try:
            lines = OPENCLAW_GATEWAY_ENV.read_text().splitlines()
        except FileNotFoundError:
            lines = []
        out, written = [], False
        for line in lines:
            if line.strip().startswith(name + "="):
                if not written:
                    out.append(rendered)
                    written = True
                continue  # drop any duplicate definition
            out.append(line)
        if not written:
            out.append(rendered)

        if OPENCLAW_GATEWAY_ENV.exists():
            import shutil
            # copy2 preserves the 0600 bits — a backup of a secrets file must not
            # land at the umask default.
            shutil.copy2(OPENCLAW_GATEWAY_ENV,
                         OPENCLAW_GATEWAY_ENV.with_suffix(".env.bak"))
        atomic_write_private(OPENCLAW_GATEWAY_ENV, "\n".join(out) + "\n",
                             fallback_mode=0o600)


def test_api_model(base_url, api, key, model_id, timeout=25):
    """Send one real, minimal request to a cloud endpoint. Returns (ok, message).

    A real request (rather than a reachability ping) is the point: it proves the
    base URL, the protocol, the key AND the model id all line up, which is
    exactly the combination that otherwise fails silently as a dead provider
    long after the dialog is closed. Mirrors test_deepseek_model().
    """
    base = (base_url or "").rstrip("/")
    if not base or not model_id:
        return False, "Base URL and model id are required."
    if not key:
        return False, "No API key available to test with."
    try:
        if api == "anthropic-messages":
            url = f"{base}/v1/messages"
            headers = {"content-type": "application/json", "x-api-key": key,
                       "anthropic-version": "2023-06-01"}
        else:
            url = f"{base}/chat/completions"
            headers = {"content-type": "application/json",
                       "authorization": f"Bearer {key}"}
        payload = {"model": model_id, "max_tokens": 64,
                   "messages": [{"role": "user", "content": "ping"}]}
        req = urllib.request.Request(url, method="POST", headers=headers,
                                     data=json.dumps(payload).encode())
        with urllib.request.urlopen(req, timeout=timeout) as r:
            if r.status == 200:
                return True, f"OK — {model_id} responded."
            return False, f"HTTP {r.status} from {url}"
    except urllib.error.HTTPError as e:
        try:
            detail = e.read().decode()[:200]
        except Exception:
            detail = ""
        if e.code in (401, 403):
            return False, f"Auth rejected (HTTP {e.code}) — check the API key. {detail}".strip()
        if e.code == 404:
            return False, (f"HTTP 404 — base URL or model id looks wrong "
                           f"({url}, model={model_id}). {detail}").strip()
        return False, f"HTTP {e.code} from {url}. {detail}".strip()
    except Exception as e:
        return False, f"Could not reach {base}: {e}"


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"},
]

# ── API (cloud) provider presets ───────────────────────────────────────────────
# Adding a cloud model means making BOTH halves of openclaw.json agree: a
# models.providers.<id> block (the connection) and an agents.defaults.models
# entry (the catalog allowlist). Miss either half and the model silently isn't
# selectable — which is why add_api_model() writes them together.
#
# These presets ONLY pre-fill the dialog. Every field stays editable and
# "Custom" starts blank, so a provider that isn't listed here needs no code
# change — that is the whole point of the generic form.
#
# cost is USD per million tokens, matching the existing deepseek entry's
# convention: cacheWrite mirrors the cache-miss input price, because writing to
# the cache is billed as ordinary input — no provider here charges a separate
# write surcharge.
API_PRESET_CUSTOM = "custom"
API_COMPAT_CHOICES = ["openai-completions", "anthropic-messages"]
API_PROVIDER_PRESETS = [
    {
        "key": "moonshot",
        "display": "Moonshot / Kimi",
        "provider": "moonshot",
        "base_url": "https://api.moonshot.ai/v1",
        "api": "openai-completions",
        "env_var": "MOONSHOT_API_KEY",
        "timeout": 1200,
        "models": [
            {
                # Kimi K3 (2026-07-16): 2.8T MoE, native vision, 1M context.
                # reasoning MUST stay true — K3's "thinking mode" is always on,
                # and a model that emits reasoning_content while configured
                # reasoning:false gets false-stalled and force-aborted by the
                # gateway.
                "id": "kimi-k3",
                "display": "Kimi K3",
                "reasoning": True,
                "vision": True,
                "context": 1048576,
                # Moonshot's default output cap; raisable to the full 1M context.
                "max_tokens": 131072,
                "cost": {"input": 3.0, "output": 15.0, "cacheRead": 0.3, "cacheWrite": 3.0},
            },
        ],
    },
    {
        "key": "deepseek",
        "display": "DeepSeek",
        "provider": "deepseek",
        "base_url": "https://api.deepseek.com/v1",
        "api": "openai-completions",
        "env_var": "DEEPSEEK_API_KEY",
        "timeout": 450,
        "models": [],
    },
    {
        "key": API_PRESET_CUSTOM,
        "display": "Custom (OpenAI/Anthropic-compatible)",
        "provider": "",
        "base_url": "",
        "api": "openai-completions",
        "env_var": "",
        "timeout": 1200,
        "models": [],
    },
]

# 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 (Moonshot / Kimi) ──
    "moonshot/kimi-k3": "Kimi K3 (Cloud)",
    # ── 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)",
    # A 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/secondary-agent 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 set_provider_model_context(self, model_id, context_window):
        """Set a model's advertised contextWindow. Returns True if it changed.

        A context window is not a cosmetic label: the runtime packs context up
        to it. A local model here was advertising 500,000 tokens against an
        engine that could not serve anything like that, so every long turn was
        rejected and nothing in the loop could correct it — the turn just
        failed. It was only fixable by hand-editing openclaw.json, which is
        precisely the kind of edit this app exists to remove.
        """
        if not model_id or "/" not in model_id:
            return False
        provider, raw_id = model_id.split("/", 1)
        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:
                new = int(context_window)
                if m.get("contextWindow") == new:
                    return False
                m["contextWindow"] = new
                return True
        return False

    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. 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},
                # 128k: what the local LM Studio builds on this box actually
                # serve. Editable per model from the catalog list afterwards.
                "contextWindow": 131072, "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 add_api_model(self, provider, raw_id, base_url, api, env_var,
                      reasoning=True, vision=False, context_window=128000,
                      max_tokens=8192, cost=None, timeout_seconds=1200):
        """Add a cloud/API model: the provider connection block AND the catalog
        entry, in one edit so the two halves can never drift apart.

        Unlike add_lmstudio_model (whose provider always already exists), this
        creates models.providers.<provider> when it's missing — that gap is why
        adding a new cloud API previously meant hand-editing openclaw.json.

        The apiKey is written as a "${VAR}" template, never a literal: the secret
        belongs in the gateway EnvironmentFile (see GatewayEnv), not in a config
        file that also carries gateway.auth.token.

        Idempotent, and deliberately non-destructive: an existing provider block
        keeps its own baseUrl/api/apiKey (a live, working connection is never
        rewritten from a preset), and an existing model id is left alone rather
        than duplicated. Returns the prefixed catalog id.
        """
        prefixed_id = f"{provider}/{raw_id}"
        providers = self.data.setdefault("models", {}).setdefault("providers", {})
        if provider not in providers:
            providers[provider] = {
                "baseUrl": base_url,
                "api": api,
                "apiKey": f"${{{env_var}}}",
                "timeoutSeconds": int(timeout_seconds),
            }
        prov = providers[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", "image"] if vision else ["text"],
                "cost": dict(cost or {"input": 0, "output": 0,
                                      "cacheRead": 0, "cacheWrite": 0}),
                "contextWindow": int(context_window), "maxTokens": int(max_tokens),
            })
        catalog = self.data.setdefault("agents", {}).setdefault("defaults", {}).setdefault("models", {})
        if prefixed_id not in catalog:
            catalog[prefixed_id] = {}
        return prefixed_id

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


# ── Secondary Agent Config ────────────────────────────────────────────────────────

class SecondaryConfig:
    """Read/write the `model.default:` field in the secondary agent's config.yaml.

    We avoid a PyYAML dependency by doing a targeted line edit. The secondary agent's 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 SECONDARY_CONFIG.exists()

    @staticmethod
    def read_default_model():
        if not SECONDARY_CONFIG.exists():
            return None
        in_model_block = False
        for line in SECONDARY_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 SECONDARY_CONFIG.exists():
            return None
        in_model_block = False
        for line in SECONDARY_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 SECONDARY_CONFIG.exists():
            raise FileNotFoundError(f"Secondary agent config not found at {SECONDARY_CONFIG}")

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

        lines = SECONDARY_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 the secondary agent's config.yaml")

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


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

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

    @staticmethod
    def restart():
        return subprocess.run(
            ["systemctl", "--user", "restart", SECONDARY_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,
    `uv` resolves, etc. — the GUI's own PATH may be narrower."""
    env = dict(os.environ)
    extra = [
        str(OPENCLAW_NODE_ROOT / "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 a now-retired chat server was removed.)


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 == "secondary":
            return Updates._secondary(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 _secondary(svc):
        steps = [("secondary-agent update --yes", Updates._proc(
            [str(SECONDARY_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 mailforge", Updates._proc(
            [str(UV_BIN), "tool", "upgrade", "mailforge"], 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 = []
        # API keys captured by the Add API Model dialog, written to
        # gateway.systemd.env on Save (never at dialog-close: an Add the user
        # then abandons with Cancel must not leave a secret on disk).
        self._pending_env_keys = {}
        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

        # Secondary Agent state (separate agent, separate config + systemd service)
        self._secondary_available = SecondaryConfig.exists()
        self._secondary_provider = SecondaryConfig.read_provider() if self._secondary_available else None
        self._secondary_saved_model = SecondaryConfig.read_default_model() if self._secondary_available else None
        self._secondary_pending_model = self._secondary_saved_model
        self._secondary_model_combo = None
        self._secondary_status_subrow = None
        self._secondary_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._secondary_available:
            right.append(self._build_secondary_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) the secondary agent
        if self._secondary_available:
            h = Adw.ExpanderRow(title="Secondary Agent")
            h.add_row(self._usage_row("Default model", SecondaryConfig.read_default_model(),
                                      subtitle=f"provider: {self._secondary_provider or '?'}"))
            group.add(h)

        # 4) Email agent
        if unit_exists(EMAIL_SERVICE):
            e = Adw.ExpanderRow(title="MailForge")
            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" / "mailforge" / "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, secondary agent, chat, 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"),
        ]
        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()
                GLib.idle_add(self._update_status, lms, ant, sub, ant_plugin_on)
            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):
        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)),
        ]
        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. the chat UI 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/secondary 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 == "secondary":
            return ("Runs `secondary-agent update`: pulls the latest from git and reinstalls "
                    "dependencies, then restarts the secondary agent. Uncommitted local changes are "
                    "auto-stashed. This can take several minutes.")
        if kind == "email":
            return ("Runs `uv tool upgrade mailforge`, 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.
        # Offer every real model — catalog entries PLUS everything the local/
        # remote providers actually advertise — so local models that were added
        # to a provider but never to the (often stale) catalog are still
        # selectable. Without this the per-agent/default/bulk combos only listed
        # the catalog, which was DeepSeek-heavy, so agents kept defaulting to the
        # DeepSeek API even though the vLLM/buildpc models existed.
        self._model_ids = self._all_known_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)
        # Offer every real model — catalog entries PLUS everything the local/
        # remote providers actually advertise — so local models that were added
        # to a provider but never to the (often stale) catalog are still
        # selectable. Without this the per-agent/default/bulk combos only listed
        # the catalog, which was DeepSeek-heavy, so agents kept defaulting to the
        # DeepSeek API even though the vLLM/buildpc models existed.
        self._model_ids = self._all_known_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()

    # ── Secondary Agent Section ──

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

        # Offer models appropriate to the secondary agent's 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._secondary_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._secondary_pending_model in model_ids:
            model_combo.set_selected(model_ids.index(self._secondary_pending_model))
        model_combo.connect("notify::selected", self._on_secondary_model_changed)
        group.add(model_combo)
        self._secondary_model_combo = model_combo

        status_row = Adw.ActionRow(title="Status", subtitle="Checking…")
        group.add(status_row)
        self._secondary_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 secondary-agent change")
        discard_btn.add_css_class("flat")
        discard_btn.connect("clicked", self._on_secondary_discard)
        action_row.add_suffix(discard_btn)
        group.add(action_row)
        self._secondary_discard_btn = discard_btn

        self._refresh_secondary_status_row()
        self._update_secondary_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_secondary_model_changed(self, combo, _pspec):
        idx = combo.get_selected()
        if 0 <= idx < len(combo._raw_items):
            self._secondary_pending_model = combo._raw_items[idx]
        self._refresh_secondary_status_row()
        self._update_secondary_sensitivity()
        self._update_dirty_state()

    def _on_secondary_discard(self, _btn):
        self._secondary_pending_model = self._secondary_saved_model
        if self._secondary_model_combo and self._secondary_saved_model in self._secondary_model_combo._raw_items:
            self._secondary_model_combo.set_selected(
                self._secondary_model_combo._raw_items.index(self._secondary_saved_model)
            )
        self._refresh_secondary_status_row()
        self._update_secondary_sensitivity()
        self._update_dirty_state()
        self._show_toast("Discarded pending secondary-agent change")

    def _refresh_secondary_status_row(self):
        if not self._secondary_status_subrow:
            return
        row = self._secondary_status_subrow
        for cls in ("success", "warning", "error"):
            row.remove_css_class(cls)
        pending = self._secondary_pending_model or "(unset)"
        provider = (self._secondary_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"The secondary agent uses {provider} (cloud). Model: “{pending}”. "
                f"Save to apply and restart the secondary agent."
            )
            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._secondary_check_lmstudio_async(provider, pending), daemon=True).start()

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

    def _secondary_update_status_from_check(self, live, provider, pending):
        if not self._secondary_status_subrow:
            return
        row = self._secondary_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 the secondary agent.")
        row.add_css_class("success")

    def _update_secondary_sensitivity(self):
        if self._secondary_discard_btn is not None:
            self._secondary_discard_btn.set_sensitive(self._secondary_is_dirty())

    def _secondary_is_dirty(self):
        if not self._secondary_available:
            return False
        return self._secondary_pending_model != self._secondary_saved_model

    def _apply_secondary_change(self):
        if not self._secondary_pending_model:
            raise ValueError("Pick a secondary-agent model before saving.")
        SecondaryConfig.write_default_model(self._secondary_pending_model)
        self._secondary_saved_model = self._secondary_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)

        add_api_btn = Gtk.Button(label="+ Add API Model")
        add_api_btn.add_css_class("pill")
        add_api_btn.connect("clicked", self._on_add_api)
        btn_box.append(add_api_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))

            # Only models the provider actually describes can have their window
            # edited — a catalog-only entry has no block to write it into.
            if detail:
                ctx_btn = Gtk.Button(icon_name="document-edit-symbolic",
                                     valign=Gtk.Align.CENTER,
                                     tooltip_text="Edit context window")
                ctx_btn.add_css_class("flat")
                ctx_btn.connect("clicked", self._on_edit_context, model_id, ctx)
                row.add_suffix(ctx_btn)

            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._secondary_is_dirty() or self._slots_is_dirty()
                 or self._agent_profiles_is_dirty()
                 or bool(self._pending_env_keys))
        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()
        secondary_dirty = self._secondary_is_dirty()
        slots_dirty = self._slots_is_dirty() or self._agent_profiles_is_dirty()
        if (not config_dirty and not sub_dirty and not secondary_dirty
                and not slots_dirty and not self._pending_env_keys):
            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, secondary_dirty, slots_dirty) if r == "save" else None)
                dialog.present(self)
                return

        self._continue_save(config_dirty, sub_dirty, secondary_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, secondary_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, secondary_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 / secondary agent). 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}")
        # API keys go to the gateway EnvironmentFile, which — unlike openclaw.json
        # — is NOT hot-reloaded: systemd reads it once at process start, so a new
        # key only reaches the gateway after a restart (offered below).
        env_written = False
        if self._pending_env_keys:
            failed = {}
            for name, value in self._pending_env_keys.items():
                try:
                    GatewayEnv.set_key(name, value)
                    env_written = True
                except Exception as e:
                    failed[name] = value
                    errors.append(f"API key {name}: {e}")
            # Keep only what failed, so a retry doesn't re-write the keys that
            # already landed and the Save button stays lit while work remains.
            self._pending_env_keys = failed
        if sub_dirty:
            try:
                self._apply_subagent_routing()
            except Exception as e:
                errors.append(f"coding-helper: {e}")
                sub_dirty = False
        if secondary_dirty:
            try:
                self._apply_secondary_change()
            except Exception as e:
                errors.append(f"Secondary agent: {e}")
                secondary_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_secondary_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 the secondary agent'
        # 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 or env_written
        secondary_needs_restart = secondary_dirty
        if not openclaw_needs_restart and not secondary_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 secondary_needs_restart:
            heading = "Restart services?"
            body = ("OpenClaw and the secondary agent both need to restart to apply changes.\n"
                    "This briefly interrupts any active conversations.")
            yes_label = "Restart both"
        elif secondary_needs_restart:
            heading = "Restart the secondary agent?"
            body = ("Secondary Agent needs to restart to pick up the new model.\n"
                    "This briefly interrupts any active secondary-agent conversations.")
            yes_label = "Restart the secondary agent"
        else:
            heading = "Restart required"
            if sub_dirty and env_written:
                what = "the new coding-helper routing and API key"
            elif env_written:
                what = "the new API key"
            else:
                what = "the new coding-helper routing"
            body = (f"OpenClaw needs to restart for {what} 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, secondary_needs_restart)
        dialog.present(self)

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

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

        def do_restart():
            results = []
            if openclaw_needed:
                results.append(("OpenClaw", ModelDiscovery.restart_gateway()))
            if secondary_needed:
                results.append(("Secondary Agent", SecondaryService.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_add_api(self, btn):
        """Add a model from any OpenAI/Anthropic-compatible cloud API.

        The presets only pre-fill this form — every field stays editable, so a
        provider nobody has written a preset for still needs no code change.
        """
        presets = API_PROVIDER_PRESETS
        dialog = Adw.AlertDialog(
            heading="Add API Model",
            body=("Pick a provider to pre-fill the form, or Custom for any "
                  "OpenAI/Anthropic-compatible endpoint.\n\n"
                  "The API key is written to gateway.systemd.env (0600) on Save — "
                  "openclaw.json only ever stores ${VARIABLE}."),
        )
        dialog.add_response("cancel", "Cancel")
        dialog.add_response("add", "Add")
        dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)

        group = Adw.PreferencesGroup()
        preset_row = Adw.ComboRow(title="Provider preset",
                                  model=Gtk.StringList.new([p["display"] for p in presets]))
        model_pick_row = Adw.ComboRow(title="Known model",
                                      subtitle="Pre-fills the fields below")
        provider_row = Adw.EntryRow(title="Provider id")
        base_row = Adw.EntryRow(title="Base URL")
        api_row = Adw.ComboRow(title="API compatibility",
                               model=Gtk.StringList.new(API_COMPAT_CHOICES))
        model_row = Adw.EntryRow(title="Model id")
        env_row = Adw.EntryRow(title="API key variable")
        key_row = Adw.PasswordEntryRow(title="API key")
        ctx_row = Adw.EntryRow(title="Context window")
        max_row = Adw.EntryRow(title="Max output tokens")
        reason_row = Adw.SwitchRow(
            title="Reasoning model", active=True,
            subtitle="Leave on unless you know it never emits reasoning — a reasoning "
                     "model marked off gets false-stalled and force-aborted")
        vision_row = Adw.SwitchRow(title="Image input", active=False)
        cost_in_row = Adw.EntryRow(title="Cost: input $/M tokens")
        cost_out_row = Adw.EntryRow(title="Cost: output $/M tokens")
        cost_cr_row = Adw.EntryRow(title="Cost: cache read $/M tokens")
        cost_cw_row = Adw.EntryRow(title="Cost: cache write $/M tokens")
        for r in (preset_row, model_pick_row, provider_row, base_row, api_row,
                  model_row, env_row, key_row, ctx_row, max_row, reason_row,
                  vision_row, cost_in_row, cost_out_row, cost_cr_row, cost_cw_row):
            group.add(r)

        test_btn = Gtk.Button(label="Test connection", halign=Gtk.Align.CENTER,
                              margin_top=10, css_classes=["pill"])
        status_lbl = Gtk.Label(label="", wrap=True, margin_top=6,
                               css_classes=["dim-label"])

        def apply_model_preset(*_):
            preset = presets[preset_row.get_selected()]
            models = preset["models"]
            if not models:
                return
            idx = min(model_pick_row.get_selected(), len(models) - 1)
            m = models[idx]
            model_row.set_text(m["id"])
            ctx_row.set_text(str(m["context"]))
            max_row.set_text(str(m["max_tokens"]))
            reason_row.set_active(bool(m.get("reasoning", True)))
            vision_row.set_active(bool(m.get("vision", False)))
            c = m.get("cost", {})
            cost_in_row.set_text(str(c.get("input", 0)))
            cost_out_row.set_text(str(c.get("output", 0)))
            cost_cr_row.set_text(str(c.get("cacheRead", 0)))
            cost_cw_row.set_text(str(c.get("cacheWrite", 0)))

        def update_key_hint(*_):
            name = env_row.get_text().strip()
            if name and (name in self._pending_env_keys or GatewayEnv.read_key(name)):
                key_row.set_title(f"API key — {name} already set, blank keeps it")
            else:
                key_row.set_title("API key")

        def apply_preset(*_):
            preset = presets[preset_row.get_selected()]
            provider_row.set_text(preset["provider"])
            base_row.set_text(preset["base_url"])
            api_row.set_selected(API_COMPAT_CHOICES.index(preset["api"])
                                 if preset["api"] in API_COMPAT_CHOICES else 0)
            env_row.set_text(preset["env_var"])
            names = [m["display"] for m in preset["models"]]
            model_pick_row.set_model(Gtk.StringList.new(names or [""]))
            model_pick_row.set_visible(bool(names))
            if names:
                model_pick_row.set_selected(0)
                apply_model_preset()
            else:
                model_row.set_text("")
                ctx_row.set_text("128000")
                max_row.set_text("8192")
                reason_row.set_active(True)
                vision_row.set_active(False)
                for r in (cost_in_row, cost_out_row, cost_cr_row, cost_cw_row):
                    r.set_text("0")
            update_key_hint()
            status_lbl.set_label("")

        def on_test(_btn):
            name = env_row.get_text().strip()
            key = (key_row.get_text().strip() or self._pending_env_keys.get(name)
                   or (GatewayEnv.read_key(name) if name else None))
            base = base_row.get_text().strip()
            api = API_COMPAT_CHOICES[api_row.get_selected()]
            model_id = model_row.get_text().strip()
            if not key:
                status_lbl.set_label(f"No key to test with — enter one above"
                                     + (f", or set {name} in gateway.systemd.env." if name else "."))
                return
            test_btn.set_sensitive(False)
            status_lbl.set_label("Testing…")

            def work():
                ok, msg = test_api_model(base, api, key, model_id)

                def done():
                    test_btn.set_sensitive(True)
                    status_lbl.set_label(("✓ " if ok else "✗ ") + msg)
                    return False
                GLib.idle_add(done)
            threading.Thread(target=work, daemon=True).start()

        preset_row.connect("notify::selected", apply_preset)
        model_pick_row.connect("notify::selected", apply_model_preset)
        env_row.connect("changed", update_key_hint)
        test_btn.connect("clicked", on_test)
        apply_preset()

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        box.append(group)
        box.append(test_btn)
        box.append(status_lbl)
        scroller = Gtk.ScrolledWindow(propagate_natural_height=True,
                                      max_content_height=460,
                                      hscrollbar_policy=Gtk.PolicyType.NEVER)
        scroller.set_child(box)
        dialog.set_extra_child(scroller)
        widgets = {
            "preset": preset_row, "provider": provider_row, "base": base_row,
            "api": api_row, "model": model_row, "env": env_row, "key": key_row,
            "ctx": ctx_row, "max": max_row, "reason": reason_row,
            "vision": vision_row, "cost_in": cost_in_row, "cost_out": cost_out_row,
            "cost_cr": cost_cr_row, "cost_cw": cost_cw_row,
        }
        dialog.connect("response", self._on_api_response, widgets)
        dialog.present(self)

    def _on_api_response(self, dialog, response, w):
        if response != "add":
            return
        provider = w["provider"].get_text().strip()
        raw_id = w["model"].get_text().strip()
        base = w["base"].get_text().strip()
        api = API_COMPAT_CHOICES[w["api"].get_selected()]
        env_var = w["env"].get_text().strip()
        key = w["key"].get_text().strip()

        if not (provider and raw_id and base and env_var):
            self._show_toast("Provider id, base URL, model id and key variable are all required")
            return
        if not re.match(r"^[A-Za-z0-9_.\-]+$", provider):
            self._show_toast("Provider id: letters, numbers, dot, dash and underscore only")
            return
        if not GatewayEnv._VALID_NAME.match(env_var):
            self._show_toast("Key variable must look like MOONSHOT_API_KEY")
            return
        if not base.startswith(("http://", "https://")):
            self._show_toast("Base URL must start with http:// or https://")
            return
        prefixed_id = f"{provider}/{raw_id}"
        if prefixed_id in self.config.get_model_catalog():
            self._show_toast(f"{prefixed_id} is already in the catalog")
            return

        def as_float(row, default):
            try:
                return float(row.get_text().strip())
            except (TypeError, ValueError):
                return default

        def as_int(row, default):
            try:
                return int(float(row.get_text().strip()))
            except (TypeError, ValueError):
                return default

        # An existing provider block keeps its own apiKey — don't imply we'd
        # rewire a live connection to this variable.
        existing_provider = self.config.data.get("models", {}).get("providers", {}).get(provider)
        if key:
            # Held until Save, so Cancel-after-Add can't leave a secret on disk
            # for a model the user never committed.
            self._pending_env_keys[env_var] = key
        have_key = bool(key) or bool(GatewayEnv.read_key(env_var)) or bool(existing_provider)

        self.config.add_api_model(
            provider=provider, raw_id=raw_id, base_url=base, api=api,
            env_var=env_var, reasoning=w["reason"].get_active(),
            vision=w["vision"].get_active(),
            context_window=as_int(w["ctx"], 128000),
            max_tokens=as_int(w["max"], 8192),
            cost={"input": as_float(w["cost_in"], 0), "output": as_float(w["cost_out"], 0),
                  "cacheRead": as_float(w["cost_cr"], 0), "cacheWrite": as_float(w["cost_cw"], 0)},
            timeout_seconds=API_PROVIDER_PRESETS[w["preset"].get_selected()]["timeout"],
        )
        self._refresh_after_catalog_change()
        if have_key:
            self._show_toast(f"Added {prefixed_id} — press Save to apply")
        else:
            self._show_toast(f"Added {prefixed_id} — no {env_var} set yet, "
                             f"so it won't answer until you add the key")

    # Common windows, in tokens. 128k is the default a local model gets: it is
    # what the LM Studio builds on this box actually serve, and over-claiming is
    # what broke a local agent here (a 500,000 window against an engine that
    # could not serve it — every long turn rejected, nothing able to correct it).
    _CONTEXT_PRESETS = [
        ("32k", 32768), ("64k", 65536), ("128k (default)", 131072),
        ("256k", 262144), ("512k", 524288), ("1M", 1048576),
    ]

    def _on_edit_context(self, btn, model_id, current):
        """Edit a model's advertised context window."""
        dialog = Adw.AlertDialog(
            heading="Context window",
            body=(f"{friendly_name(model_id)}\n{model_id}\n\n"
                  "How much context this model is advertised as accepting. The "
                  "runtime fills up to this number, so a value larger than the "
                  "engine really serves makes long turns fail outright."),
        )
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8,
                      margin_top=8, margin_bottom=8)
        labels = [p[0] for p in self._CONTEXT_PRESETS] + ["Custom…"]
        combo = Gtk.DropDown.new_from_strings(labels)
        # Preselect the current value when it matches a preset, else "Custom…".
        selected = next((i for i, (_, v) in enumerate(self._CONTEXT_PRESETS)
                         if v == current), len(self._CONTEXT_PRESETS))
        combo.set_selected(selected)
        entry = Gtk.Entry(placeholder_text="tokens, e.g. 131072",
                          text=str(current or 131072))
        entry.set_visible(selected == len(self._CONTEXT_PRESETS))

        def _on_combo(*_a):
            entry.set_visible(combo.get_selected() == len(self._CONTEXT_PRESETS))
        combo.connect("notify::selected", _on_combo)
        box.append(combo)
        box.append(entry)
        dialog.set_extra_child(box)
        dialog.add_response("cancel", "Cancel")
        dialog.add_response("save", "Save")
        dialog.set_default_response("save")
        dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)

        def _resp(_d, response):
            if response != "save":
                return
            idx = combo.get_selected()
            if idx < len(self._CONTEXT_PRESETS):
                tokens = self._CONTEXT_PRESETS[idx][1]
            else:
                try:
                    tokens = int(entry.get_text().strip().replace(",", "").replace("_", ""))
                except ValueError:
                    self._show_toast("Context window must be a whole number of tokens")
                    return
            if tokens < 1024:
                self._show_toast("That is too small to be a context window")
                return
            if self.config.set_provider_model_context(model_id, tokens):
                try:
                    self.config.save()
                except Exception as e:
                    self._show_toast(f"Could not save: {e}")
                    return
                # openclaw.json is hot-reloaded by the gateway, so no restart.
                self._show_toast(f"{friendly_name(model_id)} context set to "
                                 f"{self._fmt_tokens(tokens)}")
                self._rebuild_catalog_rows()
            else:
                self._show_toast("Unchanged")

        dialog.connect("response", _resp)
        dialog.present(self)

    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._secondary_is_dirty():
            self._show_toast("Catalog updated — your pending selections are kept")
        # Offer every real model — catalog entries PLUS everything the local/
        # remote providers actually advertise — so local models that were added
        # to a provider but never to the (often stale) catalog are still
        # selectable. Without this the per-agent/default/bulk combos only listed
        # the catalog, which was DeepSeek-heavy, so agents kept defaulting to the
        # DeepSeek API even though the vLLM/buildpc models existed.
        self._model_ids = self._all_known_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()
