#!/usr/bin/env python3
"""
InsAIts Live Web Dashboard
===========================
Zero-dependency Python web server that serves a real-time security
monitoring dashboard.  Reads the JSONL audit log written by the
Claude Code hook and renders live metrics in the browser.

Usage:
    python insaits_web_dashboard.py                          # default port 5001
    python insaits_web_dashboard.py --port 8080              # custom port
    python insaits_web_dashboard.py --log path/to/audit.jsonl

Open http://localhost:5001 in your browser.

Author: Cristi Bogdan -- Steddy Nova SRL / YuyAI
"""

from __future__ import annotations

import json
import os
import shutil
import sys  # noqa: F401
import time
import math
import argparse
import threading
import uuid  # noqa: F401
from collections import defaultdict
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DEFAULT_PORT: int = 5001
DEFAULT_LOG: str = os.environ.get(
    "INSAITS_AUDIT_LOG", ".insaits_audit_session.jsonl"
)
VERSION: str = "3.4.0"
SESSIONS_DIR: str = ".insaits_sessions"

# ---------------------------------------------------------------------------
# OWASP / CVE data -- single source of truth in services.owasp_mappings
# ---------------------------------------------------------------------------
def _load_owasp_and_reader():
    """Load OWASP data + audit reader without triggering heavy SDK init."""
    import importlib.util as _ilu
    try:
        # Method 1: API repo (services/ on sys.path)
        from services.owasp_mappings import (  # type: ignore[import-not-found]
            OWASP_CODES, OWASP_EXPLAINER, OWASP_DESCRIPTIONS,
            ANOMALY_TO_OWASP, ANOMALY_TO_CVES,
            CATEGORY_MAP, CATEGORY_COLORS, CATEGORY_ORDER,
        )
        from services.audit_reader import read_audit_entries  # type: ignore[import-not-found]
        return (OWASP_CODES, OWASP_EXPLAINER, OWASP_DESCRIPTIONS,
                ANOMALY_TO_OWASP, ANOMALY_TO_CVES,
                CATEGORY_MAP, CATEGORY_COLORS, CATEGORY_ORDER,
                read_audit_entries)
    except ImportError:
        pass
    # Method 2: SDK package — load via importlib to avoid triggering __init__.py
    _data_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
    _owasp_path = os.path.join(_data_dir, "owasp_mappings.py")
    _reader_path = os.path.join(_data_dir, "audit_reader.py")
    if os.path.exists(_owasp_path):
        _spec = _ilu.spec_from_file_location("_owasp", _owasp_path)
        _mod = _ilu.module_from_spec(_spec)
        _spec.loader.exec_module(_mod)
        _spec2 = _ilu.spec_from_file_location("_reader", _reader_path)
        _mod2 = _ilu.module_from_spec(_spec2)
        _spec2.loader.exec_module(_mod2)
        return (_mod.OWASP_CODES, _mod.OWASP_EXPLAINER, _mod.OWASP_DESCRIPTIONS,
                _mod.ANOMALY_TO_OWASP, _mod.ANOMALY_TO_CVES,
                _mod.CATEGORY_MAP, _mod.CATEGORY_COLORS, _mod.CATEGORY_ORDER,
                _mod2.read_audit_entries)
    raise ImportError("Cannot find OWASP mappings — is insa-its installed?")

_result = _load_owasp_and_reader()
OWASP_CODES = _result[0]
_OWASP_EXPLAINER = _result[1]
_OWASP_DESCRIPTIONS = _result[2]
_ANOMALY_TO_OWASP = _result[3]
_ANOMALY_TO_CVES = _result[4]
CATEGORY_MAP = _result[5]
CATEGORY_COLORS = _result[6]
CATEGORY_ORDER = _result[7]
_read_audit_entries_shared = _result[8]
del _result


# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Message History (Phase 2C)
# ---------------------------------------------------------------------------
def _load_message_history() -> List[Dict[str, Any]]:
    """Load message history entries from the sessions directory."""
    sessions_dir = Path(".insaits_sessions")
    if not sessions_dir.exists():
        return []
    entries: List[Dict[str, Any]] = []
    try:
        # Find the most recent session file
        files = sorted(
            sessions_dir.glob(".insaits_message_history_*.jsonl"),
            key=lambda f: f.stat().st_mtime,
            reverse=True,
        )
        if not files:
            return []
        # Read from the most recent session
        with open(files[0], "r", encoding="utf-8") as fh:
            for line in fh:
                line = line.strip()
                if not line:
                    continue
                try:
                    entries.append(json.loads(line))
                except json.JSONDecodeError:
                    continue
    except (OSError, PermissionError):
        pass
    # Return newest first, max 200
    entries.reverse()
    return entries[:200]


# ---------------------------------------------------------------------------
# Task Anchor data (Phase 3B)
# ---------------------------------------------------------------------------
def _load_task_anchor_data() -> Dict[str, Any]:
    """Load Task Anchor system data for the dashboard panel.

    Reads user_anchor.md and memory_check.md from the SDK prompts directory,
    and scans the audit log for TASK_ANCHOR entries.
    """
    result: Dict[str, Any] = {
        "user_anchor_text": "",
        "memory_check_text": "",
        "memory_check_enabled": False,
        "injection_count": 0,
        "last_injection_ts": None,
        "recent_injections": [],
    }

    # Locate SDK prompts directory (try several relative paths)
    base = Path(__file__).resolve().parent
    prompts_dir = base / "InsAIts-SDK" / "insa_its" / "prompts"
    if not prompts_dir.exists():
        prompts_dir = base / "insa_its" / "prompts"

    # Read user_anchor.md (fall back to .example.md if empty/missing)
    anchor_path = prompts_dir / "user_anchor.md"
    anchor_example = prompts_dir / "user_anchor.example.md"
    try:
        if anchor_path.exists() and anchor_path.stat().st_size > 0:
            result["user_anchor_text"] = anchor_path.read_text(encoding="utf-8")
        elif anchor_example.exists():
            result["user_anchor_text"] = (
                "(using example — copy user_anchor.example.md to user_anchor.md to customize)\n\n"
                + anchor_example.read_text(encoding="utf-8")
            )
    except (OSError, PermissionError):
        result["user_anchor_text"] = "(could not read user_anchor.md)"

    # Read memory_check.md (fall back to .example.md if empty/missing)
    memory_path = prompts_dir / "memory_check.md"
    memory_example = prompts_dir / "memory_check.example.md"
    try:
        if memory_path.exists() and memory_path.stat().st_size > 0:
            result["memory_check_text"] = memory_path.read_text(encoding="utf-8")
            result["memory_check_enabled"] = True
        elif memory_example.exists():
            result["memory_check_text"] = memory_example.read_text(encoding="utf-8")
            result["memory_check_enabled"] = False
    except (OSError, PermissionError):
        pass

    # Scan audit log for TASK_ANCHOR entries
    log_path = Path(
        os.environ.get("INSAITS_AUDIT_LOG", ".insaits_audit_session.jsonl")
    )
    if log_path.exists():
        try:
            with open(log_path, "r", encoding="utf-8") as fh:
                for line in fh:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        entry = json.loads(line)
                    except json.JSONDecodeError:
                        continue
                    if entry.get("tool") not in ("TASK_ANCHOR", "ANCHOR_INJECTION"):
                        continue
                    result["injection_count"] += 1
                    ts = entry.get("ts", "")
                    result["last_injection_ts"] = ts
                    # Extract anchor length from description
                    # Legacy format: "Anchor length: 123 chars."
                    # 3-tier format: "...len=123"
                    anchor_len = 0
                    for anom in entry.get("anomalies", []):
                        desc = anom.get("description", "")
                        if "Anchor length:" in desc:
                            try:
                                anchor_len = int(
                                    desc.split("Anchor length:")[1]
                                    .strip()
                                    .split()[0]
                                )
                            except (ValueError, IndexError):
                                pass
                        elif "len=" in desc:
                            try:
                                anchor_len = int(
                                    desc.split("len=")[1]
                                    .strip()
                                    .split()[0]
                                )
                            except (ValueError, IndexError):
                                pass
                    # Extract tool name, trigger type, and intervention text
                    tool_name = ""
                    anchor_type = ""
                    intervention_text = ""
                    for anom in entry.get("anomalies", []):
                        desc = anom.get("description", "")
                        # Extract tool= (word only, stop at space/end)
                        # Old format: "(tool=Bash). Anchor length: 1594"
                        # New format: "trigger=session_start tool=Bash len=1712"
                        if "tool=" in desc:
                            try:
                                raw = desc.split("tool=")[1].split()[0]
                                tool_name = raw.strip(").,:;")
                            except (IndexError,):
                                pass
                        # Extract trigger type (new format only)
                        if "trigger=" in desc:
                            try:
                                anchor_type = (
                                    desc.split("trigger=")[1]
                                    .split()[0]
                                    .strip()
                                )
                            except (IndexError,):
                                pass
                        # Old format: "task boundary" = session_start equivalent
                        elif "task boundary" in desc and not anchor_type:
                            anchor_type = "task_boundary"
                        # Extract intervention text (the actual anchor content)
                        # Only available in new collector format (post-daemon)
                        if anom.get("intervention"):
                            intervention_text = anom["intervention"]
                    # Extract layers (e.g. "opus+agent+subagent")
                    layers = ""
                    for anom in entry.get("anomalies", []):
                        desc = anom.get("description", "")
                        if "layers=[" in desc:
                            try:
                                layers = desc.split("layers=[")[1].split("]")[0]
                            except (IndexError,):
                                pass
                    result["recent_injections"].append({
                        "ts": ts,
                        "tool": tool_name or entry.get("model", "unknown"),
                        "anchor_length": anchor_len,
                        "anchor_type": anchor_type,
                        "layers": layers,
                        "text": intervention_text[:300] if intervention_text else "",
                    })
        except (OSError, PermissionError):
            pass

    # Keep only most recent 50 injections, newest first
    result["recent_injections"] = list(reversed(result["recent_injections"][-50:]))
    return result


# Data aggregation
# ---------------------------------------------------------------------------
def read_audit_log(log_path: str) -> List[Dict[str, Any]]:
    """Read all entries from the JSONL audit log."""
    return _read_audit_entries_shared(log_path)


def _get_session_history() -> List[Dict[str, Any]]:
    """Scan past session files and return per-session summaries for the
    session comparison widget (Phase 2.7). Returns last 5 sessions."""
    sessions_path = Path(SESSIONS_DIR)
    if not sessions_path.is_dir():
        return []
    session_files = sorted(sessions_path.glob("*.jsonl"))[-5:]
    history: List[Dict[str, Any]] = []
    for sf in session_files:
        try:
            lines = sf.read_text(encoding="utf-8", errors="replace").strip().splitlines()
            entries_list = []
            for line in lines:
                try:
                    entries_list.append(json.loads(line))
                except json.JSONDecodeError:
                    continue
            total = len(entries_list)
            anomaly_msgs = sum(1 for e in entries_list if e.get("anomalies"))
            total_anomalies = sum(len(e.get("anomalies", [])) for e in entries_list)
            rate = round((anomaly_msgs / max(total, 1)) * 100, 1)
            critical = sum(
                1 for e in entries_list
                for a in e.get("anomalies", [])
                if a.get("severity") == "CRITICAL"
            )
            ts_start = entries_list[0].get("ts", "") if entries_list else ""
            history.append({
                "file": sf.name,
                "date": sf.name[:8],  # YYYYMMDD
                "messages": total,
                "anomalies": total_anomalies,
                "anomaly_rate": rate,
                "critical": critical,
                "ts_start": ts_start,
            })
        except (IOError, OSError):
            continue
    return history


# Agent colors for fingerprint timeline (match Agent Intelligence panel order)
_FP_AGENT_COLORS: List[str] = [
    "#00ff88", "#4fc3f7", "#ce93d8", "#ffb74d", "#ef5350",
    "#aed581", "#80deea", "#f48fb1", "#ffe082", "#a5d6a7",
]


def _build_fingerprint_timeline(
    agent_fp_timelines: Dict[str, List[Dict[str, Any]]],
    agent_data: Dict[str, Dict[str, Any]],
) -> Dict[str, Any]:
    """Build the fingerprint timeline payload for the Canvas chart.

    Returns a dict with ``agents`` mapping each agent name to its color
    and time-series points (score + optional anomaly event name).
    """
    agents_out: Dict[str, Dict[str, Any]] = {}
    color_idx: int = 0
    for agent_name in sorted(agent_fp_timelines.keys()):
        if agent_name == "system":
            continue
        points = agent_fp_timelines[agent_name]
        if not points:
            continue
        color = _FP_AGENT_COLORS[color_idx % len(_FP_AGENT_COLORS)]
        color_idx += 1
        agents_out[agent_name] = {
            "color": color,
            "points": points,
        }
    return {"agents": agents_out}


def aggregate(entries: List[Dict[str, Any]]) -> Dict[str, Any]:
    """Aggregate audit log entries into dashboard data."""
    # Exclude insaits_guardian from message counts — it's monitoring metadata
    guardian_messages: int = sum(
        1 for e in entries if e.get("model") == "insaits_guardian"
    )
    total_messages: int = len(entries) - guardian_messages
    subagent_messages: int = sum(
        1 for e in entries
        if (e.get("extracted", False) or bool(e.get("parent_agent_id")))
        and e.get("model") != "insaits_guardian"
    )
    main_messages: int = total_messages - subagent_messages
    # Session timing: first and last entry timestamps
    session_start: str = ""
    session_latest: str = ""
    claude_session_id: str = ""
    if entries:
        session_start = entries[0].get("ts", "")
        session_latest = entries[-1].get("ts", "")
        # Extract Claude Code session_id from first entry that has one
        for _e in entries:
            _sid = _e.get("session_id", "")
            if _sid:
                claude_session_id = _sid
                break
    total_anomalies: int = 0
    resolved_count: int = 0
    critical_count: int = 0
    high_count: int = 0

    agents: Dict[str, Dict[str, Any]] = defaultdict(
        lambda: {"messages": 0, "anomalies": 0, "msgs_with_anomalies": 0, "resolved": 0}
    )
    by_type: Dict[str, int] = defaultdict(int)
    by_category: Dict[str, int] = defaultdict(int)
    feed: List[Dict[str, Any]] = []
    tool_calls: List[Dict[str, Any]] = []
    rate_history: List[float] = []
    # OWASP event tracking
    owasp_counts: Dict[str, int] = defaultdict(int)
    owasp_last_seen: Dict[str, str] = {}
    owasp_severity: Dict[str, str] = {}

    # Sliding window for rate history
    window_size: int = 5
    window_anomalies: int = 0
    window_total: int = 0

    # Behavioral fingerprint timeline: per-event scoring per agent
    # Start at 1000, anomalies reduce by severity, clean calls recover +10
    _FP_SEV_PENALTY: Dict[str, int] = {
        "CRITICAL": 200, "HIGH": 100, "MEDIUM": 50, "LOW": 20, "INFO": 0,
    }
    agent_fp_scores: Dict[str, int] = {}
    agent_fp_timelines: Dict[str, List[Dict[str, Any]]] = defaultdict(list)

    for i, entry in enumerate(entries):
        model: str = entry.get("model", "unknown")
        anomalies: List[Dict[str, str]] = entry.get("anomalies", [])
        is_resolved: bool = entry.get("resolved", False)

        # Filter out informational/metadata entries that are not real anomalies.
        # These go to their respective panels but must not pollute anomaly
        # stats, scores, or the live feed.
        _NON_ANOMALY_TYPES = frozenset({
            "subagent_tool_call", "anchor_injected", "task_anchor_injected",
            "response_awareness", "subagent_spawned", "acm_compression_cycle",
            "replay_detected", "session_continued", "subagent_complete",
            "snapshot_saved", "retry_budget_warning", "retry_budget_exceeded",
            "replay_rate_high", "loop_detected", "monitoring_gap_check",
            "session_resumed",
            # Guardian meta-events — monitoring infrastructure, not real agent anomalies
            "retry_storm", "circuit_breaker", "storm_block",
            "emergency_save", "emergency_shutdown", "storm_block_released",
            "context_compression_detected",
        })
        # insaits_guardian entries are monitoring metadata — count messages
        # but exclude from anomaly stats, threat score, and feed
        is_guardian: bool = model == "insaits_guardian"
        is_subagent_entry: bool = (
            entry.get("extracted", False)
            or bool(entry.get("parent_agent_id"))
        )
        real_anomalies: List[Dict[str, str]] = [
            a for a in anomalies
            if a.get("type") not in _NON_ANOMALY_TYPES
        ]
        # Guardian entries: track internally but exclude from global stats
        if is_guardian:
            agents[model]["messages"] += 1
            agents[model]["anomalies"] += len(real_anomalies)
            if len(real_anomalies) > 0:
                agents[model]["msgs_with_anomalies"] += 1
            if is_resolved and len(real_anomalies) > 0:
                agents[model]["resolved"] += 1
            continue  # skip global stats, feed, fingerprint, OWASP
        n_anomalies: int = len(real_anomalies)

        total_anomalies += n_anomalies
        if is_resolved:
            resolved_count += 1

        # Per-agent stats — subagent entries still count messages
        # but only real anomalies affect scores
        agents[model]["messages"] += 1
        agents[model]["anomalies"] += n_anomalies
        if n_anomalies > 0:
            agents[model]["msgs_with_anomalies"] += 1

        # Behavioral fingerprint timeline — per-event scoring
        if model != "system":
            if model not in agent_fp_scores:
                agent_fp_scores[model] = 1000
                # Record initial point
                agent_fp_timelines[model].append({
                    "ts": entry.get("ts", ""),
                    "score": 1000,
                    "event": None,
                })
            if n_anomalies > 0:
                # Apply penalty for each real anomaly (not subagent_tool_call)
                for a in real_anomalies:
                    sev: str = a.get("severity", "MEDIUM")
                    penalty: int = _FP_SEV_PENALTY.get(sev, 50)
                    agent_fp_scores[model] = max(
                        0, agent_fp_scores[model] - penalty
                    )
                    agent_fp_timelines[model].append({
                        "ts": entry.get("ts", ""),
                        "score": agent_fp_scores[model],
                        "event": a.get("type", "unknown"),
                        "severity": sev,
                    })
            else:
                # Clean call: recover +10
                prev_score = agent_fp_scores[model]
                agent_fp_scores[model] = min(
                    1000, agent_fp_scores[model] + 10
                )
                # Only record a point if score actually changed or
                # this is a sampling point (every 5th clean call)
                new_score = agent_fp_scores[model]
                msg_count = agents[model]["messages"]
                if new_score != prev_score or msg_count % 5 == 0:
                    agent_fp_timelines[model].append({
                        "ts": entry.get("ts", ""),
                        "score": new_score,
                        "event": None,
                    })
        if is_resolved and n_anomalies > 0:
            agents[model]["resolved"] += 1

        # Per-type and per-category (real anomalies only — subagent_tool_call excluded)
        for a in real_anomalies:
            atype: str = a.get("type", "unknown")
            sev: str = a.get("severity", "MEDIUM")
            by_type[atype] += 1
            category: str = CATEGORY_MAP.get(atype.lower(), "Communication")
            by_category[category] += 1
            if sev == "CRITICAL":
                critical_count += 1
            elif sev == "HIGH":
                high_count += 1

            feed.append({
                "ts": entry.get("ts", ""),
                "type": atype,
                "severity": sev,
                "agent": model,
                "resolved": is_resolved,
                "description": a.get("description", ""),
            })

            # OWASP event tracking
            atype_lower = atype.lower()
            if atype_lower in OWASP_CODES:
                owasp_counts[atype_lower] += 1
                owasp_last_seen[atype_lower] = entry.get("ts", "")
                # Keep highest severity seen
                sev_order = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}
                existing_sev = owasp_severity.get(atype_lower, "LOW")
                if sev_order.get(sev, 0) > sev_order.get(existing_sev, 0):
                    owasp_severity[atype_lower] = sev

        # Tool call tracking (all entries, not just anomalies)
        tc_entry: Dict[str, Any] = {
            "ts": entry.get("ts", ""),
            "agent": model,
            "tool": entry.get("tool", "unknown"),
            "has_anomaly": n_anomalies > 0,
            "anomaly_count": n_anomalies,
            "msg_id": entry.get("msg_id", ""),
            "resolved": is_resolved,
            "is_subagent": is_subagent_entry,
            "anomalies": [
                {
                    "type": a.get("type", "unknown"),
                    "severity": a.get("severity", "MEDIUM"),
                    "description": a.get("description", ""),
                    "intervention": a.get("intervention", ""),
                }
                for a in real_anomalies
            ],
        }
        # Subagent attribution fields
        if entry.get("parent_agent_id"):
            tc_entry["parent_agent_id"] = entry["parent_agent_id"]
        if entry.get("subagent_type"):
            tc_entry["subagent_type"] = entry["subagent_type"]
        if entry.get("subagent_task"):
            tc_entry["subagent_task"] = entry["subagent_task"][:80]
        tool_calls.append(tc_entry)

        # Rate history (rolling window)
        window_total += 1
        window_anomalies += 1 if n_anomalies > 0 else 0
        if window_total >= window_size:
            rate_val = (window_anomalies / window_total) * 100
            rate_history.append(round(rate_val, 1))
            window_total = 0
            window_anomalies = 0

    # Compute scores — use main_messages (exclude subagent metadata entries)
    # so extracted subagent calls don't dilute the anomaly rate denominator
    effective_messages: int = max(main_messages, 1)
    anomaly_rate: float = (
        (total_anomalies / effective_messages) * 100
    )
    threat_score: int = max(0, min(1000, int(
        (100 - anomaly_rate * 1.8) * 10
    )))

    # Blast radius: weighted average of "% messages affected" across agents.
    # Uses msgs_with_anomalies (not raw anomaly count) so one message
    # with 5 anomalies counts as 1 affected message, not 500%.
    # Agents with <5 messages have their weight capped (statistically unreliable).
    MIN_RELIABLE_MESSAGES: int = 5
    weighted_sum: float = 0.0
    total_weight: float = 0.0
    for _name, stats in agents.items():
        # Skip 'system' — it's the monitor generating alerts, not a real agent
        if _name == "system":
            continue
        a_msgs: int = stats["messages"]
        a_affected: int = stats["msgs_with_anomalies"]
        # Rate = % of messages that had at least one anomaly (0-100)
        a_rate_pct: float = (a_affected / max(a_msgs, 1)) * 100
        # Weight = message count, but cap unreliable agents at 2
        weight: float = float(min(a_msgs, MIN_RELIABLE_MESSAGES)) if a_msgs < MIN_RELIABLE_MESSAGES else float(a_msgs)
        weighted_sum += a_rate_pct * weight
        total_weight += weight
    blast_radius_pct: float = weighted_sum / max(total_weight, 1.0)
    blast_radius: int = max(0, min(100, int(round(blast_radius_pct))))

    intervention_rate: int = max(0, min(1000, int(
        resolved_count / effective_messages * 1000
    )))

    # Per-agent scores
    # Use msgs_with_anomalies for rate calculation so multi-anomaly messages
    # don't inflate scores. Display total anomaly count separately.
    agent_data: Dict[str, Dict[str, Any]] = {}
    for name, stats in agents.items():
        a_affected_rate: float = (
            stats["msgs_with_anomalies"] / max(stats["messages"], 1) * 100
        )
        a_score: int = max(0, min(1000, int(1000 - a_affected_rate * 10)))
        if a_affected_rate > 50:
            a_status = "CRITICAL"
        elif a_affected_rate > 20:
            a_status = "WARNING"
        else:
            a_status = "HEALTHY"
        agent_data[name] = {
            "messages": stats["messages"],
            "anomalies": stats["anomalies"],
            "resolved": stats["resolved"],
            "rate": round(a_affected_rate, 1),
            "status": a_status,
            "score": a_score,
        }

    # Build intervention log: entries with anomalies severity >= WARNING
    # that contain intervention text. Track what tool the agent used next.
    intervention_log: List[Dict[str, Any]] = []
    _SEV_INTERVENTION_MIN = {"CRITICAL": True, "HIGH": True, "WARNING": True, "MEDIUM": True}
    for idx, entry in enumerate(entries):
        entry_anomalies_list: List[Dict[str, str]] = entry.get("anomalies", [])
        for a in entry_anomalies_list:
            sev_val: str = a.get("severity", "LOW")
            # Use intervention text if present, otherwise fall back to description
            intervention_text: str = a.get("intervention", "") or a.get("description", "")
            atype_check: str = a.get("type", "")
            # Skip non-anomaly informational events
            if atype_check in (
                "subagent_tool_call", "anchor_injected", "task_anchor_injected",
                "response_awareness", "subagent_spawned", "acm_compression_cycle",
            ):
                continue
            if sev_val in _SEV_INTERVENTION_MIN and intervention_text:
                next_tool: str = "--"
                if idx + 1 < len(entries):
                    next_tool = entries[idx + 1].get("tool", "unknown")
                intervention_log.append({
                    "ts": entry.get("ts", ""),
                    "anomaly_type": a.get("type", "unknown"),
                    "severity": sev_val,
                    "intervention": intervention_text,
                    "next_tool": next_tool,
                    "agent": entry.get("model", "unknown"),
                    "remediation": "",  # filled after _REMEDIATION_MAP
                })
    intervention_log.sort(key=lambda x: x.get("ts", ""), reverse=True)
    intervention_log = intervention_log[:50]

    # Remediation text mapping for drill-down panel
    _REMEDIATION_MAP: Dict[str, str] = {
        "blank_response": "Ensure the agent provides a substantive response. Check if the prompt is too vague or if context was lost.",
        "truncated_output": "The response was cut short. Consider breaking the task into smaller parts or increasing output limits.",
        "semantic_drift": "The agent output has diverged from the original topic. Re-anchor with explicit context or restate the goal.",
        "context_collapse": "The agent lost track of structured data. Provide data in smaller chunks or use explicit references.",
        "hallucination_chain": "The agent is building on unverified claims. Request source citations and cross-check outputs.",
        "jargon_drift": "Unusual terminology detected. Verify the agent is using domain-correct language.",
        "prompt_injection": "Potential prompt injection detected. Review the input for adversarial content.",
        "tool_poisoning": "Tool description may have been tampered with. Verify tool metadata against known-good baseline.",
        "credential_exposure": "Credentials or secrets may be exposed. Rotate affected keys immediately.",
        "information_flow_violation": "Data is flowing to unauthorized destinations. Review access controls.",
        "incomplete_code": "Generated code is incomplete. Request the agent to finish the implementation.",
        "repetition_loop": "The agent is repeating itself. Reset context or rephrase the request.",
        "confidence_collapse": "The agent confidence has dropped significantly. Provide clearer instructions.",
        "behavioral_fingerprint": "Agent behavior does not match expected patterns. Investigate possible model swap.",
    }

    # Enrich intervention log with remediation text
    for il_item in intervention_log:
        atype = (il_item.get("anomaly_type", "") or "").lower()
        il_item["remediation"] = _REMEDIATION_MAP.get(
            atype, "Review the anomaly details and take appropriate action."
        )

    # Sort feed newest first, limit to last 20
    feed.sort(key=lambda x: x.get("ts", ""), reverse=True)
    feed = feed[:20]

    # Enrich feed entries with drill-down data
    for f_item in feed:
        atype_key = (f_item.get("type", "") or "").lower()
        f_item["remediation"] = _REMEDIATION_MAP.get(atype_key, "Review the anomaly details and take appropriate action.")
        owasp_code = OWASP_CODES.get(atype_key, "")
        f_item["owasp_code"] = owasp_code
        f_item["owasp_desc"] = _OWASP_DESCRIPTIONS.get(owasp_code, "")
        f_item["owasp_ids"] = _ANOMALY_TO_OWASP.get(atype_key, [owasp_code] if owasp_code else [])
        f_item["cve_refs"] = _ANOMALY_TO_CVES.get(atype_key, [])
        explainer = _OWASP_EXPLAINER.get(owasp_code, {})
        f_item["explainer_what"] = explainer.get("what", "")
        f_item["explainer_risk"] = explainer.get("risk", "")
        f_item["explainer_response"] = explainer.get("response", "")

    # Tool calls: separate main agent vs subagent entries (never mix them)
    tool_calls_main = [tc for tc in tool_calls if not tc.get("is_subagent")]
    subagent_calls_raw = [tc for tc in tool_calls if tc.get("is_subagent") or tc.get("extracted")]
    tool_calls_sorted = sorted(tool_calls_main, key=lambda x: x.get("ts", ""), reverse=True)[:50]
    subagent_calls_sorted = sorted(subagent_calls_raw, key=lambda x: x.get("ts", ""), reverse=True)[:100]

    # OWASP events summary — deduplicated by OWASP code
    # Multiple anomaly types can map to the same code (e.g. rogue_agent,
    # agent_surrender, npc_behavior, blame_shift all → ASI09).  We aggregate
    # counts, take the highest severity, and the most-recent last_seen.
    _SEV_RANK: Dict[str, int] = {"CRITICAL": 4, "HIGH": 3, "WARNING": 2, "MEDIUM": 1, "LOW": 0}
    _owasp_agg: Dict[str, Dict[str, Any]] = {}
    for atype_key in OWASP_CODES:
        code = OWASP_CODES[atype_key]
        count = owasp_counts.get(atype_key, 0)
        last = owasp_last_seen.get(atype_key, "")
        sev = owasp_severity.get(atype_key, "LOW")
        cves = _ANOMALY_TO_CVES.get(atype_key, [])
        if code not in _owasp_agg:
            explainer = _OWASP_EXPLAINER.get(code, {})
            _owasp_agg[code] = {
                "code": code,
                "count": count,
                "last_seen": last,
                "severity": sev,
                "owasp_ids": [code],
                "cve_refs": list(cves),
                "description": _OWASP_DESCRIPTIONS.get(code, ""),
                "explainer_name": explainer.get("name", ""),
                "explainer_what": explainer.get("what", ""),
                "explainer_risk": explainer.get("risk", ""),
                "explainer_response": explainer.get("response", ""),
                "anomaly_types": [atype_key],
            }
        else:
            agg = _owasp_agg[code]
            agg["count"] += count
            agg["anomaly_types"].append(atype_key)
            if last and (not agg["last_seen"] or last > agg["last_seen"]):
                agg["last_seen"] = last
            if _SEV_RANK.get(sev, 0) > _SEV_RANK.get(agg["severity"], 0):
                agg["severity"] = sev
            for c in cves:
                if c not in agg["cve_refs"]:
                    agg["cve_refs"].append(c)
    # Add a fallback "type" field (first anomaly type) for frontend compatibility
    for _entry in _owasp_agg.values():
        _entry["type"] = _entry["anomaly_types"][0]
    owasp_events: List[Dict[str, Any]] = list(_owasp_agg.values())

    # Stealth / checkpoint / human-review counters
    stealth_count: int = 0
    checkpoint_count: int = 0
    human_review_count: int = 0
    for entry in entries:
        if entry.get("stealth"):
            stealth_count += 1
        if entry.get("checkpoint"):
            checkpoint_count += 1
        if entry.get("requires_human_review"):
            human_review_count += 1

    # Session history (needed for score decay and session comparison widget)
    session_history = _get_session_history()

    # Inter-Agent Communication Map (Phase 2.4)
    # Build edges: sequential tool calls between different agents = communication
    agent_edges: Dict[str, int] = defaultdict(int)
    prev_agent: str = ""
    for entry in entries:
        curr_agent: str = entry.get("model", "unknown")
        if prev_agent and curr_agent != prev_agent:
            edge_key = f"{prev_agent}||{curr_agent}"
            agent_edges[edge_key] += 1
        prev_agent = curr_agent

    comm_map_nodes: List[Dict[str, Any]] = []
    comm_map_edges: List[Dict[str, Any]] = []
    seen_nodes: set = set()
    for edge_key, count in sorted(agent_edges.items(), key=lambda x: -x[1])[:20]:
        src, tgt = edge_key.split("||", 1)
        for n in (src, tgt):
            if n not in seen_nodes:
                seen_nodes.add(n)
                a_info = agent_data.get(n, {})
                comm_map_nodes.append({
                    "id": n,
                    "score": a_info.get("score", 500),
                    "status": a_info.get("status", "UNKNOWN"),
                    "messages": a_info.get("messages", 0),
                })
        comm_map_edges.append({"source": src, "target": tgt, "weight": count})

    # Agent score decay (Phase 2.6): for agents seen in past sessions,
    # decay score toward 500 by 10% per session gap
    if session_history:
        for agent_name in list(agent_data.keys()):
            # No historical data yet — just apply a mild decay based on session count
            sessions_since = len(session_history)
            decay_factor = max(0.5, 1.0 - (sessions_since * 0.05))
            base_score = agent_data[agent_name]["score"]
            decayed = int(500 + (base_score - 500) * decay_factor)
            agent_data[agent_name]["decayed_score"] = decayed

    # Category distribution for chart
    distribution: List[Dict[str, Any]] = []
    for cat in CATEGORY_ORDER:
        count = by_category.get(cat, 0)
        distribution.append({
            "type": cat,
            "count": count,
            "color": CATEGORY_COLORS.get(cat, "#6a7a8a"),
        })

    # Load ACM state from file written by hook runner
    acm_state: Dict[str, Any] = {}
    try:
        acm_path = Path(".insaits_acm_state.json")
        if acm_path.exists():
            with open(acm_path, "r", encoding="utf-8") as _af:
                acm_state = json.load(_af)
    except (OSError, json.JSONDecodeError):
        pass

    return {
        "total_messages": total_messages,
        "main_messages": main_messages,
        "subagent_messages": subagent_messages,
        "total_anomalies": total_anomalies,
        "anomaly_rate": round(anomaly_rate, 1),
        "resolved": resolved_count,
        "critical": critical_count,
        "agents": agent_data,
        "feed": feed,
        "by_type": dict(by_type),
        "distribution": distribution,
        "rate_history": rate_history,
        "threat_score": threat_score,
        "blast_radius": blast_radius,
        "intervention_rate": intervention_rate,
        "version": VERSION,
        "ts": datetime.now(timezone.utc).isoformat(),
        "session_start": session_start,
        "session_latest": session_latest,
        "claude_session_id": claude_session_id,
        "tool_calls": tool_calls_sorted,
        "subagent_calls": subagent_calls_sorted,
        "owasp_events": owasp_events,
        "stealth_active": stealth_count > 0,
        "stealth_messages": stealth_count,
        "checkpoint_messages": checkpoint_count,
        "human_review_alerts": human_review_count,
        "intervention_log": intervention_log,
        "session_history": session_history,
        "comm_map": {"nodes": comm_map_nodes, "edges": comm_map_edges},
        "fingerprint_timeline": _build_fingerprint_timeline(
            agent_fp_timelines, agent_data
        ),
        "acm_state": acm_state,
    }


# ---------------------------------------------------------------------------
# Session management
# ---------------------------------------------------------------------------
def ensure_sessions_dir() -> Path:
    """Create the sessions archive directory if it doesn't exist."""
    sessions_path = Path(SESSIONS_DIR)
    sessions_path.mkdir(exist_ok=True)
    return sessions_path


def archive_current_session(log_path: str) -> Dict[str, Any]:
    """Archive current JSONL log to sessions directory, clear main log."""
    sessions_dir = ensure_sessions_dir()
    src = Path(log_path)
    if not src.exists() or src.stat().st_size == 0:
        return {"success": False, "error": "No active session to archive"}

    entries = read_audit_log(log_path)
    ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
    archive_name = f"{ts_str}.jsonl"
    dest = sessions_dir / archive_name
    shutil.copy2(str(src), str(dest))

    # Clear the main log
    with open(src, "w", encoding="utf-8") as fh:
        fh.write("")

    return {
        "success": True,
        "filename": archive_name,
        "message_count": len(entries),
        "timestamp": ts_str,
    }


def list_sessions(log_path: str) -> List[Dict[str, Any]]:
    """List current + archived sessions with metadata."""
    sessions: List[Dict[str, Any]] = []

    # Current session
    current_entries = read_audit_log(log_path)
    anomaly_count = sum(len(e.get("anomalies", [])) for e in current_entries)
    sessions.append({
        "filename": "__current__",
        "label": "Current Session",
        "message_count": len(current_entries),
        "anomaly_count": anomaly_count,
        "timestamp": datetime.now().isoformat(),
        "is_current": True,
    })

    # Archived sessions
    sessions_dir = ensure_sessions_dir()
    for f in sorted(sessions_dir.glob("*.jsonl"), reverse=True):
        archived_entries = read_audit_log(str(f))
        a_anomalies = sum(len(e.get("anomalies", [])) for e in archived_entries)
        # Parse timestamp from filename
        stem = f.stem  # e.g. "20260312_143000"
        try:
            ts = datetime.strptime(stem, "%Y%m%d_%H%M%S")
            label = ts.strftime("%Y-%m-%d %H:%M:%S")
        except ValueError:
            label = stem
        sessions.append({
            "filename": f.name,
            "label": label,
            "message_count": len(archived_entries),
            "anomaly_count": a_anomalies,
            "timestamp": label,
            "is_current": False,
        })

    return sessions


def delete_session(filename: str) -> Dict[str, Any]:
    """Delete an archived session file."""
    if filename == "__current__":
        return {"success": False, "error": "Cannot delete current session"}
    sessions_dir = ensure_sessions_dir()
    target = sessions_dir / filename
    # Prevent path traversal
    if not target.resolve().parent == sessions_dir.resolve():
        return {"success": False, "error": "Invalid filename"}
    if not target.exists():
        return {"success": False, "error": "Session not found"}
    target.unlink()
    return {"success": True, "message": f"Deleted {filename}"}


def load_session_entries(filename: str) -> List[Dict[str, Any]]:
    """Read entries from an archived session file.

    Validates the filename to prevent path traversal attacks before
    reading.  Returns an empty list if the file does not exist or the
    filename is invalid.
    """
    # Reject any path traversal characters
    if ".." in filename or "/" in filename or "\\" in filename:
        return []
    sessions_dir = ensure_sessions_dir()
    target = sessions_dir / filename
    # Secondary traversal guard: resolved path must stay inside sessions dir
    if not target.resolve().parent == sessions_dir.resolve():
        return []
    if not target.exists():
        return []
    return read_audit_log(str(target))


def generate_demo_data() -> Dict[str, Any]:
    """Generate synthetic demo data when no audit log exists."""
    t = time.time()
    base_msgs = 541 + int((t % 3600) / 10)
    base_anoms = 68 + int((t % 3600) / 40)
    rate = round((base_anoms / base_msgs) * 100, 1)
    resolved = int(base_anoms * 0.73)
    criticals = 14
    threat = max(0, min(1000, int((100 - rate * 1.8) * 10)))
    blast = max(0, min(100, int(18 + math.sin(t * 0.08) * 8)))
    intervention = max(0, min(1000, int(730 + math.sin(t * 0.12) * 40)))

    demo_agents = {
        "claude:Edit": {
            "messages": 142, "anomalies": 3, "rate": 2.1,
            "status": "HEALTHY", "score": 979,
        },
        "claude:Bash": {
            "messages": 98, "anomalies": 14, "rate": 14.3,
            "status": "WARNING", "score": 857,
        },
        "claude:Read": {
            "messages": 203, "anomalies": 2, "rate": 1.0,
            "status": "HEALTHY", "score": 990,
        },
        "subagent:Explore": {
            "messages": 67, "anomalies": 8, "rate": 11.9,
            "status": "WARNING", "score": 881,
        },
        "subagent:Plan": {
            "messages": 31, "anomalies": 1, "rate": 3.2,
            "status": "HEALTHY", "score": 968,
        },
    }
    demo_feed = [
        {
            "ts": datetime.now(timezone.utc).isoformat(),
            "type": "truncated_output", "severity": "HIGH",
            "agent": "claude:Bash", "resolved": False,
            "description": "Response appears truncated",
        },
        {
            "ts": datetime.now(timezone.utc).isoformat(),
            "type": "blank_response", "severity": "CRITICAL",
            "agent": "claude:Bash", "resolved": False,
            "description": "Response is 17 chars",
        },
        {
            "ts": datetime.now(timezone.utc).isoformat(),
            "type": "semantic_drift", "severity": "MEDIUM",
            "agent": "subagent:Explore", "resolved": True,
            "description": "Semantic drift detected in output",
        },
    ]
    demo_distribution = [
        {"type": "Security (OWASP MCP)", "count": 23, "color": "#ff2d55"},
        {"type": "Hallucination", "count": 18, "color": "#ffaa00"},
        {"type": "Semantic Drift", "count": 14, "color": "#00d4ff"},
        {"type": "Communication", "count": 9, "color": "#9d4edd"},
        {"type": "Model Integrity", "count": 4, "color": "#00ff88"},
    ]
    rate_history = [
        30 + math.sin(i * 0.4) * 15 + (i % 5) * 2
        for i in range(40)
    ]

    return {
        "total_messages": base_msgs,
        "total_anomalies": base_anoms,
        "anomaly_rate": rate,
        "resolved": resolved,
        "critical": criticals,
        "agents": demo_agents,
        "feed": demo_feed,
        "by_type": {
            "blank_response": 4, "truncated_output": 8,
            "semantic_drift": 6, "hallucination_chain": 3,
        },
        "distribution": demo_distribution,
        "rate_history": rate_history,
        "threat_score": threat,
        "blast_radius": blast,
        "intervention_rate": intervention,
        "version": VERSION,
        "ts": datetime.now(timezone.utc).isoformat(),
        "demo": True,
        "fingerprint_timeline": _build_demo_fingerprint_timeline(),
    }


def _build_demo_fingerprint_timeline() -> Dict[str, Any]:
    """Generate synthetic fingerprint timeline for demo mode."""
    now = datetime.now(timezone.utc)
    agents: Dict[str, Dict[str, Any]] = {}
    demo_profiles = [
        ("claude:Bash", "#00ff88", [
            (0, 1000, None), (10, 1000, None), (20, 900, "truncated_output"),
            (30, 910, None), (40, 920, None), (50, 820, "credential_exposure"),
            (60, 830, None), (70, 840, None), (80, 850, None), (90, 860, None),
        ]),
        ("claude:Edit", "#4fc3f7", [
            (0, 1000, None), (15, 1000, None), (25, 1000, None),
            (35, 950, "context_collapse"), (45, 960, None), (55, 970, None),
            (65, 980, None), (75, 990, None), (85, 1000, None),
        ]),
        ("claude:Read", "#ce93d8", [
            (0, 1000, None), (12, 1000, None), (24, 1000, None),
            (36, 1000, None), (48, 1000, None), (60, 1000, None),
            (72, 1000, None), (84, 1000, None),
        ]),
        ("subagent:Explore", "#ffb74d", [
            (0, 1000, None), (8, 1000, None), (18, 900, "semantic_drift"),
            (28, 850, "hallucination_chain"), (38, 860, None),
            (48, 870, None), (58, 880, None), (68, 890, None),
        ]),
    ]
    for name, color, profile in demo_profiles:
        points = []
        for offset_s, score, event in profile:
            ts = (now.timestamp() - 300 + offset_s)
            iso = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
            pt: Dict[str, Any] = {"ts": iso, "score": score, "event": event}
            if event:
                pt["severity"] = "HIGH" if score < 900 else "MEDIUM"
            points.append(pt)
        agents[name] = {"color": color, "points": points}
    return {"agents": agents}


# ---------------------------------------------------------------------------
# HTML template
# ---------------------------------------------------------------------------
DASHBOARD_HTML: str = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>InsAIts - Runtime AI Security Monitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
  --bg:#060810;--panel:#0a0e1a;--border:#0f1829;--border-hot:#1a2d4a;
  --cyan:#00d4ff;--cyan-dim:#0a4a5a;
  --amber:#ffaa00;--amber-dim:#3d2800;
  --red:#ff2d55;--red-dim:#3d0011;
  --green:#00ff88;--green-dim:#003d20;
  --purple:#9d4edd;
  --gray:#3a4a5a;--gray-light:#6a7a8a;
  --white:#e8f0f8;
}
body{background:var(--bg);font-family:'Space Mono',monospace;color:var(--white);min-height:100vh;-webkit-user-select:text;user-select:text}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes fadeIn{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
@keyframes glow{0%,100%{filter:drop-shadow(0 0 6px currentColor)}50%{filter:drop-shadow(0 0 12px currentColor)}}
::-webkit-scrollbar{width:12px}
::-webkit-scrollbar-track{background:var(--bg)}
::-webkit-scrollbar-thumb{background:var(--gray);border-radius:6px;border:3px solid var(--bg)}
::-webkit-scrollbar-thumb:hover{background:var(--gray-light)}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:16px 20px}
.panel-glow-cyan{border-color:var(--border-hot);box-shadow:0 0 20px rgba(0,212,255,.09)}
.panel-glow-amber{border-color:var(--border-hot);box-shadow:0 0 20px rgba(255,170,0,.09)}
.panel-glow-green{border-color:var(--border-hot);box-shadow:0 0 20px rgba(0,255,136,.09)}
.label{font-family:'Syne',sans-serif;font-size:10px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--gray-light);margin-bottom:6px}
.metric-value{font-size:28px;font-weight:700;line-height:1}
.metric-sub{font-size:10px;color:var(--gray-light);margin-top:2px}
.sev-badge{font-size:8px;font-weight:700;border-radius:3px;padding:2px 6px;letter-spacing:.5px;text-align:center;display:inline-block}
.sev-CRITICAL{color:var(--red);background:var(--red-dim);border:1px solid rgba(255,45,85,.19)}
.sev-HIGH{color:var(--amber);background:var(--amber-dim);border:1px solid rgba(255,170,0,.19)}
.sev-MEDIUM{color:var(--cyan);background:var(--cyan-dim);border:1px solid rgba(0,212,255,.19)}
.sev-LOW{color:var(--green);background:var(--green-dim);border:1px solid rgba(0,255,136,.19)}
.status-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;display:inline-block}
.status-HEALTHY{background:var(--green);box-shadow:0 0 6px var(--green)}
.status-WARNING{background:var(--amber);box-shadow:0 0 6px var(--amber)}
.status-CRITICAL{background:var(--red);box-shadow:0 0 6px var(--red);animation:pulse 1s infinite}
.header{border-bottom:1px solid var(--border);padding:14px 28px;display:flex;align-items:center;justify-content:space-between;background:linear-gradient(90deg,var(--panel) 0%,var(--bg) 100%)}
.header-title{font-family:'Syne',sans-serif;font-size:22px;font-weight:800;color:var(--cyan);letter-spacing:-.5px;text-shadow:0 0 20px rgba(0,212,255,.38)}
.header-sub{font-size:10px;color:var(--gray-light);letter-spacing:2px;text-transform:uppercase}
.live-badge{background:var(--green-dim);border:1px solid rgba(0,255,136,.25);border-radius:4px;padding:2px 8px;font-size:9px;color:var(--green);letter-spacing:1px;font-weight:700;text-transform:uppercase}
/* idle-badge removed — dashboard is either LIVE, PAUSED, or STOPPED */
.stopped-badge{background:var(--red-dim);border:1px solid rgba(255,45,85,.25);border-radius:4px;padding:2px 8px;font-size:9px;color:var(--red);letter-spacing:1px;font-weight:700;text-transform:uppercase}
.demo-badge{background:var(--amber-dim);border:1px solid rgba(255,170,0,.25);border-radius:4px;padding:2px 8px;font-size:9px;color:var(--amber);letter-spacing:1px;font-weight:700;text-transform:uppercase}
.paused-badge{background:var(--amber-dim);border:1px solid rgba(255,170,0,.25);border-radius:4px;padding:2px 8px;font-size:9px;color:var(--amber);letter-spacing:1px;font-weight:700;text-transform:uppercase}
.version-badge{background:var(--cyan-dim);border:1px solid rgba(0,212,255,.25);border-radius:4px;padding:3px 10px;font-size:9px;color:var(--cyan);letter-spacing:1px;text-transform:uppercase}
.content{padding:20px 28px;display:flex;flex-direction:column;gap:16px}
.top-row{display:grid;grid-template-columns:1fr 1fr 1fr 1fr 1fr 1fr;gap:12px}
.mid-row{display:grid;grid-template-columns:1.6fr 1fr;gap:12px}
.bot-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.ring-panel{display:flex;flex-direction:column;align-items:center;gap:8px;padding:20px 12px}
.kpi-panel{display:flex;flex-direction:column;justify-content:space-between}
.kpi-divider{height:1px;background:var(--border);margin:8px 0}
.feed-header,.agent-header{padding:12px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.feed-body{padding:8px 0;max-height:320px;overflow-y:auto}
.feed-row{display:grid;grid-template-columns:72px 74px 1fr 120px 24px;gap:10px;padding:7px 20px;align-items:center;border-bottom:1px solid var(--border)}
.feed-row:first-child{animation:fadeIn .3s ease}
.feed-time{font-size:9px;color:var(--gray-light)}
.feed-type{font-size:10px;color:var(--white);letter-spacing:.3px}
.feed-agent{font-size:9px;color:var(--cyan);text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed-resolved{font-size:11px}
.agent-body{padding:8px 0}
.agent-row{padding:10px 20px;border-bottom:1px solid var(--border)}
.agent-row:last-child{border-bottom:none}
.agent-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.agent-name{font-size:11px;font-weight:700;color:var(--white);flex:1}
.agent-model{font-size:9px;color:var(--gray-light)}
.agent-bottom{display:flex;align-items:center;gap:10px}
.agent-stats{font-size:9px;color:var(--gray-light);min-width:80px}
.score-bar{flex:1;height:4px;background:var(--border);border-radius:2px;overflow:hidden}
.score-fill{height:100%;border-radius:2px}
.score-num{font-size:10px;min-width:32px;text-align:right}
.dist-row{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.dist-label{font-size:10px;color:var(--gray-light);min-width:160px}
.dist-bar{flex:1;height:6px;background:var(--border);border-radius:3px;overflow:hidden}
.dist-fill{height:100%;border-radius:3px}
.dist-count{font-size:10px;min-width:24px;text-align:right}
.cb-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}
.cb-card{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px 12px;display:flex;flex-direction:column;gap:4px}
.cb-top{display:flex;justify-content:space-between;align-items:center}
.cb-name{font-size:10px;font-weight:700;color:var(--white)}
.cb-state{font-size:8px;border-radius:3px;padding:1px 5px;letter-spacing:.5px}
.cb-rate{font-size:9px;color:var(--gray-light)}
.cb-bar{height:3px;background:var(--border);border-radius:2px;overflow:hidden;margin-top:2px}
.cb-fill{height:100%;border-radius:2px}
.footer{display:flex;justify-content:space-between;align-items:center;padding:8px 0 0;border-top:1px solid var(--border)}
.footer-left{font-size:9px;color:var(--gray-light)}
.footer-right{font-size:9px;color:var(--cyan)}
.ring-label{font-family:'Syne',sans-serif;font-size:11px;font-weight:700;color:var(--white);letter-spacing:2px;text-transform:uppercase}
.ring-sublabel{font-size:9px;color:var(--gray-light)}
@media(max-width:1200px){.top-row{grid-template-columns:1fr 1fr 1fr}.mid-row,.bot-row,.extra-row{grid-template-columns:1fr}}
/* Session management */
.session-controls{display:flex;align-items:center;gap:8px}
.session-select{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:3px 8px;font-size:9px;color:var(--white);font-family:'Space Mono',monospace;cursor:pointer;max-width:200px}
.session-select option{background:var(--panel);color:var(--white)}
.btn-new-session{background:rgba(157,78,221,.15);border:1px solid rgba(157,78,221,.25);border-radius:4px;padding:3px 10px;font-size:9px;color:var(--purple);letter-spacing:1px;text-transform:uppercase;cursor:pointer;font-family:'Space Mono',monospace;font-weight:700}
.viewing-badge{background:rgba(157,78,221,.15);border:1px solid rgba(157,78,221,.25);border-radius:4px;padding:2px 8px;font-size:9px;color:var(--purple);letter-spacing:1px;font-weight:700;text-transform:uppercase}
.archived-badge{background:var(--purple);color:#fff;border-radius:4px;padding:2px 8px;font-size:9px;letter-spacing:1px;font-weight:700;text-transform:uppercase}
/* Extra panels row */
.extra-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
/* Tool call inspector */
.tc-body{padding:8px 0;max-height:320px;overflow-y:auto}
.tc-row{display:grid;grid-template-columns:80px 120px 1fr 80px 32px;gap:8px;padding:7px 20px;align-items:center;border-bottom:1px solid var(--border);font-size:10px}
.tc-row:hover{background:rgba(0,212,255,.04)}
.tc-time{font-size:9px;color:var(--gray-light)}
.tc-agent{font-size:10px;color:var(--cyan);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tc-tool{font-size:10px;color:var(--white)}
.tc-anomaly{font-size:9px;text-align:center}
.tc-expand{font-size:10px;color:var(--gray-light);cursor:pointer;text-align:center}
.tc-expand:hover{color:var(--cyan)}
.tc-detail{display:none;grid-column:1/-1;padding:10px 20px 14px;background:rgba(0,0,0,.25);border-bottom:1px solid var(--border);font-size:10px;line-height:1.6}
.tc-detail.tc-detail--open{display:block}
.tc-detail-label{color:var(--gray-light);font-size:9px;text-transform:uppercase;letter-spacing:.5px;margin-top:8px;margin-bottom:2px}
.tc-detail-label:first-child{margin-top:0}
.tc-detail-value{color:var(--white);word-break:break-word;white-space:pre-wrap}
.tc-detail-anomaly{margin:4px 0;padding:6px 10px;border-left:2px solid var(--red);background:rgba(255,45,85,.06);border-radius:0 4px 4px 0}
.tc-detail-anomaly--resolved{border-left-color:var(--green);background:rgba(50,215,75,.06)}
.tc-detail-field{display:flex;gap:8px;align-items:baseline;margin:2px 0}
.tc-detail-field-key{color:var(--gray-light);min-width:80px;font-size:9px}
.tc-detail-field-val{color:var(--white);font-size:10px}
/* OWASP panel */
.owasp-row{display:grid;grid-template-columns:60px 1fr 50px 90px 70px;gap:10px;padding:10px 20px;align-items:start;border-bottom:1px solid var(--border)}
.owasp-row:last-child{border-bottom:none}
.owasp-code{font-size:11px;font-weight:700;color:var(--red);letter-spacing:.5px}
.owasp-type{font-size:10px;color:var(--white)}
.owasp-type-desc{font-size:8px;color:var(--gray-light);margin-top:2px}
.owasp-cves{font-size:8px;color:var(--amber);margin-top:2px;word-break:break-all}
.owasp-count{font-size:11px;font-weight:700;text-align:center}
.owasp-lastseen{font-size:9px;color:var(--gray-light)}
.owasp-row-clickable{cursor:pointer;transition:background .15s}
.owasp-row-clickable:hover{background:rgba(100,210,255,.06) !important}
.owasp-detail{padding:8px 20px 12px 70px;border-bottom:1px solid var(--border);background:rgba(100,210,255,.03)}
.owasp-detail-section{margin-bottom:8px}
.owasp-detail-section:last-child{margin-bottom:0}
.owasp-detail-label{font-size:8px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--gray-light);margin-bottom:2px}
.owasp-detail-text{font-size:10px;color:var(--white);line-height:1.5}
/* Stealth badge */
.stealth-badge{background:rgba(157,78,221,.15);border:1px solid rgba(157,78,221,.3);border-radius:4px;padding:2px 8px;font-size:9px;color:var(--purple);letter-spacing:1px;font-weight:700;text-transform:uppercase}
.stealth-badge-hidden{display:none}
/* Phase 2 panels */
.phase2-row{display:grid;grid-template-columns:1fr;gap:12px}.phase2-row .panel{font-size:12px}.phase2-row .feed-header .label{font-size:12px}.phase2-row .feed-time,.phase2-row .feed-type,.phase2-row .feed-agent{font-size:11px}
/* Intervention log */
.il-body{padding:8px 0;max-height:360px;overflow-y:auto}
.il-row{display:grid;grid-template-columns:80px 60px 140px 1fr 90px;gap:8px;padding:9px 20px;align-items:start;border-bottom:1px solid var(--border);font-size:10px;line-height:1.5;cursor:pointer;transition:background .15s}
.il-row:hover{background:rgba(255,170,0,.08)}
.il-row--critical{border-left:3px solid var(--red);animation:il-pulse 2s ease-in-out infinite}
.il-row--high{border-left:3px solid #ff6e40}
@keyframes il-pulse{0%,100%{background:rgba(255,50,50,.02)}50%{background:rgba(255,50,50,.08)}}
.il-time{font-size:9px;color:var(--gray-light)}
.il-type{font-size:9px;color:var(--white);word-break:break-word}
.il-intervention{font-size:9px;color:var(--white);word-break:break-word;max-height:60px;overflow-y:auto;padding-right:4px}
.il-detail{display:none;grid-column:1/-1;background:rgba(0,0,0,.3);border:1px solid var(--border);border-radius:4px;padding:10px 14px;margin-top:6px;font-size:9px;line-height:1.6;color:var(--white);word-break:break-word}
.il-detail--open{display:block}
.il-detail-label{color:var(--gray-light);font-weight:600;margin-right:6px}
.il-next-tool{font-size:9px;color:var(--cyan);text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Drill-down modal */
.dd-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.7);z-index:1000;justify-content:center;align-items:center}
.dd-overlay.dd-open{display:flex}
.dd-modal{background:var(--panel);border:1px solid var(--border-hot);border-radius:10px;padding:24px 28px;max-width:640px;width:90%;max-height:80vh;overflow-y:auto;position:relative;box-shadow:0 0 40px rgba(0,0,0,.6)}
.dd-close{position:absolute;top:12px;right:16px;font-size:18px;color:var(--gray-light);cursor:pointer;background:none;border:none;font-family:'Space Mono',monospace}
.dd-close:hover{color:var(--white)}
.dd-title{font-family:'Syne',sans-serif;font-size:14px;font-weight:700;color:var(--white);margin-bottom:16px;padding-right:24px}
.dd-section{margin-bottom:14px}
.dd-section-label{font-size:9px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--gray-light);margin-bottom:4px}
.dd-section-value{font-size:11px;color:var(--white);line-height:1.6;word-break:break-word}
.dd-tag{display:inline-block;font-size:8px;font-weight:700;border-radius:3px;padding:2px 6px;letter-spacing:.5px;margin-right:4px;margin-bottom:4px}
.dd-remediation{background:rgba(0,212,255,.06);border:1px solid rgba(0,212,255,.15);border-radius:6px;padding:10px 14px;font-size:10px;color:var(--cyan);line-height:1.6}
/* Feed row clickable */
.feed-row-clickable{cursor:pointer;transition:background .15s}
.feed-row-clickable:hover{background:rgba(0,212,255,.06)}
/* Message History panel */
.mh-body{padding:8px 0;max-height:500px;overflow-y:auto}
.mh-filters{display:flex;gap:6px;padding:4px 16px 8px;flex-wrap:wrap}
.mh-filter-btn{font-size:8px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:3px 10px;border-radius:3px;cursor:pointer;border:1px solid var(--border);background:transparent;color:var(--gray-light);font-family:'Space Mono',monospace;transition:all .15s}
.mh-filter-btn:hover{border-color:var(--cyan);color:var(--cyan)}
.mh-filter-btn.mh-filter-active{background:rgba(0,212,255,.12);border-color:rgba(0,212,255,.4);color:var(--cyan)}
.mh-row{display:grid;grid-template-columns:80px 24px 110px 1fr 70px 70px;gap:8px;padding:7px 20px;align-items:center;border-bottom:1px solid var(--border);font-size:10px;cursor:pointer;transition:background .15s}
.mh-row:hover{background:rgba(0,212,255,.04)}
.mh-row--agent{border-left:3px solid rgba(0,212,255,.5)}
.mh-row--insaits{border-left:3px solid rgba(200,60,255,.5)}
.mh-time{font-size:9px;color:var(--gray-light)}
.mh-dir-icon{font-size:12px;text-align:center}
.mh-agent{font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mh-agent--agent{color:var(--cyan)}
.mh-agent--insaits{color:#c77dff}
.mh-preview{font-size:9px;color:var(--white);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.8}
.mh-hash{font-size:8px;color:var(--gray-light);font-family:'Space Mono',monospace;letter-spacing:.5px}
.mh-type-badge{display:inline-block;font-size:7px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:2px 6px;border-radius:3px;border:1px solid}
.mh-type-tool_input{color:var(--cyan);border-color:rgba(0,212,255,.3);background:rgba(0,212,255,.08)}
.mh-type-tool_output{color:var(--green);border-color:rgba(50,215,75,.3);background:rgba(50,215,75,.08)}
.mh-type-intervention{color:var(--red);border-color:rgba(255,45,85,.3);background:rgba(255,45,85,.08)}
.mh-type-task_anchor{color:var(--amber);border-color:rgba(255,170,0,.3);background:rgba(255,170,0,.08)}
.mh-type-circuit_breaker_event{color:#ff6b6b;border-color:rgba(255,107,107,.3);background:rgba(255,107,107,.08)}
.mh-detail{display:none;grid-column:1/-1;padding:10px 20px 14px;background:rgba(0,0,0,.25);border-bottom:1px solid var(--border);font-size:10px;line-height:1.6}
.mh-detail.mh-detail--open{display:block}
.mh-detail-label{color:var(--gray-light);font-size:9px;text-transform:uppercase;letter-spacing:.5px;margin-top:8px;margin-bottom:2px}
.mh-detail-label:first-child{margin-top:0}
.mh-detail-value{color:var(--white);word-break:break-word;white-space:pre-wrap;max-height:200px;overflow-y:auto;user-select:text}
/* Task Anchor panel (Phase 3B) */
.ta-body{padding:12px 16px;max-height:600px;overflow-y:auto}
.ta-status-row{display:flex;gap:16px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
.ta-status-item{display:flex;align-items:center;gap:6px;font-size:10px;color:var(--gray-light)}
.ta-status-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.ta-status-dot--on{background:var(--green);box-shadow:0 0 6px var(--green)}
.ta-status-dot--off{background:var(--red);box-shadow:0 0 6px var(--red)}
.ta-anchor-pre{background:rgba(0,0,0,.35);border:1px solid var(--border);border-radius:6px;padding:10px 14px;font-size:11px;line-height:1.5;color:var(--cyan);max-height:250px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;font-family:'Space Mono',monospace;margin-bottom:12px;user-select:text}
.ta-injection-row{display:grid;grid-template-columns:90px 100px 1fr;gap:8px;padding:5px 0;align-items:center;border-bottom:1px solid var(--border);font-size:10px}
.ta-injection-row--anchor{border-left:3px solid rgba(0,212,255,.5);padding-left:8px}
.ta-injection-row--memory{border-left:3px solid rgba(200,60,255,.5);padding-left:8px}
.ta-injection-time{font-size:9px;color:var(--gray-light)}
.ta-injection-tool{color:var(--cyan);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ta-injection-len{font-size:9px;color:var(--gray-light);text-align:right}
.ta-section-label{font-size:9px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--gray-light);margin-bottom:6px;margin-top:12px}
.ta-section-label:first-child{margin-top:0}
.ta-injections-list{max-height:300px;overflow-y:auto}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
  <div style="display:flex;align-items:center;gap:16px">
    <div class="header-title">InsAIts</div>
    <div style="width:1px;height:20px;background:var(--border)"></div>
    <div class="header-sub">Runtime AI Security Monitor</div>
    <div id="live-badge" class="live-badge"></div>
    <div id="stealth-badge" class="stealth-badge-hidden"></div>
    <button id="btn-stealth" onclick="toggleStealth()" style="background:rgba(157,78,221,.12);border:1px solid rgba(157,78,221,.3);border-radius:4px;padding:3px 10px;font-size:9px;color:#9d4edd;letter-spacing:1px;text-transform:uppercase;cursor:pointer;font-family:'Space Mono',monospace;font-weight:700">Stealth: OFF</button>
  </div>
  <div style="display:flex;gap:20px;align-items:center">
    <div id="last-tool-call" style="font-size:9px;color:var(--green);font-weight:600"></div>
    <div style="width:1px;height:14px;background:var(--border)"></div>
    <div id="session-time" style="font-size:9px;color:var(--gray-light)"></div>
    <div style="width:1px;height:14px;background:var(--border)"></div>
    <div id="clock" style="font-size:9px;color:var(--gray-light)"></div>
    <div class="session-controls">
      <select id="session-select" class="session-select" onchange="onSessionChange(this.value)">
        <option value="__current__">Current Session</option>
      </select>
      <button class="btn-new-session" onclick="newSession()">New Session</button>
      <span id="viewing-badge" class="viewing-badge" style="display:none"></span>
    </div>
    <div class="version-badge">v__VERSION__</div>
    <div style="width:1px;height:14px;background:var(--border)"></div>
    <button onclick="exportJSON()" style="background:rgba(63,185,80,.12);border:1px solid rgba(63,185,80,.25);border-radius:4px;padding:3px 10px;font-size:9px;color:var(--green);letter-spacing:1px;text-transform:uppercase;cursor:pointer;font-family:'Space Mono',monospace;font-weight:700">Export JSON</button>
    <button onclick="exportPDF()" style="background:rgba(255,165,0,.12);border:1px solid rgba(255,165,0,.25);border-radius:4px;padding:3px 10px;font-size:9px;color:var(--yellow);letter-spacing:1px;text-transform:uppercase;cursor:pointer;font-family:'Space Mono',monospace;font-weight:700">Export PDF</button>
    <button id="btn-pause" onclick="togglePause()" style="background:var(--cyan-dim);border:1px solid rgba(0,212,255,.25);border-radius:4px;padding:3px 10px;font-size:9px;color:var(--cyan);letter-spacing:1px;text-transform:uppercase;cursor:pointer;font-family:'Space Mono',monospace;font-weight:700">Pause</button>
    <button id="btn-shutdown" onclick="shutdownServer()" style="background:var(--red-dim);border:1px solid rgba(255,45,85,.25);border-radius:4px;padding:3px 10px;font-size:9px;color:var(--red);letter-spacing:1px;text-transform:uppercase;cursor:pointer;font-family:'Space Mono',monospace;font-weight:700">Shutdown</button>
  </div>
</div>

<div class="content">
  <!-- Top row: 3 rings + 3 KPIs -->
  <div class="top-row">
    <!-- Threat Index -->
    <div class="panel panel-glow-cyan ring-panel">
      <div id="ring-threat"></div>
      <div class="ring-label">Threat Index</div>
      <div class="ring-sublabel">system health</div>
      <div id="spark-threat"></div>
    </div>
    <!-- Blast Radius -->
    <div class="panel panel-glow-amber ring-panel">
      <div id="ring-blast"></div>
      <div class="ring-label">Blast Radius</div>
      <div class="ring-sublabel">weighted avg rate</div>
      <div id="spark-blast"></div>
    </div>
    <!-- Intervention Rate -->
    <div class="panel panel-glow-green ring-panel">
      <div id="ring-intervention"></div>
      <div class="ring-label">Intervention</div>
      <div class="ring-sublabel">anomalies resolved</div>
      <div id="spark-intervention"></div>
    </div>
    <!-- KPI: Messages + Anomaly Rate -->
    <div class="panel kpi-panel">
      <div>
        <div class="label">Messages</div>
        <div id="kpi-messages" class="metric-value" style="color:var(--cyan);text-shadow:0 0 12px rgba(0,212,255,.38)">0</div>
        <div id="kpi-messages-sub" class="metric-sub"></div>
      </div>
      <div class="kpi-divider"></div>
      <div>
        <div class="label">Anomaly Rate</div>
        <div id="kpi-rate" class="metric-value">0%</div>
        <div class="metric-sub">rolling window</div>
      </div>
    </div>
    <!-- KPI: Anomalies + Critical -->
    <div class="panel kpi-panel">
      <div>
        <div class="label">Anomalies</div>
        <div id="kpi-anomalies" class="metric-value" style="color:var(--amber);text-shadow:0 0 12px rgba(255,170,0,.38)">0</div>
        <div id="kpi-resolved-sub" class="metric-sub"></div>
      </div>
      <div class="kpi-divider"></div>
      <div>
        <div class="label">Critical</div>
        <div id="kpi-critical" class="metric-value" style="color:var(--red);text-shadow:0 0 12px rgba(255,45,85,.38)">0</div>
        <div class="metric-sub">require action</div>
      </div>
    </div>
    <!-- KPI: Agents + Processing -->
    <div class="panel kpi-panel">
      <div>
        <div class="label">Agents</div>
        <div id="kpi-agents" class="metric-value" style="color:var(--purple);text-shadow:0 0 12px rgba(157,78,221,.38)">0</div>
        <div class="metric-sub">monitored</div>
      </div>
      <div class="kpi-divider"></div>
      <div>
        <div class="label">Processing</div>
        <div class="metric-value" style="color:var(--green);text-shadow:0 0 12px rgba(0,255,136,.38)">100%</div>
        <div class="metric-sub">local only</div>
      </div>
    </div>
  </div>

  <!-- Middle row: Feed + Agents -->
  <div class="mid-row">
    <!-- Live Feed -->
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--cyan);margin-bottom:0">&#9889; Live Anomaly Feed</div>
        <div id="feed-count" style="font-size:9px;color:var(--gray-light)">0 events</div>
      </div>
      <div id="feed-body" class="feed-body"></div>
    </div>
    <!-- Agent Scores -->
    <div class="panel" style="padding:0">
      <div class="agent-header">
        <div class="label" style="color:var(--purple);margin-bottom:0">&#9670; Agent Intelligence Scores</div>
      </div>
      <div id="agent-body" class="agent-body"></div>
    </div>
  </div>

  <!-- Bottom row: Distribution + Circuit Breaker -->
  <div class="bot-row">
    <!-- Anomaly Distribution -->
    <div class="panel">
      <div class="label" style="color:var(--amber)">&#9638; Anomaly Distribution</div>
      <div id="distribution" style="margin-top:8px"></div>
    </div>
    <!-- Circuit Breaker -->
    <div class="panel">
      <div class="label" style="color:var(--red)">&#9889; Circuit Breaker Status</div>
      <div id="circuit-breakers" class="cb-grid"></div>
    </div>
  </div>

  <!-- Pattern Insights (from Pattern Miner) -->
  <div class="extra-row" style="grid-template-columns:1fr 1fr">
    <div class="panel">
      <div style="display:flex;justify-content:space-between;align-items:center">
        <div class="label" style="color:var(--cyan)">&#128269; PATTERN INSIGHTS</div>
        <button id="mine-patterns-btn" onclick="minePatterns()" style="background:rgba(0,176,255,.12);border:1px solid rgba(0,176,255,.3);color:var(--cyan);border-radius:3px;padding:3px 10px;font-size:10px;cursor:pointer;font-weight:600">MINE NOW</button>
      </div>
      <div id="pattern-insights" style="margin-top:8px;max-height:300px;overflow-y:auto">
        <div style="color:var(--gray-light);font-size:11px">Click MINE NOW to analyze all session logs</div>
      </div>
    </div>
    <div class="panel">
      <div class="label" style="color:var(--amber)">&#128202; TOP ANOMALY COMBOS</div>
      <div id="pattern-combos" style="margin-top:8px;max-height:300px;overflow-y:auto">
        <div style="color:var(--gray-light);font-size:11px">Run pattern mining first</div>
      </div>
    </div>
  </div>

  <!-- Fingerprint row: Behavioral Fingerprint Timeline -->
  <div class="extra-row" style="grid-template-columns:1fr">
    <div class="panel">
      <div style="display:flex;justify-content:space-between;align-items:center">
        <div class="label" style="color:var(--cyan)">&#9707; BEHAVIORAL FINGERPRINT TIMELINE</div>
        <div style="display:flex;gap:6px;align-items:center">
          <button id="fp-pan-left" style="background:var(--bg-panel);border:1px solid var(--border);color:var(--gray-light);border-radius:3px;padding:2px 8px;font-size:10px;cursor:pointer" title="Pan left (history)">&larr;</button>
          <button id="fp-live-btn" style="background:rgba(0,230,118,.12);border:1px solid rgba(0,230,118,.3);color:var(--green);border-radius:3px;padding:2px 8px;font-size:9px;cursor:pointer;font-weight:600" title="Jump to live">LIVE</button>
          <button id="fp-pan-right" style="background:var(--bg-panel);border:1px solid var(--border);color:var(--gray-light);border-radius:3px;padding:2px 8px;font-size:10px;cursor:pointer" title="Pan right (recent)">&rarr;</button>
        </div>
      </div>
      <div id="fingerprint-wrap" style="margin-top:8px;height:210px;position:relative;overflow:hidden;cursor:grab">
        <canvas id="fingerprint-canvas" style="display:block;width:100%;height:180px"></canvas>
        <div id="fp-tooltip" style="display:none;position:absolute;pointer-events:none;background:rgba(20,20,30,.95);border:1px solid var(--border);border-radius:4px;padding:6px 10px;font-size:9px;color:var(--white);white-space:nowrap;z-index:10;box-shadow:0 2px 8px rgba(0,0,0,.5)"></div>
      </div>
      <div id="fingerprint-legend" style="margin-top:6px;display:flex;flex-wrap:wrap;gap:8px;font-size:9px"></div>
    </div>
  </div>

  <!-- Extra row: Tool Call Inspector + OWASP Security -->
  <div class="extra-row">
    <!-- Tool Call Inspector -->
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--green);margin-bottom:0">&#9654; Tool Call Inspector</div>
        <div id="tc-count" style="font-size:9px;color:var(--gray-light)">0 calls</div>
      </div>
      <div id="tc-body" class="tc-body"></div>
    </div>
    <!-- OWASP Security Panel -->
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--red);margin-bottom:0">&#9888; OWASP MCP Security</div>
        <div id="owasp-count" style="font-size:9px;color:var(--gray-light)">0 events</div>
      </div>
      <div id="owasp-body" style="padding:8px 0"></div>
    </div>
  </div>

  <!-- Phase 2C: Message History -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--cyan);margin-bottom:0">&#9889; Message History</div>
        <div id="mh-count" style="font-size:9px;color:var(--gray-light)">0 messages</div>
      </div>
      <div class="mh-filters" id="mh-filters">
        <button class="mh-filter-btn mh-filter-active" data-filter="all" onclick="setMsgHistoryFilter('all')">All</button>
        <button class="mh-filter-btn" data-filter="intervention" onclick="setMsgHistoryFilter('intervention')">Interventions Only</button>
        <button class="mh-filter-btn" data-filter="tool" onclick="setMsgHistoryFilter('tool')">Tool Calls Only</button>
        <button class="mh-filter-btn" data-filter="circuit_breaker" onclick="setMsgHistoryFilter('circuit_breaker')">Circuit Breaker</button>
      </div>
      <div id="mh-body" class="mh-body"></div>
    </div>
  </div>

  <!-- Phase 3B: Task Anchor -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--cyan);margin-bottom:0">&#9875; TASK ANCHOR</div>
        <div id="ta-count" style="font-size:9px;color:var(--gray-light)">0 injections</div>
      </div>
      <div id="ta-body" class="ta-body"></div>
    </div>
  </div>

  <!-- InsAIts Monitor Status (system agent panel) -->
  <div class="extra-row" style="grid-template-columns:1fr">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--amber);margin-bottom:0">&#9881; InsAIts Monitor Status</div>
        <div id="monitor-count" style="font-size:9px;color:var(--gray-light)">0 alerts</div>
      </div>
      <div id="monitor-body" style="padding:8px 12px;max-height:350px;overflow-y:auto"></div>
    </div>
  </div>

  <!-- Phase 2: Intervention Log -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--amber);margin-bottom:0">&#9654; InsAIts Intervention Log</div>
        <div id="il-count" style="font-size:9px;color:var(--gray-light)">0 interventions</div>
      </div>
      <div id="il-body" class="il-body"></div>
    </div>
  </div>

  <!-- Phase 3: Persistence State (escalation levels per agent) -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--red);margin-bottom:0">&#9888; Persistence Engine</div>
        <div id="pe-count" style="font-size:10px;color:var(--gray-light)">no escalations</div>
      </div>
      <div id="pe-body" style="padding:10px;max-height:350px;overflow-y:auto;font-size:12px"></div>
    </div>
  </div>

  <!-- Phase 7C.5: Token Usage Monitor -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--green);margin-bottom:0">&#128176; Token Usage</div>
        <div id="token-status" style="font-size:10px;color:var(--gray-light)">waiting for data...</div>
      </div>
      <div id="token-body" style="padding:10px;max-height:350px;overflow-y:auto;font-size:12px"></div>
    </div>
  </div>

  <!-- PHASE_GUARDIAN: Session Health Panel -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--cyan);margin-bottom:0">&#128737; Session Health (Guardian)</div>
        <div id="guardian-status" style="font-size:10px;color:var(--gray-light)">connecting...</div>
      </div>
      <div id="guardian-body" style="padding:10px;max-height:400px;overflow-y:auto;font-size:12px"></div>
    </div>
  </div>

  <!-- PHASE_GUARDIAN Phase 2: Session Vault Panel -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--cyan);margin-bottom:0">&#128451; Session Vault</div>
        <div style="display:flex;align-items:center;gap:8px">
          <span id="vault-last-save" style="font-size:9px;color:var(--gray-light)"></span>
          <button id="save-now-btn" onclick="saveSessionNow()" style="background:var(--cyan);color:#000;border:none;padding:3px 10px;border-radius:4px;font-size:10px;cursor:pointer;font-weight:bold">SAVE NOW</button>
          <button id="emergency-pause-btn" onclick="emergencyPause()" style="background:var(--red);color:#fff;border:none;padding:3px 10px;border-radius:4px;font-size:10px;cursor:pointer;font-weight:bold">EMERGENCY PAUSE</button>
        </div>
      </div>
      <div id="vault-alert" style="display:none;background:rgba(255,0,0,.15);border:1px solid var(--red);padding:8px 12px;color:var(--red);font-weight:bold;font-size:11px;text-align:center"></div>
      <div id="vault-body" style="padding:10px;max-height:350px;overflow-y:auto;font-size:12px"></div>
    </div>
  </div>

  <!-- Phase 2.7: Session Comparison -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--cyan);margin-bottom:0">&#128200; Session Comparison</div>
        <div id="sc-count" style="font-size:9px;color:var(--gray-light)">0 sessions</div>
      </div>
      <div id="sc-body" style="padding:8px;max-height:350px;overflow-y:auto"></div>
    </div>
  </div>

  <!-- Phase 2.4: Inter-Agent Communication Map -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--cyan);margin-bottom:0">&#127760; Agent Communication Map</div>
        <div id="comm-map-count" style="font-size:9px;color:var(--gray-light)">0 connections</div>
      </div>
      <div id="comm-map-body" style="padding:8px;min-height:120px"></div>
    </div>
  </div>

  <!-- Inter-Session Dialog Panel (Central Collector) -->
  <div class="phase2-row" style="grid-template-columns:1fr">
    <div class="panel" style="padding:0">
      <div class="feed-header" style="display:flex;justify-content:space-between;align-items:center">
        <div style="display:flex;align-items:center;gap:8px">
          <div class="label" style="color:var(--cyan);margin-bottom:0">&#9993; Inter-Session Dialog</div>
          <span id="dialog-status" style="font-size:9px;padding:2px 6px;border-radius:3px;background:rgba(255,82,82,.15);color:var(--red)">OFFLINE</span>
        </div>
        <div style="display:flex;gap:8px;align-items:center">
          <span id="dialog-session-count" style="font-size:9px;color:var(--gray-light)">0 sessions</span>
          <span id="dialog-msg-count" style="font-size:9px;color:var(--gray-light)">0 messages</span>
          <span id="dialog-evidence-count" style="font-size:9px;color:var(--gray-light)">0 evidence</span>
        </div>
      </div>
      <!-- Session pills -->
      <div id="dialog-sessions" style="padding:4px 12px;border-bottom:1px solid var(--border);display:flex;flex-wrap:wrap;gap:4px;min-height:24px">
        <span style="font-size:9px;color:var(--gray-light)">No active sessions</span>
      </div>
      <!-- Dialog thread -->
      <div id="dialog-thread" style="max-height:400px;min-height:80px;overflow-y:auto;scroll-behavior:smooth;padding:8px 12px;font-size:11px;font-family:'Courier New',monospace">
        <div id="collector-offline-msg" style="text-align:center;color:var(--gray-light);font-size:10px;padding:20px">
          Collector offline.
          <button onclick="startCollector()" style="margin-left:8px;background:var(--cyan);color:var(--bg);border:none;padding:3px 10px;border-radius:3px;font-size:10px;font-weight:600;cursor:pointer">START COLLECTOR</button>
          <span id="collector-start-status" style="font-size:9px;margin-left:6px;color:var(--gray-light)"></span>
        </div>
      </div>
      <!-- Input bar -->
      <div style="padding:6px 12px;border-top:1px solid var(--border);display:flex;gap:6px;align-items:center">
        <span style="font-size:9px;color:var(--cyan);white-space:nowrap">OPERATOR &gt;</span>
        <input id="dialog-input" type="text" placeholder="Type message or /command (/status, /pause, /resume, /task, /broadcast, /evidence, /files)"
          style="flex:1;background:var(--bg-card);border:1px solid var(--border);color:var(--fg);padding:4px 8px;border-radius:3px;font-size:11px;font-family:'Courier New',monospace;outline:none" />
        <label style="font-size:9px;color:var(--red);display:flex;align-items:center;gap:2px;cursor:pointer" title="Mark as URGENT — delivered immediately via file channel">
          <input type="checkbox" id="dialog-urgent" style="margin:0" /> URGENT
        </label>
        <button id="dialog-send-btn" style="background:var(--cyan);color:var(--bg);border:none;padding:4px 12px;border-radius:3px;font-size:10px;font-weight:600;cursor:pointer">SEND</button>
      </div>
    </div>
  </div>

  <!-- Subagent Activity Panel -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--purple);margin-bottom:0">&#9670; Subagent Activity</div>
        <div id="sub-count" style="font-size:9px;color:var(--gray-light)">0 calls</div>
      </div>
      <div id="sub-summary" style="padding:6px 12px;border-bottom:1px solid var(--border);display:none"></div>
      <div id="sub-body" style="max-height:450px;overflow-y:auto;font-size:11px"></div>
    </div>
  </div>

  <!-- Context Health Panel (ACM) -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--cyan);margin-bottom:0">&#9729; Context Health (ACM)</div>
        <div id="acm-status" style="font-size:9px;color:var(--gray-light)">idle</div>
      </div>
      <div id="acm-body" style="padding:10px">
        <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px">
          <div style="text-align:center">
            <div id="acm-hot" style="font-size:20px;font-weight:bold;color:var(--red)">0</div>
            <div style="font-size:9px;color:var(--gray-light)">HOT items</div>
          </div>
          <div style="text-align:center">
            <div id="acm-cold" style="font-size:20px;font-weight:bold;color:var(--blue)">0</div>
            <div style="font-size:9px;color:var(--gray-light)">COLD items</div>
          </div>
          <div style="text-align:center">
            <div id="acm-compressed" style="font-size:20px;font-weight:bold;color:var(--green)">0</div>
            <div style="font-size:9px;color:var(--gray-light)">Compressed</div>
          </div>
          <div style="text-align:center">
            <div id="acm-duplicates" style="font-size:20px;font-weight:bold;color:var(--purple)">0</div>
            <div style="font-size:9px;color:var(--gray-light)">Duplicates</div>
          </div>
        </div>
        <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:10px">
          <div style="text-align:center;padding:6px;border:1px solid var(--border);border-radius:4px">
            <div id="acm-ratio" style="font-size:14px;font-weight:bold;color:var(--cyan)">0%</div>
            <div style="font-size:9px;color:var(--gray-light)">Compression Ratio</div>
          </div>
          <div style="text-align:center;padding:6px;border:1px solid var(--border);border-radius:4px">
            <div id="acm-tokens" style="font-size:14px;font-weight:bold;color:var(--green)">0</div>
            <div style="font-size:9px;color:var(--gray-light)">Tokens Saved</div>
          </div>
          <div style="text-align:center;padding:6px;border:1px solid var(--border);border-radius:4px">
            <div id="acm-risk" style="font-size:14px;font-weight:bold;color:var(--green)">none</div>
            <div style="font-size:9px;color:var(--gray-light)">Quality Risk</div>
          </div>
        </div>
        <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
          <div style="padding:6px;border:1px solid var(--border);border-radius:4px;font-size:11px">
            <div style="color:var(--gray-light);margin-bottom:4px">Guard Stats</div>
            <div id="acm-guard-stats" style="color:var(--text)">--</div>
          </div>
          <div style="padding:6px;border:1px solid var(--border);border-radius:4px;font-size:11px">
            <div style="color:var(--gray-light);margin-bottom:4px">Last Cycle</div>
            <div id="acm-last-cycle" style="color:var(--text)">--</div>
          </div>
        </div>
      </div>
    </div>
  </div>

  <!-- Config & Toggles Panel -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--amber);margin-bottom:0">&#9881; Detector &amp; Feature Toggles</div>
        <div id="cfg-status" style="font-size:9px;color:var(--gray-light)">loading...</div>
      </div>
      <div id="cfg-body" style="padding:10px;max-height:450px;overflow-y:auto;font-size:12px"></div>
    </div>
  </div>

  <!-- Task Anchor Editor Panel — 3-Layer Hierarchy -->
  <div class="phase2-row">
    <div class="panel" style="padding:0">
      <div class="feed-header">
        <div class="label" style="color:var(--green);margin-bottom:0">&#9998; Task Anchor Editor (3-Layer)</div>
        <div id="anchor-status" style="font-size:9px;color:var(--gray-light)">hot-reload enabled</div>
      </div>
      <div id="anchor-editor" style="padding:8px">
        <!-- Layer 1: Opus/Leader -->
        <div style="margin-bottom:4px;font-size:10px;color:var(--red);font-weight:700">Layer 1 — Opus Leader (YOU — orchestrator)</div>
        <div style="font-size:9px;color:var(--gray-light);margin-bottom:4px">Top-level directives, session rules, workflow preferences. Injected at session start + periodic refresh.</div>
        <textarea id="anchor-layer1" style="width:100%;height:120px;background:var(--bg-dark);color:var(--fg);border:1px solid var(--red)40;font-family:monospace;font-size:10px;padding:4px;resize:vertical"></textarea>

        <!-- Layer 2: Main Agents -->
        <div style="margin:8px 0 4px;font-size:10px;color:var(--amber);font-weight:700">Layer 2 — Main Agents (claude:Bash, claude:Edit, etc.)</div>
        <div style="font-size:9px;color:var(--gray-light);margin-bottom:4px">Tool-specific guardrails, file handling rules. Injected when agent's first tool call detected.</div>
        <textarea id="anchor-layer2" style="width:100%;height:100px;background:var(--bg-dark);color:var(--fg);border:1px solid var(--amber)40;font-family:monospace;font-size:10px;padding:4px;resize:vertical"></textarea>

        <!-- Layer 3: Subagents -->
        <div style="margin:8px 0 4px;font-size:10px;color:var(--purple);font-weight:700">Layer 3 — Subagents (subagent:Explore, subagent:Plan, etc.)</div>
        <div style="font-size:9px;color:var(--gray-light);margin-bottom:4px">Scope constraints, output format, depth limits. Injected on every subagent spawn (more frequent).</div>
        <textarea id="anchor-layer3" style="width:100%;height:100px;background:var(--bg-dark);color:var(--fg);border:1px solid var(--purple)40;font-family:monospace;font-size:10px;padding:4px;resize:vertical"></textarea>

        <!-- Legacy: User Anchor + Memory Check -->
        <details style="margin-top:8px">
          <summary style="font-size:10px;color:var(--gray-light);cursor:pointer">Legacy: User Anchor &amp; Memory Check</summary>
          <div style="margin-top:4px">
            <div style="font-size:9px;color:var(--gray-light);margin-bottom:2px">User Anchor (task boundary injection):</div>
            <textarea id="anchor-user" style="width:100%;height:80px;background:var(--bg-dark);color:var(--fg);border:1px solid var(--border);font-family:monospace;font-size:10px;padding:4px;resize:vertical"></textarea>
            <div style="margin:4px 0 2px;font-size:9px;color:var(--gray-light)">Memory Check (auto-reminder):</div>
            <textarea id="anchor-memory" style="width:100%;height:60px;background:var(--bg-dark);color:var(--fg);border:1px solid var(--border);font-family:monospace;font-size:10px;padding:4px;resize:vertical"></textarea>
          </div>
        </details>

        <div style="margin-top:8px;display:flex;gap:6px">
          <button onclick="saveAnchor()" style="background:var(--green);color:#000;border:none;padding:4px 12px;cursor:pointer;font-size:10px;font-weight:700">Save All Layers</button>
          <button onclick="resetAnchor()" style="background:var(--gray-light);color:#000;border:none;padding:4px 12px;cursor:pointer;font-size:10px">Reset from Disk</button>
        </div>
      </div>
    </div>
  </div>

  <!-- Drill-Down Modal (hidden by default) -->
  <div id="dd-overlay" class="dd-overlay" onclick="closeDrillDown(event)">
    <div class="dd-modal" onclick="event.stopPropagation()">
      <button class="dd-close" onclick="closeDrillDown()">&times;</button>
      <div class="dd-title" id="dd-title">Anomaly Detail</div>
      <div id="dd-content"></div>
    </div>
  </div>

  <!-- Footer -->
  <div class="footer">
    <div class="footer-left">
      InsAIts v__VERSION__ &middot; 100% local processing &middot; OWASP MCP Top 10 coverage &middot; Apache 2.0
      <span id="footer-checkpoints" style="margin-left:12px;color:var(--gray-light)"></span>
      <span id="footer-human-review" style="margin-left:12px;color:var(--amber)"></span>
      <span id="footer-observability" style="margin-left:16px;font-size:11px;font-weight:600;letter-spacing:0.5px"></span>
    </div>
    <div class="footer-right">
      pip install insa-its &middot; github.com/Nomadu27/InsAIts
    </div>
  </div>
</div>

<script>
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let lastData = null;
let threatHistory = [];
let blastHistory = [];
let interventionHistory = [];
const MAX_HISTORY = 40;

// Message History state (persists across auto-refresh)
let _msgHistoryFilter = 'all';
const _msgHistoryExpanded = new Set();

// ---------------------------------------------------------------------------
// SVG helpers
// ---------------------------------------------------------------------------
function renderRing(containerId, score, color, size) {
  size = size || 110;
  const r = size * 0.38;
  const cx = size / 2;
  const cy = size / 2;
  const pct = Math.max(0, Math.min(1, score / 1000));
  const startAngle = -Math.PI / 2;
  const endAngle = startAngle + pct * 2 * Math.PI;
  const x1 = cx + r * Math.cos(startAngle);
  const y1 = cy + r * Math.sin(startAngle);
  const x2 = cx + r * Math.cos(endAngle);
  const y2 = cy + r * Math.sin(endAngle);
  const largeArc = pct > 0.5 ? 1 : 0;
  const sw = size * 0.07;

  let arc = '';
  if (pct > 0.001) {
    arc = `<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${sw}" stroke-linecap="round" style="filter:drop-shadow(0 0 6px ${color})"/>`;
  }
  document.getElementById(containerId).innerHTML = `
    <svg width="${size}" height="${size}">
      <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(--border)" stroke-width="${sw}"/>
      ${arc}
      <text x="${cx}" y="${cy - 4}" text-anchor="middle" fill="${color}" style="font-family:'Space Mono',monospace;font-size:${size * 0.18}px;font-weight:700">${score}</text>
      <text x="${cx}" y="${cy + size * 0.14}" text-anchor="middle" fill="var(--gray-light)" style="font-family:'Space Mono',monospace;font-size:${size * 0.08}px">/1000</text>
    </svg>`;
}

function renderRingPct(containerId, pct, color, size) {
  // Same as renderRing but for 0-100 percentage scale (blast radius)
  size = size || 110;
  const r = size * 0.38;
  const cx = size / 2;
  const cy = size / 2;
  const val = Math.max(0, Math.min(100, pct));
  const frac = val / 100;
  const startAngle = -Math.PI / 2;
  const endAngle = startAngle + frac * 2 * Math.PI;
  const x1 = cx + r * Math.cos(startAngle);
  const y1 = cy + r * Math.sin(startAngle);
  const x2 = cx + r * Math.cos(endAngle);
  const y2 = cy + r * Math.sin(endAngle);
  const largeArc = frac > 0.5 ? 1 : 0;
  const sw = size * 0.07;

  let arc = '';
  if (frac > 0.001) {
    arc = `<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${sw}" stroke-linecap="round" style="filter:drop-shadow(0 0 6px ${color})"/>`;
  }
  document.getElementById(containerId).innerHTML = `
    <svg width="${size}" height="${size}">
      <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(--border)" stroke-width="${sw}"/>
      ${arc}
      <text x="${cx}" y="${cy - 4}" text-anchor="middle" fill="${color}" style="font-family:'Space Mono',monospace;font-size:${size * 0.18}px;font-weight:700">${val}%</text>
      <text x="${cx}" y="${cy + size * 0.14}" text-anchor="middle" fill="var(--gray-light)" style="font-family:'Space Mono',monospace;font-size:${size * 0.08}px">avg impact</text>
    </svg>`;
}

function renderSparkline(containerId, data, color, w, h) {
  w = w || 80; h = h || 24;
  if (!data || data.length < 2) { document.getElementById(containerId).innerHTML = ''; return; }
  const max = Math.max(...data, 1);
  const min = Math.min(...data);
  const range = max - min || 1;
  const pts = data.map((v, i) => {
    const x = (i / (data.length - 1)) * w;
    const y = h - ((v - min) / range) * h * 0.9 - h * 0.05;
    return x + ',' + y;
  }).join(' ');
  const lastX = w;
  const lastY = h - ((data[data.length - 1] - min) / range) * h * 0.9 - h * 0.05;
  document.getElementById(containerId).innerHTML = `
    <svg width="${w}" height="${h}" style="overflow:visible">
      <polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" style="filter:drop-shadow(0 0 3px ${color})"/>
      <circle cx="${lastX}" cy="${lastY}" r="3" fill="${color}" style="filter:drop-shadow(0 0 4px ${color})"/>
    </svg>`;
}

// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function scoreColor(score) {
  return score > 700 ? 'var(--green)' : score > 400 ? 'var(--amber)' : 'var(--red)';
}
function rateColor(rate) {
  return rate > 20 ? 'var(--red)' : rate > 10 ? 'var(--amber)' : 'var(--green)';
}
function statusColor(s) {
  return s === 'HEALTHY' ? 'var(--green)' : s === 'WARNING' ? 'var(--amber)' : 'var(--red)';
}
function cbState(rate) {
  return rate > 40 ? 'OPEN' : rate > 15 ? 'HALF-OPEN' : 'CLOSED';
}

function timeAgo(ts) {
  if (!ts) return '--:--:--';
  try {
    const d = new Date(ts);
    const now = new Date();
    const diffMs = now - d;
    const diffSec = Math.floor(diffMs / 1000);
    if (diffSec < 60) return 'just now';
    const diffMin = Math.floor(diffSec / 60);
    if (diffMin < 60) return diffMin + 'm ago';
    const diffHr = Math.floor(diffMin / 60);
    if (diffHr < 24) return diffHr + 'h ago';
    const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
    const mon = months[d.getMonth()];
    const day = d.getDate();
    const hh = String(d.getHours()).padStart(2, '0');
    const mm = String(d.getMinutes()).padStart(2, '0');
    return mon + ' ' + day + ' ' + hh + ':' + mm;
  } catch (e) { return '--:--:--'; }
}

function formatTime(ts) {
  if (!ts) return '--:--:--';
  try {
    const d = new Date(ts);
    return d.toLocaleTimeString('en', { hour12: false });
  } catch (e) { return ts.slice(11, 19) || '--:--:--'; }
}

function escapeHtml(s) {
  const d = document.createElement('div');
  d.textContent = s;
  return d.innerHTML;
}

function update(data) {
  lastData = data;
  const isDemo = data.demo === true;

  // Badge + Activity Status (7C.1-7C.2)
  const badge = document.getElementById('live-badge');
  if (isPaused) {
    badge.textContent = 'PAUSED';
    badge.className = 'paused-badge';
  } else {
    const latestTs = data.session_latest ? new Date(data.session_latest) : null;
    const agoSec = latestTs ? Math.floor((Date.now() - latestTs.getTime()) / 1000) : 999;
    if (data.total_messages > 0) {
      badge.textContent = '\u25CF LIVE';
      badge.className = 'live-badge';
    } else {
      badge.textContent = '\u25CF STOPPED';
      badge.className = 'stopped-badge';
    }
    // "Last tool call: Xs ago" (7C.1)
    const ltcEl = document.getElementById('last-tool-call');
    if (ltcEl && latestTs) {
      if (agoSec < 60) ltcEl.textContent = 'Last tool call: ' + agoSec + 's ago';
      else if (agoSec < 3600) ltcEl.textContent = 'Last tool call: ' + Math.floor(agoSec / 60) + 'm ago';
      else ltcEl.textContent = 'Last tool call: ' + Math.floor(agoSec / 3600) + 'h ago';
      ltcEl.style.color = 'var(--green)';
    }
  }

  // Clock
  document.getElementById('clock').textContent = new Date().toLocaleTimeString('en', { hour12: false });

  // Session timing
  if (data.session_start) {
    const start = new Date(data.session_start);
    const latest = data.session_latest ? new Date(data.session_latest) : new Date();
    const durMs = latest - start;
    const durMin = Math.floor(durMs / 60000);
    const durSec = Math.floor((durMs % 60000) / 1000);
    const startStr = start.toLocaleTimeString('en', { hour12: false });
    const sessionInfo = 'Session: ' + startStr + ' &middot; ' + durMin + 'm ' + durSec + 's';
    const csid = data.claude_session_id || '';
    document.getElementById('session-time').innerHTML =
      sessionInfo + (csid ? ' &middot; <span style="color:var(--cyan);opacity:.6" title="Claude Code Session ID">' + csid.substring(0, 8) + '...</span>' : '');
  } else {
    document.getElementById('session-time').textContent = 'No session';
  }

  // History
  threatHistory.push(data.threat_score);
  blastHistory.push(data.blast_radius);
  interventionHistory.push(data.intervention_rate);
  if (threatHistory.length > MAX_HISTORY) threatHistory.shift();
  if (blastHistory.length > MAX_HISTORY) blastHistory.shift();
  if (interventionHistory.length > MAX_HISTORY) interventionHistory.shift();

  // Rings
  const tc = scoreColor(data.threat_score);
  // Blast radius is 0-100 (weighted avg anomaly rate). Lower is better.
  const bc = data.blast_radius > 40 ? 'var(--red)' : data.blast_radius > 15 ? 'var(--amber)' : 'var(--green)';
  const ic = data.intervention_rate > 700 ? 'var(--green)' : 'var(--amber)';
  renderRing('ring-threat', data.threat_score, tc);
  renderRingPct('ring-blast', data.blast_radius, bc);
  renderRing('ring-intervention', data.intervention_rate, ic);

  // Sparklines
  renderSparkline('spark-threat', threatHistory, 'var(--cyan)');
  renderSparkline('spark-blast', blastHistory, 'var(--amber)');
  renderSparkline('spark-intervention', interventionHistory, 'var(--green)');

  // KPIs
  document.getElementById('kpi-messages').textContent = data.total_messages.toLocaleString();
  const subMsgs = data.subagent_messages || 0;
  const mainMsgs = data.main_messages || data.total_messages;
  document.getElementById('kpi-messages-sub').textContent = subMsgs > 0
    ? mainMsgs + ' main + ' + subMsgs + ' subagent'
    : 'this session';

  const rateEl = document.getElementById('kpi-rate');
  rateEl.textContent = data.anomaly_rate + '%';
  rateEl.style.color = rateColor(data.anomaly_rate);
  rateEl.style.textShadow = '0 0 12px ' + rateColor(data.anomaly_rate) + '60';

  document.getElementById('kpi-anomalies').textContent = data.total_anomalies;
  document.getElementById('kpi-resolved-sub').textContent = data.resolved + ' resolved';
  document.getElementById('kpi-critical').textContent = data.critical;

  const agentNames = Object.keys(data.agents || {}).filter(n => n !== 'system');
  document.getElementById('kpi-agents').textContent = agentNames.length;

  // Feed
  const feedBody = document.getElementById('feed-body');
  const feedItems = data.feed || [];
  document.getElementById('feed-count').textContent = feedItems.length + ' events';
  let feedHtml = '';
  feedItems.forEach((item, i) => {
    const isGap = (item.type || '').includes('monitoring_gap');
    const bg = i === 0 ? 'background:rgba(' + (isGap ? '120,60,0' : item.severity === 'CRITICAL' ? '61,0,17' : item.severity === 'HIGH' ? '61,40,0' : '10,74,90') + ',.25)' : '';
    feedHtml += `<div class="feed-row feed-row-clickable" style="${bg}" onclick="openDrillDown(${i})" title="Click for details">
      <div class="feed-time">${escapeHtml(timeAgo(item.ts))}</div>
      <div><span class="sev-badge sev-${escapeHtml(item.severity)}">${escapeHtml(item.severity)}</span></div>
      <div class="feed-type">${escapeHtml((item.type || '').replace(/_/g, ' ').toUpperCase())}</div>
      <div class="feed-agent">${escapeHtml(item.agent || 'unknown')}</div>
      <div class="feed-resolved" style="color:${item.resolved ? 'var(--green)' : 'var(--amber)'}">${item.resolved ? '\u2713' : '\u26A0'}</div>
    </div>`;
  });
  if (feedItems.length === 0) {
    feedHtml = '<div style="padding:20px;text-align:center;color:var(--gray-light);font-size:11px">No anomalies detected yet. System is clean.</div>';
  }
  feedBody.innerHTML = feedHtml;

  // Agents — split into Main Agents vs Subagents
  const agentBody = document.getElementById('agent-body');
  let agentHtml = '';
  const sortOrder = { CRITICAL: 0, WARNING: 1, HEALTHY: 2 };
  const sortedAgents = agentNames.sort((a, b) => {
    const sa = sortOrder[data.agents[a].status] || 2;
    const sb = sortOrder[data.agents[b].status] || 2;
    return sa - sb;
  });
  const mainAgents = sortedAgents.filter(n => n !== 'system' && !n.startsWith('subagent:'));
  const subAgents = sortedAgents.filter(n => n.startsWith('subagent:'));

  function renderAgentRow(name) {
    const ag = data.agents[name];
    const sc = scoreColor(ag.score);
    const stc = statusColor(ag.status);
    const w = (ag.score / 1000) * 100;
    const isSub = name.startsWith('subagent:');
    const subIcon = isSub ? '<span style="color:var(--purple);font-size:8px;margin-right:2px" title="Subagent">&#9670;</span>' : '';
    return `<div class="agent-row">
      <div class="agent-top">
        <span class="status-dot status-${escapeHtml(ag.status)}"></span>
        ${subIcon}<span class="agent-name">${escapeHtml(name)}</span>
        <span class="agent-model">${ag.messages}msg</span>
        <span class="sev-badge" style="color:${stc};background:${stc}18;border:1px solid ${stc}30;font-size:8px">${escapeHtml(ag.status)}</span>
      </div>
      <div class="agent-bottom">
        <span class="agent-stats">${ag.messages}msg &middot; ${ag.rate}% anom</span>
        <div class="score-bar"><div class="score-fill" style="width:${w}%;background:${sc};box-shadow:0 0 6px ${sc}"></div></div>
        <span class="score-num" style="color:${sc}">${ag.score}</span>
        ${ag.decayed_score !== undefined && ag.decayed_score !== ag.score ? '<span style="font-size:7px;color:var(--gray-light);margin-left:4px" title="Historical decay">(' + ag.decayed_score + ')</span>' : ''}
      </div>
    </div>`;
  }

  // Main agents section
  if (mainAgents.length > 0) {
    agentHtml += '<div style="padding:4px 10px 2px;font-size:8px;color:var(--cyan);text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid var(--border)">Main Agents</div>';
    mainAgents.forEach(name => { agentHtml += renderAgentRow(name); });
  }
  // Subagents section
  if (subAgents.length > 0) {
    agentHtml += '<div style="padding:6px 10px 2px;font-size:8px;color:var(--purple);text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid var(--border);margin-top:4px">Subagents</div>';
    subAgents.forEach(name => { agentHtml += renderAgentRow(name); });
  }
  if (mainAgents.length === 0 && subAgents.length === 0) {
    agentHtml = '<div style="padding:20px;text-align:center;color:var(--gray-light);font-size:11px">Waiting for agent activity...</div>';
  }
  agentBody.innerHTML = agentHtml;

  // Distribution
  const distEl = document.getElementById('distribution');
  const dist = data.distribution || [];
  const maxCount = Math.max(...dist.map(d => d.count), 1);
  let distHtml = '';
  dist.forEach(item => {
    const pct = (item.count / maxCount) * 100;
    distHtml += `<div class="dist-row">
      <div class="dist-label">${escapeHtml(item.type)}</div>
      <div class="dist-bar"><div class="dist-fill" style="width:${pct}%;background:${item.color};box-shadow:0 0 6px ${item.color}80"></div></div>
      <div class="dist-count" style="color:${item.color}">${item.count}</div>
    </div>`;
  });
  distEl.innerHTML = distHtml;

  // Circuit Breakers (with manual override support)
  const cbEl = document.getElementById('circuit-breakers');
  const cbOverrides = data.cb_overrides || {};
  let cbHtml = '';
  sortedAgents.forEach(name => {
    const ag = data.agents[name];
    const override = cbOverrides[name] || null;
    const state = override || cbState(ag.rate);
    const isOverride = override !== null && override !== undefined;
    const stateColor = state === 'OPEN' ? 'var(--red)' : state === 'HALF-OPEN' ? 'var(--amber)' : 'var(--green)';
    const barW = Math.min(ag.rate / 60 * 100, 100);
    cbHtml += `<div class="cb-card" style="cursor:pointer" onclick="toggleCB('${escapeHtml(name)}')" title="Click to cycle: AUTO > OPEN > HALF-OPEN > CLOSED">
      <div class="cb-top">
        <div class="cb-name">${escapeHtml(name)}</div>
        <div class="cb-state" style="color:${stateColor};background:${stateColor}18;border:1px solid ${stateColor}30">${state}${isOverride ? ' (manual)' : ''}</div>
      </div>
      <div class="cb-rate">anomaly rate: <span style="color:${stateColor}">${ag.rate}%</span></div>
      <div class="cb-bar"><div class="cb-fill" style="width:${barW}%;background:${stateColor};box-shadow:0 0 4px ${stateColor}"></div></div>
    </div>`;
  });
  cbEl.innerHTML = cbHtml;

  // Tool Call Inspector — skip re-render if a detail panel is open
  const hasOpenDetail = document.querySelector('.tc-detail--open');
  if (!hasOpenDetail) {
    renderToolCalls(data);
  }

  // OWASP Security Panel
  renderOwaspEvents(data);

  // Message History Panel
  renderMessageHistory(data);

  // Task Anchor Panel (Phase 3B)
  renderTaskAnchor(data);

  // Stealth / checkpoint / human-review indicators
  renderStealthIndicators(data);

  // Critical alert sound check
  checkCriticalAlerts(data);

  // Intervention Log
  renderInterventionLog(data);

  // Persistence Engine state
  renderPersistenceState(data);
  renderTokenUsage(data);

  // Session Comparison
  renderSessionComparison(data);

  // Agent Communication Map
  renderCommMap(data);

  // Behavioral Fingerprint Timeline
  renderFingerprintTimeline(data);

  // InsAIts Monitor Status (system agent)
  renderMonitorStatus(data);

  // Subagent Activity Panel
  renderSubagentPanel(data);

  // Context Health (ACM) Panel
  renderACMPanel(data);

  // PHASE_GUARDIAN: Session Health Panel (fetches from collector independently)
  fetchGuardianData();

  // PHASE_GUARDIAN Phase 2: Session Vault
  fetchVaultData();

  // Config Toggles + Anchor Editor load via setTimeout on page load (not here)

  // Viewing session indicator
  const viewBadge = document.getElementById('viewing-badge');
  if (data.viewing_session) {
    viewBadge.style.display = 'inline-block';
    viewBadge.className = 'archived-badge';
    viewBadge.textContent = 'ARCHIVED';
  } else {
    viewBadge.style.display = 'none';
    viewBadge.className = 'viewing-badge';
  }
}

// ---------------------------------------------------------------------------
// Polling
// ---------------------------------------------------------------------------
function fetchData() {
  fetch('/api/data')
    .then(r => r.json())
    .then(data => update(data))
    .catch(() => {
      // Keep last known state on error
      if (lastData) {
        document.getElementById('clock').textContent = new Date().toLocaleTimeString('en', { hour12: false });
      }
    });
}

// ---------------------------------------------------------------------------
// Pause / Resume / Shutdown controls
// ---------------------------------------------------------------------------
let pollInterval = null;
let isPaused = false;

function startPolling() {
  if (pollInterval) clearInterval(pollInterval);
  pollInterval = setInterval(fetchData, 1000);
}

function togglePause() {
  isPaused = !isPaused;
  const btn = document.getElementById('btn-pause');
  const badge = document.getElementById('live-badge');
  if (isPaused) {
    clearInterval(pollInterval);
    pollInterval = null;
    btn.textContent = 'Resume';
    btn.style.background = 'var(--green-dim)';
    btn.style.borderColor = 'rgba(0,255,136,.25)';
    btn.style.color = 'var(--green)';
    badge.textContent = 'PAUSED';
    badge.className = 'paused-badge';
  } else {
    startPolling();
    fetchData();
    btn.textContent = 'Pause';
    btn.style.background = 'var(--cyan-dim)';
    btn.style.borderColor = 'rgba(0,212,255,.25)';
    btn.style.color = 'var(--cyan)';
    // Badge will be updated on next fetchData call
  }
}

function shutdownServer() {
  if (!confirm('Shutdown the InsAIts dashboard server?')) return;
  fetch('/api/shutdown', { method: 'POST' })
    .then(() => {
      document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:var(--gray-light);font-family:Space Mono,monospace;font-size:14px">Dashboard server stopped.</div>';
    })
    .catch(() => {
      document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:var(--gray-light);font-family:Space Mono,monospace;font-size:14px">Dashboard server stopped.</div>';
    });
}

// ---------------------------------------------------------------------------
// Export JSON (downloads current session data as JSON file)
// ---------------------------------------------------------------------------
function exportJSON() {
  fetch('/api/data')
    .then(r => r.json())
    .then(data => {
      // Build export with CVE/OWASP mappings
      const exportData = {
        export_version: '1.0',
        generated_at: new Date().toISOString(),
        session_metadata: {
          session_id: data.session_id || 'live',
          claude_session_id: data.claude_session_id || '',
          start: data.session_start || '',
          end: data.session_latest || '',
        },
        summary: {
          total_messages: data.total_messages || 0,
          total_anomalies: data.total_anomalies || 0,
          anomaly_rate: data.anomaly_rate || 0,
          resolved: data.resolved || 0,
          critical: data.critical || 0,
          agents_monitored: data.agents ? Object.keys(data.agents).length : 0,
        },
        agents: data.agents || {},
        anomalies: (data.feed || []).map(a => ({
          timestamp: a.ts,
          type: a.type,
          severity: a.severity,
          agent: a.agent,
          description: a.description || '',
          resolved: a.resolved || false,
        })),
        owasp_coverage: data.owasp_events || [],
        tool_calls: data.tool_calls || [],
        subagent_calls: data.subagent_calls || [],
      };
      const blob = new Blob([JSON.stringify(exportData, null, 2)], {type: 'application/json'});
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = 'insaits-session-' + new Date().toISOString().slice(0,19).replace(/:/g,'-') + '.json';
      a.click();
      URL.revokeObjectURL(url);
    })
    .catch(err => alert('Export failed: ' + err.message));
}

// ---------------------------------------------------------------------------
// Export PDF (builds PDF via API and downloads)
// ---------------------------------------------------------------------------
function exportPDF() {
  // Build the same export data as JSON, then render as PDF client-side
  fetch('/api/data')
    .then(r => r.json())
    .then(data => {
      const exportData = {
        export_version: '1.0',
        generated_at: new Date().toISOString(),
        session_metadata: {
          session_id: data.session_id || 'live',
          start: data.session_start || '',
          end: data.session_latest || '',
        },
        summary: {
          total_messages: data.total_messages || 0,
          total_anomalies: data.total_anomalies || 0,
          anomaly_rate: data.anomaly_rate || 0,
          resolved: data.resolved || 0,
          critical: data.critical || 0,
          agents_monitored: data.agents ? Object.keys(data.agents).length : 0,
        },
        agents: data.agents || {},
        anomalies: (data.feed || []).map(a => ({
          timestamp: a.ts,
          type: a.type,
          severity: a.severity,
          agent: a.agent,
          description: a.description || '',
          resolved: a.resolved || false,
        })),
        owasp_coverage: data.owasp_events || [],
      };

      // Open a print-friendly page for PDF generation
      const printWin = window.open('', '_blank');
      const sevColor = s => s === 'CRITICAL' ? '#dc3545' : s === 'HIGH' ? '#fd7e14' : s === 'MEDIUM' ? '#ffc107' : '#28a745';
      const sm = exportData.summary;
      const meta = exportData.session_metadata;

      let anomalyRows = '';
      exportData.anomalies.slice(0, 100).forEach(a => {
        anomalyRows += '<tr style="border-bottom:1px solid #eee">'
          + '<td style="padding:4px 6px;font-size:11px">' + (a.timestamp || '').slice(11,19) + '</td>'
          + '<td style="padding:4px 6px;font-size:11px">' + (a.type || '') + '</td>'
          + '<td style="padding:4px 6px;font-size:11px;color:' + sevColor(a.severity) + ';font-weight:bold">' + (a.severity || '') + '</td>'
          + '<td style="padding:4px 6px;font-size:11px">' + (a.agent || '') + '</td>'
          + '<td style="padding:4px 6px;font-size:11px">' + (a.description || '').slice(0,80) + '</td>'
          + '</tr>';
      });

      let agentRows = '';
      if (exportData.agents && typeof exportData.agents === 'object') {
        Object.entries(exportData.agents).forEach(([id, info]) => {
          const msgs = info.messages || 0;
          const anoms = info.anomalies || 0;
          const rate = msgs > 0 ? ((anoms/msgs)*100).toFixed(1) : '0.0';
          const score = Math.max(0, 1000 - anoms * 50);
          const status = rate > 20 ? 'CRITICAL' : rate > 10 ? 'WARNING' : 'HEALTHY';
          agentRows += '<tr style="border-bottom:1px solid #eee">'
            + '<td style="padding:4px 6px;font-size:11px">' + id + '</td>'
            + '<td style="padding:4px 6px;font-size:11px;text-align:center">' + msgs + '</td>'
            + '<td style="padding:4px 6px;font-size:11px;text-align:center">' + anoms + '</td>'
            + '<td style="padding:4px 6px;font-size:11px;text-align:center">' + rate + '%</td>'
            + '<td style="padding:4px 6px;font-size:11px;text-align:center">' + score + '</td>'
            + '<td style="padding:4px 6px;font-size:11px;text-align:center;color:' + sevColor(status) + '">' + status + '</td>'
            + '</tr>';
        });
      }

      printWin.document.write('<!DOCTYPE html><html><head><title>InsAIts Security Report</title>'
        + '<style>body{font-family:Arial,sans-serif;margin:40px;color:#333}'
        + 'h1{color:#009688;margin-bottom:0}h2{color:#555;border-bottom:2px solid #009688;padding-bottom:4px;margin-top:30px}'
        + '.card{display:inline-block;text-align:center;padding:10px 20px;margin:5px;border:1px solid #ddd;border-radius:6px;min-width:80px}'
        + '.card .val{font-size:24px;font-weight:bold}.card .lbl{font-size:10px;color:#888;text-transform:uppercase}'
        + 'table{width:100%;border-collapse:collapse}th{background:#009688;color:white;padding:6px;font-size:11px;text-align:left}'
        + '.footer{margin-top:40px;padding-top:10px;border-top:1px solid #009688;font-size:9px;color:#999}'
        + '@media print{body{margin:20px}}</style></head><body>'
        + '<h1>InsAIts</h1><p style="color:#666;font-size:14px">AI Runtime Security Report</p>'
        + '<h2>Session Information</h2>'
        + '<table style="width:auto"><tr><td style="padding:2px 15px 2px 0;font-weight:bold;font-size:12px">Session ID</td><td style="font-size:12px">' + meta.session_id + '</td></tr>'
        + '<tr><td style="padding:2px 15px 2px 0;font-weight:bold;font-size:12px">Start</td><td style="font-size:12px">' + (meta.start || 'N/A').slice(0,19) + '</td></tr>'
        + '<tr><td style="padding:2px 15px 2px 0;font-weight:bold;font-size:12px">End</td><td style="font-size:12px">' + (meta.end || 'N/A').slice(0,19) + '</td></tr>'
        + '<tr><td style="padding:2px 15px 2px 0;font-weight:bold;font-size:12px">Generated</td><td style="font-size:12px">' + exportData.generated_at.slice(0,19) + '</td></tr></table>'
        + '<h2>Summary</h2>'
        + '<div class="card"><div class="val">' + sm.total_messages + '</div><div class="lbl">Messages</div></div>'
        + '<div class="card"><div class="val">' + sm.total_anomalies + '</div><div class="lbl">Anomalies</div></div>'
        + '<div class="card"><div class="val" style="color:' + (sm.anomaly_rate > 15 ? '#dc3545' : sm.anomaly_rate > 5 ? '#fd7e14' : '#28a745') + '">' + sm.anomaly_rate + '%</div><div class="lbl">Anomaly Rate</div></div>'
        + '<div class="card"><div class="val" style="color:' + (sm.critical > 0 ? '#dc3545' : '#28a745') + '">' + sm.critical + '</div><div class="lbl">Critical</div></div>'
        + '<div class="card"><div class="val">' + sm.resolved + '</div><div class="lbl">Resolved</div></div>'
        + '<div class="card"><div class="val">' + sm.agents_monitored + '</div><div class="lbl">Agents</div></div>'
        + (agentRows ? '<h2>Agent Trust Scores</h2><table><tr><th>Agent</th><th>Messages</th><th>Anomalies</th><th>Rate</th><th>Score</th><th>Status</th></tr>' + agentRows + '</table>' : '')
        + (anomalyRows ? '<h2>Anomaly Timeline (' + exportData.anomalies.length + ' events)</h2><table><tr><th>Time</th><th>Type</th><th>Severity</th><th>Agent</th><th>Description</th></tr>' + anomalyRows + '</table>' : '<h2>Anomaly Timeline</h2><p style="color:#28a745">No anomalies detected. Session is healthy.</p>')
        + '<div class="footer">Generated by InsAIts - Runtime AI Security Monitor | https://nomadu27.github.io/InsAIts/<br>Use browser Print (Ctrl+P) to save as PDF</div>'
        + '</body></html>');
      printWin.document.close();
      // Auto-trigger print dialog after a short delay
      setTimeout(() => printWin.print(), 500);
    })
    .catch(err => alert('PDF export failed: ' + err.message));
}

// ---------------------------------------------------------------------------
// Critical Alert Sound (Web Audio API oscillator)
// ---------------------------------------------------------------------------
let lastKnownCriticalCount = 0;
function playAlertSound() {
  try {
    const ctx = new (window.AudioContext || window.webkitAudioContext)();
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    osc.connect(gain);
    gain.connect(ctx.destination);
    osc.frequency.value = 880;
    osc.type = 'square';
    gain.gain.value = 0.1;
    osc.start();
    osc.stop(ctx.currentTime + 0.15);
  } catch (e) {}
}

function checkCriticalAlerts(data) {
  const currentCritical = data.critical || 0;
  if (currentCritical > lastKnownCriticalCount && lastKnownCriticalCount > 0) {
    if (document.hidden === true) {
      playAlertSound();
    }
  }
  lastKnownCriticalCount = currentCritical;
}

// ---------------------------------------------------------------------------
// Tool Call Inspector rendering
// ---------------------------------------------------------------------------
function renderToolCalls(data) {
  const tcBody = document.getElementById('tc-body');
  // tool_calls is pre-filtered to main-agent only (subagents in data.subagent_calls)
  const calls = data.tool_calls || [];
  document.getElementById('tc-count').textContent = calls.length + ' calls';
  let html = '';
  calls.forEach((item, idx) => {
    const anomalyDisplay = item.has_anomaly
      ? `<span style="color:var(--red);font-weight:700">${item.anomaly_count}</span>`
      : `<span style="color:var(--green)">&#10003;</span>`;
    const detailId = 'tc-detail-' + idx;
    const subBadge = item.parent_agent_id
      ? `<span style="font-size:7px;color:var(--purple);background:rgba(180,80,255,.15);padding:1px 4px;border-radius:3px;margin-left:4px" title="Subagent of ${escapeHtml(item.parent_agent_id.slice(0,12))}">SUB</span>`
      : '';
    html += `<div class="tc-row">
      <div class="tc-time">${escapeHtml(timeAgo(item.ts))}</div>
      <div class="tc-agent">${escapeHtml(item.agent || 'unknown')}${subBadge}</div>
      <div class="tc-tool">${escapeHtml((item.tool || 'unknown').replace(/_/g, ' '))}</div>
      <div class="tc-anomaly">${anomalyDisplay}</div>
      <div class="tc-expand" data-detail="${detailId}" title="Details">&#8943;</div>
    </div>`;
    // Build detail content
    let detailHtml = '<div class="tc-detail-label">Timestamp</div>';
    detailHtml += '<div class="tc-detail-value">' + escapeHtml(item.ts || 'N/A') + '</div>';
    detailHtml += '<div class="tc-detail-label">Agent</div>';
    detailHtml += '<div class="tc-detail-value">' + escapeHtml(item.agent || 'unknown') + '</div>';
    detailHtml += '<div class="tc-detail-label">Tool</div>';
    detailHtml += '<div class="tc-detail-value">' + escapeHtml(item.tool || 'unknown') + '</div>';
    if (item.parent_agent_id) {
      detailHtml += '<div class="tc-detail-label">Subagent Attribution</div>';
      detailHtml += '<div class="tc-detail-value" style="color:var(--purple)">' +
        '<span style="font-size:8px;background:rgba(180,80,255,.15);padding:2px 6px;border-radius:3px">&#9670; ' +
        escapeHtml(item.subagent_type || 'unknown') + '</span>' +
        ' &larr; parent: <span style="font-size:8px;color:var(--gray-light)">' + escapeHtml(item.parent_agent_id.slice(0,20)) + '</span>' +
        '</div>';
      if (item.subagent_task) {
        detailHtml += '<div class="tc-detail-label">Subagent Task</div>';
        detailHtml += '<div class="tc-detail-value" style="font-size:9px;color:var(--gray-light)">' + escapeHtml(item.subagent_task) + '</div>';
      }
    }
    if (item.msg_id) {
      detailHtml += '<div class="tc-detail-label">Message ID</div>';
      detailHtml += '<div class="tc-detail-value" style="font-size:9px;color:var(--gray-light)">' + escapeHtml(item.msg_id) + '</div>';
    }
    detailHtml += '<div class="tc-detail-label">Status</div>';
    detailHtml += '<div class="tc-detail-value" style="color:' + (item.resolved ? 'var(--green)' : (item.has_anomaly ? 'var(--red)' : 'var(--green)')) + '">' + (item.resolved ? 'RESOLVED' : (item.has_anomaly ? 'UNRESOLVED' : 'CLEAN')) + '</div>';
    const anomalies = item.anomalies || [];
    if (anomalies.length > 0) {
      detailHtml += '<div class="tc-detail-label">Anomalies (' + anomalies.length + ')</div>';
      anomalies.forEach(a => {
        const cls = item.resolved ? 'tc-detail-anomaly tc-detail-anomaly--resolved' : 'tc-detail-anomaly';
        detailHtml += '<div class="' + cls + '">';
        detailHtml += '<div class="tc-detail-field"><span class="tc-detail-field-key">Type</span><span class="tc-detail-field-val">' + escapeHtml((a.type || '').replace(/_/g, ' ')) + '</span></div>';
        detailHtml += '<div class="tc-detail-field"><span class="tc-detail-field-key">Severity</span><span class="tc-detail-field-val" style="color:' + severityColor(a.severity) + '">' + escapeHtml(a.severity || 'MEDIUM') + '</span></div>';
        if (a.description) {
          detailHtml += '<div class="tc-detail-field"><span class="tc-detail-field-key">Description</span><span class="tc-detail-field-val">' + escapeHtml(a.description) + '</span></div>';
        }
        if (a.intervention) {
          detailHtml += '<div class="tc-detail-field"><span class="tc-detail-field-key">Action</span><span class="tc-detail-field-val" style="color:var(--amber)">' + escapeHtml(a.intervention) + '</span></div>';
        }
        detailHtml += '</div>';
      });
    } else {
      detailHtml += '<div class="tc-detail-label">Anomalies</div>';
      detailHtml += '<div class="tc-detail-value" style="color:var(--green)">None detected</div>';
    }
    html += '<div class="tc-detail" id="' + detailId + '">' + detailHtml + '</div>';
  });
  if (calls.length === 0) {
    html = '<div style="padding:20px;text-align:center;color:var(--gray-light);font-size:11px">No tool calls recorded yet.</div>';
  }
  tcBody.innerHTML = html;

  // Attach click handlers to expand buttons
  tcBody.querySelectorAll('.tc-expand').forEach(btn => {
    btn.addEventListener('click', function(e) {
      e.stopPropagation();
      const targetId = this.getAttribute('data-detail');
      const detail = document.getElementById(targetId);
      if (!detail) return;
      const isOpen = detail.classList.contains('tc-detail--open');
      // Close all other open details
      tcBody.querySelectorAll('.tc-detail--open').forEach(d => d.classList.remove('tc-detail--open'));
      tcBody.querySelectorAll('.tc-expand').forEach(b => { b.style.color = ''; });
      if (!isOpen) {
        detail.classList.add('tc-detail--open');
        this.style.color = 'var(--cyan)';
      }
    });
  });
}

// ---------------------------------------------------------------------------
// Subagent Activity Panel
// ---------------------------------------------------------------------------
function renderSubagentPanel(data) {
  const calls = data.subagent_calls || [];
  const countEl = document.getElementById('sub-count');
  const summaryEl = document.getElementById('sub-summary');
  const bodyEl = document.getElementById('sub-body');
  countEl.textContent = calls.length + ' calls';

  if (calls.length === 0) {
    summaryEl.style.display = 'none';
    bodyEl.innerHTML = '<div style="padding:20px;text-align:center;color:var(--gray-light);font-size:11px">No subagent activity detected. Subagent tool calls from Agent/Explore/Plan spawns appear here.</div>';
    return;
  }

  // Compute summary stats
  const byType = {};       // subagent_type -> count
  const byTool = {};       // tool name -> count
  const byAgent = {};      // unique agentId -> {type, tools, count}
  calls.forEach(c => {
    const st = c.subagent_type || 'unknown';
    byType[st] = (byType[st] || 0) + 1;
    const tool = c.tool || 'unknown';
    byTool[tool] = (byTool[tool] || 0) + 1;
    const aid = c.parent_agent_id || 'unknown';
    if (!byAgent[aid]) byAgent[aid] = { type: st, tools: new Set(), count: 0 };
    byAgent[aid].tools.add(tool);
    byAgent[aid].count++;
  });

  // Summary bar
  summaryEl.style.display = 'flex';
  summaryEl.style.flexWrap = 'wrap';
  summaryEl.style.gap = '12px';
  summaryEl.style.alignItems = 'center';
  summaryEl.style.fontSize = '10px';

  const uniqueAgents = Object.keys(byAgent).length;
  const topTool = Object.entries(byTool).sort((a,b) => b[1]-a[1])[0];
  const typeLabels = Object.entries(byType).map(([k,v]) =>
    `<span style="color:var(--purple);background:rgba(180,80,255,.12);padding:1px 6px;border-radius:3px">${escapeHtml(k)}: ${v}</span>`
  ).join(' ');

  summaryEl.innerHTML =
    `<span style="color:var(--white);font-weight:700">${calls.length}</span><span style="color:var(--gray-light)">total calls</span>` +
    `<span style="color:var(--gray-light)">&middot;</span>` +
    `<span style="color:var(--cyan);font-weight:700">${uniqueAgents}</span><span style="color:var(--gray-light)">unique agents</span>` +
    `<span style="color:var(--gray-light)">&middot;</span>` +
    `<span style="color:var(--gray-light)">top tool:</span><span style="color:var(--green)">${topTool ? escapeHtml(topTool[0]) + ' (' + topTool[1] + ')' : 'N/A'}</span>` +
    `<span style="color:var(--gray-light)">&middot;</span>` + typeLabels;

  // Tool breakdown mini-chart
  const maxToolCount = Math.max(...Object.values(byTool), 1);
  let toolChartHtml = '<div style="padding:8px 12px;border-bottom:1px solid var(--border)">';
  toolChartHtml += '<div style="font-size:9px;color:var(--gray-light);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px">Tool Breakdown</div>';
  Object.entries(byTool).sort((a,b) => b[1]-a[1]).forEach(([tool, count]) => {
    const pct = (count / maxToolCount) * 100;
    toolChartHtml += `<div style="display:flex;align-items:center;gap:6px;margin:2px 0">
      <div style="width:60px;font-size:9px;color:var(--cyan);text-align:right;flex-shrink:0">${escapeHtml(tool)}</div>
      <div style="flex:1;height:10px;background:var(--bg-dark);border-radius:2px;overflow:hidden">
        <div style="width:${pct}%;height:100%;background:var(--purple);box-shadow:0 0 4px rgba(157,78,221,.5);border-radius:2px"></div>
      </div>
      <div style="width:28px;font-size:9px;color:var(--gray-light);text-align:right">${count}</div>
    </div>`;
  });
  toolChartHtml += '</div>';

  // Call list (newest first, already sorted)
  let listHtml = '';
  calls.slice(0, 100).forEach((item, idx) => {
    const subType = item.subagent_type || 'unknown';
    const hasAnomaly = item.has_anomaly;
    const anomalyBadge = hasAnomaly
      ? `<span style="color:var(--red);font-weight:700;font-size:9px;margin-left:4px">${item.anomaly_count} anomal${item.anomaly_count === 1 ? 'y' : 'ies'}</span>`
      : `<span style="color:var(--green);font-size:9px;margin-left:4px">&#10003;</span>`;
    const taskSnippet = item.subagent_task
      ? `<div style="font-size:8px;color:var(--gray-light);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:300px" title="${escapeHtml(item.subagent_task)}">${escapeHtml(item.subagent_task)}</div>`
      : '';
    listHtml += `<div style="display:flex;align-items:flex-start;gap:8px;padding:4px 12px;border-bottom:1px solid rgba(255,255,255,.03);${hasAnomaly ? 'background:rgba(255,45,85,.04)' : ''}">
      <div style="width:55px;font-size:8px;color:var(--gray-light);flex-shrink:0;padding-top:2px">${escapeHtml(timeAgo(item.ts))}</div>
      <div style="width:70px;flex-shrink:0">
        <span style="font-size:8px;color:var(--purple);background:rgba(180,80,255,.12);padding:1px 5px;border-radius:3px">&#9670; ${escapeHtml(subType)}</span>
      </div>
      <div style="flex:1;min-width:0">
        <div style="display:flex;align-items:center;gap:4px">
          <span style="font-size:10px;color:var(--cyan);font-weight:600">${escapeHtml(item.tool || 'unknown')}</span>
          ${anomalyBadge}
        </div>
        ${taskSnippet}
      </div>
    </div>`;
  });

  if (calls.length > 100) {
    listHtml += `<div style="padding:8px 12px;text-align:center;font-size:9px;color:var(--gray-light)">Showing 100 of ${calls.length} calls</div>`;
  }

  bodyEl.innerHTML = toolChartHtml + listHtml;
}

function severityColor(sev) {
  switch ((sev || '').toUpperCase()) {
    case 'CRITICAL': return 'var(--red)';
    case 'HIGH': return '#ff6b35';
    case 'MEDIUM': return 'var(--amber)';
    case 'LOW': return 'var(--green)';
    default: return 'var(--gray-light)';
  }
}

// ---------------------------------------------------------------------------
// Context Health (ACM) Panel rendering
// ---------------------------------------------------------------------------
function renderACMPanel(data) {
  const acm = data.acm_state || {};
  const statusEl = document.getElementById('acm-status');

  if (!acm || Object.keys(acm).length === 0) {
    if (statusEl) statusEl.textContent = 'awaiting first cycle';
    // Show zeroed defaults instead of '--' placeholders
    const setZ = (id, v) => { const e = document.getElementById(id); if(e) e.textContent = v; };
    setZ('acm-hot', '0'); setZ('acm-cold', '0'); setZ('acm-compressed', '0');
    setZ('acm-duplicates', '0'); setZ('acm-ratio', '0%'); setZ('acm-tokens', '0');
    const riskEl0 = document.getElementById('acm-risk');
    if (riskEl0) { riskEl0.textContent = 'none'; riskEl0.style.color = 'var(--green)'; }
    const guardEl0 = document.getElementById('acm-guard-stats');
    if (guardEl0) guardEl0.innerHTML = 'Approvals: <b>0</b><br>Vetoes: <b>0</b><br>Veto rate: <b>0%</b>';
    const cycleEl0 = document.getElementById('acm-last-cycle');
    if (cycleEl0) cycleEl0.innerHTML = 'Result: <b>idle</b><br>Approved: <b style="color:var(--green)">0</b> &middot; Vetoed: <b style="color:var(--red)">0</b>';
    return;
  }

  // Update KPIs
  const hot = acm.hot_items || 0;
  const cold = acm.cold_items || 0;
  const compressed = acm.compressed_items || 0;
  const duplicates = acm.duplicates_compressed || 0;
  const ratio = acm.compression_ratio || 0;
  const tokensSaved = acm.estimated_tokens_saved || 0;
  const risk = acm.quality_risk || 'none';
  const cycles = acm.compression_cycles || 0;

  const setT = (id, v) => { const e = document.getElementById(id); if(e) e.textContent = v; };
  setT('acm-hot', hot);
  setT('acm-cold', cold);
  setT('acm-compressed', compressed);
  setT('acm-duplicates', duplicates);
  setT('acm-ratio', ratio + '%');
  setT('acm-tokens', tokensSaved > 1000 ? (tokensSaved/1000).toFixed(1) + 'K' : tokensSaved);

  // Quality risk with color coding
  const riskEl = document.getElementById('acm-risk');
  if (riskEl) {
    riskEl.textContent = risk;
    const riskColors = { none: 'var(--green)', low: 'var(--amber)', medium: 'var(--red)' };
    riskEl.style.color = riskColors[risk] || 'var(--green)';
  }

  // Status badge
  if (statusEl) {
    const lastResult = acm.last_cycle_result || 'idle';
    const resultColors = { approved: 'var(--green)', partial: 'var(--amber)', vetoed: 'var(--red)' };
    statusEl.innerHTML = `cycle #${cycles} &middot; <span style="color:${resultColors[lastResult] || 'var(--gray-light)'}">${lastResult}</span>`;
  }

  // Guard stats
  const guardEl = document.getElementById('acm-guard-stats');
  if (guardEl && acm.guard_stats) {
    const gs = acm.guard_stats;
    guardEl.innerHTML = [
      `Approvals: <b>${gs.approvals || 0}</b>`,
      `Vetoes: <b style="color:${gs.vetoes > 0 ? 'var(--red)' : 'var(--text)'}">${gs.vetoes || 0}</b>`,
      `Veto rate: <b>${gs.veto_rate_pct || 0}%</b>`,
    ].join('<br>');
    if (gs.recent_veto_reasons && gs.recent_veto_reasons.length > 0) {
      const last = gs.recent_veto_reasons[gs.recent_veto_reasons.length - 1];
      guardEl.innerHTML += `<br><span style="color:var(--gray-light);font-size:9px">Last: ${Array.isArray(last) ? last.join(', ') : last}</span>`;
    }
  }

  // Last cycle info
  const cycleEl = document.getElementById('acm-last-cycle');
  if (cycleEl) {
    const approved = acm.last_cycle_approved || 0;
    const vetoed = acm.last_cycle_vetoed || 0;
    const result = acm.last_cycle_result || '--';
    const reasons = acm.last_cycle_reasons || [];
    let html = `Result: <b>${result}</b><br>`;
    html += `Approved: <b style="color:var(--green)">${approved}</b> &middot; Vetoed: <b style="color:var(--red)">${vetoed}</b>`;
    if (reasons.length > 0) {
      html += `<br><span style="color:var(--gray-light);font-size:9px">${reasons.join(', ')}</span>`;
    }
    cycleEl.innerHTML = html;
  }
}

// ---------------------------------------------------------------------------
// OWASP Security Panel rendering
// ---------------------------------------------------------------------------
function renderOwaspEvents(data) {
  const owaspBody = document.getElementById('owasp-body');
  const events = data.owasp_events || [];
  const totalEvents = events.reduce((s, e) => s + e.count, 0);
  const firingCount = events.filter(e => e.count > 0).length;
  document.getElementById('owasp-count').textContent = totalEvents + ' events | ' + firingCount + '/' + events.length + ' codes active';
  let html = '';
  events.forEach((item, idx) => {
    const countColor = item.count > 0 ? 'var(--red)' : 'var(--gray-light)';
    const typeName = (item.type || '').replace(/_/g, ' ').toUpperCase();
    const lastSeen = item.last_seen ? timeAgo(item.last_seen) : 'never';
    const sevClass = item.count > 0 ? 'sev-' + item.severity : '';
    const owaspIds = (item.owasp_ids || [item.code]).join(', ');
    const cveRefs = (item.cve_refs || []);
    const desc = item.description || '';
    const hasExplainer = item.explainer_what || item.explainer_risk;
    html += `<div class="owasp-row${hasExplainer ? ' owasp-row-clickable' : ''}" style="${item.count > 0 ? 'background:rgba(255,45,85,.04)' : ''}" ${hasExplainer ? 'onclick="toggleOwaspDetail(' + idx + ')"' : ''}>
      <div class="owasp-code" title="${escapeHtml(owaspIds)}">${escapeHtml(item.code)}</div>
      <div>
        <div class="owasp-type">${escapeHtml(item.explainer_name || typeName)}${hasExplainer ? ' <span style="font-size:8px;color:var(--cyan);opacity:.6">&#9432;</span>' : ''}</div>
        ${desc ? '<div class="owasp-type-desc">' + escapeHtml(desc) + '</div>' : ''}
        ${cveRefs.length > 0 ? '<div class="owasp-cves">CVE: ' + cveRefs.map(c => escapeHtml(c)).join(', ') + '</div>' : ''}
      </div>
      <div class="owasp-count" style="color:${countColor}">${item.count}</div>
      <div class="owasp-lastseen">${escapeHtml(lastSeen)}</div>
      <div>${item.count > 0 ? '<span class="sev-badge ' + sevClass + '">' + escapeHtml(item.severity) + '</span>' : '<span class="sev-badge" style="color:var(--gray-light);background:var(--border);border:1px solid var(--border)">NONE</span>'}</div>
    </div>`;
    if (hasExplainer) {
      html += `<div class="owasp-detail" id="owasp-detail-${idx}" style="display:none">
        <div class="owasp-detail-section">
          <div class="owasp-detail-label">WHAT IT DETECTS</div>
          <div class="owasp-detail-text">${escapeHtml(item.explainer_what || '')}</div>
        </div>
        <div class="owasp-detail-section">
          <div class="owasp-detail-label">WHY IT MATTERS</div>
          <div class="owasp-detail-text" style="color:var(--amber)">${escapeHtml(item.explainer_risk || '')}</div>
        </div>
        <div class="owasp-detail-section">
          <div class="owasp-detail-label">HOW INSAITS HANDLES IT</div>
          <div class="owasp-detail-text" style="color:var(--green)">${escapeHtml(item.explainer_response || '')}</div>
        </div>
      </div>`;
    }
  });
  if (events.length === 0) {
    html = '<div style="padding:20px;text-align:center;color:var(--gray-light);font-size:11px">No OWASP MCP events detected.</div>';
  }
  owaspBody.innerHTML = html;
  // Restore expanded state for any previously-opened detail rows
  _owaspOpenDetails.forEach(idx => {
    const el = document.getElementById('owasp-detail-' + idx);
    if (el) el.style.display = 'block';
  });
}

// Track which OWASP detail rows are expanded (persists across re-renders)
const _owaspOpenDetails = new Set();

function toggleOwaspDetail(idx) {
  const el = document.getElementById('owasp-detail-' + idx);
  if (!el) return;
  if (_owaspOpenDetails.has(idx)) {
    _owaspOpenDetails.delete(idx);
    el.style.display = 'none';
  } else {
    _owaspOpenDetails.add(idx);
    el.style.display = 'block';
  }
}

// ---------------------------------------------------------------------------
// Message History Panel rendering
// ---------------------------------------------------------------------------
function renderMessageHistory(data) {
  const mhBody = document.getElementById('mh-body');
  const messages = data.message_history || [];
  const countEl = document.getElementById('mh-count');

  // Apply filter
  let filtered = messages;
  if (_msgHistoryFilter === 'intervention') {
    filtered = messages.filter(m => m.message_type === 'intervention');
  } else if (_msgHistoryFilter === 'tool') {
    filtered = messages.filter(m => m.message_type === 'tool_input' || m.message_type === 'tool_output');
  } else if (_msgHistoryFilter === 'circuit_breaker') {
    filtered = messages.filter(m => m.message_type === 'circuit_breaker_event');
  }

  countEl.textContent = filtered.length + ' messages' + (filtered.length !== messages.length ? ' (filtered from ' + messages.length + ')' : '');

  // Update filter button active state
  document.querySelectorAll('.mh-filter-btn').forEach(btn => {
    if (btn.dataset.filter === _msgHistoryFilter) {
      btn.classList.add('mh-filter-active');
    } else {
      btn.classList.remove('mh-filter-active');
    }
  });

  let html = '';
  filtered.forEach((msg, idx) => {
    const isAgent = msg.direction === 'agent_to_insaits';
    const dirClass = isAgent ? 'mh-row--agent' : 'mh-row--insaits';
    const agentClass = isAgent ? 'mh-agent--agent' : 'mh-agent--insaits';
    const dirIcon = isAgent ? '&#8594;' : '&#8592;';
    const dirTitle = isAgent ? 'Agent to InsAIts' : 'InsAIts to Agent';
    const dirColor = isAgent ? 'color:var(--cyan)' : 'color:#c77dff';
    const msgType = msg.message_type || 'unknown';
    const typeClass = 'mh-type-' + msgType;
    const preview = msg.content_preview || '(no preview)';
    const hashTrunc = (msg.content_hash || '').substring(0, 8);
    const ts = msg.timestamp || '';

    // Severity badge
    let sevBadge = '';
    if (msg.anomaly_severity) {
      const sevColors = { CRITICAL: 'var(--red)', HIGH: '#ff8c00', MEDIUM: 'var(--amber)', LOW: 'var(--gray-light)' };
      const sevColor = sevColors[msg.anomaly_severity] || 'var(--gray-light)';
      sevBadge = ' <span class="sev-badge" style="color:' + sevColor + ';background:' + sevColor + '18;border:1px solid ' + sevColor + '30;font-size:7px;margin-left:4px">' + escapeHtml(msg.anomaly_severity) + '</span>';
    }

    html += '<div class="mh-row ' + dirClass + '" onclick="toggleMsgHistoryDetail(' + idx + ')" title="' + escapeHtml(dirTitle) + ' - Click to expand">';
    html += '<div class="mh-time">' + escapeHtml(timeAgo(ts)) + '</div>';
    html += '<div class="mh-dir-icon" style="' + dirColor + '">' + dirIcon + '</div>';
    html += '<div class="mh-agent ' + agentClass + '">' + escapeHtml(msg.agent_id || 'unknown') + '</div>';
    html += '<div class="mh-preview"><span class="mh-type-badge ' + typeClass + '">' + escapeHtml(msgType.replace(/_/g, ' ')) + '</span>' + sevBadge + ' ' + escapeHtml(preview) + '</div>';
    html += '<div class="mh-hash">' + escapeHtml(hashTrunc) + '</div>';
    html += '<div style="font-size:10px;color:var(--gray-light);text-align:center;cursor:pointer">' + (_msgHistoryExpanded.has(idx) ? '&#9660;' : '&#9654;') + '</div>';
    html += '</div>';

    // Expandable detail row
    const fullContent = msg.content_full || msg.content_preview || '(no content available)';
    const openClass = _msgHistoryExpanded.has(idx) ? ' mh-detail--open' : '';
    html += '<div class="mh-detail' + openClass + '" id="mh-detail-' + idx + '">';
    html += '<div class="mh-detail-label">Full Content</div>';
    html += '<div class="mh-detail-value">' + escapeHtml(fullContent) + '</div>';
    if (msg.triggered_by) {
      html += '<div class="mh-detail-label">Triggered By</div>';
      html += '<div class="mh-detail-value" style="color:var(--amber)">' + escapeHtml(msg.triggered_by) + '</div>';
    }
    if (msg.session_id) {
      html += '<div class="mh-detail-label">Session ID</div>';
      html += '<div class="mh-detail-value" style="color:var(--gray-light);font-size:9px">' + escapeHtml(msg.session_id) + '</div>';
    }
    if (msg.content_hash) {
      html += '<div class="mh-detail-label">Content Hash</div>';
      html += '<div class="mh-detail-value" style="color:var(--gray-light);font-size:9px">' + escapeHtml(msg.content_hash) + '</div>';
    }
    html += '</div>';
  });

  if (filtered.length === 0) {
    html = '<div style="padding:20px;text-align:center;color:var(--gray-light);font-size:11px">' + (messages.length === 0 ? 'No message history recorded yet.' : 'No messages match the current filter.') + '</div>';
  }
  mhBody.innerHTML = html;
}

function setMsgHistoryFilter(filter) {
  _msgHistoryFilter = filter;
  if (lastData) renderMessageHistory(lastData);
}

function toggleMsgHistoryDetail(idx) {
  const el = document.getElementById('mh-detail-' + idx);
  if (!el) return;
  if (_msgHistoryExpanded.has(idx)) {
    _msgHistoryExpanded.delete(idx);
    el.classList.remove('mh-detail--open');
  } else {
    _msgHistoryExpanded.add(idx);
    el.classList.add('mh-detail--open');
  }
}

// ---------------------------------------------------------------------------
// Task Anchor Panel rendering (Phase 3B)
// ---------------------------------------------------------------------------
function renderTaskAnchor(data) {
  const body = document.getElementById('ta-body');
  const countEl = document.getElementById('ta-count');
  if (!body || !countEl) return;

  const ta = data.task_anchor || {};
  const injections = ta.recent_injections || [];
  const count = ta.injection_count || 0;
  const memEnabled = ta.memory_check_enabled || false;
  const lastTs = ta.last_injection_ts || null;
  const anchorText = ta.user_anchor_text || '(no anchor loaded)';

  countEl.textContent = count + ' injection' + (count !== 1 ? 's' : '');

  let html = '';

  // Status row
  html += '<div class="ta-status-row">';
  html += '<div class="ta-status-item"><span class="ta-status-dot ta-status-dot--on"></span> Anchor Active</div>';
  html += '<div class="ta-status-item"><span class="ta-status-dot ' + (memEnabled ? 'ta-status-dot--on' : 'ta-status-dot--off') + '"></span> Memory Check ' + (memEnabled ? 'ON' : 'OFF') + '</div>';
  html += '<div class="ta-status-item">Injections: <span style="color:var(--cyan);font-weight:700">' + count + '</span></div>';
  if (lastTs) {
    html += '<div class="ta-status-item">Last: <span style="color:var(--white)">' + escapeHtml(timeAgo(lastTs)) + '</span></div>';
  }
  html += '</div>';

  // Anchor text
  html += '<div class="ta-section-label">Current User Anchor</div>';
  html += '<pre class="ta-anchor-pre">' + escapeHtml(anchorText) + '</pre>';

  // Recent injections timeline (preserve scroll position)
  html += '<div class="ta-section-label">Recent Anchor Injections</div>';
  html += '<div class="ta-injections-list" id="ta-injections-scroll" style="max-height:300px;overflow-y:auto">';
  if (injections.length === 0) {
    html += '<div style="padding:10px 0;text-align:center;color:var(--gray-light);font-size:10px">No anchor injections recorded yet.</div>';
  } else {
    injections.forEach(function(inj) {
      var rowClass = 'ta-injection-row ta-injection-row--anchor';
      var layerBadge = '';
      // Layer badges (L1=opus, L2=agent, L3=subagent)
      if (inj.layers) {
        var parts = inj.layers.split('+');
        var layerColors = {opus:'#00d4ff', agent:'#f0b400', subagent:'#ff6b6b'};
        var layerLabels = {opus:'L1', agent:'L2', subagent:'L3'};
        parts.forEach(function(p) {
          var c = layerColors[p] || 'var(--gray-light)';
          var label = layerLabels[p] || p;
          layerBadge += ' <span style="font-size:7px;color:' + c + ';background:' + c + '15;padding:1px 3px;border-radius:2px;font-weight:700">' + label + '</span>';
        });
      }
      // Trigger type badge
      if (inj.anchor_type) {
        layerBadge += ' <span style="font-size:7px;color:var(--green);background:var(--green)15;padding:1px 3px;border-radius:2px">' + escapeHtml(inj.anchor_type) + '</span>';
      }
      var injId = 'ta-inj-' + inj.ts.replace(/[^a-zA-Z0-9]/g, '');
      html += '<div class="' + rowClass + '" style="cursor:pointer" onclick="var el=document.getElementById(\'' + injId + '\');if(el)el.style.display=el.style.display===\'none\'?\'block\':\'none\'">';
      html += '<div class="ta-injection-time">' + escapeHtml(timeAgo(inj.ts)) + '</div>';
      html += '<div class="ta-injection-tool">' + escapeHtml(inj.tool || 'unknown') + layerBadge + '</div>';
      html += '<div class="ta-injection-len">' + (inj.anchor_length || 0) + ' chars</div>';
      html += '</div>';
      if (inj.text) {
        html += '<pre id="' + injId + '" style="display:none;font-size:9px;color:var(--gray-light);background:var(--surface);padding:6px 8px;margin:0 0 4px 0;border-left:2px solid var(--cyan);white-space:pre-wrap;word-break:break-word;max-height:150px;overflow-y:auto">' + escapeHtml(inj.text) + '</pre>';
      }
    });
  }
  html += '</div>';

  // Preserve scroll position across re-renders
  const oldScroll = document.getElementById('ta-injections-scroll');
  const savedScrollTop = oldScroll ? oldScroll.scrollTop : 0;
  body.innerHTML = html;
  const newScroll = document.getElementById('ta-injections-scroll');
  if (newScroll && savedScrollTop > 0) newScroll.scrollTop = savedScrollTop;
}

function renderStealthIndicators(data) {
  const stealthBadge = document.getElementById('stealth-badge');
  const btnStealth = document.getElementById('btn-stealth');
  const footerCheckpoints = document.getElementById('footer-checkpoints');
  const footerHumanReview = document.getElementById('footer-human-review');

  // Update toggle button state from server
  const enabled = data.stealth_enabled === true;
  if (btnStealth) {
    btnStealth.textContent = enabled ? 'STEALTH: ON' : 'STEALTH: OFF';
    btnStealth.style.background = enabled ? 'rgba(157,78,221,.3)' : 'rgba(157,78,221,.12)';
    btnStealth.style.borderColor = enabled ? 'rgba(157,78,221,.6)' : 'rgba(157,78,221,.3)';
    btnStealth.style.color = enabled ? '#c77dff' : '#9d4edd';
  }

  if (data.stealth_active) {
    stealthBadge.className = 'stealth-badge';
    stealthBadge.textContent = 'STEALTH ' + (data.stealth_messages || 0);
    stealthBadge.title = data.stealth_messages + ' stealth-mode entries';
  } else {
    stealthBadge.className = 'stealth-badge-hidden';
    stealthBadge.textContent = '';
  }

  const cpCount = data.checkpoint_messages || 0;
  footerCheckpoints.textContent = cpCount > 0 ? 'Checkpoints: ' + cpCount : '';

  const hrCount = data.human_review_alerts || 0;
  footerHumanReview.textContent = hrCount > 0 ? 'Human Review: ' + hrCount : '';

  // Observability coverage indicator
  const obsEl = document.getElementById('footer-observability');
  if (obsEl) {
    const layers = [
      { name: 'L1:Agent', active: true },
      { name: 'L2:Subagent', active: (data.subagent_messages || 0) > 0 || (data.tool_calls || []).some(c => !!c.parent_agent_id) },
      { name: 'L3:Filesystem', active: true },
      { name: 'L4:Process', active: true },
      { name: 'L5:Prompt', active: true },
      { name: 'ACM', active: !!(data.acm_state && Object.keys(data.acm_state).length > 0) || true },
    ];
    const activeCount = layers.filter(l => l.active).length;
    const blindCount = layers.length - activeCount;
    let obsHtml = layers.map(l =>
      '<span style="color:' + (l.active ? 'var(--green)' : 'var(--red)') + ';margin-right:4px" title="' + l.name + (l.active ? ' (monitored)' : ' (blind)') + '">' + l.name + (l.active ? ' &#10003;' : ' &#10007;') + '</span>'
    ).join('');
    obsHtml += ' <span style="color:' + (blindCount > 0 ? 'var(--amber)' : 'var(--green)') + '">[' + activeCount + '/' + layers.length + ' layers | ' + blindCount + ' BLIND]</span>';
    obsEl.innerHTML = obsHtml;
  }
}

function toggleStealth() {
  const btn = document.getElementById('btn-stealth');
  btn.textContent = 'STEALTH: ...';
  fetch('/api/stealth/toggle', { method: 'POST' })
    .then(r => r.json())
    .then(d => {
      const on = d.stealth_enabled;
      btn.textContent = on ? 'STEALTH: ON' : 'STEALTH: OFF';
      btn.style.background = on ? 'rgba(157,78,221,.3)' : 'rgba(157,78,221,.12)';
      btn.style.borderColor = on ? 'rgba(157,78,221,.6)' : 'rgba(157,78,221,.3)';
      btn.style.color = on ? '#c77dff' : '#9d4edd';
    })
    .catch(() => { btn.textContent = 'STEALTH: ERR'; });
}

// ---------------------------------------------------------------------------
// Intervention Log (Phase 2.1)
// ---------------------------------------------------------------------------
function renderInterventionLog(data) {
  const ilBody = document.getElementById('il-body');
  const ilCount = document.getElementById('il-count');
  const log = data.intervention_log || [];
  const criticalCount = log.filter(i => i.severity === 'CRITICAL' || i.severity === 'HIGH').length;
  ilCount.textContent = log.length + ' interventions' + (criticalCount > 0 ? ' (' + criticalCount + ' require attention)' : '');

  if (log.length === 0) {
    ilBody.innerHTML = '<div style="padding:20px;text-align:center;color:var(--gray-light);font-size:11px">No interventions recorded yet. InsAIts is monitoring silently.</div>';
    return;
  }

  let html = '';
  log.forEach((item, idx) => {
    const sevColor = severityColor(item.severity);
    const rowClass = item.severity === 'CRITICAL' ? 'il-row il-row--critical' :
                     item.severity === 'HIGH' ? 'il-row il-row--high' : 'il-row';
    const expandIcon = (item.severity === 'CRITICAL' || item.severity === 'HIGH') ? ' &#9660;' : '';
    html += `<div class="${rowClass}" onclick="toggleILDetail(${idx})">
      <div class="il-time">${escapeHtml(timeAgo(item.ts))}</div>
      <div><span class="sev-badge" style="color:${sevColor};background:${sevColor}18;border:1px solid ${sevColor}30;font-size:8px">${escapeHtml(item.severity)}${expandIcon}</span></div>
      <div class="il-type">${escapeHtml((item.anomaly_type || '').replace(/_/g, ' ').toUpperCase())}</div>
      <div class="il-intervention">${escapeHtml(item.intervention)}</div>
      <div class="il-next-tool" title="Agent's next action">${escapeHtml(item.next_tool)}</div>
      <div class="il-detail" id="il-detail-${idx}">
        <div><span class="il-detail-label">Timestamp:</span>${escapeHtml(item.ts || 'N/A')}</div>
        <div><span class="il-detail-label">Anomaly Type:</span>${escapeHtml((item.anomaly_type || '').replace(/_/g, ' '))}</div>
        <div><span class="il-detail-label">Severity:</span><span style="color:${sevColor};font-weight:700">${escapeHtml(item.severity)}</span></div>
        <div><span class="il-detail-label">Intervention:</span>${escapeHtml(item.intervention)}</div>
        <div><span class="il-detail-label">Agent:</span>${escapeHtml(item.agent || 'unknown')}</div>
        <div><span class="il-detail-label">Next Tool Used:</span>${escapeHtml(item.next_tool || '--')}</div>
        ${item.remediation ? '<div style="margin-top:6px;padding:6px;background:rgba(255,170,0,.08);border-radius:3px"><span class="il-detail-label">Recommended Action:</span>' + escapeHtml(item.remediation) + '</div>' : ''}
      </div>
    </div>`;
  });
  ilBody.innerHTML = html;

  // Auto-expand CRITICAL items so user immediately sees the error details
  log.forEach((item, idx) => {
    if (item.severity === 'CRITICAL') {
      const detail = document.getElementById('il-detail-' + idx);
      if (detail) detail.classList.add('il-detail--open');
    }
  });
}

function toggleILDetail(idx) {
  const detail = document.getElementById('il-detail-' + idx);
  if (detail) detail.classList.toggle('il-detail--open');
}

// ---------------------------------------------------------------------------
// Session Comparison (Phase 2.7)
// ---------------------------------------------------------------------------
function renderSessionComparison(data) {
  const scBody = document.getElementById('sc-body');
  const scCount = document.getElementById('sc-count');
  const history = data.session_history || [];
  scCount.textContent = history.length + ' past sessions';

  if (history.length === 0) {
    scBody.innerHTML = '<div style="padding:12px;text-align:center;color:var(--gray-light);font-size:11px">No past sessions found. Sessions are archived when you start a new one.</div>';
    return;
  }

  // Add current session as the latest entry
  const current = {
    date: 'NOW',
    messages: data.total_messages || 0,
    anomalies: data.total_anomalies || 0,
    anomaly_rate: data.anomaly_rate || 0,
    critical: data.critical || 0,
  };

  const allSessions = [...history, current];
  const maxRate = Math.max(...allSessions.map(s => s.anomaly_rate), 1);

  let html = '<div style="display:flex;gap:6px;align-items:flex-end;height:100px;padding:8px 0">';
  allSessions.forEach((s, i) => {
    const barH = Math.max(4, (s.anomaly_rate / maxRate) * 80);
    const isNow = s.date === 'NOW';
    const barColor = s.critical > 0 ? 'var(--red)' : s.anomaly_rate > 10 ? 'var(--amber)' : 'var(--green)';
    const label = isNow ? 'NOW' : s.date.substring(4, 6) + '/' + s.date.substring(6, 8);
    html += `<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:2px" title="${s.messages} msgs, ${s.anomalies} anomalies, ${s.critical} critical">
      <div style="font-size:8px;color:var(--gray-light)">${s.anomaly_rate}%</div>
      <div style="width:100%;height:${barH}px;background:${barColor};border-radius:2px;opacity:${isNow ? '1' : '0.7'};${isNow ? 'box-shadow:0 0 6px ' + barColor : ''}"></div>
      <div style="font-size:7px;color:${isNow ? 'var(--cyan)' : 'var(--gray-light)'};font-weight:${isNow ? '600' : '400'}">${label}</div>
    </div>`;
  });
  html += '</div>';

  // Summary row
  const rates = history.map(s => s.anomaly_rate);
  const avgRate = rates.length ? (rates.reduce((a, b) => a + b, 0) / rates.length).toFixed(1) : 0;
  const trend = current.anomaly_rate < avgRate ? 'improving' : current.anomaly_rate > avgRate ? 'degrading' : 'stable';
  const trendColor = trend === 'improving' ? 'var(--green)' : trend === 'degrading' ? 'var(--red)' : 'var(--gray-light)';
  const trendArrow = trend === 'improving' ? '\\u2193' : trend === 'degrading' ? '\\u2191' : '\\u2192';
  html += `<div style="display:flex;justify-content:space-between;font-size:9px;padding:4px 0;border-top:1px solid var(--border)">
    <span style="color:var(--gray-light)">Avg: ${avgRate}% anomaly rate</span>
    <span style="color:${trendColor};font-weight:600">${trendArrow} ${trend.toUpperCase()}</span>
  </div>`;

  scBody.innerHTML = html;
}

// ---------------------------------------------------------------------------
// Agent Communication Map (Phase 2.4)
// ---------------------------------------------------------------------------
function renderCommMap(data) {
  const body = document.getElementById('comm-map-body');
  const countEl = document.getElementById('comm-map-count');
  const map = data.comm_map || { nodes: [], edges: [] };
  countEl.textContent = map.edges.length + ' connections | ' + map.nodes.length + ' agents';

  if (map.nodes.length === 0) {
    body.innerHTML = '<div style="padding:16px;text-align:center;color:var(--gray-light);font-size:11px">No inter-agent communication detected yet.</div>';
    return;
  }

  // CSS-based force-directed-like layout using a grid
  const cols = Math.min(map.nodes.length, 4);
  let html = '<div style="display:grid;grid-template-columns:repeat(' + cols + ',1fr);gap:8px;margin-bottom:8px">';

  map.nodes.forEach(node => {
    const statusColor = node.status === 'CRITICAL' ? 'var(--red)' : node.status === 'WARNING' ? 'var(--amber)' : 'var(--green)';
    const shortName = node.id.length > 18 ? node.id.substring(0, 16) + '..' : node.id;
    html += `<div style="background:var(--surface);border:1px solid ${statusColor}40;border-radius:6px;padding:8px;text-align:center">
      <div style="font-size:9px;color:${statusColor};font-weight:600">${escapeHtml(shortName)}</div>
      <div style="font-size:16px;color:${statusColor};font-weight:700;margin:2px 0">${node.score}</div>
      <div style="font-size:7px;color:var(--gray-light)">${node.messages} msgs | ${escapeHtml(node.status)}</div>
    </div>`;
  });
  html += '</div>';

  // Edge list (communication flows)
  if (map.edges.length > 0) {
    html += '<div style="border-top:1px solid var(--border);padding-top:6px">';
    const maxW = Math.max(...map.edges.map(e => e.weight));
    map.edges.slice(0, 10).forEach(edge => {
      const pct = Math.max(10, (edge.weight / maxW) * 100);
      const srcShort = edge.source.length > 14 ? edge.source.substring(0, 12) + '..' : edge.source;
      const tgtShort = edge.target.length > 14 ? edge.target.substring(0, 12) + '..' : edge.target;
      html += `<div style="display:flex;align-items:center;gap:6px;margin:2px 0;font-size:8px">
        <span style="color:var(--cyan);min-width:90px;text-align:right">${escapeHtml(srcShort)}</span>
        <div style="flex:1;height:3px;background:var(--border);border-radius:2px;overflow:hidden">
          <div style="width:${pct}%;height:100%;background:var(--cyan);border-radius:2px"></div>
        </div>
        <span style="color:var(--cyan);min-width:90px">${escapeHtml(tgtShort)}</span>
        <span style="color:var(--gray-light);min-width:30px;text-align:right">${edge.weight}x</span>
      </div>`;
    });
    html += '</div>';
  }

  body.innerHTML = html;
}

// ---------------------------------------------------------------------------
// Behavioral Fingerprint Timeline — Canvas line chart per agent
// ---------------------------------------------------------------------------
let _fpPointCache = [];  // cached point positions for hover detection

// Fingerprint timeline pan state
let _fpPanOffset = 0;       // 0 = live (rightmost), negative = looking at history
let _fpTotalRange = 0;      // total time range in ms
let _fpIsLive = true;       // true = auto-scroll to latest
let _fpDragStart = null;    // mouse drag tracking
let _fpDragStartOffset = 0;

function renderFingerprintTimeline(data) {
  const wrap = document.getElementById('fingerprint-wrap');
  const canvas = document.getElementById('fingerprint-canvas');
  const legend = document.getElementById('fingerprint-legend');
  const tooltip = document.getElementById('fp-tooltip');
  const ft = data.fingerprint_timeline || {};
  const agentsMap = ft.agents || {};
  const agentNames = Object.keys(agentsMap);

  if (agentNames.length === 0 || agentNames.every(n => (agentsMap[n].points || []).length < 2)) {
    if (canvas) canvas.style.display = 'none';
    if (!wrap.querySelector('.fp-placeholder')) {
      const ph = document.createElement('div');
      ph.className = 'fp-placeholder';
      ph.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;color:var(--gray-light);font-size:11px;position:absolute;top:0;left:0;right:0;bottom:0';
      ph.textContent = 'Collecting behavioral data...';
      wrap.appendChild(ph);
    }
    legend.innerHTML = '';
    _fpPointCache = [];
    return;
  }
  const oldPh = wrap.querySelector('.fp-placeholder');
  if (oldPh) oldPh.remove();

  canvas.style.display = 'block';
  const dpr = window.devicePixelRatio || 1;
  const cssW = wrap.clientWidth || 600;
  const cssH = 180;
  canvas.width = cssW * dpr;
  canvas.height = cssH * dpr;
  canvas.style.width = cssW + 'px';
  canvas.style.height = cssH + 'px';

  const ctx = canvas.getContext('2d');
  ctx.scale(dpr, dpr);
  ctx.clearRect(0, 0, cssW, cssH);

  const PAD_L = 44, PAD_R = 12, PAD_T = 12, PAD_B = 26;
  const chartW = cssW - PAD_L - PAD_R;
  const chartH = cssH - PAD_T - PAD_B;

  // Collect all timestamps to build a unified X axis
  let allTs = [];
  agentNames.forEach(name => {
    (agentsMap[name].points || []).forEach(p => {
      if (p.ts) allTs.push(new Date(p.ts).getTime());
    });
  });
  allTs.sort((a, b) => a - b);
  const fullMinT = allTs[0] || 0;
  const fullMaxT = allTs[allTs.length - 1] || 1;
  _fpTotalRange = Math.max(fullMaxT - fullMinT, 1);

  // Viewport: show a window of the full range, pannable left/right
  // Default viewport = last 30 minutes or full range if shorter
  const VIEWPORT_MS = Math.min(30 * 60 * 1000, _fpTotalRange);

  // When live, anchor viewport to the right edge (latest data)
  if (_fpIsLive) {
    _fpPanOffset = 0;
  }
  // Clamp pan offset: 0 = rightmost (live), negative = history
  const maxPan = 0;
  const minPan = -(Math.max(_fpTotalRange - VIEWPORT_MS, 0));
  _fpPanOffset = Math.max(minPan, Math.min(maxPan, _fpPanOffset));

  const viewMaxT = fullMaxT + _fpPanOffset;
  const viewMinT = viewMaxT - VIEWPORT_MS;
  const rangeT = Math.max(VIEWPORT_MS, 1);

  // Update live button style
  const liveBtn = document.getElementById('fp-live-btn');
  if (liveBtn) {
    liveBtn.style.opacity = _fpIsLive ? '1' : '0.5';
    liveBtn.style.background = _fpIsLive ? 'rgba(0,230,118,.12)' : 'var(--bg-panel)';
  }

  const scaleX = ts => PAD_L + ((ts - viewMinT) / rangeT) * chartW;
  const scaleY = score => PAD_T + (1 - score / 1000) * chartH;

  // Danger zone fill (below 500)
  ctx.fillStyle = 'rgba(255,50,50,0.04)';
  ctx.fillRect(PAD_L, scaleY(500), chartW, scaleY(0) - scaleY(500));

  // Grid lines + Y labels
  ctx.font = '8px "Space Mono", monospace';
  ctx.textAlign = 'right';
  [0, 250, 500, 750, 1000].forEach(v => {
    const y = scaleY(v);
    ctx.strokeStyle = 'rgba(255,255,255,0.06)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(PAD_L, y);
    ctx.lineTo(cssW - PAD_R, y);
    ctx.stroke();
    ctx.fillStyle = 'rgba(255,255,255,0.3)';
    ctx.fillText(String(v), PAD_L - 4, y + 3);
  });

  // X-axis time labels (up to 5 evenly spaced)
  ctx.textAlign = 'center';
  ctx.fillStyle = 'rgba(255,255,255,0.25)';
  const xLabels = Math.min(5, allTs.length);
  for (let i = 0; i < xLabels; i++) {
    const t = viewMinT + (rangeT * i) / Math.max(xLabels - 1, 1);
    const d = new Date(t);
    const lbl = d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0') + ':' + d.getSeconds().toString().padStart(2, '0');
    ctx.fillText(lbl, scaleX(t), cssH - PAD_B + 14);
  }

  // Draw lines and collect point cache for hover
  _fpPointCache = [];
  let legendHtml = '';

  agentNames.forEach(name => {
    const agentInfo = agentsMap[name];
    const pts = agentInfo.points || [];
    const color = agentInfo.color || '#4fc3f7';
    // Filter points to viewport (with 1-point margin for continuity)
    const viewPts = pts.filter(p => {
      const t = new Date(p.ts).getTime();
      return t >= viewMinT - rangeT * 0.05 && t <= viewMaxT + rangeT * 0.05;
    });
    if (viewPts.length < 2) return;

    // Draw polyline
    ctx.strokeStyle = color;
    ctx.lineWidth = 2;
    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';
    ctx.globalAlpha = 0.85;
    ctx.beginPath();
    viewPts.forEach((p, i) => {
      const px = scaleX(new Date(p.ts).getTime());
      const py = scaleY(p.score);
      if (i === 0) ctx.moveTo(px, py);
      else ctx.lineTo(px, py);
    });
    ctx.stroke();
    ctx.globalAlpha = 1.0;

    // Draw dots — anomaly events get a larger filled circle
    viewPts.forEach(p => {
      const px = scaleX(new Date(p.ts).getTime());
      const py = scaleY(p.score);
      const isEvent = !!p.event;
      const radius = isEvent ? 4.5 : 2.5;
      const dotColor = p.score < 500 ? '#ef5350' : (isEvent ? '#ff6e40' : color);

      ctx.beginPath();
      ctx.arc(px, py, radius, 0, Math.PI * 2);
      ctx.fillStyle = dotColor;
      ctx.fill();
      if (isEvent) {
        ctx.strokeStyle = 'rgba(255,255,255,0.3)';
        ctx.lineWidth = 1;
        ctx.stroke();
      }

      // Cache for hover
      _fpPointCache.push({
        x: px, y: py, r: radius + 3,
        agent: name, score: p.score,
        event: p.event || null,
        severity: p.severity || null,
        ts: p.ts, color: color,
      });
    });

    // Legend
    const shortName = name.length > 22 ? name.substring(0, 20) + '..' : name;
    const lastScore = pts[pts.length - 1].score;
    const eventCount = pts.filter(p => p.event).length;
    legendHtml += '<span style="color:' + color + '">&#9632; ' + escapeHtml(shortName) +
      ' <span style="font-weight:700">' + lastScore + '</span>' +
      (eventCount > 0 ? ' <span style="color:var(--gray-light);font-size:8px">(' + eventCount + ' events)</span>' : '') +
      '</span>';
  });

  legend.innerHTML = legendHtml;

  // Hover handler (attach once, keyed by canvas)
  if (!canvas._fpHoverBound) {
    canvas._fpHoverBound = true;
    canvas.addEventListener('mousemove', function(e) {
      const rect = canvas.getBoundingClientRect();
      const mx = e.clientX - rect.left;
      const my = e.clientY - rect.top;
      const tip = document.getElementById('fp-tooltip');
      let hit = null;
      let bestDist = Infinity;
      for (let i = 0; i < _fpPointCache.length; i++) {
        const pt = _fpPointCache[i];
        const dx = mx - pt.x, dy = my - pt.y;
        const d = Math.sqrt(dx * dx + dy * dy);
        if (d < pt.r && d < bestDist) {
          bestDist = d;
          hit = pt;
        }
      }
      if (hit) {
        const tStr = hit.ts ? new Date(hit.ts).toLocaleTimeString() : '';
        let html = '<span style="color:' + hit.color + ';font-weight:700">' + escapeHtml(hit.agent) + '</span>';
        html += '<br>Score: <b>' + hit.score + '</b>';
        if (hit.event) {
          const sevColor = hit.severity === 'CRITICAL' ? 'var(--red)' :
                           hit.severity === 'HIGH' ? '#ff6e40' :
                           hit.severity === 'MEDIUM' ? 'var(--amber)' : 'var(--gray-light)';
          html += '<br>Event: <span style="color:' + sevColor + '">' + escapeHtml(hit.event) + '</span>';
          if (hit.severity) html += ' <span style="color:' + sevColor + ';font-size:8px">(' + hit.severity + ')</span>';
        }
        if (tStr) html += '<br><span style="color:var(--gray-light)">' + tStr + '</span>';
        tip.innerHTML = html;
        tip.style.display = 'block';
        // Position tooltip near cursor but within bounds
        let tx = mx + 12;
        let ty = my - 10;
        if (tx + 180 > cssW) tx = mx - 180;
        if (ty < 0) ty = 4;
        tip.style.left = tx + 'px';
        tip.style.top = ty + 'px';
      } else {
        tip.style.display = 'none';
      }
    });
    canvas.addEventListener('mouseleave', function() {
      const tip = document.getElementById('fp-tooltip');
      if (tip) tip.style.display = 'none';
    });

    // Mouse drag to pan
    canvas.addEventListener('mousedown', function(e) {
      _fpDragStart = e.clientX;
      _fpDragStartOffset = _fpPanOffset;
      canvas.style.cursor = 'grabbing';
    });
    canvas.addEventListener('mousemove', function(e) {
      if (_fpDragStart !== null) {
        const dx = e.clientX - _fpDragStart;
        // Convert pixel delta to time delta
        const pxPerMs = chartW / Math.min(30 * 60 * 1000, _fpTotalRange);
        _fpPanOffset = _fpDragStartOffset + (dx / pxPerMs) * 1;
        _fpIsLive = (_fpPanOffset >= -100);  // snap to live if near edge
        // Force re-render with current data
      }
    });
    canvas.addEventListener('mouseup', function() {
      _fpDragStart = null;
      canvas.style.cursor = 'grab';
    });
  }

  // Pan/Live button handlers (attach once)
  if (!wrap._fpButtonsBound) {
    wrap._fpButtonsBound = true;
    const VIEWPORT_MS_BTN = Math.min(30 * 60 * 1000, _fpTotalRange || 1800000);
    const panStep = VIEWPORT_MS_BTN * 0.3;  // pan 30% of viewport per click

    document.getElementById('fp-pan-left').addEventListener('click', function() {
      _fpPanOffset -= panStep;
      _fpIsLive = false;
    });
    document.getElementById('fp-pan-right').addEventListener('click', function() {
      _fpPanOffset += panStep;
      if (_fpPanOffset >= -100) { _fpPanOffset = 0; _fpIsLive = true; }
    });
    document.getElementById('fp-live-btn').addEventListener('click', function() {
      _fpPanOffset = 0;
      _fpIsLive = true;
    });
  }
}

// ---------------------------------------------------------------------------
// Persistence Engine State Panel — shows escalation levels per agent
// ---------------------------------------------------------------------------
function renderPersistenceState(data) {
  const body = document.getElementById('pe-body');
  const count = document.getElementById('pe-count');
  if (!body || !data.feed) return;

  // Extract surrender/NPC/blame events from feed
  // Feed items are FLAT: {ts, type, severity, agent, resolved, description}
  // NOT nested {anomalies: [...]} — filter on the flat .type field directly.
  const peTypes = ['agent_surrender', 'npc_behavior', 'blame_shift'];
  const events = data.feed.filter(e => peTypes.includes(e.type));

  if (events.length === 0) {
    count.textContent = 'no escalations';
    body.innerHTML = '<div style="padding:12px;text-align:center;color:var(--gray-light);font-size:11px">No surrender, NPC, or blame events detected this session.</div>';
    return;
  }

  count.textContent = events.length + ' event(s)';

  // Group by agent
  const byAgent = {};
  events.forEach(e => {
    const agent = e.agent || 'unknown';
    if (!byAgent[agent]) byAgent[agent] = { events: [], types: {} };
    byAgent[agent].events.push(e);
    byAgent[agent].types[e.type] = (byAgent[agent].types[e.type] || 0) + 1;
  });

  const levelNames = ['', 'NUDGE', 'REDIRECT', 'DECOMPOSE', 'ESCALATE', 'CIRCUIT BREAK'];
  const levelColors = ['', 'var(--amber)', 'var(--amber)', '#ff8800', 'var(--red)', '#ff0044'];

  let html = '';
  for (const [agent, info] of Object.entries(byAgent)) {
    // Extract escalation level from the latest event description
    let level = 0;
    const lastEvent = info.events[info.events.length - 1];
    // Feed items are flat — lastEvent IS the anomaly (has .type, .description directly)
    if (lastEvent && lastEvent.description) {
      const m = lastEvent.description.match(/Escalation level: (\d)/);
      if (m) level = parseInt(m[1], 10);
    }
    const levelName = levelNames[level] || 'UNKNOWN';
    const levelColor = levelColors[level] || 'var(--gray-light)';

    html += '<div style="border:1px solid var(--border);border-left:3px solid ' + levelColor + ';padding:8px;margin-bottom:6px;border-radius:4px">';
    html += '<div style="display:flex;justify-content:space-between;align-items:center">';
    html += '<span style="color:var(--cyan);font-weight:bold;font-size:11px">' + escapeHtml(agent) + '</span>';
    html += '<span style="color:' + levelColor + ';font-weight:bold;font-size:11px">L' + level + ' ' + levelName + '</span>';
    html += '</div>';

    // Type counts
    html += '<div style="margin-top:4px;display:flex;gap:8px;flex-wrap:wrap">';
    for (const [type, cnt] of Object.entries(info.types)) {
      const badge = type === 'agent_surrender' ? '#ff4444' : type === 'npc_behavior' ? '#ff8800' : '#ffaa00';
      html += '<span style="font-size:10px;color:' + badge + '">' + type.replace(/_/g, ' ') + ': ' + cnt + '</span>';
    }
    html += '</div>';

    // Latest event description
    if (lastEvent && lastEvent.description) {
      html += '<div style="margin-top:4px;font-size:10px;color:var(--gray-light);font-style:italic">' + escapeHtml(lastEvent.description.slice(0, 120)) + '</div>';
    }
    html += '</div>';
  }
  body.innerHTML = html;
}

// Token Usage Panel (Phase 7C.5)
// ---------------------------------------------------------------------------
function renderTokenUsage(data) {
  const body = document.getElementById('token-body');
  const status = document.getElementById('token-status');
  if (!body) return;

  const ts = data.token_stats;
  if (!ts || !ts.api_calls) {
    status.textContent = 'waiting for data...';
    body.innerHTML = '<div style="padding:12px;text-align:center;color:var(--gray-light);font-size:11px">Token data extracted from session transcript by background tailer. Refreshes every 10s.</div>';
    return;
  }

  const rawIn = ts.input_tokens || 0;
  const totalOut = ts.output_tokens || 0;
  const cacheRead = ts.cache_read_tokens || 0;
  const cacheWrite = ts.cache_write_tokens || 0;
  const calls = ts.api_calls || 1;
  // Total context = non-cached + cached (what the model actually processes)
  const totalContext = rawIn + cacheRead + cacheWrite;
  const totalTokens = totalContext + totalOut;

  // Cost estimate (Opus 4 pricing: $15/M input, $75/M output, cache read $0.30/M, cache write $3.75/M)
  const costIn = (rawIn / 1000000) * 15;
  const costOut = (totalOut / 1000000) * 75;
  const costCache = (cacheRead / 1000000) * 0.30;
  const costCacheWrite = (cacheWrite / 1000000) * 3.75;
  const totalCost = costIn + costOut + costCache + costCacheWrite;

  // Per-minute rate if session has timing
  let rateInfo = '';
  if (data.session_start && data.session_latest) {
    const durMs = new Date(data.session_latest) - new Date(data.session_start);
    const durMin = Math.max(1, durMs / 60000);
    const tokPerMin = Math.round(totalTokens / durMin);
    rateInfo = '<div style="font-size:11px;color:var(--gray-light);margin-top:6px">' + tokPerMin.toLocaleString() + ' tokens/min &middot; $' + (totalCost / durMin).toFixed(3) + '/min</div>';
  }

  // Efficiency metric (7C.6)
  const resolvedAnoms = data.resolved || 0;
  const efficiency = resolvedAnoms > 0 ? Math.round(totalTokens / resolvedAnoms) : 0;

  status.textContent = calls + ' API calls';

  // Format helper: show M/K for large numbers
  const fmtTok = (n) => n >= 1000000 ? (n/1000000).toFixed(1)+'M' : n >= 10000 ? Math.round(n/1000)+'K' : n.toLocaleString();

  let html = '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:8px">';
  html += '<div style="text-align:center;padding:8px;background:rgba(0,0,0,.25);border-radius:6px">';
  html += '<div style="font-size:16px;font-weight:bold;color:var(--cyan)">' + fmtTok(totalContext) + '</div>';
  html += '<div style="font-size:9px;color:var(--gray-light)">total input</div>';
  html += '<div style="font-size:8px;color:var(--gray)">' + fmtTok(rawIn) + ' new + ' + fmtTok(cacheRead) + ' cached</div></div>';
  html += '<div style="text-align:center;padding:8px;background:rgba(0,0,0,.25);border-radius:6px">';
  html += '<div style="font-size:16px;font-weight:bold;color:var(--green)">' + fmtTok(totalOut) + '</div>';
  html += '<div style="font-size:9px;color:var(--gray-light)">output tokens</div></div>';
  html += '<div style="text-align:center;padding:8px;background:rgba(0,0,0,.25);border-radius:6px">';
  html += '<div style="font-size:16px;font-weight:bold;color:var(--amber)">' + fmtTok(cacheWrite) + '</div>';
  html += '<div style="font-size:9px;color:var(--gray-light)">cache write</div></div>';
  html += '</div>';

  // Cost summary
  html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-top:1px solid var(--border)">';
  html += '<span style="font-size:11px;color:var(--gray-light)">Cache hit: ' + (cacheRead > 0 ? Math.round(cacheRead / totalContext * 100) : 0) + '%</span>';
  html += '<span style="font-size:12px;font-weight:bold;color:' + (totalCost > 1 ? 'var(--red)' : totalCost > 0.1 ? 'var(--amber)' : 'var(--green)') + '">Est. cost: $' + totalCost.toFixed(3) + '</span>';
  html += '</div>';

  if (rateInfo) html += rateInfo;

  if (efficiency > 0) {
    html += '<div style="font-size:10px;color:var(--gray-light);margin-top:4px">Efficiency: ' + efficiency.toLocaleString() + ' tokens per resolved anomaly</div>';
  }

  body.innerHTML = html;
}

// Config Toggles Panel — per-detector and per-feature ON/OFF switches
// ---------------------------------------------------------------------------
let _cfgLoaded = false;
let _cfgData = null;

function loadConfig() {
  fetch('/api/config')
    .then(r => r.json())
    .then(d => {
      if (d.success) { _cfgData = d.config; renderConfigPanel(); }
    })
    .catch(() => {});
}

function renderConfigPanel() {
  if (!_cfgData) return;
  const body = document.getElementById('cfg-body');
  const status = document.getElementById('cfg-status');
  if (!body) return;

  const detectors = _cfgData.detectors || {};
  const features = _cfgData.features || {};
  const enabledCount = Object.values(detectors).filter(v => v).length;
  const totalCount = Object.keys(detectors).length;
  if (status) status.textContent = enabledCount + '/' + totalCount + ' detectors active';

  let html = '<div style="font-size:10px;color:var(--cyan);margin-bottom:4px;font-weight:bold">DETECTORS</div>';
  for (const [key, val] of Object.entries(detectors)) {
    html += renderToggleRow(key, val, 'detectors');
  }
  html += '<div style="font-size:10px;color:var(--amber);margin:8px 0 4px;font-weight:bold">FEATURES</div>';
  for (const [key, val] of Object.entries(features)) {
    html += renderToggleRow(key, val, 'features');
  }
  html += '<div style="margin-top:6px;display:flex;align-items:center;gap:6px">';
  html += '<span style="font-size:9px;color:var(--gray-light)">Awareness interval:</span>';
  html += '<input type="number" id="cfg-ra-interval" value="' + (_cfgData.response_awareness_interval || 8) + '" min="3" max="50" style="width:40px;background:var(--bg-dark);color:var(--fg);border:1px solid var(--border);font-size:10px;padding:2px" onchange="updateRAInterval(this.value)">';
  html += '</div>';
  body.innerHTML = html;
}

function renderToggleRow(key, isOn, section) {
  const label = key.replace(/_/g, ' ');
  const bg = isOn ? 'var(--green)' : 'var(--gray-light)';
  const pos = isOn ? '14px' : '2px';
  return '<div style="display:flex;justify-content:space-between;align-items:center;padding:2px 4px;border-bottom:1px solid rgba(255,255,255,0.05)">' +
    '<span style="font-size:9px;color:var(--fg)">' + label + '</span>' +
    '<div onclick="toggleConfig(\'' + section + '\',\'' + key + '\')" style="cursor:pointer;width:28px;height:16px;border-radius:8px;background:' + bg + ';position:relative">' +
    '<div style="width:12px;height:12px;border-radius:50%;background:#fff;position:absolute;top:2px;left:' + pos + ';transition:left 0.2s"></div>' +
    '</div></div>';
}

function toggleConfig(section, key) {
  if (!_cfgData || !_cfgData[section]) return;
  _cfgData[section][key] = !_cfgData[section][key];
  const payload = {};
  payload[section] = {};
  payload[section][key] = _cfgData[section][key];
  fetch('/api/config', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(payload)
  }).then(() => renderConfigPanel())
    .catch(() => {});
}

function updateRAInterval(val) {
  const n = parseInt(val, 10);
  if (isNaN(n) || n < 3) return;
  fetch('/api/config', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({response_awareness_interval: n})
  }).catch(() => {});
}

// Task Anchor Editor — load/save/reset 3-layer anchor text + legacy
// ---------------------------------------------------------------------------
let _anchorLoaded = false;

function loadAnchor() {
  fetch('/api/anchor')
    .then(r => r.json())
    .then(d => {
      if (d.success) {
        const l1 = document.getElementById('anchor-layer1');
        const l2 = document.getElementById('anchor-layer2');
        const l3 = document.getElementById('anchor-layer3');
        const ua = document.getElementById('anchor-user');
        const mc = document.getElementById('anchor-memory');
        if (l1) l1.value = d.layer1 || '';
        if (l2) l2.value = d.layer2 || '';
        if (l3) l3.value = d.layer3 || '';
        if (ua) ua.value = d.user_anchor || '';
        if (mc) mc.value = d.memory_check || '';
        _anchorLoaded = true;
      }
    })
    .catch(() => {});
}

function saveAnchor() {
  const l1 = document.getElementById('anchor-layer1');
  const l2 = document.getElementById('anchor-layer2');
  const l3 = document.getElementById('anchor-layer3');
  const ua = document.getElementById('anchor-user');
  const mc = document.getElementById('anchor-memory');
  const status = document.getElementById('anchor-status');
  const body = {};
  if (l1) body.layer1 = l1.value;
  if (l2) body.layer2 = l2.value;
  if (l3) body.layer3 = l3.value;
  if (ua) body.user_anchor = ua.value;
  if (mc) body.memory_check = mc.value;
  fetch('/api/anchor', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(r => r.json())
    .then(d => {
      if (status) status.textContent = d.success ? 'all layers saved! (hot-reload active)' : 'save failed: ' + d.error;
      setTimeout(() => { if (status) status.textContent = 'hot-reload enabled'; }, 3000);
    })
    .catch(() => { if (status) status.textContent = 'save failed (network error)'; });
}

function resetAnchor() {
  _anchorLoaded = false;
  loadAnchor();
  const status = document.getElementById('anchor-status');
  if (status) status.textContent = 'reset from disk';
  setTimeout(() => { if (status) status.textContent = 'hot-reload enabled'; }, 2000);
}

// Load config + anchor on page load (once)
setTimeout(() => { loadConfig(); loadAnchor(); }, 500);

// ---------------------------------------------------------------------------
// PHASE_GUARDIAN: Session Health Panel
// ---------------------------------------------------------------------------
let _guardianCache = null;

function fetchGuardianData() {
  fetch('/api/collector/guardian')
    .then(r => r.json())
    .then(d => {
      if (d.error || d.offline) {
        _guardianCache = null;
        renderSessionHealth(null);
      } else {
        _guardianCache = d;
        renderSessionHealth(d);
      }
    })
    .catch(() => { _guardianCache = null; renderSessionHealth(null); });
}

function renderSessionHealth(gd) {
  const body = document.getElementById('guardian-body');
  const status = document.getElementById('guardian-status');
  if (!body) return;

  if (!gd || !gd.sessions) {
    status.textContent = 'collector offline';
    status.style.color = 'var(--red)';
    body.innerHTML = '<div style="padding:12px;text-align:center;color:var(--gray-light);font-size:11px">Guardian data unavailable — collector not running on port 5003.<br><br><button onclick="startCollector()" style="background:var(--cyan);color:var(--bg);border:none;padding:5px 14px;border-radius:4px;font-size:11px;font-weight:700;cursor:pointer">START COLLECTOR</button><span id="guardian-start-status" style="display:block;font-size:9px;margin-top:6px;color:var(--gray-light)"></span></div>';
    return;
  }

  const sessions = gd.sessions;
  const sessionIds = Object.keys(sessions);

  if (sessionIds.length === 0) {
    status.textContent = 'no sessions';
    status.style.color = 'var(--gray-light)';
    body.innerHTML = '<div style="padding:12px;text-align:center;color:var(--gray-light);font-size:11px">No active sessions tracked by Guardian.</div>';
    return;
  }

  // Show the most recent (or only) session
  const sid = sessionIds[sessionIds.length - 1];
  const s = sessions[sid];

  const toolCount = s.tool_call_count || 0;
  const replayCount = s.replay_count || 0;
  const replayPct = s.replay_pct || 0;
  const fpCount = s.fingerprint_count || 0;
  const anomalyCount = s.anomaly_count || 0;
  const budgetStatus = s.budget_status || 'ok';
  const isContinued = s.is_continued || false;
  const restored = s.restored_from_snapshot || false;
  const parentSid = s.parent_session_id || '';
  const lastSnapshotAt = s.last_snapshot_at || 0;
  const callsSinceSnapshot = s.calls_since_snapshot || 0;
  const nextSnapshotIn = s.next_snapshot_in || 0;
  const snapshotCount = gd.snapshot_count || 0;

  // Budget status color
  let budgetColor = 'var(--green)';
  let budgetLabel = 'HEALTHY';
  if (budgetStatus === 'exceeded') {
    budgetColor = 'var(--red)';
    budgetLabel = 'EXCEEDED';
  } else if (budgetStatus === 'warning') {
    budgetColor = 'var(--amber)';
    budgetLabel = 'WARNING';
  }

  // Overall health assessment
  let healthColor = 'var(--green)';
  let healthLabel = 'HEALTHY';
  if (replayPct > 50) {
    healthColor = 'var(--red)';
    healthLabel = 'HIGH REPLAY';
  } else if (replayPct > 20) {
    healthColor = 'var(--amber)';
    healthLabel = 'MODERATE REPLAY';
  }

  status.textContent = toolCount + ' calls | ' + replayCount + ' replays';
  status.style.color = healthColor;

  let html = '';

  // State restored / resumed badge
  const resumeSource = s.resume_source || '';
  if (restored || isContinued || resumeSource) {
    const sourceLabel = resumeSource ? ('RESUMED from ' + resumeSource) : 'Session Restored from Snapshot';
    html += '<div style="background:rgba(0,188,212,.12);border:1px solid var(--cyan);border-radius:6px;padding:8px 12px;margin-bottom:10px;display:flex;align-items:center;gap:8px">';
    html += '<span style="font-size:16px">&#128260;</span>';
    html += '<div>';
    html += '<div style="font-size:11px;font-weight:bold;color:var(--cyan)">' + escapeHtml(sourceLabel) + '</div>';
    if (parentSid) {
      html += '<div style="font-size:9px;color:var(--gray-light)">Continued from: ' + escapeHtml(parentSid.substring(0, 12)) + '...</div>';
    }
    html += '</div></div>';
  }

  // KPI grid
  html += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:8px;margin-bottom:10px">';

  // Tool calls
  html += '<div style="text-align:center;padding:8px;background:rgba(0,0,0,.25);border-radius:6px">';
  html += '<div style="font-size:18px;font-weight:bold;color:var(--cyan)">' + toolCount + '</div>';
  html += '<div style="font-size:8px;color:var(--gray-light)">TOOL CALLS</div></div>';

  // Unique fingerprints
  html += '<div style="text-align:center;padding:8px;background:rgba(0,0,0,.25);border-radius:6px">';
  html += '<div style="font-size:18px;font-weight:bold;color:var(--green)">' + fpCount + '</div>';
  html += '<div style="font-size:8px;color:var(--gray-light)">UNIQUE CALLS</div></div>';

  // Replays
  html += '<div style="text-align:center;padding:8px;background:rgba(0,0,0,.25);border-radius:6px">';
  html += '<div style="font-size:18px;font-weight:bold;color:' + (replayCount > 0 ? 'var(--amber)' : 'var(--green)') + '">' + replayCount + '</div>';
  html += '<div style="font-size:8px;color:var(--gray-light)">REPLAYS</div></div>';

  // Replay %
  html += '<div style="text-align:center;padding:8px;background:rgba(0,0,0,.25);border-radius:6px">';
  html += '<div style="font-size:18px;font-weight:bold;color:' + healthColor + '">' + replayPct.toFixed(1) + '%</div>';
  html += '<div style="font-size:8px;color:var(--gray-light)">REPLAY RATE</div></div>';

  html += '</div>';

  // Budget + Snapshot status row
  html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">';

  // Retry Budget
  html += '<div style="padding:8px 12px;background:rgba(0,0,0,.25);border-radius:6px;border-left:3px solid ' + budgetColor + '">';
  html += '<div style="font-size:8px;color:var(--gray-light);text-transform:uppercase;letter-spacing:1px">Retry Budget</div>';
  html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:4px">';
  html += '<span style="font-size:14px;font-weight:bold;color:' + budgetColor + '">' + budgetLabel + '</span>';
  html += '<span style="font-size:9px;color:var(--gray-light)">' + replayCount + ' / ' + (gd.config ? gd.config.max_replays_block : 10) + ' max</span>';
  html += '</div>';
  // Progress bar
  const budgetPct = Math.min(100, (replayCount / (gd.config ? gd.config.max_replays_block : 10)) * 100);
  html += '<div style="background:rgba(255,255,255,.08);border-radius:3px;height:4px;margin-top:6px;overflow:hidden">';
  html += '<div style="background:' + budgetColor + ';height:100%;width:' + budgetPct + '%;border-radius:3px;transition:width .3s"></div>';
  html += '</div></div>';

  // Snapshot Status
  html += '<div style="padding:8px 12px;background:rgba(0,0,0,.25);border-radius:6px;border-left:3px solid ' + (lastSnapshotAt > 0 ? 'var(--green)' : 'var(--gray-light)') + '">';
  html += '<div style="font-size:8px;color:var(--gray-light);text-transform:uppercase;letter-spacing:1px">Snapshots</div>';
  if (lastSnapshotAt > 0) {
    html += '<div style="font-size:11px;color:var(--green);margin-top:4px">Last at call #' + lastSnapshotAt + '</div>';
    html += '<div style="font-size:9px;color:var(--gray-light)">Next in ' + nextSnapshotIn + ' calls</div>';
  } else {
    html += '<div style="font-size:11px;color:var(--gray-light);margin-top:4px">No snapshot yet</div>';
    html += '<div style="font-size:9px;color:var(--gray-light)">First at call #' + (gd.config ? gd.config.snapshot_interval : 50) + '</div>';
  }
  html += '<div style="font-size:8px;color:var(--gray-light);margin-top:2px">' + snapshotCount + ' saved on disk</div>';
  html += '</div>';

  html += '</div>';

  // Session ID
  html += '<div style="border-top:1px solid var(--border);padding-top:6px;font-size:9px;color:var(--gray-light)">';
  html += 'Session: ' + escapeHtml((s.session_id || sid).substring(0, 20)) + '...';
  if (sessionIds.length > 1) {
    html += ' &middot; ' + sessionIds.length + ' sessions tracked';
  }
  html += '</div>';

  body.innerHTML = html;
}

// ---------------------------------------------------------------------------
// PHASE_GUARDIAN Phase 2: Session Vault Panel
// ---------------------------------------------------------------------------
function fetchVaultData() {
  // Fetch both guardian (for storm/emergency status) and sessions (for actual state file content)
  Promise.all([
    fetch('/api/collector/guardian').then(r => r.json()).catch(() => null),
    fetch('/api/collector/guardian/sessions').then(r => r.json()).catch(() => null)
  ]).then(([gd, sess]) => {
    if (!gd || gd.error || gd.offline) { renderVault(null); return; }
    // Merge: use sessions endpoint data for state_files (has trigger, tool_call_count, etc.)
    if (sess && sess.state_files) {
      gd.state_files = sess.state_files;
    }
    renderVault(gd);
  }).catch(() => renderVault(null));
}

function renderVault(gd) {
  const body = document.getElementById('vault-body');
  const lastSave = document.getElementById('vault-last-save');
  const alert = document.getElementById('vault-alert');
  if (!body) return;

  // Check for retry storm alert + M16 storm block status
  if (gd && gd.sessions) {
    let stormActive = false;
    let blockActive = false;
    let blockTier = 0;
    let blockRemaining = 0;
    let blockedCalls = 0;
    Object.values(gd.sessions).forEach(s => {
      if (s.retry_storm_active) stormActive = true;
      if (s.storm_block_active) {
        blockActive = true;
        blockTier = s.storm_block_tier || 0;
        blockRemaining = s.storm_block_remaining || 0;
        blockedCalls = s.circuit_breaker_blocked_count || 0;
      }
    });
    if (blockActive && alert) {
      alert.style.display = 'block';
      alert.style.background = 'rgba(244,67,54,.2)';
      alert.style.borderColor = 'var(--red)';
      alert.style.color = 'var(--red)';
      const tierLabel = blockTier === 1 ? 'FIREWALL' : 'CIRCUIT BREAKER';
      alert.innerHTML = 'STORM BLOCKED [' + tierLabel + '] — API paused for ' + blockRemaining + 's | ' + blockedCalls + ' calls blocked <button onclick="unblockStorm()" style="margin-left:8px;background:var(--red);color:#fff;border:none;padding:2px 8px;border-radius:3px;cursor:pointer;font-size:10px">UNBLOCK NOW</button>';
    } else if (stormActive && alert) {
      alert.style.display = 'block';
      alert.style.background = 'rgba(244,67,54,.15)';
      alert.style.borderColor = 'var(--red)';
      alert.style.color = 'var(--red)';
      alert.textContent = 'RETRY STORM DETECTED — SESSION SAVED';
    } else if (alert) {
      alert.style.display = 'none';
    }
  }

  // Check for emergency markers
  if (gd && gd.emergency_markers && gd.emergency_markers.length > 0 && alert) {
    const m = gd.emergency_markers[0];
    alert.style.display = 'block';
    alert.style.background = 'rgba(255,152,0,.15)';
    alert.style.borderColor = 'var(--amber)';
    alert.style.color = 'var(--amber)';
    alert.textContent = 'RECOVERED FROM CRASH — ' + (m.sessions_saved || 0) + ' sessions restored (' + (m.timestamp || '').substring(0, 19) + ')';
  }

  const stateFiles = (gd && gd.state_files) || [];

  if (stateFiles.length === 0) {
    if (lastSave) lastSave.textContent = 'no saves yet';
    body.innerHTML = '<div style="text-align:center;color:var(--gray-light);font-size:11px;padding:12px">No session states saved yet. Use SAVE NOW or wait for auto-save every 25 tool calls.</div>';
    return;
  }

  // Show last save time
  if (lastSave && stateFiles.length > 0) {
    const latest = stateFiles[0];
    lastSave.textContent = 'Last: ' + (latest.saved_at || '').substring(11, 19);
  }

  let html = '';
  stateFiles.forEach((sf, i) => {
    const trigger = sf.trigger || 'UNKNOWN';
    let triggerColor = 'var(--gray-light)';
    let triggerIcon = '&#128190;';
    if (trigger === 'RETRY_STORM') { triggerColor = 'var(--red)'; triggerIcon = '&#9889;'; }
    else if (trigger === 'STORM_BLOCK') { triggerColor = 'var(--red)'; triggerIcon = '&#9889;'; }
    else if (trigger === 'MANUAL') { triggerColor = 'var(--cyan)'; triggerIcon = '&#128190;'; }
    else if (trigger === 'MANUAL_SAVE') { triggerColor = 'var(--cyan)'; triggerIcon = '&#128190;'; }
    else if (trigger === 'EMERGENCY') { triggerColor = 'var(--amber)'; triggerIcon = '&#9888;'; }
    else if (trigger === 'SCHEDULED') { triggerColor = 'var(--gray-light)'; triggerIcon = '&#128336;'; }
    else if (trigger === 'MILESTONE') { triggerColor = 'var(--green)'; triggerIcon = '&#9745;'; }
    else if (trigger === 'CONTEXT_COMPRESSION') { triggerColor = 'var(--purple)'; triggerIcon = '&#128257;'; }

    html += '<div style="padding:8px 10px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">';
    html += '<div style="flex:1">';
    html += '<span style="color:' + triggerColor + ';font-weight:bold;font-size:10px">' + triggerIcon + ' ' + trigger + '</span>';
    html += ' <span style="font-size:9px;color:var(--gray-light)">' + (sf.saved_at || '').substring(0, 19) + '</span>';
    html += '<div style="font-size:10px;color:var(--text);margin-top:2px">' + escapeHtml(sf.resume_hint || '') + '</div>';
    html += '<div style="font-size:9px;color:var(--gray-light)">' + (sf.tool_call_count || 0) + ' calls, ' + (sf.anomaly_count || 0) + ' anomalies, ' + (sf.files_touched_count || 0) + ' files</div>';
    html += '</div>';
    html += '<div style="display:flex;gap:4px">';
    html += '<button onclick="copyResumeHint(' + i + ')" style="background:rgba(0,188,212,.2);color:var(--cyan);border:1px solid var(--cyan);padding:2px 8px;border-radius:3px;font-size:9px;cursor:pointer">RESUME</button>';
    html += '<button onclick="deleteStateFile(\'' + escapeHtml(sf.filename || '') + '\')" style="background:rgba(255,0,0,.1);color:var(--red);border:1px solid rgba(255,0,0,.3);padding:2px 6px;border-radius:3px;font-size:9px;cursor:pointer">DEL</button>';
    html += '</div></div>';
  });

  body.innerHTML = html;
}

let _vaultStateFiles = [];
function saveSessionNow() {
  const btn = document.getElementById('save-now-btn');
  if (btn) { btn.textContent = 'SAVING...'; btn.disabled = true; }
  fetch('/api/collector/guardian/save', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'})
    .then(r => r.json())
    .then(d => {
      if (btn) { btn.textContent = d.saved ? 'SAVED!' : 'FAILED'; setTimeout(() => { btn.textContent = 'SAVE NOW'; btn.disabled = false; }, 2000); }
      fetchVaultData();
    })
    .catch(() => { if (btn) { btn.textContent = 'ERROR'; setTimeout(() => { btn.textContent = 'SAVE NOW'; btn.disabled = false; }, 2000); } });
}

function unblockStorm() {
  fetch('/api/collector/guardian/unblock', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'})
    .then(() => { setTimeout(fetchGuardianData, 300); setTimeout(fetchVaultData, 300); });
}

function emergencyPause() {
  const btn = document.getElementById('emergency-pause-btn');
  if (btn) { btn.textContent = 'PAUSING...'; btn.disabled = true; }
  fetch('/api/collector/guardian/emergency-pause', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{"duration":30}'})
    .then(r => r.json())
    .then(d => {
      if (btn) {
        btn.textContent = d.paused ? 'PAUSED!' : 'FAILED';
        setTimeout(() => { btn.textContent = 'EMERGENCY PAUSE'; btn.disabled = false; }, 5000);
      }
    })
    .catch(() => { if (btn) { btn.textContent = 'ERROR'; setTimeout(() => { btn.textContent = 'EMERGENCY PAUSE'; btn.disabled = false; }, 2000); } });
}

function startCollector() {
  // Update all start status elements
  const updateStatus = (msg, color) => {
    ['collector-start-status', 'guardian-start-status'].forEach(id => {
      const el = document.getElementById(id);
      if (el) { el.textContent = msg; el.style.color = color || 'var(--gray-light)'; }
    });
  };
  updateStatus('Starting...', 'var(--cyan)');
  fetch('/api/start-collector', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'})
    .then(r => r.json())
    .then(d => {
      if (d.started) {
        updateStatus('Started! PID ' + d.pid + ' — waiting for connection...', 'var(--green)');
        // Poll until collector responds, then refresh guardian data
        let attempts = 0;
        const poll = setInterval(() => {
          attempts++;
          fetch('/api/collector/health').then(r => {
            if (r.ok) {
              clearInterval(poll);
              updateStatus('Collector online', 'var(--green)');
              fetchGuardianData();
              pollDialog();
            }
          }).catch(() => {});
          if (attempts > 20) { clearInterval(poll); updateStatus('Timeout — check logs', 'var(--red)'); }
        }, 1000);
      } else if (d.already_running) {
        updateStatus('Already running', 'var(--green)');
        fetchGuardianData();
      } else {
        updateStatus('Error: ' + (d.error || 'unknown'), 'var(--red)');
      }
    })
    .catch(() => updateStatus('Request failed', 'var(--red)'));
}

function copyResumeHint(idx) {
  if (_guardianCache && _guardianCache.state_files && _guardianCache.state_files[idx]) {
    const sf = _guardianCache.state_files[idx];
    const text = sf.resume_hint || 'No resume hint available.';
    navigator.clipboard.writeText(text).then(() => {
      // Brief flash feedback
    }).catch(() => {});
  }
}

function deleteStateFile(filename) {
  if (!filename) return;
  fetch('/api/collector/guardian/sessions/' + encodeURIComponent(filename), {method:'DELETE'})
    .then(() => fetchVaultData())
    .catch(() => {});
}

// InsAIts Monitor Status — system agent's own panel
// ---------------------------------------------------------------------------
function renderMonitorStatus(data) {
  const body = document.getElementById('monitor-body');
  const countEl = document.getElementById('monitor-count');
  const sysAgent = (data.agents || {})['system'];

  if (!sysAgent || sysAgent.messages === 0) {
    countEl.textContent = '0 alerts';
    body.innerHTML = '<div style="text-align:center;color:var(--green);font-size:11px;padding:12px">InsAIts monitor is active. No behavioral alerts generated.</div>';
    return;
  }

  countEl.textContent = sysAgent.anomalies + ' alerts / ' + sysAgent.messages + ' checks';

  // Collect system feed entries
  const sysFeed = (data.feed || []).filter(f => f.agent === 'system');

  let html = '<div style="display:flex;gap:16px;margin-bottom:8px;flex-wrap:wrap">';

  // Stats cards
  const alertTypes = {};
  sysFeed.forEach(f => {
    const t = (f.type || 'unknown').replace(/_/g, ' ');
    alertTypes[t] = (alertTypes[t] || 0) + 1;
  });

  html += '<div style="background:var(--surface);border:1px solid var(--amber)30;border-radius:6px;padding:8px 12px;min-width:120px">';
  html += '<div style="font-size:8px;color:var(--amber);text-transform:uppercase;letter-spacing:1px">Total Alerts</div>';
  html += '<div style="font-size:20px;color:var(--amber);font-weight:700">' + sysAgent.anomalies + '</div>';
  html += '</div>';

  html += '<div style="background:var(--surface);border:1px solid var(--green)30;border-radius:6px;padding:8px 12px;min-width:120px">';
  html += '<div style="font-size:8px;color:var(--green);text-transform:uppercase;letter-spacing:1px">Resolved</div>';
  html += '<div style="font-size:20px;color:var(--green);font-weight:700">' + sysAgent.resolved + '</div>';
  html += '</div>';

  // Alert type breakdown
  Object.entries(alertTypes).forEach(([type, count]) => {
    html += '<div style="background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:8px 12px;min-width:100px">';
    html += '<div style="font-size:8px;color:var(--gray-light);text-transform:uppercase;letter-spacing:1px">' + escapeHtml(type) + '</div>';
    html += '<div style="font-size:16px;color:var(--text);font-weight:600">' + count + '</div>';
    html += '</div>';
  });

  html += '</div>';

  // Recent system alerts (last 5)
  const recent = sysFeed.slice(-5).reverse();
  if (recent.length > 0) {
    html += '<div style="border-top:1px solid var(--border);padding-top:6px;margin-top:4px">';
    html += '<div style="font-size:8px;color:var(--gray-light);text-transform:uppercase;margin-bottom:4px">Recent Monitor Alerts</div>';
    recent.forEach(f => {
      const sevColor = f.severity === 'CRITICAL' ? 'var(--red)' : f.severity === 'HIGH' ? 'var(--amber)' : 'var(--gray-light)';
      html += '<div style="display:flex;gap:8px;align-items:center;font-size:9px;padding:2px 0">';
      html += '<span style="color:var(--gray-light);min-width:50px">' + escapeHtml(timeAgo(f.ts)) + '</span>';
      html += '<span class="sev-badge sev-' + escapeHtml(f.severity) + '" style="font-size:7px">' + escapeHtml(f.severity) + '</span>';
      html += '<span style="color:' + sevColor + '">' + escapeHtml((f.type || '').replace(/_/g, ' ').toUpperCase()) + '</span>';
      html += '<span style="color:var(--gray-light);font-size:8px;margin-left:auto">' + (f.resolved ? '&#10003; resolved' : '&#9888; open') + '</span>';
      html += '</div>';
    });
    html += '</div>';
  }

  body.innerHTML = html;
}

// ---------------------------------------------------------------------------
// Anomaly Drill-Down Modal (Phase 2.2)
// ---------------------------------------------------------------------------
function openDrillDown(feedIndex) {
  if (!lastData || !lastData.feed || !lastData.feed[feedIndex]) return;
  const item = lastData.feed[feedIndex];
  const overlay = document.getElementById('dd-overlay');
  const title = document.getElementById('dd-title');
  const content = document.getElementById('dd-content');

  const sevColor = severityColor(item.severity);
  const typeName = (item.type || '').replace(/_/g, ' ').toUpperCase();

  let html = '';

  // Type + Severity
  html += `<div class="dd-section">
    <div class="dd-section-label">Anomaly Type</div>
    <div class="dd-section-value">${escapeHtml(typeName)}
      <span class="dd-tag" style="color:${sevColor};background:${sevColor}18;border:1px solid ${sevColor}30;margin-left:8px">${escapeHtml(item.severity)}</span>
      ${item.resolved ? '<span class="dd-tag" style="color:var(--green);background:rgba(0,255,136,.1);border:1px solid rgba(0,255,136,.25)">RESOLVED</span>' : '<span class="dd-tag" style="color:var(--amber);background:rgba(255,170,0,.1);border:1px solid rgba(255,170,0,.25)">UNRESOLVED</span>'}
    </div>
  </div>`;

  // Agent + Timestamp
  html += `<div class="dd-section">
    <div class="dd-section-label">Agent</div>
    <div class="dd-section-value">${escapeHtml(item.agent || 'unknown')}</div>
  </div>`;
  html += `<div class="dd-section">
    <div class="dd-section-label">Timestamp</div>
    <div class="dd-section-value">${escapeHtml(formatTime(item.ts))}</div>
  </div>`;

  // Description
  if (item.description) {
    html += `<div class="dd-section">
      <div class="dd-section-label">Description</div>
      <div class="dd-section-value">${escapeHtml(item.description)}</div>
    </div>`;
  }

  // OWASP Category
  if (item.owasp_code) {
    let owaspHtml = `<span class="dd-tag" style="color:var(--red);background:rgba(255,45,85,.1);border:1px solid rgba(255,45,85,.25)">${escapeHtml(item.owasp_code)}</span>`;
    if (item.owasp_desc) owaspHtml += ` <span style="color:var(--gray-light)">${escapeHtml(item.owasp_desc)}</span>`;
    if (item.owasp_ids && item.owasp_ids.length > 0) {
      item.owasp_ids.forEach(id => {
        if (id !== item.owasp_code) owaspHtml += ` <span class="dd-tag" style="color:var(--amber);background:rgba(255,170,0,.08);border:1px solid rgba(255,170,0,.2)">${escapeHtml(id)}</span>`;
      });
    }
    html += `<div class="dd-section">
      <div class="dd-section-label">OWASP MCP Category</div>
      <div class="dd-section-value">${owaspHtml}</div>
    </div>`;
  }

  // OWASP Explainer
  if (item.explainer_what) {
    html += `<div class="dd-section" style="background:rgba(100,210,255,.04);border-radius:6px;padding:10px 12px;margin:8px 0">
      <div class="dd-section-label" style="color:var(--cyan)">WHAT THIS DETECTS</div>
      <div class="dd-section-value" style="font-size:10px;line-height:1.6">${escapeHtml(item.explainer_what)}</div>
      ${item.explainer_risk ? '<div class="dd-section-label" style="color:var(--amber);margin-top:8px">WHY IT MATTERS</div><div class="dd-section-value" style="font-size:10px;line-height:1.6;color:var(--amber)">' + escapeHtml(item.explainer_risk) + '</div>' : ''}
      ${item.explainer_response ? '<div class="dd-section-label" style="color:var(--green);margin-top:8px">HOW INSAITS HANDLES IT</div><div class="dd-section-value" style="font-size:10px;line-height:1.6;color:var(--green)">' + escapeHtml(item.explainer_response) + '</div>' : ''}
    </div>`;
  }

  // CVE References
  if (item.cve_refs && item.cve_refs.length > 0) {
    let cveHtml = item.cve_refs.map(c => `<span class="dd-tag" style="color:var(--cyan);background:rgba(0,212,255,.08);border:1px solid rgba(0,212,255,.2)">${escapeHtml(c)}</span>`).join(' ');
    html += `<div class="dd-section">
      <div class="dd-section-label">CVE References</div>
      <div class="dd-section-value">${cveHtml}</div>
    </div>`;
  }

  // Remediation
  html += `<div class="dd-section">
    <div class="dd-section-label">Recommended Action</div>
    <div class="dd-remediation">${escapeHtml(item.remediation || 'Review the anomaly and take appropriate action.')}</div>
  </div>`;

  title.textContent = typeName;
  content.innerHTML = html;
  overlay.classList.add('dd-open');
}

function closeDrillDown(event) {
  if (event && event.target !== event.currentTarget) return;
  document.getElementById('dd-overlay').classList.remove('dd-open');
}

// Close drill-down on Escape key
document.addEventListener('keydown', function(e) {
  if (e.key === 'Escape') closeDrillDown();
});

// ---------------------------------------------------------------------------
// Circuit Breaker Manual Toggle (Phase 2.3)
// ---------------------------------------------------------------------------
function toggleCB(agentName) {
  fetch('/api/cb/toggle', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ agent: agentName })
  })
  .then(r => r.json())
  .then(d => {
    if (d.success) fetchData(); // Refresh immediately
  });
}

// ---------------------------------------------------------------------------
// Session management
// ---------------------------------------------------------------------------
let isViewingArchive = false;

function loadSessionList() {
  fetch('/api/sessions')
    .then(r => r.json())
    .then(data => {
      if (!data.success) return;
      const sel = document.getElementById('session-select');
      const currentVal = sel.value;
      sel.innerHTML = '';
      (data.sessions || []).forEach(s => {
        const opt = document.createElement('option');
        opt.value = s.filename;
        if (s.is_current) {
          opt.textContent = 'Current Session (' + s.message_count + ' msgs)';
        } else {
          // Format archived session label as "Mar 12 14:30 (X msgs)"
          let archiveLabel = s.label;
          try {
            const d = new Date(s.label.replace(' ', 'T'));
            if (!isNaN(d.getTime())) {
              const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
              archiveLabel = months[d.getMonth()] + ' ' + d.getDate() + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
            }
          } catch(e) {}
          opt.textContent = archiveLabel + ' (' + s.message_count + ' msgs)';
        }
        if (s.filename === (data.viewing || '__current__')) {
          opt.selected = true;
        }
        sel.appendChild(opt);
      });
      // Update viewing state
      const viewing = data.viewing;
      const badge = document.getElementById('viewing-badge');
      if (viewing) {
        isViewingArchive = true;
        badge.style.display = 'inline-block';
        badge.className = 'archived-badge';
        badge.textContent = 'ARCHIVED';
        // Stop auto-refresh when viewing archive
        if (pollInterval) {
          clearInterval(pollInterval);
          pollInterval = null;
        }
      } else {
        isViewingArchive = false;
        badge.style.display = 'none';
        badge.className = 'viewing-badge';
        if (!isPaused && !pollInterval) {
          startPolling();
        }
      }
    })
    .catch(() => {});
}

function onSessionChange(filename) {
  fetch('/api/session/load', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ filename: filename })
  })
    .then(r => r.json())
    .then(() => {
      loadSessionList();
      fetchData();
    })
    .catch(() => {});
}

function newSession() {
  if (!confirm('Archive current session and start a new one?')) return;
  fetch('/api/session/new', { method: 'POST' })
    .then(r => r.json())
    .then(data => {
      if (data.success) {
        loadSessionList();
        fetchData();
      } else {
        alert(data.error || 'Failed to create new session');
      }
    })
    .catch(() => alert('Failed to create new session'));
}

function deleteSession(filename) {
  if (!filename || filename === '__current__') return;
  if (!confirm('Delete this archived session? This cannot be undone.')) return;
  fetch('/api/session/delete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ filename: filename })
  })
    .then(r => r.json())
    .then(data => {
      if (data.success) {
        loadSessionList();
        fetchData();
      } else {
        alert(data.error || 'Failed to delete session');
      }
    })
    .catch(() => alert('Failed to delete session'));
}

// ---------------------------------------------------------------------------
// Inter-Session Dialog Panel (Central Collector integration)
// ---------------------------------------------------------------------------
let _dialogSeq = 0;
let _dialogOnline = false;

function pollDialog() {
  fetch('/api/collector/dialog/thread')
    .then(r => r.json())
    .then(data => {
      if (data.offline) {
        setDialogOffline();
        return;
      }
      setDialogOnline();
      renderDialogThread(data.thread || [], data.active_sessions || [], data.file_locks || {});
      _dialogSeq = data.latest_sequence || 0;
    })
    .catch(() => setDialogOffline());

  // Also poll health for stats
  fetch('/api/collector/health')
    .then(r => r.json())
    .then(h => {
      if (h.offline) return;
      const sc = document.getElementById('dialog-session-count');
      const mc = document.getElementById('dialog-msg-count');
      const ec = document.getElementById('dialog-evidence-count');
      if (sc) sc.textContent = (h.sessions_count || 0) + ' sessions';
      if (mc) mc.textContent = (h.dialog_messages || 0) + ' messages';
      if (ec) ec.textContent = (h.evidence_records || 0) + ' evidence';
    })
    .catch(() => {});
}

function setDialogOnline() {
  _dialogOnline = true;
  const s = document.getElementById('dialog-status');
  if (s) { s.textContent = 'ONLINE'; s.style.background = 'rgba(0,230,118,.15)'; s.style.color = 'var(--green)'; }
}

function setDialogOffline() {
  _dialogOnline = false;
  const s = document.getElementById('dialog-status');
  if (s) { s.textContent = 'OFFLINE'; s.style.background = 'rgba(255,82,82,.15)'; s.style.color = 'var(--red)'; }
}

function renderDialogThread(thread, activeSessions, fileLocks) {
  const el = document.getElementById('dialog-thread');
  if (!el) return;

  // Sessions pills
  const sessEl = document.getElementById('dialog-sessions');
  if (sessEl) {
    if (activeSessions.length === 0) {
      sessEl.innerHTML = '<span style="font-size:9px;color:var(--gray-light)">No active sessions</span>';
    } else {
      sessEl.innerHTML = activeSessions.map(sid => {
        const short = sid.length > 12 ? sid.slice(0,12) + '..' : sid;
        return '<span style="font-size:9px;padding:1px 6px;border-radius:3px;background:rgba(0,230,118,.1);color:var(--green);border:1px solid rgba(0,230,118,.2)">' + short + '</span>';
      }).join('');
      // File locks
      const lockKeys = Object.keys(fileLocks);
      if (lockKeys.length > 0) {
        sessEl.innerHTML += '<span style="font-size:9px;padding:1px 6px;border-radius:3px;background:rgba(255,171,0,.1);color:var(--amber);border:1px solid rgba(255,171,0,.2)">FILES: ' + lockKeys.length + ' locked</span>';
      }
    }
  }

  if (thread.length === 0) {
    el.innerHTML = '<div style="text-align:center;color:var(--gray-light);font-size:10px;padding:20px">No dialog messages yet. Use the input below to send a message or /command.</div>';
    return;
  }

  // Filter out guardian meta-messages — Dialog is for human-Claude-Claude communication,
  // guardian has its own panel (Session Health / Session Vault)
  const filteredThread = thread.filter(msg =>
    msg.from_session_id !== 'insaits_guardian' &&
    msg.from_session_name !== 'InsAIts Guardian'
  );

  const html = filteredThread.slice(-50).map(msg => {
    const isHuman = msg.is_human || msg.from_session_id === 'human_operator';
    const isCmd = msg.message_type === 'command';
    const isCoord = msg.message_type === 'coordinate';
    const blocked = msg.blocked;
    const hasAnomalies = (msg.anomalies || []).length > 0;
    const hasInjections = (msg.injections || []).length > 0;

    const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString('en',{hour12:false}) : '';
    const name = msg.from_session_name || msg.from_session_id || '?';

    let bgColor = 'transparent';
    let borderLeft = '2px solid var(--border)';
    if (isHuman) { borderLeft = '2px solid var(--cyan)'; bgColor = 'rgba(0,188,212,.04)'; }
    else if (blocked) { borderLeft = '2px solid var(--red)'; bgColor = 'rgba(255,82,82,.06)'; }
    else if (isCoord) { borderLeft = '2px solid var(--amber)'; bgColor = 'rgba(255,171,0,.04)'; }
    else if (hasAnomalies) { borderLeft = '2px solid var(--amber)'; }

    let badge = '';
    if (blocked) badge = '<span style="font-size:8px;padding:1px 4px;border-radius:2px;background:rgba(255,82,82,.2);color:var(--red);margin-left:4px">BLOCKED</span>';
    else if (hasAnomalies) badge = '<span style="font-size:8px;padding:1px 4px;border-radius:2px;background:rgba(255,171,0,.2);color:var(--amber);margin-left:4px">ANOMALY</span>';
    if (isCoord) badge += '<span style="font-size:8px;padding:1px 4px;border-radius:2px;background:rgba(0,230,118,.15);color:var(--green);margin-left:4px">' + (msg.action||'COORD') + '</span>';
    if (hasInjections) {
      msg.injections.forEach(inj => {
        if (inj.type === 'file_conflict_warning') badge += '<span style="font-size:8px;padding:1px 4px;border-radius:2px;background:rgba(255,82,82,.15);color:var(--red);margin-left:4px">FILE CONFLICT</span>';
      });
    }

    let content = (msg.content || '').replace(/</g,'&lt;').replace(/>/g,'&gt;');
    if (content.length > 2000) content = content.slice(0,2000) + '...';

    let extra = '';
    if (isCmd && msg.command_response) {
      extra = '<div style="margin-top:4px;padding:4px 8px;background:var(--bg-card);border-radius:3px;font-size:10px;color:var(--green);white-space:pre-wrap">' + msg.command_response.replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</div>';
    }
    if (hasInjections) {
      msg.injections.filter(i => i.text).forEach(inj => {
        extra += '<div style="margin-top:2px;font-size:9px;color:var(--amber);font-style:italic">[InsAIts] ' + inj.text.replace(/</g,'&lt;').slice(0,200) + '</div>';
      });
    }

    return '<div style="padding:4px 8px;border-left:' + borderLeft + ';background:' + bgColor + ';margin-bottom:4px">'
      + '<div style="display:flex;justify-content:space-between;align-items:center">'
      + '<span style="font-size:9px;color:' + (isHuman?'var(--cyan)':'var(--purple)') + ';font-weight:600">' + name + badge + '</span>'
      + '<span style="font-size:8px;color:var(--gray-light)">' + ts + '</span>'
      + '</div>'
      + '<div style="margin-top:2px;color:var(--fg);font-size:10px;white-space:pre-wrap;word-break:break-word">' + content + '</div>'
      + extra
      + '</div>';
  }).join('');

  el.innerHTML = html;
  el.scrollTop = el.scrollHeight;
}

function sendDialogMessage() {
  const input = document.getElementById('dialog-input');
  const urgent = document.getElementById('dialog-urgent');
  if (!input || !input.value.trim()) return;

  const msg = input.value.trim();
  input.value = '';

  fetch('/api/collector/dialog/send', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      session_id: 'human_operator',
      session_name: 'Cri',
      message_type: msg.startsWith('/') ? 'command' : 'message',
      content: msg,
      urgent: urgent ? urgent.checked : false,
    })
  })
  .then(r => r.json())
  .then(data => {
    if (data.offline) { setDialogOffline(); return; }
    pollDialog(); // Refresh immediately
  })
  .catch(() => setDialogOffline());
}

// Wire up send button and Enter key
document.getElementById('dialog-send-btn')?.addEventListener('click', sendDialogMessage);
document.getElementById('dialog-input')?.addEventListener('keydown', e => {
  if (e.key === 'Enter') { e.preventDefault(); sendDialogMessage(); }
});

// Start
fetchData();
startPolling();
loadSessionList();
// Refresh session list every 10 seconds
setInterval(loadSessionList, 10000);
// Poll dialog every 2 seconds
setInterval(pollDialog, 2000);
pollDialog();
setInterval(() => {
  document.getElementById('clock').textContent = new Date().toLocaleTimeString('en', { hour12: false });
}, 200);

// --- Pattern Mining ---
async function minePatterns() {
  const btn = document.getElementById('mine-patterns-btn');
  btn.textContent = 'MINING...';
  btn.disabled = true;
  try {
    const resp = await fetch('/api/patterns/mine', { method: 'POST' });
    const data = await resp.json();
    if (data.error) {
      document.getElementById('pattern-insights').innerHTML = '<div style="color:var(--red)">' + data.error + '</div>';
      return;
    }
    renderPatternInsights(data);
  } catch (e) {
    document.getElementById('pattern-insights').innerHTML = '<div style="color:var(--red)">Mining failed: ' + e.message + '</div>';
  } finally {
    btn.textContent = 'MINE NOW';
    btn.disabled = false;
  }
}

function renderPatternInsights(data) {
  const el = document.getElementById('pattern-insights');
  const meta = data.metadata || {};
  const freq = data.anomaly_frequency || {};
  const res = data.resolution_stats || {};
  const sevs = data.severity_distribution || {};

  let html = '<div style="font-size:11px;color:var(--gray-light);margin-bottom:8px">' +
    'Files: ' + meta.files_loaded + ' | Entries: ' + meta.total_entries +
    ' | Anomalies: ' + meta.total_anomalies + ' | Sessions: ' + meta.unique_sessions + '</div>';

  // Resolution rate bar
  const resRate = res.resolution_rate || 0;
  const resColor = resRate > 50 ? 'var(--green)' : resRate > 20 ? 'var(--amber)' : 'var(--red)';
  html += '<div style="margin-bottom:8px"><span style="font-size:10px;color:var(--gray-light)">Resolution Rate</span>' +
    '<div style="background:rgba(255,255,255,.05);border-radius:3px;height:14px;overflow:hidden;margin-top:2px">' +
    '<div style="width:' + resRate + '%;background:' + resColor + ';height:100%;border-radius:3px;transition:width .3s"></div>' +
    '</div><span style="font-size:10px;color:' + resColor + '">' + resRate + '% (' + (res.resolved||0) + '/' + (res.total||0) + ')</span></div>';

  // Top anomaly types
  html += '<div style="font-size:10px;color:var(--amber);margin-bottom:4px;font-weight:600">TOP ANOMALY TYPES</div>';
  const topTypes = Object.entries(freq).slice(0, 8);
  const maxCount = topTypes.length ? topTypes[0][1] : 1;
  for (const [atype, count] of topTypes) {
    const pct = (count / maxCount * 100).toFixed(0);
    html += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">' +
      '<span style="font-size:10px;color:var(--gray-light);min-width:140px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap">' + atype + '</span>' +
      '<div style="flex:1;background:rgba(255,255,255,.05);border-radius:2px;height:10px;overflow:hidden">' +
      '<div style="width:' + pct + '%;background:var(--amber);height:100%;border-radius:2px"></div></div>' +
      '<span style="font-size:10px;color:var(--amber);min-width:30px;text-align:right">' + count + '</span></div>';
  }

  // Severity distribution
  html += '<div style="font-size:10px;color:var(--red);margin:8px 0 4px;font-weight:600">SEVERITY BREAKDOWN</div>';
  const sevColors = { CRITICAL: '#ff1744', HIGH: '#ff5252', MEDIUM: '#ff9100', LOW: '#ffd740', INFO: '#90a4ae' };
  for (const [sev, count] of Object.entries(sevs)) {
    html += '<span style="font-size:10px;color:' + (sevColors[sev]||'var(--gray-light)') + ';margin-right:12px">' + sev + ': ' + count + '</span>';
  }

  el.innerHTML = html;

  // Combos panel
  const combosEl = document.getElementById('pattern-combos');
  const combos = data.agent_anomaly_combos || [];
  const dangerous = data.top_dangerous_combos || [];
  let chtml = '';
  if (dangerous.length) {
    chtml += '<div style="font-size:10px;color:var(--red);margin-bottom:4px;font-weight:600">DANGEROUS COMBOS</div>';
    for (const c of dangerous.slice(0, 8)) {
      chtml += '<div style="font-size:10px;margin-bottom:2px">' +
        '<span style="color:var(--cyan)">' + c.agent + '</span>' +
        '<span style="color:var(--gray-light)"> + </span>' +
        '<span style="color:var(--amber)">' + c.anomaly_type + '</span>' +
        '<span style="color:var(--red);margin-left:6px">x' + c.count + '</span></div>';
    }
  }

  // Intervention effectiveness
  const istats = data.intervention_stats || {};
  const iEntries = Object.entries(istats).filter(([_,v]) => v.has_intervention).sort((a,b) => b[1].total_occurrences - a[1].total_occurrences);
  if (iEntries.length) {
    chtml += '<div style="font-size:10px;color:var(--green);margin:8px 0 4px;font-weight:600">INTERVENTION EFFECTIVENESS</div>';
    for (const [atype, info] of iEntries.slice(0, 6)) {
      const rate = info.resolution_rate;
      const rateColor = rate > 50 ? 'var(--green)' : rate > 20 ? 'var(--amber)' : 'var(--red)';
      chtml += '<div style="font-size:10px;margin-bottom:2px">' +
        '<span style="color:var(--gray-light)">' + atype + '</span>' +
        '<span style="color:' + rateColor + ';margin-left:6px">' + rate + '% resolved</span>' +
        '<span style="color:var(--gray-light)"> (' + info.resolved + '/' + info.total_occurrences + ')</span></div>';
    }
  }

  combosEl.innerHTML = chtml || '<div style="color:var(--gray-light);font-size:11px">No combo data</div>';
}

// Auto-load patterns if file exists
fetch('/api/patterns').then(r => r.json()).then(data => {
  if (!data.error) renderPatternInsights(data);
}).catch(() => {});
</script>
</body>
</html>"""


# ---------------------------------------------------------------------------
# HTTP Server
# ---------------------------------------------------------------------------
class DashboardHandler(BaseHTTPRequestHandler):
    """Handles dashboard HTTP requests."""

    log_path: str = DEFAULT_LOG
    server_ref: HTTPServer = None  # Set by main() for shutdown support
    viewing_session: Optional[str] = None  # None = current, else filename
    stealth_enabled: bool = False  # Loaded from .insaits_config.json on first request
    cb_overrides: Dict[str, str] = {}  # agent_name -> "OPEN"|"HALF-OPEN"|"CLOSED"|None

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        path = parsed.path.rstrip("/")

        if path == "" or path == "/index.html":
            self._serve_html()
        elif path == "/api/data":
            self._serve_data()
        elif path == "/api/sessions":
            self._handle_list_sessions()
        elif path == "/api/config":
            self._handle_get_config()
        elif path == "/api/anchor":
            self._handle_get_anchor()
        elif path == "/api/patterns":
            self._handle_get_patterns()
        elif path.startswith("/api/collector/"):
            self._proxy_collector_get(path[len("/api/collector"):])
        else:
            self.send_error(404)

    def do_POST(self) -> None:
        parsed = urlparse(self.path)
        path = parsed.path.rstrip("/")

        if path == "/api/shutdown":
            self._handle_shutdown()
        elif path == "/api/stealth/toggle":
            self._handle_stealth_toggle()
        elif path == "/api/cb/toggle":
            self._handle_cb_toggle()
        elif path == "/api/session/new":
            self._handle_new_session()
        elif path == "/api/session/delete":
            self._handle_delete_session()
        elif path == "/api/session/load":
            self._handle_load_session()
        elif path == "/api/config":
            self._handle_set_config()
        elif path == "/api/anchor":
            self._handle_set_anchor()
        elif path == "/api/patterns/mine":
            self._handle_mine_patterns()
        elif path == "/api/start-collector":
            self._handle_start_collector()
        elif path.startswith("/api/collector/"):
            self._proxy_collector_post(path[len("/api/collector"):])
        else:
            self.send_error(404)

    def do_DELETE(self) -> None:
        parsed = urlparse(self.path)
        path = parsed.path.rstrip("/")
        if path.startswith("/api/collector/"):
            self._proxy_collector_delete(path[len("/api/collector"):])
        else:
            self.send_error(404)

    def _proxy_collector_delete(self, path: str) -> None:
        """Proxy DELETE requests to the Central Collector."""
        import urllib.request
        url = f"{self._COLLECTOR_URL}{path}"
        try:
            req = urllib.request.Request(url, method="DELETE")
            with urllib.request.urlopen(req, timeout=2.0) as resp:
                data = resp.read()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()
            self.wfile.write(data)
        except Exception:
            self._send_json({"error": "Collector offline", "offline": True})

    def _handle_get_patterns(self) -> None:
        """Return cached session_patterns.json if it exists."""
        patterns_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "session_patterns.json")
        if not os.path.isfile(patterns_path):
            body = json.dumps({"error": "No patterns file. Click MINE NOW."}).encode("utf-8")
        else:
            try:
                with open(patterns_path, "r", encoding="utf-8") as fh:
                    body = fh.read().encode("utf-8")
            except (IOError, OSError):
                body = json.dumps({"error": "Cannot read patterns file"}).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def _handle_mine_patterns(self) -> None:
        """Run pattern mining and return results."""
        try:
            from insa_its.learning.pattern_miner import PatternMiner
            miner = PatternMiner()
            project_dir = os.path.dirname(os.path.abspath(__file__))
            miner.add_project_dir(project_dir)
            patterns = miner.mine()
            output_path = os.path.join(project_dir, "session_patterns.json")
            miner.save(patterns, output_path)
            body = json.dumps(patterns, default=str).encode("utf-8")
        except Exception as exc:
            body = json.dumps({"error": str(exc)}).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def _handle_shutdown(self) -> None:
        """Gracefully stop the dashboard server."""
        body = json.dumps({"success": True, "message": "Shutting down"}).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)
        # Shutdown in a separate thread so the response is sent first
        if self.server_ref:
            threading.Thread(
                target=self.server_ref.shutdown, daemon=True,
            ).start()

    def _handle_stealth_toggle(self) -> None:
        """Toggle stealth mode ON/OFF. Persists to .insaits_config.json so the
        collector (separate process) can read it on next hook call."""
        DashboardHandler.stealth_enabled = not DashboardHandler.stealth_enabled
        new_state = DashboardHandler.stealth_enabled
        # Persist to config file (shared between dashboard + collector processes)
        config_path = Path(".insaits_config.json")
        try:
            existing = {}
            if config_path.exists():
                with open(config_path, "r", encoding="utf-8") as f:
                    existing = json.load(f)
            existing["stealth_enabled"] = new_state
            with open(config_path, "w", encoding="utf-8") as f:
                json.dump(existing, f, indent=2)
        except Exception:
            pass  # Best-effort — dashboard toggle still works in-memory
        self._send_json({
            "success": True,
            "stealth_enabled": new_state,
            "message": f"Stealth mode {'ON' if new_state else 'OFF'}",
        })

    def _handle_cb_toggle(self) -> None:
        """Cycle circuit breaker state for an agent: auto -> OPEN -> HALF-OPEN -> CLOSED -> auto."""
        req = self._read_json_body()
        agent_name: str = req.get("agent", "")
        if not agent_name:
            self._send_json({"success": False, "error": "Missing agent name"})
            return
        cycle = [None, "OPEN", "HALF-OPEN", "CLOSED"]
        current = DashboardHandler.cb_overrides.get(agent_name)
        idx = cycle.index(current) if current in cycle else 0
        next_state = cycle[(idx + 1) % len(cycle)]
        if next_state is None:
            DashboardHandler.cb_overrides.pop(agent_name, None)
        else:
            DashboardHandler.cb_overrides[agent_name] = next_state
        body = json.dumps({
            "success": True,
            "agent": agent_name,
            "state": next_state or "AUTO",
            "message": f"Circuit breaker for {agent_name}: {next_state or 'AUTO'}",
        }).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def _read_json_body(self) -> Dict[str, Any]:
        """Read and parse JSON request body."""
        content_length = int(self.headers.get("Content-Length", 0))
        if content_length == 0:
            return {}
        raw = self.rfile.read(content_length)
        try:
            return json.loads(raw.decode("utf-8"))
        except (json.JSONDecodeError, UnicodeDecodeError):
            return {}

    def _send_json(self, data: Dict[str, Any], status: int = 200) -> None:
        """Send a JSON response."""
        body = json.dumps(data, default=str).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def _handle_new_session(self) -> None:
        """Archive current session and start a new one."""
        result = archive_current_session(self.log_path)
        DashboardHandler.viewing_session = None  # Reset to current
        self._send_json(result)

    def _handle_list_sessions(self) -> None:
        """List all sessions."""
        sessions = list_sessions(self.log_path)
        self._send_json({
            "success": True,
            "sessions": sessions,
            "viewing": DashboardHandler.viewing_session,
        })

    def _handle_delete_session(self) -> None:
        """Delete an archived session."""
        body = self._read_json_body()
        filename = body.get("filename", "")
        result = delete_session(filename)
        # If we were viewing the deleted session, switch back to current
        if result.get("success") and DashboardHandler.viewing_session == filename:
            DashboardHandler.viewing_session = None
        self._send_json(result)

    def _handle_load_session(self) -> None:
        """Switch to viewing a specific session."""
        body = self._read_json_body()
        filename = body.get("filename", "")
        if filename == "__current__" or filename == "":
            DashboardHandler.viewing_session = None
            self._send_json({"success": True, "viewing": None})
        else:
            sessions_dir = ensure_sessions_dir()
            target = sessions_dir / filename
            if not target.exists():
                self._send_json({"success": False, "error": "Session not found"})
                return
            DashboardHandler.viewing_session = filename
            self._send_json({"success": True, "viewing": filename})

    def _handle_get_config(self) -> None:
        """GET /api/config — return current InsAIts config (detector/feature toggles)."""
        config_path = Path(".insaits_config.json")
        default_config = {
            "detectors": {
                "credential_exposure": True, "prompt_injection": True,
                "tool_poisoning": True, "data_exfiltration": True,
                "rogue_agent": True, "unauthorized_access": True,
                "context_collapse": True, "truncated_output": True,
                "hallucination_chain": True, "shorthand_emergence": True,
                "uncertainty_propagation": True, "governance_gap": True,
                "memory_poisoning": True, "shadow_server": True,
                "information_flow": True, "phantom_citation": True,
            },
            "features": {
                "response_awareness": True, "task_anchor": True,
                "loop_detection": True, "surrender_detection": True,
                "sensitive_file_access": True, "monitoring_gap": True,
                "write_tracking": True, "frustration_detection": True,
                "ask_user_intervention": True,
            },
            "response_awareness_interval": 8,
        }
        try:
            if config_path.exists():
                with open(config_path, "r", encoding="utf-8") as f:
                    cfg = json.load(f)
                # Merge with defaults
                for section in ("detectors", "features"):
                    if section in cfg:
                        default_config[section] = {**default_config[section], **cfg[section]}
                if "response_awareness_interval" in cfg:
                    default_config["response_awareness_interval"] = cfg["response_awareness_interval"]
        except Exception:
            pass
        self._send_json({"success": True, "config": default_config})

    def _handle_set_config(self) -> None:
        """POST /api/config — update InsAIts config (detector/feature toggles)."""
        body = self._read_json_body()
        config_path = Path(".insaits_config.json")
        try:
            # Load existing
            existing = {}
            if config_path.exists():
                with open(config_path, "r", encoding="utf-8") as f:
                    existing = json.load(f)
            # Merge updates
            for section in ("detectors", "features"):
                if section in body:
                    if section not in existing:
                        existing[section] = {}
                    existing[section].update(body[section])
            if "response_awareness_interval" in body:
                existing["response_awareness_interval"] = body["response_awareness_interval"]
            with open(config_path, "w", encoding="utf-8") as f:
                json.dump(existing, f, indent=2)
            self._send_json({"success": True, "config": existing})
        except Exception as exc:
            self._send_json({"success": False, "error": str(exc)})

    def _handle_get_anchor(self) -> None:
        """GET /api/anchor — return 3-layer anchor templates + legacy anchor text."""
        sdk_base = Path("InsAIts-SDK/insa_its/prompts")
        templates_base = Path("InsAIts-SDK/insa_its/anchor/templates")
        result = {}
        # 3-layer templates
        layer_files = {"layer1": "layer1.txt", "layer2": "layer2.txt", "layer3": "layer3.txt"}
        for key, filename in layer_files.items():
            p = templates_base / filename
            try:
                if p.exists():
                    result[key] = p.read_text(encoding="utf-8")
                else:
                    result[key] = ""
            except Exception:
                result[key] = ""
        # Legacy anchor files
        for name in ("user_anchor", "memory_check"):
            p = sdk_base / f"{name}.md"
            try:
                if p.exists():
                    result[name] = p.read_text(encoding="utf-8")
                else:
                    result[name] = ""
            except Exception:
                result[name] = ""
        self._send_json({"success": True, **result})

    def _handle_set_anchor(self) -> None:
        """POST /api/anchor — update 3-layer templates + legacy anchor text files."""
        body = self._read_json_body()
        sdk_base = Path("InsAIts-SDK/insa_its/prompts")
        templates_base = Path("InsAIts-SDK/insa_its/anchor/templates")
        updated = []
        # 3-layer templates
        layer_files = {"layer1": "layer1.txt", "layer2": "layer2.txt", "layer3": "layer3.txt"}
        for key, filename in layer_files.items():
            if key in body:
                p = templates_base / filename
                try:
                    p.parent.mkdir(parents=True, exist_ok=True)
                    p.write_text(body[key], encoding="utf-8")
                    updated.append(key)
                except Exception as exc:
                    self._send_json({"success": False, "error": f"Failed to write {key}: {exc}"})
                    return
        # Legacy anchor files
        for name in ("user_anchor", "memory_check"):
            if name in body:
                p = sdk_base / f"{name}.md"
                try:
                    p.write_text(body[name], encoding="utf-8")
                    updated.append(name)
                except Exception as exc:
                    self._send_json({"success": False, "error": f"Failed to write {name}: {exc}"})
                    return
        self._send_json({"success": True, "updated": updated})

    # ------------------------------------------------------------------
    # Collector proxy — forwards requests to localhost:5003
    # ------------------------------------------------------------------
    _COLLECTOR_URL = "http://127.0.0.1:5003"

    def _proxy_collector_get(self, path: str) -> None:
        """Proxy GET requests to the Central Collector."""
        import urllib.request
        qs = urlparse(self.path).query
        url = f"{self._COLLECTOR_URL}{path}"
        if qs:
            url += f"?{qs}"
        try:
            with urllib.request.urlopen(url, timeout=1.0) as resp:
                data = resp.read()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()
            self.wfile.write(data)
        except Exception:
            self._send_json({"error": "Collector offline", "offline": True})

    def _proxy_collector_post(self, path: str) -> None:
        """Proxy POST requests to the Central Collector."""
        import urllib.request
        body = self._read_json_body()
        url = f"{self._COLLECTOR_URL}{path}"
        try:
            req = urllib.request.Request(
                url, data=json.dumps(body, default=str).encode("utf-8"),
                headers={"Content-Type": "application/json"}, method="POST",
            )
            with urllib.request.urlopen(req, timeout=2.0) as resp:
                data = resp.read()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()
            self.wfile.write(data)
        except Exception:
            self._send_json({"error": "Collector offline", "offline": True})

    def _serve_html(self) -> None:
        html = DASHBOARD_HTML.replace("__VERSION__", VERSION)
        content = html.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(content)))
        self.send_header("Cache-Control", "no-cache")
        self.end_headers()
        self.wfile.write(content)

    def _handle_start_collector(self) -> None:
        """Start the InsAIts collector daemon if not already running."""
        import socket
        import subprocess

        # Check if already running
        try:
            with socket.create_connection(("127.0.0.1", 5003), timeout=0.5):
                self._send_json({"started": False, "already_running": True, "message": "Collector already running"})
                return
        except (OSError, ConnectionRefusedError):
            pass

        # Locate collector script
        collector_script = Path(__file__).resolve().parent / "insaits_collector.py"
        if not collector_script.exists():
            self._send_json({"started": False, "error": f"Collector not found at {collector_script}"})
            return

        # Find python executable — prefer the venv used to run the dashboard
        python_exe = sys.executable
        log_file = Path(__file__).resolve().parent / "insaits_collector.log"
        pid_file = Path(__file__).resolve().parent / ".insaits_collector.pid"

        try:
            # On Windows use DETACHED_PROCESS so collector survives dashboard restarts
            kwargs: Dict[str, Any] = dict(
                cwd=str(Path(__file__).resolve().parent),
            )
            import platform
            if platform.system() == "Windows":
                DETACHED_PROCESS = 0x00000008
                kwargs["creationflags"] = DETACHED_PROCESS
            with open(log_file, "a") as lf:
                proc = subprocess.Popen(
                    [python_exe, str(collector_script)],
                    stdout=lf,
                    stderr=lf,
                    **kwargs,
                )
            pid_file.write_text(str(proc.pid))
            self._send_json({"started": True, "pid": proc.pid, "message": f"Collector started (PID {proc.pid})"})
        except Exception as exc:
            self._send_json({"started": False, "error": str(exc)})

    def _serve_data(self) -> None:
        viewing = DashboardHandler.viewing_session
        if viewing:
            # Viewing an archived session
            sessions_dir = ensure_sessions_dir()
            log_file = str(sessions_dir / viewing)
            entries = read_audit_log(log_file)
            if entries:
                data = aggregate(entries)
            else:
                data = generate_demo_data()
            data["viewing_session"] = viewing
            data["sessions_available"] = True
        else:
            entries = read_audit_log(self.log_path)
            if entries:
                data = aggregate(entries)
            else:
                data = generate_demo_data()
            data["viewing_session"] = None
            data["sessions_available"] = True
        data["stealth_enabled"] = DashboardHandler.stealth_enabled
        data["cb_overrides"] = dict(DashboardHandler.cb_overrides)

        # Message history (Phase 2C)
        data["message_history"] = _load_message_history()

        # Task Anchor data (Phase 3B)
        data["task_anchor"] = _load_task_anchor_data()

        # Token stats (Phase 7C.4) — read from file written by tailer
        token_file = Path(self.log_path).parent / _TOKEN_STATS_FILE
        if token_file.exists():
            try:
                with open(token_file, "r", encoding="utf-8") as f:
                    data["token_stats"] = json.load(f)
            except (json.JSONDecodeError, OSError):
                data["token_stats"] = None
        else:
            data["token_stats"] = None

        self._send_json(data)

    def log_message(self, format: str, *args: Any) -> None:
        """Suppress default request logging noise."""
        pass


# ---------------------------------------------------------------------------
# Transcript Tailer (Phase 7B.5-7B.8) — background extraction of subagent
# tool calls from Claude Code session transcripts
# ---------------------------------------------------------------------------
_TAILER_INTERVAL = 10  # seconds between extraction runs


_TOKEN_STATS_FILE = ".insaits_token_stats.json"


def _extract_token_stats(transcript_path: Path, last_line: int) -> Dict[str, Any]:
    """Extract cumulative token usage from a Claude Code session transcript.

    Reads new lines since last_line and accumulates input/output/cache tokens.
    """
    totals: Dict[str, int] = {
        "input_tokens": 0,
        "output_tokens": 0,
        "cache_read_tokens": 0,
        "cache_write_tokens": 0,
    }
    msg_count = 0

    try:
        with open(transcript_path, "r", encoding="utf-8", errors="replace") as fh:
            for line_num, raw_line in enumerate(fh):
                if line_num < last_line:
                    continue
                raw_line = raw_line.strip()
                if not raw_line or "usage" not in raw_line:
                    continue
                try:
                    data = json.loads(raw_line)
                except json.JSONDecodeError:
                    continue
                # Navigate to message.usage
                msg = data.get("message", {})
                if not isinstance(msg, dict):
                    continue
                usage = msg.get("usage", {})
                if not isinstance(usage, dict):
                    continue
                inp = usage.get("input_tokens", 0)
                out = usage.get("output_tokens", 0)
                cr = usage.get("cache_read_input_tokens", 0)
                cw = usage.get("cache_creation_input_tokens", 0)
                if inp or out:
                    totals["input_tokens"] += inp
                    totals["output_tokens"] += out
                    totals["cache_read_tokens"] += cr
                    totals["cache_write_tokens"] += cw
                    msg_count += 1
    except OSError:
        pass

    totals["api_calls"] = msg_count
    return totals


def _run_transcript_tailer(audit_log: str) -> None:
    """Background thread: periodically extract subagent calls + token stats."""
    # Lazy import to avoid startup cost
    try:
        scripts_dir = Path(__file__).resolve().parent / "InsAIts-SDK" / "scripts"
        sys.path.insert(0, str(scripts_dir))
        from extract_subagent_calls import find_latest_transcript, extract_from_transcript
        sys.path.pop(0)
    except ImportError:
        print("  [Tailer] extract_subagent_calls not found — subagent tailing disabled")
        return

    project_dir = str(Path.cwd())
    state_file = Path(audit_log).parent / ".insaits_subagent_extract_state.json"
    token_file = Path(audit_log).parent / _TOKEN_STATS_FILE
    print(f"  [Tailer] Watching for subagent activity in {project_dir}")

    while True:
        try:
            time.sleep(_TAILER_INTERVAL)
            transcript = find_latest_transcript(project_dir)
            if transcript is None:
                continue

            # Load state
            last_line = 0
            if state_file.exists():
                try:
                    with open(state_file, "r", encoding="utf-8") as f:
                        state = json.load(f)
                    key = f"{transcript.name}"
                    last_line = state.get(key, 0)
                except (json.JSONDecodeError, OSError):
                    pass

            new_last = extract_from_transcript(transcript, audit_log, last_line)
            if new_last > last_line:
                # Save state
                state = {}
                if state_file.exists():
                    try:
                        with open(state_file, "r", encoding="utf-8") as f:
                            state = json.load(f)
                    except (json.JSONDecodeError, OSError):
                        pass
                state[transcript.name] = new_last
                try:
                    with open(state_file, "w", encoding="utf-8") as f:
                        json.dump(state, f, indent=2)
                except OSError:
                    pass

            # Extract token stats (Phase 7C.4)
            token_stats = _extract_token_stats(transcript, 0)  # always full scan for accuracy
            if token_stats.get("api_calls", 0) > 0:
                try:
                    with open(token_file, "w", encoding="utf-8") as f:
                        json.dump(token_stats, f, indent=2)
                except OSError:
                    pass
        except Exception:
            pass  # never crash the tailer


def main() -> None:
    parser = argparse.ArgumentParser(
        description="InsAIts Live Web Dashboard"
    )
    parser.add_argument(
        "--port", type=int, default=DEFAULT_PORT,
        help=f"Port to serve on (default: {DEFAULT_PORT})",
    )
    parser.add_argument(
        "--log", type=str, default=DEFAULT_LOG,
        help=f"Path to JSONL audit log (default: {DEFAULT_LOG})",
    )
    parser.add_argument(
        "--no-tailer", action="store_true",
        help="Disable transcript tailer (subagent extraction)",
    )
    args = parser.parse_args()

    DashboardHandler.log_path = args.log

    # Load stealth state from persisted config
    try:
        _cfg_path = Path(".insaits_config.json")
        if _cfg_path.exists():
            with open(_cfg_path, "r", encoding="utf-8") as _f:
                _cfg = json.load(_f)
            DashboardHandler.stealth_enabled = _cfg.get("stealth_enabled", False)
    except Exception:
        pass

    # Start transcript tailer thread (Phase 7B.5)
    if not args.no_tailer:
        tailer = threading.Thread(
            target=_run_transcript_tailer,
            args=(args.log,),
            daemon=True,
        )
        tailer.start()
    else:
        print("  [Tailer] Disabled via --no-tailer")

    server = HTTPServer(("0.0.0.0", args.port), DashboardHandler)
    DashboardHandler.server_ref = server
    print(f"InsAIts Dashboard v{VERSION}")
    print(f"  Audit log: {os.path.abspath(args.log)}")
    print(f"  Open: http://localhost:{args.port}")
    print("  Press Ctrl+C to stop")

    # Heartbeat watchdog — auto-shutdown if no hook activity for N minutes
    heartbeat_file = os.path.join(os.path.dirname(os.path.abspath(args.log)), ".insaits_last_activity")
    shutdown_minutes = int(os.environ.get("INSAITS_DASHBOARD_TIMEOUT", "0"))

    def _heartbeat_watchdog():
        """Check heartbeat file every 60s. Shut down if stale."""
        import time as _time
        while True:
            _time.sleep(60)
            try:
                if os.path.exists(heartbeat_file):
                    last_activity = float(open(heartbeat_file).read().strip())
                    stale_seconds = _time.time() - last_activity
                    if stale_seconds > shutdown_minutes * 60:
                        print(f"\n[InsAIts] No hook activity for {shutdown_minutes}min. Auto-shutdown.")
                        server.shutdown()
                        return
            except (ValueError, IOError, OSError):
                pass  # No heartbeat file yet — keep running

    if shutdown_minutes > 0:
        watchdog = threading.Thread(target=_heartbeat_watchdog, daemon=True)
        watchdog.start()
        print(f"  Auto-shutdown after {shutdown_minutes}min inactivity (set INSAITS_DASHBOARD_TIMEOUT=0 to disable)")

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nShutting down.")
        server.server_close()


if __name__ == "__main__":
    main()
