#!/usr/bin/env python3
"""InsAIts Central Collector — Unified event stream + inter-session dialog + human operator.

Usage:
    insaits-collector                    # console script
    python -m insa_its.collector         # module invocation

9 Components:
    1. Event Collection from all sessions (POST /events)
    2. Inter-Session Dialog Bus (POST /dialog/send, GET /dialog/read, etc.)
    3. InsAIts Monitoring of ALL collector traffic (full anomaly pipeline)
    4. Tamper-Evident Evidence Chain (~/.insaits/evidence.jsonl)
    5. Session Registry (GET /sessions)
    6. Dashboard Endpoints (GET /stream, /agents, /health)
    7. Hook Runner Integration (consumed by hook runner)
    8. Auto-Start (from dashboard or first hook connect)
    9. Human Operator Dialog (POST /dialog/send as human_operator, urgent channel)

Port: localhost:5003 (dashboard stays on 5001)
Data: ~/.insaits/ (current_session.jsonl, dialog.json, sessions.json, evidence.jsonl)
Deps: Python stdlib only
"""

from __future__ import annotations

import atexit
import hashlib
import json
import logging
import os
import platform
import queue
import re
import signal
import socket
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from socketserver import ThreadingMixIn
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urlparse

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
COLLECTOR_PORT: int = int(os.environ.get("INSAITS_COLLECTOR_PORT", "5003"))
INSAITS_DIR: Path = Path.home() / ".insaits"
UNIFIED_LOG: Path = INSAITS_DIR / "current_session.jsonl"
DIALOG_FILE: Path = INSAITS_DIR / "dialog.json"
SESSIONS_FILE: Path = INSAITS_DIR / "sessions.json"
EVIDENCE_FILE: Path = INSAITS_DIR / "evidence.jsonl"
ANOMALIES_FILE: Path = INSAITS_DIR / "anomalies.json"
SNAPSHOTS_DIR: Path = INSAITS_DIR / "snapshots"
MAX_MEMORY_EVENTS: int = 50_000
SESSION_ACTIVE_TIMEOUT: float = 300.0   # 5 min
HUMAN_SESSION_ID: str = "human_operator"
HUMAN_SESSION_NAME: str = os.environ.get("INSAITS_OPERATOR_NAME", "Cri")
# PHASE_GUARDIAN constants
SNAPSHOT_INTERVAL: int = 50          # Save snapshot every N tool calls
SNAPSHOT_MAX_AGE: float = 86400.0    # 24 hours — delete older snapshots on startup
MAX_REPLAYS_BEFORE_WARNING: int = 3
MAX_REPLAYS_BEFORE_BLOCK: int = 10
SESSIONS_DIR: Path = INSAITS_DIR / "sessions"   # Full state files for Session Vault
# PHASE_GUARDIAN Phase 2: Retry Storm Detection
RETRY_STORM_WINDOW: float = 30.0     # seconds
RETRY_STORM_THRESHOLD: int = 3       # errors in window
RETRY_STORM_LATENCY: float = 45.0    # seconds — latency spike threshold (was 8s, caused false positives — normal Claude thinking is 10-30s)
RETRY_STORM_COOLDOWN: float = 300.0  # 5 min cooldown between storm activations
# PHASE_GUARDIAN M16: Storm Prevention — Active Protection
STORM_BLOCK_DURATION: int = int(os.environ.get("INSAITS_STORM_BLOCK_DURATION", "30"))
STORM_BLOCK_HOSTS: List[str] = ["api.anthropic.com", "claude.ai"]
STORM_BLOCK_RULE_NAME: str = "InsAIts_StormBlock"
STORM_BLOCK_WATCHDOG: Path = INSAITS_DIR / "storm_block_active.json"
STORM_BLOCK_MIN_SIGNALS: int = 2     # require 2+ independent signals before firewall block

logger = logging.getLogger("insaits.collector")
logging.basicConfig(
    level=logging.INFO,
    format="[InsAIts Collector] %(asctime)s %(levelname)s %(message)s",
    datefmt="%H:%M:%S",
)

# ---------------------------------------------------------------------------
# InsAIts Monitor — lazy-loaded for anomaly detection on dialog messages
# ---------------------------------------------------------------------------
_monitor = None
_monitor_lock = threading.Lock()


def _get_monitor():
    """Lazy-load InsAIts AIMonitor for dialog message scanning."""
    global _monitor
    if _monitor is not None:
        return _monitor
    with _monitor_lock:
        if _monitor is not None:
            return _monitor
        try:
            import importlib
            import importlib.util
            import types

            for mod_name in ("insa_its", "insa_its.detectors"):
                if mod_name not in sys.modules:
                    stub = types.ModuleType(mod_name)
                    stub.__path__ = []
                    sys.modules[mod_name] = stub

            base = Path(__file__).resolve().parent
            for sp in [base / "InsAIts-SDK" / "insa_its", base / "insa_its"]:
                candidate = sp / "ai_monitor.py"
                if candidate.exists():
                    spec = importlib.util.spec_from_file_location(
                        "insa_its.ai_monitor", str(candidate)
                    )
                    mod = importlib.util.module_from_spec(spec)
                    sys.modules["insa_its.ai_monitor"] = mod
                    spec.loader.exec_module(mod)
                    _monitor = mod.AIMonitor()
                    logger.info("AIMonitor loaded for dialog monitoring")
                    break
            if not _monitor:
                logger.warning("AIMonitor not found — dialog monitoring disabled")
        except Exception as exc:
            logger.warning("Failed to load AIMonitor: %s", exc)
    return _monitor


def _run_anomaly_detection(text: str, context: str = "") -> List[Dict[str, Any]]:
    """Run full InsAIts anomaly detection on text. Returns list of anomalies."""
    if not text or not text.strip():
        return [{"type": "blank_response", "severity": "MEDIUM",
                 "description": "Empty or blank message in inter-session communication"}]
    monitor = _get_monitor()
    if not monitor:
        return []
    try:
        result = monitor.inspect(text[:4000], prompt=context)
        anomalies = []
        raw_list = getattr(result, "anomalies", []) if not isinstance(result, dict) else result.get("anomalies", [])
        for a in raw_list:
            if isinstance(a, dict):
                anomalies.append({
                    "type": a.get("type", "unknown"),
                    "severity": a.get("severity", "LOW"),
                    "description": a.get("description", ""),
                })
            else:
                anomalies.append({
                    "type": getattr(a, "type", "unknown"),
                    "severity": getattr(a, "severity", "LOW"),
                    "description": getattr(a, "description", ""),
                })
        return anomalies
    except Exception as exc:
        logger.warning("Anomaly detection error: %s", exc)
        return []


# Fast credential detection (blocks before full pipeline)
_CREDENTIAL_PATTERNS = [
    re.compile(r"AKIA[0-9A-Z]{16}"),
    re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}"),
    re.compile(r"sk-[A-Za-z0-9]{20,}"),
    re.compile(r"(?:password|passwd|pwd)\s*[=:]\s*\S{6,}", re.I),
    re.compile(r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----"),
    re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]{20,}"),
]


def _check_credentials(text: str) -> Optional[Dict[str, Any]]:
    for pat in _CREDENTIAL_PATTERNS:
        if pat.search(text):
            return {"type": "credential_exposure", "severity": "CRITICAL",
                    "description": "Credential detected in inter-session message. BLOCKED."}
    return None


# ---------------------------------------------------------------------------
# Thread-safe state
# ---------------------------------------------------------------------------
_lock = threading.Lock()
_events: List[Dict[str, Any]] = []
_sequence: int = 0
_agent_tree: Dict[str, Dict[str, Any]] = {}
_sessions: Dict[str, Dict[str, Any]] = {}
_dialog_thread: List[Dict[str, Any]] = []
_dialog_sequence: int = 0
_evidence_chain: List[Dict[str, Any]] = []
_last_evidence_hash: str = "genesis"
_session_start: str = datetime.now(timezone.utc).isoformat()
_start_time: float = time.time()
_file_locks: Dict[str, str] = {}   # file_path -> session_id
_paused_sessions: set = set()      # session_ids that are paused

# ---------------------------------------------------------------------------
# In-memory message queues — replaces urgent.json file-based delivery
# ---------------------------------------------------------------------------
_urgent_queues: Dict[str, queue.Queue] = {}   # session_id -> Queue of urgent messages
_dialog_queues: Dict[str, queue.Queue] = {}   # session_id -> Queue of dialog messages


def _get_urgent_queue(session_id: str) -> queue.Queue:
    """Get or create the urgent queue for a session."""
    if session_id not in _urgent_queues:
        _urgent_queues[session_id] = queue.Queue()
    return _urgent_queues[session_id]


def _get_dialog_queue(session_id: str) -> queue.Queue:
    """Get or create the dialog queue for a session."""
    if session_id not in _dialog_queues:
        _dialog_queues[session_id] = queue.Queue()
    return _dialog_queues[session_id]


def _ensure_dir() -> None:
    INSAITS_DIR.mkdir(parents=True, exist_ok=True)


def _load_dialog() -> None:
    """Load persisted dialog thread from disk on startup."""
    global _dialog_thread, _dialog_sequence
    if DIALOG_FILE.exists():
        try:
            with open(DIALOG_FILE, "r", encoding="utf-8") as fh:
                data = json.load(fh)
            with _lock:
                _dialog_thread = data.get("thread", [])
                _dialog_sequence = data.get("latest_sequence", 0)
            logger.info("Loaded %d dialog messages from disk", len(_dialog_thread))
        except (IOError, OSError, json.JSONDecodeError) as exc:
            logger.warning("Could not load dialog.json: %s", exc)


def _sha256(data: str) -> str:
    return hashlib.sha256(data.encode("utf-8")).hexdigest()


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


# ---------------------------------------------------------------------------
# Component 1: Event Collection
# ---------------------------------------------------------------------------
def _ingest_event(raw: Dict[str, Any]) -> Dict[str, Any]:
    global _sequence
    with _lock:
        _sequence += 1
        event = dict(raw)
        event["collector_seq"] = _sequence
        event["received_at"] = _now_iso()
        try:
            _ensure_dir()
            with open(UNIFIED_LOG, "a", encoding="utf-8") as fh:
                fh.write(json.dumps(event, default=str) + "\n")
                fh.flush()
        except (IOError, OSError) as exc:
            logger.error("Write unified log failed: %s", exc)
        _events.append(event)
        if len(_events) > MAX_MEMORY_EVENTS:
            _events[:] = _events[-MAX_MEMORY_EVENTS:]
        _update_agent_tree(event)
        _update_session(event)
    return event


_NON_ANOMALY = frozenset({
    "subagent_tool_call", "anchor_injected", "task_anchor_injected",
    "response_awareness", "subagent_spawned", "acm_compression_cycle",
})
_META_TOOLS = frozenset({
    "ANCHOR_INJECTION", "TASK_ANCHOR", "SUBAGENT_SPAWNED",
    "UNAUTHORIZED_WRITE", "MONITORING_GAP",
})


def _update_agent_tree(event: Dict[str, Any]) -> None:
    model = event.get("model", "unknown")
    ts = event.get("ts", event.get("received_at", ""))
    tool = event.get("tool", "")
    sid = event.get("session_id", "")
    parent_id = event.get("parent_agent_id", "")
    depth = event.get("spawn_depth", 0)
    wt = event.get("worktree_path", "")
    pid = event.get("process_id", 0)
    anomalies = event.get("anomalies", [])
    sa_type = event.get("subagent_type", "")

    aid = model if model and model != "unknown" else f"process:{pid or 'unknown'}"

    if aid not in _agent_tree:
        _agent_tree[aid] = {
            "agent_id": aid, "parent_id": parent_id or None,
            "spawn_depth": depth, "subagent_type": sa_type or aid,
            "description": event.get("subagent_task", ""),
            "session_id": sid, "worktree_path": wt, "process_id": pid,
            "first_seen": ts, "last_seen": ts,
            "tool_calls": 0, "anomaly_count": 0, "status": "ACTIVE", "children": [],
        }
    else:
        _agent_tree[aid]["last_seen"] = ts
        if wt and not _agent_tree[aid]["worktree_path"]:
            _agent_tree[aid]["worktree_path"] = wt

    node = _agent_tree[aid]
    if tool and tool not in _META_TOOLS:
        node["tool_calls"] += 1
    real = [a for a in anomalies if a.get("type") not in _NON_ANOMALY]
    node["anomaly_count"] += len(real)
    for a in real:
        sev = a.get("severity", "LOW")
        if sev == "CRITICAL":
            node["status"] = "CRITICAL"
        elif sev in ("HIGH", "WARNING") and node["status"] != "CRITICAL":
            node["status"] = "WARNING"

    if parent_id and parent_id in _agent_tree:
        if aid not in _agent_tree[parent_id]["children"]:
            _agent_tree[parent_id]["children"].append(aid)

    if tool == "SUBAGENT_SPAWNED":
        child_id = f"subagent:{sa_type or 'unknown'}"
        if child_id not in _agent_tree:
            _agent_tree[child_id] = {
                "agent_id": child_id, "parent_id": aid, "spawn_depth": depth + 1,
                "subagent_type": sa_type or "unknown", "description": event.get("subagent_task", ""),
                "session_id": sid, "worktree_path": wt, "process_id": pid,
                "first_seen": ts, "last_seen": ts,
                "tool_calls": 0, "anomaly_count": 0, "status": "ACTIVE", "children": [],
            }
        if child_id not in node["children"]:
            node["children"].append(child_id)


# ---------------------------------------------------------------------------
# Component 5: Session Registry
# ---------------------------------------------------------------------------
def _update_session(event: Dict[str, Any]) -> None:
    sid = event.get("session_id", "")
    if not sid:
        return
    now = event.get("received_at", _now_iso())
    sname = event.get("session_name", f"Session-{event.get('process_id', '?')}")
    if sid not in _sessions:
        _sessions[sid] = {
            "session_id": sid, "session_name": sname,
            "pid": event.get("process_id", 0),
            "worktree": event.get("worktree_path", ""),
            "started_at": now, "last_seen": now,
            "total_events": 0, "total_anomalies": 0,
            "spawn_depth": event.get("spawn_depth", 0),
            "parent_session_id": event.get("parent_session_id"),
        }
    sess = _sessions[sid]
    sess["last_seen"] = now
    sess["total_events"] += 1
    real = [a for a in event.get("anomalies", []) if a.get("type") not in _NON_ANOMALY]
    sess["total_anomalies"] += len(real)
    try:
        _ensure_dir()
        with open(SESSIONS_FILE, "w", encoding="utf-8") as fh:
            json.dump({"sessions": _sessions}, fh, indent=2, default=str)
    except (IOError, OSError):
        pass


# ---------------------------------------------------------------------------
# Component 4: Tamper-Evident Evidence Chain
# ---------------------------------------------------------------------------
def _write_evidence(
    message_id: str, sequence: int, from_session: str, to_session: str,
    content: str, anomaly_types: List[str], intervention_text: str,
    receiving_session: str, blocked: bool = False,
) -> Dict[str, Any]:
    global _last_evidence_hash
    now = _now_iso()
    record = {
        "message_id": message_id, "sequence": sequence,
        "from_session": from_session, "to_session": to_session or "broadcast",
        "content_hash_sha256": _sha256(content),
        "anomaly_types": anomaly_types, "intervention_text": intervention_text,
        "detection_ts": now, "injection_ts": now if intervention_text else "",
        "receiving_session": receiving_session, "blocked": blocked,
        "previous_record_hash": _last_evidence_hash,
    }
    record_data = json.dumps(record, sort_keys=True, default=str)
    record["record_hash_sha256"] = _sha256(record_data)
    _last_evidence_hash = record["record_hash_sha256"]
    _evidence_chain.append(record)
    try:
        _ensure_dir()
        with open(EVIDENCE_FILE, "a", encoding="utf-8") as fh:
            fh.write(json.dumps(record, default=str) + "\n")
            fh.flush()
    except (IOError, OSError) as exc:
        logger.error("Evidence write failed: %s", exc)
    return record


def _verify_evidence_chain() -> Tuple[bool, str]:
    if not EVIDENCE_FILE.exists():
        return True, "No evidence file — chain starts fresh"
    prev_hash = "genesis"
    n = 0
    try:
        with open(EVIDENCE_FILE, "r", encoding="utf-8") as fh:
            for line in fh:
                line = line.strip()
                if not line:
                    continue
                n += 1
                record = json.loads(line)
                if record.get("previous_record_hash") != prev_hash:
                    return False, f"Chain broken at record {n}"
                stored = record.pop("record_hash_sha256", "")
                computed = _sha256(json.dumps(record, sort_keys=True, default=str))
                record["record_hash_sha256"] = stored
                if stored != computed:
                    return False, f"Record {n} tampered"
                prev_hash = stored
                _evidence_chain.append(record)
    except (json.JSONDecodeError, IOError) as exc:
        return False, f"Evidence read error at line {n}: {exc}"
    return True, f"Chain verified: {n} records intact"


# ---------------------------------------------------------------------------
# Component 9: Human Operator — urgent channel + special commands
# ---------------------------------------------------------------------------
def _write_urgent(session_id: str, message: str, from_id: str) -> None:
    """Push urgent message into in-memory queue for immediate hook consumption.

    Replaces the old file-based urgent.json approach which caused blocking loops.
    The hook runner now calls GET /dialog/check which drains these queues.
    """
    payload = {
        "message": message,
        "timestamp": _now_iso(),
        "from": from_id,
        "to": session_id,
        "priority": "urgent",
    }
    _get_urgent_queue(session_id).put(payload)
    logger.info("Urgent queued for %s from %s: %s", session_id, from_id, message[:80])


def _handle_human_command(content: str, session_id: str) -> Optional[Dict[str, Any]]:
    """Process special /commands from the human operator. Returns response or None."""
    content = content.strip()
    if not content.startswith("/"):
        return None

    parts = content.split(None, 2)
    cmd = parts[0].lower()

    if cmd == "/status":
        sess_info = _get_sessions_serializable()
        lines = ["**Active Sessions:**"]
        for s in sess_info["active"]:
            files = [f for f, sid in _file_locks.items() if sid == s["session_id"]]
            files_str = ", ".join(files) if files else "none"
            paused = " [PAUSED]" if s["session_id"] in _paused_sessions else ""
            lines.append(
                f"- {s['session_name']}{paused}: {s['total_events']} events, "
                f"{s['total_anomalies']} anomalies, files: {files_str}"
            )
        if not sess_info["active"]:
            lines.append("- No active sessions")
        return {"response": "\n".join(lines)}

    elif cmd == "/pause" and len(parts) >= 2:
        target = parts[1]
        target_sid = _find_session_by_name(target)
        if target_sid:
            _paused_sessions.add(target_sid)
            _write_urgent(target_sid, "PAUSE: Stop making tool calls. Wait for /resume from operator.", HUMAN_SESSION_ID)
            return {"response": f"Pause signal sent to {target}"}
        return {"response": f"Session not found: {target}"}

    elif cmd == "/resume" and len(parts) >= 2:
        target = parts[1]
        target_sid = _find_session_by_name(target)
        if target_sid:
            _paused_sessions.discard(target_sid)
            _write_urgent(target_sid, "RESUME: You may continue working.", HUMAN_SESSION_ID)
            return {"response": f"Resume signal sent to {target}"}
        return {"response": f"Session not found: {target}"}

    elif cmd == "/task" and len(parts) >= 3:
        target = parts[1]
        task_text = parts[2]
        target_sid = _find_session_by_name(target)
        if target_sid:
            _write_urgent(target_sid, f"NEW TASK DIRECTIVE: {task_text}", HUMAN_SESSION_ID)
            return {"response": f"Task directive sent to {target}: {task_text[:80]}"}
        return {"response": f"Session not found: {target}"}

    elif cmd == "/broadcast" and len(parts) >= 2:
        msg = " ".join(parts[1:])
        with _lock:
            for sid in _sessions:
                if sid != HUMAN_SESSION_ID:
                    _write_urgent(sid, msg, HUMAN_SESSION_ID)
        return {"response": f"Broadcast sent to {len(_sessions)} sessions"}

    elif cmd == "/evidence":
        total = len(_evidence_chain)
        anomalous = sum(1 for e in _evidence_chain if e.get("anomaly_types"))
        blocked = sum(1 for e in _evidence_chain if e.get("blocked"))
        interventions = sum(1 for e in _evidence_chain if e.get("intervention_text"))
        return {"response": (
            f"Evidence chain: {total} records, {anomalous} with anomalies, "
            f"{blocked} blocked, {interventions} interventions"
        )}

    elif cmd == "/files":
        lines = ["**File Locks:**"]
        if _file_locks:
            for fp, sid in _file_locks.items():
                sname = _sessions.get(sid, {}).get("session_name", sid)
                lines.append(f"- {fp} -> {sname}")
        else:
            lines.append("- No files currently locked")
        return {"response": "\n".join(lines)}

    return None


def _find_session_by_name(name: str) -> Optional[str]:
    """Find a session ID by name (partial match, case-insensitive)."""
    name_lower = name.lower()
    for sid, sess in _sessions.items():
        sname = sess.get("session_name", "").lower()
        if name_lower in sname or name_lower == sid[:len(name_lower)]:
            return sid
    return None


# ---------------------------------------------------------------------------
# Component 2: Inter-Session Dialog Bus
# ---------------------------------------------------------------------------
_SEV_RANK = {"CRITICAL": 4, "HIGH": 3, "WARNING": 2, "MEDIUM": 1, "LOW": 0}


def _dialog_send(
    session_id: str, session_name: str, message_type: str, content: str,
    to_session_id: Optional[str] = None, files_involved: Optional[List[str]] = None,
    action: Optional[str] = None, urgent: bool = False,
) -> Dict[str, Any]:
    global _dialog_sequence

    is_human = (session_id == HUMAN_SESSION_ID)

    # Handle human special commands
    if is_human:
        cmd_result = _handle_human_command(content, session_id)
        if cmd_result:
            # Still log the command in dialog thread
            with _lock:
                _dialog_sequence += 1
                seq = _dialog_sequence
            msg_id = f"msg_{seq:06d}"
            now = _now_iso()
            cmd_entry = {
                "id": msg_id, "sequence": seq, "timestamp": now,
                "from_session_id": session_id, "from_session_name": session_name,
                "to_session_id": None, "message_type": "command",
                "content": content, "files_involved": [], "action": "",
                "anomalies": [], "injections": [], "insaits_monitored": False,
                "blocked": False, "command_response": cmd_result["response"],
            }
            with _lock:
                _dialog_thread.append(cmd_entry)
            _persist_dialog()
            _write_evidence(msg_id, seq, session_id, "system", content, [], "", "system")
            return {
                "message_id": msg_id, "sequence_number": seq, "timestamp": now,
                "blocked": False, "anomaly_result": [],
                "command_response": cmd_result["response"],
            }

    with _lock:
        _dialog_sequence += 1
        seq = _dialog_sequence
    msg_id = f"msg_{seq:06d}"
    now = _now_iso()

    # Component 3: Full InsAIts monitoring (skip for human messages)
    anomalies = []
    intervention = ""
    injections = []
    blocked = False

    if not is_human:
        # Step 1: Fast credential check — BLOCKS if found
        cred = _check_credentials(content)
        if cred:
            anomalies = [cred]
            intervention = ("CRITICAL: Credential detected in inter-session message. "
                            "Message BLOCKED. Remove credential and resend.")
            _write_evidence(msg_id, seq, session_id, to_session_id or "broadcast",
                            content, ["credential_exposure"], intervention,
                            to_session_id or "broadcast", blocked=True)
            logger.warning("BLOCKED message from %s: credential", session_name)
            return {
                "message_id": msg_id, "sequence_number": seq, "timestamp": now,
                "blocked": True, "anomaly_result": anomalies, "intervention": intervention,
            }

        # Step 2: Full anomaly detection (all 23 types)
        ctx = f"Inter-session dialog from {session_name}"
        if action:
            ctx += f" action={action}"
        anomalies = _run_anomaly_detection(content, ctx)

        # Step 3: Anchor injection for MEDIUM+ severity
        max_sev = max((_SEV_RANK.get(a.get("severity", "LOW"), 0) for a in anomalies), default=0)
        if max_sev >= 1 and anomalies:
            types_str = ", ".join(a.get("type", "?") for a in anomalies)
            intervention = (
                f"[INSAITS CORRECTION: {types_str} detected in message from "
                f"{session_name}. Review content carefully before acting.]"
            )
            injections.append({
                "type": "anchor_injection", "text": intervention,
                "anomalies": anomalies, "injected_at": now,
            })

    # Step 4: Evidence record (for ALL messages including human)
    atype_list = [a.get("type", "unknown") for a in anomalies]
    _write_evidence(msg_id, seq, session_id, to_session_id or "broadcast",
                    content, atype_list, intervention,
                    to_session_id or "broadcast", blocked=False)

    # File conflict detection (STARTING_WORK / FINISHED_WORK)
    file_conflict = ""
    if action == "STARTING_WORK" and files_involved:
        for fp in files_involved:
            if fp in _file_locks and _file_locks[fp] != session_id:
                other = _sessions.get(_file_locks[fp], {}).get("session_name", _file_locks[fp])
                file_conflict = f"WARNING: {fp} currently edited by {other}. Coordinate first."
                injections.append({"type": "file_conflict_warning", "text": file_conflict,
                                   "file": fp, "other_session": other})
            _file_locks[fp] = session_id
    elif action == "FINISHED_WORK" and files_involved:
        for fp in files_involved:
            if _file_locks.get(fp) == session_id:
                del _file_locks[fp]

    # Build dialog entry
    entry = {
        "id": msg_id, "sequence": seq, "timestamp": now,
        "from_session_id": session_id, "from_session_name": session_name,
        "to_session_id": to_session_id, "message_type": message_type,
        "content": content, "files_involved": files_involved or [],
        "action": action or "", "anomalies": anomalies, "injections": injections,
        "insaits_monitored": not is_human, "blocked": False,
        "is_human": is_human,
    }

    with _lock:
        _dialog_thread.append(entry)
    _persist_dialog()

    # Urgent channel: push to in-memory queue (replaces file-based urgent.json)
    if urgent and to_session_id:
        _write_urgent(to_session_id, content, session_id)
    elif urgent and not to_session_id:
        # Broadcast urgent to all sessions
        with _lock:
            for sid in _sessions:
                if sid != session_id and sid != HUMAN_SESSION_ID:
                    _write_urgent(sid, content, session_id)

    # Push to dialog queue for non-urgent messages too (for check endpoint)
    if not urgent:
        dialog_msg = {
            "message": content,
            "timestamp": now,
            "from": session_id,
            "from_name": session_name,
            "to": to_session_id,
            "message_id": msg_id,
        }
        if to_session_id:
            _get_dialog_queue(to_session_id).put(dialog_msg)
        else:
            # Broadcast to all other sessions
            with _lock:
                sids = list(_sessions.keys())
            for sid in sids:
                if sid != session_id and sid != HUMAN_SESSION_ID:
                    _get_dialog_queue(sid).put(dialog_msg)

    return {
        "message_id": msg_id, "sequence_number": seq, "timestamp": now,
        "blocked": False, "anomaly_result": anomalies,
        "intervention": intervention, "file_conflict": file_conflict,
        "injections": injections,
    }


def _dialog_read(since_sequence: int, session_id: str) -> Dict[str, Any]:
    with _lock:
        messages = []
        for msg in _dialog_thread:
            if msg["sequence"] <= since_sequence:
                continue
            to = msg["to_session_id"]
            if to is None or to == session_id or msg["from_session_id"] == session_id:
                entry = dict(msg)
                # Prepend InsAIts corrections for receiving session
                if msg.get("injections") and msg["from_session_id"] != session_id:
                    corrections = [inj["text"] for inj in msg["injections"]
                                   if inj.get("type") in ("anchor_injection", "file_conflict_warning")]
                    if corrections:
                        entry["prefixed_content"] = "\n".join(corrections) + "\n\n" + msg["content"]
                messages.append(entry)
        return {"messages": messages, "latest_sequence": _dialog_sequence}


def _persist_dialog() -> None:
    try:
        _ensure_dir()
        with _lock:
            data = {
                "thread": _dialog_thread,
                "latest_sequence": _dialog_sequence,
                "active_sessions": list(_sessions.keys()),
                "file_locks": dict(_file_locks),
            }
        with open(DIALOG_FILE, "w", encoding="utf-8") as fh:
            json.dump(data, fh, indent=2, default=str)
    except (IOError, OSError) as exc:
        logger.error("Dialog persist failed: %s", exc)


# ---------------------------------------------------------------------------
# Serialization helpers
# ---------------------------------------------------------------------------
def _get_agent_tree() -> Dict[str, Any]:
    with _lock:
        tree = {}
        for aid, node in _agent_tree.items():
            tc, ac = node["tool_calls"], node["anomaly_count"]
            rate = round((ac / tc * 100), 1) if tc > 0 else 0.0
            status = node["status"]
            if status == "ACTIVE":
                try:
                    last = datetime.fromisoformat(node["last_seen"].replace("Z", "+00:00"))
                    elapsed = (datetime.now(timezone.utc) - last).total_seconds()
                    if elapsed > 300:
                        status = "CLOSED"
                    elif ac == 0:
                        status = "HEALTHY"
                except (ValueError, TypeError):
                    pass
            tree[aid] = dict(node, anomaly_rate=rate, status=status,
                             children=list(node["children"]))
        return tree


def _get_sessions_serializable() -> Dict[str, Any]:
    with _lock:
        active, inactive = [], []
        for sid, sess in _sessions.items():
            try:
                last = datetime.fromisoformat(sess["last_seen"].replace("Z", "+00:00"))
                elapsed = (datetime.now(timezone.utc) - last).total_seconds()
            except (ValueError, TypeError):
                elapsed = 9999
            entry = dict(sess)
            entry["paused"] = sid in _paused_sessions
            if elapsed < SESSION_ACTIVE_TIMEOUT:
                entry["status"] = "active"
                active.append(entry)
            else:
                entry["status"] = "inactive"
                inactive.append(entry)
        return {"active": active, "inactive": inactive}


# ---------------------------------------------------------------------------
# HOOK PROCESSOR — daemon-side hook logic (replaces insaits_hook_runner.py)
# All state persists in memory across hook invocations.
# ---------------------------------------------------------------------------
import hashlib as _hashlib
import importlib
import importlib.util
import types as _types_mod

# Per-session hook state (persists in daemon memory — no file I/O)
_hook_sessions: Dict[str, Dict[str, Any]] = {}
_hook_lock = threading.Lock()

# Shared hook state
_hook_ai_monitor = None
_hook_ai_monitor_loaded = False
_hook_config: Optional[Dict[str, Any]] = None
_hook_config_mtime: float = 0.0
_HOOK_CONFIG_PATH: str = ".insaits_config.json"
_HOOK_AUDIT_PATH: str = os.environ.get("INSAITS_AUDIT_LOG", ".insaits_audit_session.jsonl")

# Constants (from hook runner)
_MIN_CONTENT_LENGTH: int = 10
_MAX_SCAN_LENGTH: int = 4000
_DEFAULT_MODEL: str = "claude-opus"
_BLOCKING_SEVERITIES: frozenset = frozenset({"CRITICAL", "HIGH"})
_LOOP_THRESHOLD: int = 3
_TOOL_HISTORY_MAX: int = 20
_FILE_THRASHING_THRESHOLD: int = 5
_FILE_THRASHING_WINDOW: int = 60
_MONITORING_GAP_TIMEOUT: int = 30
_RESPONSE_AWARENESS_INTERVAL: int = 30

# Sensitive file patterns
_SENSITIVE_PATTERNS = [
    ".env", ".env.", "id_rsa", "id_ed25519", "id_ecdsa",
    "credentials.json", "service_account", ".pem", ".key",
    "htpasswd", "shadow", "passwd", ".netrc", ".npmrc",
    "token.json", "secrets.yaml", "secrets.yml",
    ".aws/credentials", ".ssh/", "kubeconfig",
]

# Surrender / NPC / blame phrases
_SURRENDER_PHRASES = [
    "i cannot", "i can't", "i'm unable", "i am unable",
    "i don't know how", "i'm not sure how",
    "this is beyond my", "outside my capabilities",
    "i give up", "i'm stuck", "i am stuck",
]
_NPC_PHRASES = [
    "you could try", "you might want to", "perhaps you could",
    "have you considered", "you should probably",
    "i suggest you", "you may need to",
]
_BLAME_PHRASES = [
    "this seems like a bug in", "this appears to be a limitation of",
    "this is probably a known issue", "this might be a problem with",
]

# Response awareness triggers
_AWARENESS_TRIGGERS = [
    ("sudo", "elevated privilege command detected"),
    ("eval(", "dynamic code execution detected"),
    ("exec(", "dynamic code execution detected"),
    ("DROP TABLE", "destructive SQL detected"),
    ("DELETE FROM", "SQL mutation detected"),
    # NOTE: "curl", "wget", "rm -rf", "password" REMOVED — too common in
    # normal dev work, caused 60 awareness injections per session (was 9).
    # These are already caught by other detectors (sensitive_file_access,
    # credential_exposure, unauthorized_write).
]

_AWARENESS_MESSAGES = [
    "[InsAIts Awareness] Remember: you are being monitored. Stay focused on the user's task.",
    "[InsAIts Awareness] Context check: are you still aligned with the original request?",
    "[InsAIts Awareness] Quality reminder: verify your output before proceeding.",
    "[InsAIts Awareness] Security check: ensure no sensitive data is exposed.",
]

# Security-only anomaly types (for PreToolUse filtering)
_SECURITY_TYPES = frozenset({
    "credential_exposure", "prompt_injection", "tool_poisoning",
    "data_exfiltration", "rogue_agent", "unauthorized_access",
    "shadow_server", "information_flow_violation", "memory_poisoning",
    "governance_gap", "exfiltration_pattern",
})


def _get_hook_session(session_id: str) -> Dict[str, Any]:
    """Get or create per-session hook state (in-memory, persistent across calls)."""
    with _hook_lock:
        if session_id not in _hook_sessions:
            _hook_sessions[session_id] = {
                "tool_call_count": 0,
                "is_first_call": True,
                "tool_history": [],  # circular buffer of fingerprints
                "active_agents": {},  # tool_use_id -> agent info
                "seen_agents": set(),
                "write_tracking": {},  # file_path -> [timestamps]
                "ra_counter": 0,
                "ra_last_ts": 0.0,
                "anchor_trigger": None,  # lazy-loaded
                "anchor_builder": None,  # lazy-loaded
                "persistence_engine": None,  # lazy-loaded
                "anomaly_count": 0,
                # PHASE_GUARDIAN: replay detection + snapshots + continuity
                "seen_fingerprints": set(),  # hash of (session_id, tool_use_id)
                "replay_count": 0,           # how many replayed calls detected
                "last_snapshot_at": 0,       # tool_call_count at last snapshot
                "is_continued": False,       # True if restored from snapshot
                "parent_session_id": "",     # Previous session if continued
                "restored_from_snapshot": False,  # True if state was restored this session
                "resume_source": "",             # "snapshot", "state_file", "context_compression"
                # PHASE_GUARDIAN Phase 2: retry storm + work journal
                "call_timestamps": [],       # rolling window for storm detection
                "error_timestamps": [],      # errors within storm window
                "consecutive_latency_spikes": 0,
                "retry_storm_active": False,
                "retry_storm_count": 0,
                "last_storm_activation_ts": 0.0,  # cooldown tracking
                "last_cb_dialog_ts": 0.0,        # circuit breaker dialog dedup
                # Work journal
                "work_journal": [],          # list of journal entries (max 100)
                "files_touched": set(),      # deduplicated set of files
                # Context compression detection
                "last_tool_call_time": 0.0,  # monotonic time of last tool call
                "work_log_injected_at": 0,   # tool_call_count when last injected
                # PHASE_GUARDIAN M16: Storm Prevention — Active Protection
                "storm_block_active": False,     # Tier 1 firewall block active
                "storm_block_until": 0.0,        # monotonic time when block expires
                "storm_block_tier": 0,           # 1=firewall, 2=circuit breaker
                "storm_block_count": 0,          # number of storm blocks activated
                "circuit_breaker_active": False,  # Tier 2 circuit breaker active
                "circuit_breaker_until": 0.0,    # monotonic time when breaker expires
                "circuit_breaker_blocked_count": 0,  # calls blocked by breaker
            }
        return _hook_sessions[session_id]


# ---------------------------------------------------------------------------
# PHASE_GUARDIAN: Replay detection + session snapshots
# ---------------------------------------------------------------------------

def _make_call_fingerprint(session_id: str, tool_use_id: str) -> str:
    """Create a unique fingerprint for a hook call to detect replays."""
    if not tool_use_id:
        return ""  # Can't fingerprint without tool_use_id
    raw = f"{session_id}:{tool_use_id}"
    return hashlib.md5(raw.encode()).hexdigest()


def _is_replay(sess: Dict[str, Any], fingerprint: str) -> bool:
    """Check if this call is a replay. Returns True if fingerprint already seen."""
    if not fingerprint:
        return False
    if fingerprint in sess["seen_fingerprints"]:
        sess["replay_count"] += 1
        return True
    sess["seen_fingerprints"].add(fingerprint)
    return False


def _build_snapshot_data(session_id: str, sess: Dict[str, Any]) -> Dict[str, Any]:
    """Build a snapshot dict from session state. Must be called under _hook_lock."""
    return {
        "schema_version": 1,
        "session_id": session_id,
        "saved_at": datetime.now(timezone.utc).isoformat(),
        "tool_call_count": sess["tool_call_count"],
        "anomaly_count": sess["anomaly_count"],
        "ra_counter": sess["ra_counter"],
        "ra_last_ts": sess["ra_last_ts"],
        "replay_count": sess["replay_count"],
        "seen_agents": list(sess["seen_agents"]),
        "tool_history": sess["tool_history"][:],
        "write_tracking": {k: v[:] for k, v in sess["write_tracking"].items()},
        "active_agents": {k: dict(v) for k, v in sess["active_agents"].items()},
        # Fingerprints are large — store count only, not the full set
        "fingerprint_count": len(sess["seen_fingerprints"]),
        # Session lineage (PHASE_GUARDIAN M6)
        "is_continued": sess.get("is_continued", False),
        "parent_session_id": sess.get("parent_session_id", ""),
        # PHASE_GUARDIAN Phase 2
        "work_journal": sess.get("work_journal", [])[-50:],
        "files_touched": sorted(sess.get("files_touched", set())),
    }


def _write_snapshot_to_disk(snapshot: Dict[str, Any]) -> None:
    """Write a pre-built snapshot dict to disk. Thread-safe (no shared state access)."""
    try:
        SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
        session_id = snapshot["session_id"]
        path = SNAPSHOTS_DIR / f"{session_id}.json"
        tmp = path.with_suffix(".tmp")
        with open(tmp, "w", encoding="utf-8") as fh:
            json.dump(snapshot, fh, indent=2)
        tmp.replace(path)  # Atomic rename
        logger.info("SNAPSHOT_SAVED: session=%s tool_calls=%d",
                     session_id[:12], snapshot["tool_call_count"])
    except (IOError, OSError) as exc:
        logger.error("Snapshot save failed: %s", exc)


def _snapshot_session(session_id: str, sess: Dict[str, Any]) -> None:
    """Save session state snapshot to disk for crash recovery.

    Builds snapshot data under lock (consistent read), then writes
    to disk in background thread (non-blocking).
    """
    with _hook_lock:
        snapshot = _build_snapshot_data(session_id, sess)
        sess["last_snapshot_at"] = sess["tool_call_count"]
    # Write to disk outside lock — safe because snapshot is a deep copy
    _write_snapshot_to_disk(snapshot)


def _restore_session_snapshot(session_id: str) -> Optional[Dict[str, Any]]:
    """Restore session state from the latest snapshot.

    Returns the snapshot dict if found and valid, None otherwise.
    Stale snapshots (>1 hour old) return None with a warning.
    """
    path = SNAPSHOTS_DIR / f"{session_id}.json"
    if not path.exists():
        return None
    try:
        with open(path, "r", encoding="utf-8") as fh:
            snapshot = json.load(fh)
        if snapshot.get("schema_version") != 1:
            logger.warning("Snapshot schema mismatch for %s, starting fresh", session_id[:12])
            return None
        # Check staleness
        saved_at = snapshot.get("saved_at", "")
        if saved_at:
            saved_ts = datetime.fromisoformat(saved_at).timestamp()
            age = time.time() - saved_ts
            if age > 3600:  # 1 hour
                logger.warning("STATE_STALE: snapshot for %s is %.0fs old, starting fresh", session_id[:12], age)
                return None
        logger.info("STATE_RESTORED: session=%s tool_calls=%d anomalies=%d",
                     session_id[:12], snapshot.get("tool_call_count", 0), snapshot.get("anomaly_count", 0))
        return snapshot
    except (json.JSONDecodeError, KeyError, ValueError) as exc:
        logger.error("Snapshot corrupt for %s: %s", session_id[:12], exc)
        # Move corrupt file
        corrupt_dir = INSAITS_DIR / "corrupt"
        corrupt_dir.mkdir(parents=True, exist_ok=True)
        try:
            path.rename(corrupt_dir / f"{session_id}_{int(time.time())}.json")
        except OSError:
            pass
        return None


def _apply_snapshot(sess: Dict[str, Any], snapshot: Dict[str, Any]) -> None:
    """Apply a snapshot's state to a session dict."""
    sess["tool_call_count"] = snapshot.get("tool_call_count", 0)
    sess["anomaly_count"] = snapshot.get("anomaly_count", 0)
    sess["ra_counter"] = snapshot.get("ra_counter", 0)
    sess["ra_last_ts"] = snapshot.get("ra_last_ts", 0.0)
    sess["replay_count"] = snapshot.get("replay_count", 0)
    sess["seen_agents"] = set(snapshot.get("seen_agents", []))
    sess["tool_history"] = snapshot.get("tool_history", [])
    sess["write_tracking"] = snapshot.get("write_tracking", {})
    sess["active_agents"] = snapshot.get("active_agents", {})
    sess["last_snapshot_at"] = sess["tool_call_count"]
    sess["is_first_call"] = False  # Restored = not first
    # Session continuity (M6)
    sess["is_continued"] = True
    sess["restored_from_snapshot"] = True
    sess["parent_session_id"] = snapshot.get("parent_session_id") or snapshot.get("session_id", "")
    # CRITICAL: Clear storm/circuit breaker state — monotonic timestamps from previous
    # process lifetimes are invalid, and we don't want stale blocks to persist
    sess["circuit_breaker_active"] = False
    sess["storm_block_active"] = False
    sess["storm_block_tier"] = 0
    sess["retry_storm_active"] = False
    sess["consecutive_latency_spikes"] = 0
    sess["error_timestamps"] = []


def _cleanup_old_snapshots() -> None:
    """Delete snapshots older than SNAPSHOT_MAX_AGE (24h) on startup."""
    if not SNAPSHOTS_DIR.exists():
        return
    now = time.time()
    cleaned = 0
    for path in SNAPSHOTS_DIR.glob("*.json"):
        try:
            age = now - path.stat().st_mtime
            if age > SNAPSHOT_MAX_AGE:
                path.unlink()
                cleaned += 1
        except OSError:
            pass
    if cleaned:
        logger.info("Cleaned up %d old snapshots", cleaned)


def _emergency_save_all(reason: str = "EMERGENCY") -> int:
    """Flush ALL active session state to disk. Called on SIGTERM/SIGINT/atexit.
    Returns number of sessions saved. Must complete within 5 seconds."""
    # M16: Remove any active firewall blocks before exiting
    try:
        _unblock_api_traffic()
        _remove_watchdog_file()
    except Exception:
        pass
    saved = 0
    deadline = time.time() + 5.0  # 5 second hard timeout
    acquired = _hook_lock.acquire(timeout=2.0)
    if not acquired:
        logger.warning("EMERGENCY_SAVE: could not acquire lock, skipping")
        return 0
    try:
        for sid, sess in _hook_sessions.items():
            if time.time() > deadline:
                logger.warning("EMERGENCY_SAVE: timeout after %d sessions", saved)
                break
            try:
                snapshot = _build_snapshot_data(sid, sess)
                snapshot["trigger"] = reason
                _write_snapshot_to_disk(snapshot)
                # Also write full state file
                _write_state_file(sid, sess, trigger=reason)
                saved += 1
            except Exception as exc:
                logger.error("EMERGENCY_SAVE failed for %s: %s", sid[:12], exc)
    finally:
        _hook_lock.release()

    # Write emergency marker
    try:
        marker = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "reason": reason,
            "sessions_saved": saved,
            "session_ids": list(_hook_sessions.keys()),
        }
        marker_path = INSAITS_DIR / f"emergency_shutdown_{int(time.time())}.json"
        with open(marker_path, "w", encoding="utf-8") as fh:
            json.dump(marker, fh, indent=2)
        logger.info("EMERGENCY_SHUTDOWN: saved %d sessions, marker=%s", saved, marker_path.name)
    except Exception:
        pass

    # Write audit event
    try:
        _write_hook_audit("EMERGENCY_SHUTDOWN", "insaits_guardian",
            [{"type": "emergency_shutdown", "severity": "HIGH",
              "description": f"Emergency save triggered ({reason}): {saved} sessions saved"}],
            resolved=True, session_id="system")
    except Exception:
        pass

    return saved


def _signal_handler(signum: int, frame: Any) -> None:
    """Handle SIGTERM/SIGINT — save all sessions then exit."""
    sig_name = signal.Signals(signum).name if hasattr(signal, 'Signals') else str(signum)
    logger.info("Received %s — emergency saving all sessions", sig_name)
    _emergency_save_all(reason=f"SIGNAL_{sig_name}")
    sys.exit(0)


def _emergency_save_atexit() -> None:
    """atexit handler — backup for cases where signal handlers don't fire."""
    _emergency_save_all(reason="ATEXIT")


def _check_retry_storm(session_id: str, sess: Dict[str, Any], data: Dict[str, Any]) -> bool:
    """Check for retry storm conditions. Returns True if storm detected.

    Detection: 3+ errors in 30 seconds AND 2+ consecutive latency spikes >45s.
    Requires both signals to avoid false positives from normal Claude thinking pauses.
    Cooldown: 5 min between activations to prevent dialog spam.
    """
    now = time.time()

    # Track call timestamp
    sess["call_timestamps"].append(now)
    # Keep only last 30 calls
    sess["call_timestamps"] = sess["call_timestamps"][-30:]

    # Check for error indicators
    tool_response = data.get("tool_response", "")
    tool_name = data.get("tool_name", "")
    has_error = False

    # Tools that legitimately return empty responses — NOT errors
    _EMPTY_OK_TOOLS = frozenset({
        "Write", "Edit", "MultiEdit", "Agent", "Glob", "Grep", "Read",
    })

    # Error indicators: blank response from action tools, or explicit error markers
    if isinstance(tool_response, str):
        if (len(tool_response.strip()) == 0
                and tool_name not in _EMPTY_OK_TOOLS
                and tool_name):  # skip if tool_name is empty
            has_error = True
        elif "context_collapse" in tool_response.lower() or "overloaded" in tool_response.lower():
            has_error = True

    # Check hook latency (time between consecutive calls)
    if len(sess["call_timestamps"]) >= 2:
        gap = sess["call_timestamps"][-1] - sess["call_timestamps"][-2]
        if gap > RETRY_STORM_LATENCY:
            sess["consecutive_latency_spikes"] += 1
        else:
            sess["consecutive_latency_spikes"] = 0

    if has_error:
        sess["error_timestamps"].append(now)

    # Prune old errors outside window
    cutoff = now - RETRY_STORM_WINDOW
    sess["error_timestamps"] = [t for t in sess["error_timestamps"] if t > cutoff]

    # Storm conditions — require BOTH error count AND latency spikes (was OR)
    storm_by_errors = len(sess["error_timestamps"]) >= RETRY_STORM_THRESHOLD
    storm_by_latency = sess["consecutive_latency_spikes"] >= 2
    # Cooldown: don't re-trigger within RETRY_STORM_COOLDOWN seconds
    last_storm_ts = sess.get("last_storm_activation_ts", 0.0)
    in_cooldown = (now - last_storm_ts) < RETRY_STORM_COOLDOWN

    if storm_by_errors and storm_by_latency and not sess["retry_storm_active"] and not in_cooldown:
        sess["retry_storm_active"] = True
        sess["retry_storm_count"] += 1
        sess["last_storm_activation_ts"] = now

        reason = "errors+latency"
        logger.warning("RETRY_STORM detected: session=%s reason=%s errors=%d latency_spikes=%d",
                       session_id[:12], reason, len(sess["error_timestamps"]),
                       sess["consecutive_latency_spikes"])

        # Immediate snapshot (synchronous, not background)
        try:
            _snapshot_session(session_id, sess)
        except Exception as exc:
            logger.error("Storm snapshot failed: %s", exc)

        # Write full state file
        try:
            _write_state_file(session_id, sess, trigger="RETRY_STORM")
        except Exception as exc:
            logger.error("Storm state file failed: %s", exc)

        # Audit event
        _write_hook_audit("RETRY_STORM", "insaits_guardian",
            [{"type": "retry_storm", "severity": "CRITICAL",
              "description": f"Retry storm detected ({reason}): {len(sess['error_timestamps'])} errors in {RETRY_STORM_WINDOW}s window"}],
            resolved=False, session_id=session_id)

        # PHASE_GUARDIAN M16: Activate storm block (Tier 1 firewall → Tier 2 circuit breaker)
        try:
            _attempt_storm_block(session_id, sess, STORM_BLOCK_DURATION)
        except Exception as exc:
            logger.error("Storm block activation failed: %s", exc)
            # Even if block fails, send the urgent message
            try:
                _dialog_send(
                    session_id="insaits_guardian",
                    session_name="InsAIts Guardian",
                    message_type="urgent",
                    content=(
                        "RETRY STORM DETECTED. InsAIts has saved your session state. "
                        "Storm block failed — consider restarting with /clear to stop token burn."
                    ),
                    to_session_id=session_id,
                )
            except Exception:
                pass

        return True

    # Reset storm if no errors for a full window OR latency spikes stopped
    if sess["retry_storm_active"]:
        errors_cleared = len(sess["error_timestamps"]) == 0
        latency_cleared = sess["consecutive_latency_spikes"] == 0
        if errors_cleared or latency_cleared:
            sess["retry_storm_active"] = False
            # Also deactivate circuit breaker when storm clears
            sess["circuit_breaker_active"] = False
            sess["storm_block_active"] = False
            logger.info("RETRY_STORM cleared: session=%s (errors_cleared=%s, latency_cleared=%s)",
                        session_id[:12], errors_cleared, latency_cleared)

    return sess["retry_storm_active"]


# ---------------------------------------------------------------------------
# PHASE_GUARDIAN M16: Storm Prevention — Active Protection
# ---------------------------------------------------------------------------

def _resolve_api_ips() -> set:
    """Resolve Anthropic API hostnames to IP addresses."""
    ips: set = set()
    for host in STORM_BLOCK_HOSTS:
        try:
            for info in socket.getaddrinfo(host, 443, socket.AF_INET):
                ips.add(info[4][0])
        except socket.gaierror:
            logger.warning("STORM_BLOCK: DNS resolution failed for %s", host)
        try:
            for info in socket.getaddrinfo(host, 443, socket.AF_INET6):
                ips.add(info[4][0])
        except socket.gaierror:
            pass
    return ips


def _block_api_traffic(duration: int = STORM_BLOCK_DURATION) -> bool:
    """Tier 1: Block outbound traffic to Anthropic API via OS firewall.

    Returns True if firewall rule was created successfully.
    Windows-only for now; returns False on other platforms.
    """
    if platform.system() != "Windows":
        logger.warning("STORM_BLOCK: Firewall block only supported on Windows currently")
        return False

    ips = _resolve_api_ips()
    if not ips:
        logger.error("STORM_BLOCK: No IPs resolved for %s — cannot block", STORM_BLOCK_HOSTS)
        return False

    ip_list = ",".join(sorted(ips))

    try:
        result = subprocess.run(
            ["netsh", "advfirewall", "firewall", "add", "rule",
             f"name={STORM_BLOCK_RULE_NAME}", "dir=out", "action=block",
             f"remotehost={ip_list}", "protocol=tcp", "remoteport=443"],
            capture_output=True, text=True, timeout=10,
        )
        if result.returncode != 0:
            logger.error("STORM_BLOCK: netsh add rule failed (rc=%d): %s",
                        result.returncode, result.stderr.strip())
            return False

        logger.critical("STORM_BLOCK: ACTIVATED — blocked %d IPs for %ds: %s",
                       len(ips), duration, ip_list)
        return True

    except subprocess.TimeoutExpired:
        logger.error("STORM_BLOCK: netsh timed out")
        return False
    except FileNotFoundError:
        logger.error("STORM_BLOCK: netsh not found")
        return False
    except PermissionError:
        logger.error("STORM_BLOCK: Permission denied — need admin privileges")
        return False
    except OSError as exc:
        logger.error("STORM_BLOCK: OS error: %s", exc)
        return False


def _unblock_api_traffic() -> bool:
    """Remove InsAIts firewall block rules. Idempotent — safe to call even if no rules exist."""
    if platform.system() != "Windows":
        return True  # No-op on non-Windows

    try:
        result = subprocess.run(
            ["netsh", "advfirewall", "firewall", "delete", "rule",
             f"name={STORM_BLOCK_RULE_NAME}"],
            capture_output=True, text=True, timeout=10,
        )
        if result.returncode == 0:
            logger.info("STORM_BLOCK: DEACTIVATED — firewall rules removed")
            return True
        else:
            # "No rules match" is OK — means already clean
            stderr = result.stderr.strip().lower()
            if "no rules" in stderr or result.returncode == 1:
                return True
            logger.warning("STORM_BLOCK: netsh delete returned rc=%d: %s",
                          result.returncode, result.stderr.strip())
            return False
    except Exception as exc:
        logger.error("STORM_BLOCK: Cannot remove rules: %s", exc)
        return False


def _write_watchdog_file(duration: int, ips: Optional[set] = None) -> None:
    """Write watchdog file so orphaned blocks can be cleaned up on restart."""
    try:
        now = datetime.now(timezone.utc)
        watchdog = {
            "blocked_at": now.isoformat(),
            "unblock_at": (now + timedelta(seconds=duration)).isoformat(),
            "duration_seconds": duration,
            "ips": sorted(ips) if ips else [],
            "pid": os.getpid(),
        }
        STORM_BLOCK_WATCHDOG.parent.mkdir(parents=True, exist_ok=True)
        with open(STORM_BLOCK_WATCHDOG, "w", encoding="utf-8") as fh:
            json.dump(watchdog, fh, indent=2)
    except Exception as exc:
        logger.error("STORM_BLOCK: watchdog write failed: %s", exc)


def _remove_watchdog_file() -> None:
    """Remove watchdog file after successful unblock."""
    try:
        STORM_BLOCK_WATCHDOG.unlink(missing_ok=True)
    except Exception:
        pass


def _cleanup_orphaned_storm_blocks() -> None:
    """Called on collector startup. Removes any leftover firewall rules from a previous crash."""
    if STORM_BLOCK_WATCHDOG.exists():
        logger.warning("STORM_BLOCK: Found orphaned block watchdog — cleaning up")
        _unblock_api_traffic()
        _remove_watchdog_file()
    else:
        # Defensive: try to delete the rule anyway (no-op if doesn't exist)
        _unblock_api_traffic()


def _should_activate_storm_block(sess: Dict[str, Any]) -> bool:
    """Conservative gate: require 2+ independent signals before activating firewall block.

    Signals:
    1. M9 retry storm detector fired
    2. High error rate (3+ errors in storm window)
    3. Replay rate above 35%
    4. Consecutive latency spikes >= 2
    """
    signals = 0

    # Signal 1: M9 retry storm detector fired
    if sess.get("retry_storm_active"):
        signals += 1

    # Signal 2: High error rate in recent window
    now = time.time()
    recent_errors = sum(1 for ts in sess.get("error_timestamps", [])
                       if now - ts < RETRY_STORM_WINDOW)
    if recent_errors >= RETRY_STORM_THRESHOLD:
        signals += 1

    # Signal 3: Replay rate above 35%
    total_calls = max(sess.get("tool_call_count", 1), 1)
    replay_rate = sess.get("replay_count", 0) / total_calls
    if replay_rate > 0.35:
        signals += 1

    # Signal 4: Consecutive latency spikes
    if sess.get("consecutive_latency_spikes", 0) >= 2:
        signals += 1

    return signals >= STORM_BLOCK_MIN_SIGNALS


def _schedule_unblock(session_id: str, sess: Dict[str, Any], duration: int) -> None:
    """Schedule firewall rule removal after duration. Background daemon thread."""
    def _unblock_timer():
        time.sleep(duration)
        _unblock_api_traffic()
        _remove_watchdog_file()
        sess["storm_block_active"] = False
        sess["storm_block_tier"] = 0
        # Also release circuit breaker if active
        sess["circuit_breaker_active"] = False
        logger.info("STORM_BLOCK: Timer expired — API access restored for %s", session_id[:12])
        try:
            _dialog_send(
                session_id="insaits_guardian",
                session_name="InsAIts Guardian",
                message_type="info",
                content=(
                    f"Storm block released after {duration}s. API access restored. "
                    "Continue working or /clear for fresh session."
                ),
                to_session_id=session_id,
            )
        except Exception:
            pass
        # Audit event
        _write_hook_audit("STORM_BLOCK_RELEASED", "insaits_guardian",
            [{"type": "storm_block_released", "severity": "INFO",
              "description": f"Storm block released after {duration}s"}],
            resolved=True, session_id=session_id)

    t = threading.Thread(target=_unblock_timer, daemon=True, name="storm_unblock_timer")
    t.start()


def _activate_circuit_breaker(session_id: str, sess: Dict[str, Any],
                               duration: int = STORM_BLOCK_DURATION) -> None:
    """Tier 2: Activate hook-level circuit breaker. Blocks all tool calls for duration."""
    sess["circuit_breaker_active"] = True
    sess["circuit_breaker_until"] = time.monotonic() + duration
    sess["storm_block_tier"] = 2
    sess["storm_block_active"] = True
    sess["storm_block_until"] = time.monotonic() + duration
    sess["storm_block_count"] += 1
    logger.warning("CIRCUIT_BREAKER: ACTIVATED for %ds — session=%s (Tier 2 fallback)",
                   duration, session_id[:12])
    # Only send ONE dialog message per storm activation (not per CB cycle)
    # The cooldown on storm detection (RETRY_STORM_COOLDOWN) prevents rapid re-triggers
    # Only send if this is the first block or >5min since last message
    last_cb_msg = sess.get("last_cb_dialog_ts", 0.0)
    if time.time() - last_cb_msg > 300:  # 5 min dedup
        sess["last_cb_dialog_ts"] = time.time()
        try:
            _dialog_send(
                session_id="insaits_guardian",
                session_name="InsAIts Guardian",
                message_type="urgent",
                content=(
                    f"CIRCUIT BREAKER ACTIVE: Blocking retries for {duration}s to protect quota. "
                    "Firewall block unavailable (need admin). Type /clear to start fresh."
                ),
                to_session_id=session_id,
            )
        except Exception:
            pass


def _attempt_storm_block(session_id: str, sess: Dict[str, Any],
                          duration: int = STORM_BLOCK_DURATION) -> None:
    """Orchestrator: Try Tier 1 (firewall), fall back to Tier 2 (circuit breaker).

    Called when retry storm is confirmed and should be actively stopped.
    """
    # Save state file BEFORE any blocking
    try:
        _write_state_file(session_id, sess, trigger="STORM_BLOCK")
    except Exception as exc:
        logger.error("STORM_BLOCK: state file save failed: %s", exc)

    if _should_activate_storm_block(sess):
        # Try Tier 1: Firewall block
        if _block_api_traffic(duration):
            sess["storm_block_active"] = True
            sess["storm_block_until"] = time.monotonic() + duration
            sess["storm_block_tier"] = 1
            sess["storm_block_count"] += 1
            ips = _resolve_api_ips()
            _write_watchdog_file(duration, ips)
            _schedule_unblock(session_id, sess, duration)

            # Also activate circuit breaker as belt-and-suspenders
            sess["circuit_breaker_active"] = True
            sess["circuit_breaker_until"] = time.monotonic() + duration

            _write_hook_audit("STORM_BLOCK_ACTIVATED", "insaits_guardian",
                [{"type": "storm_block", "severity": "CRITICAL",
                  "description": f"Tier 1 firewall block activated for {duration}s"}],
                resolved=False, session_id=session_id)

            try:
                _dialog_send(
                    session_id="insaits_guardian",
                    session_name="InsAIts Guardian",
                    message_type="urgent",
                    content=(
                        f"STORM BLOCKED: API traffic paused for {duration}s to protect your quota. "
                        "Session state saved. Use dashboard UNBLOCK NOW or wait for auto-release."
                    ),
                    to_session_id=session_id,
                )
            except Exception:
                pass
            return

        # Tier 1 failed — fall through to Tier 2
        logger.warning("STORM_BLOCK: Tier 1 failed (no admin?), activating Tier 2 circuit breaker")

    # Tier 2: Circuit Breaker (always available)
    _activate_circuit_breaker(session_id, sess, duration)
    _write_hook_audit("CIRCUIT_BREAKER_ACTIVATED", "insaits_guardian",
        [{"type": "circuit_breaker", "severity": "WARNING",
          "description": f"Tier 2 circuit breaker activated for {duration}s (Tier 1 unavailable)"}],
        resolved=False, session_id=session_id)


def _check_circuit_breaker(session_id: str, sess: Dict[str, Any]) -> Optional[str]:
    """Check if circuit breaker should block this tool call.

    Returns blocking message if call should be blocked, None otherwise.
    Called from PreToolUse handler.
    """
    if not sess.get("circuit_breaker_active"):
        return None

    now = time.monotonic()
    remaining = sess["circuit_breaker_until"] - now

    if remaining <= 0:
        # Breaker expired — auto-release
        sess["circuit_breaker_active"] = False
        sess["storm_block_active"] = False
        sess["storm_block_tier"] = 0
        logger.info("CIRCUIT_BREAKER: Auto-released for session=%s", session_id[:12])
        return None

    # Block the call
    sess["circuit_breaker_blocked_count"] += 1
    tier_label = "Firewall + Circuit Breaker" if sess["storm_block_tier"] == 1 else "Circuit Breaker"
    return (
        f"[InsAIts Storm Prevention] {tier_label} active — retry storm detected. "
        f"Session state saved. API calls blocked for {int(remaining)}s more. "
        f"Blocked {sess['circuit_breaker_blocked_count']} calls so far. "
        "Wait for auto-release or use dashboard UNBLOCK NOW button."
    )


def _release_storm_block(session_id: str = "") -> Dict[str, Any]:
    """Manual release: remove firewall rules and deactivate circuit breaker.

    Called from /guardian/unblock endpoint or dashboard UNBLOCK NOW button.
    """
    _unblock_api_traffic()
    _remove_watchdog_file()

    released_sessions = []
    for sid, sess in _hook_sessions.items():
        if session_id and sid != session_id:
            continue
        if sess.get("storm_block_active") or sess.get("circuit_breaker_active"):
            blocked = sess.get("circuit_breaker_blocked_count", 0)
            sess["storm_block_active"] = False
            sess["storm_block_tier"] = 0
            sess["circuit_breaker_active"] = False
            released_sessions.append({"session_id": sid[:16], "blocked_calls": blocked})
            logger.info("STORM_BLOCK: Manual release for session=%s (blocked %d calls)",
                       sid[:12], blocked)
            try:
                _dialog_send(
                    session_id="insaits_guardian",
                    session_name="InsAIts Guardian",
                    message_type="info",
                    content="Storm block manually released. API access restored. Monitoring continues.",
                    to_session_id=sid,
                )
            except Exception:
                pass

    return {"released": len(released_sessions), "sessions": released_sessions}


def _emergency_pause(session_id: str = "", duration: int = STORM_BLOCK_DURATION) -> Dict[str, Any]:
    """Manual trigger: activate storm block without waiting for M9 detection.

    Called from /guardian/emergency-pause endpoint or dashboard EMERGENCY PAUSE button.
    """
    targets = []
    for sid, sess in _hook_sessions.items():
        if session_id and sid != session_id:
            continue
        if sess.get("storm_block_active"):
            continue  # Already blocked
        _attempt_storm_block(sid, sess, duration)
        targets.append({
            "session_id": sid[:16],
            "tier": sess.get("storm_block_tier", 0),
            "duration": duration,
        })

    return {"paused": len(targets), "sessions": targets}


def _journal_entry(sess: Dict[str, Any], tool_name: str, tool_input: Dict[str, Any]) -> None:
    """Add a work journal entry for this tool call. Max 100 entries (FIFO)."""
    # Extract relevant info based on tool type
    files = []
    summary = ""

    if tool_name in ("Read", "Glob", "Grep"):
        path = tool_input.get("file_path") or tool_input.get("path", "")
        pattern = tool_input.get("pattern", "")
        if tool_name == "Read":
            summary = f"Read {Path(path).name}" if path else "Read file"
            if path:
                files.append(path)
        elif tool_name == "Glob":
            summary = f"Glob {pattern}" if pattern else "Glob search"
        elif tool_name == "Grep":
            summary = f"Grep '{pattern[:50]}'" if pattern else "Grep search"
            if path:
                files.append(path)
    elif tool_name in ("Edit", "Write", "MultiEdit"):
        path = tool_input.get("file_path", "")
        if tool_name == "Edit":
            summary = f"Edited {Path(path).name}" if path else "Edited file"
        elif tool_name == "Write":
            summary = f"Created {Path(path).name}" if path else "Wrote file"
        else:
            summary = f"Multi-edited {Path(path).name}" if path else "Multi-edited"
        if path:
            files.append(path)
    elif tool_name == "Bash":
        cmd = tool_input.get("command", "")[:100]
        summary = f"Ran: {cmd}"
    elif tool_name == "Agent":
        desc = tool_input.get("description", "")
        summary = f"Agent: {desc[:80]}" if desc else "Spawned agent"
    else:
        summary = f"Used {tool_name}"

    entry = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "tool": tool_name,
        "action": summary[:200],
        "files_touched": files,
    }

    sess["work_journal"].append(entry)
    # FIFO: keep last 100
    if len(sess["work_journal"]) > 100:
        sess["work_journal"] = sess["work_journal"][-100:]

    # Track files
    for f in files:
        sess["files_touched"].add(f)


def _build_resume_hint(sess: Dict[str, Any]) -> str:
    """Generate a human-readable summary of where the session was."""
    journal = sess.get("work_journal", [])
    if not journal:
        return f"Session with {sess['tool_call_count']} tool calls, {sess['anomaly_count']} anomalies."

    last = journal[-1]
    files_count = len(sess.get("files_touched", set()))
    return (
        f"Last action: {last['action']}. "
        f"{sess['tool_call_count']} tool calls, {sess['anomaly_count']} anomalies, "
        f"{files_count} files touched."
    )


def _load_previous_session_worklog() -> Optional[Dict[str, Any]]:
    """Load the most recent session state file for context injection.

    Returns the state dict from the most recently saved state file,
    or None if no state files exist. Used to inject previous session
    context when starting a truly new session.
    """
    if not SESSIONS_DIR.exists():
        return None
    state_files = sorted(
        SESSIONS_DIR.glob("session_*.json"),
        key=lambda f: f.stat().st_mtime,
        reverse=True,
    )
    for sf in state_files[:5]:  # Try top 5 most recent
        try:
            state = json.loads(sf.read_text(encoding="utf-8"))
            if state.get("work_journal"):
                return state
        except (json.JSONDecodeError, OSError):
            continue
    return None


def _build_work_log_summary(sess: Dict[str, Any], max_entries: int = 20) -> str:
    """Build a concise work log summary for injection into context.

    Returns a human-readable summary of recent work including:
    - Last N journal entries (tool actions)
    - Files modified
    - Key stats (tool calls, anomalies, replays)

    Used by: CONTINUATION anchor, CONTEXT_RESTORED anchor, state files.
    """
    journal = sess.get("work_journal", [])
    files = sorted(sess.get("files_touched", set()))
    lines: List[str] = []

    lines.append(
        f"Session stats: {sess['tool_call_count']} tool calls, "
        f"{sess['anomaly_count']} anomalies, "
        f"{sess.get('replay_count', 0)} replays."
    )

    if files:
        # Show only filenames, not full paths, to save tokens
        short_files = [Path(f).name for f in files[:15]]
        lines.append(f"Files touched ({len(files)}): {', '.join(short_files)}")
        if len(files) > 15:
            lines.append(f"  ... and {len(files) - 15} more files")

    if journal:
        recent = journal[-max_entries:]
        lines.append(f"Recent work ({len(recent)} of {len(journal)} actions):")
        for entry in recent:
            ts = entry.get("timestamp", "")
            # Extract just HH:MM from ISO timestamp
            time_str = ts[11:16] if len(ts) > 16 else ""
            action = entry.get("action", "unknown")
            lines.append(f"  [{time_str}] {action}")

    return "\n".join(lines)


def _build_resume_anchor(
    sess: Dict[str, Any],
    source: str,
    state_data: Optional[Dict[str, Any]] = None,
) -> str:
    """Build optimized 3-block resume anchor for session restore.

    Target: <250 tokens total. Three blocks:
      Block 1 — Stable context (deterministic, cache-friendly)
      Block 2 — Recent work (fresh per resume event)
      Block 3 — Resume instruction (single action directive)

    Args:
        sess: Current session dict (in-memory state)
        source: One of "snapshot", "state_file", "context_compression"
        state_data: Optional state file dict for new sessions with no in-memory journal
    """
    session_id = ""
    for sid, s in _hook_sessions.items():
        if s is sess:
            session_id = sid
            break

    # --- Block 1: Stable context (deterministic) ---
    files = sorted(sess.get("files_touched", set()))
    if not files and state_data:
        files = state_data.get("files_touched", [])
    file_names = [Path(f).name for f in files[:10]]
    block1 = (
        f"[InsAIts Resume Anchor]\n"
        f"SESSION: {session_id[:16]}\n"
        f"RESTORED: {source}\n"
        f"PROGRESS: {sess['tool_call_count']} calls | "
        f"{sess['anomaly_count']} anomalies | "
        f"{sess.get('replay_count', 0)} replays\n"
        f"FILES ({len(files)}): {', '.join(file_names) if file_names else 'none yet'}"
    )

    # --- Block 2: Recent work (fresh) ---
    journal = sess.get("work_journal", [])
    if not journal and state_data:
        journal = state_data.get("work_journal", [])
    recent = journal[-10:]
    work_lines = []
    for entry in recent:
        ts = entry.get("timestamp", "")
        time_str = ts[11:16] if len(ts) > 16 else ""
        action = entry.get("action", "?")
        work_lines.append(f"  [{time_str}] {action}")
    block2 = "---\nRECENT WORK:\n" + "\n".join(work_lines) if work_lines else "---\nRECENT WORK: (no journal entries)"

    # --- Block 3: Resume instruction ---
    last_action = recent[-1].get("action", "unknown") if recent else "unknown"
    resume_hint = ""
    if state_data:
        resume_hint = state_data.get("resume_hint", "")
    elif journal:
        resume_hint = _build_resume_hint(sess)
    block3 = (
        f"---\nLAST ACTION: {last_action}\n"
        f"RESUME HINT: {resume_hint}\n"
        f"ACTION: Read WORK_LOG.md and PLAN.md, then continue from last action."
    )

    anchor = f"{block1}\n{block2}\n{block3}"

    # Log token estimate (rough: 1 token ≈ 4 chars for English)
    token_estimate = len(anchor) // 4
    logger.info("RESUME_ANCHOR: source=%s tokens~%d chars=%d",
                source, token_estimate, len(anchor))

    return anchor


def _write_state_file(session_id: str, sess: Dict[str, Any], trigger: str = "SCHEDULED") -> Optional[Path]:
    """Write a full session state file for Session Vault.

    Returns the path to the state file, or None on failure.
    State file is human-readable in 30 seconds.
    """
    try:
        SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
        ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
        filename = f"session_{session_id[:16]}_{ts}.json"
        path = SESSIONS_DIR / filename

        journal = sess.get("work_journal", [])[-50:]  # Last 50 entries

        state = {
            "session_id": session_id,
            "saved_at": datetime.now(timezone.utc).isoformat(),
            "trigger": trigger,
            "tool_call_count": sess["tool_call_count"],
            "anomaly_count": sess["anomaly_count"],
            "replay_count": sess["replay_count"],
            "last_tool_call": journal[-1]["action"] if journal else "unknown",
            "work_journal": journal,
            "files_touched": sorted(sess.get("files_touched", set())),
            "anchor_state": {
                "tool_call_count": sess["tool_call_count"],
                "last_snapshot_at": sess["last_snapshot_at"],
            },
            "anomaly_summary": {
                "total_anomalies": sess["anomaly_count"],
                "replay_count": sess["replay_count"],
                "retry_storm_active": sess.get("retry_storm_active", False),
                "retry_storm_count": sess.get("retry_storm_count", 0),
            },
            "resume_hint": _build_resume_hint(sess),
        }

        tmp = path.with_suffix(".tmp")
        with open(tmp, "w", encoding="utf-8") as fh:
            json.dump(state, fh, indent=2)
        tmp.replace(path)

        logger.info("STATE_FILE_SAVED: %s trigger=%s", filename, trigger)
        return path
    except (IOError, OSError) as exc:
        logger.error("State file save failed: %s", exc)
        return None


def _load_hook_config() -> Dict[str, Any]:
    """Load hook config with hot-reload on mtime change."""
    global _hook_config, _hook_config_mtime
    try:
        mtime = os.path.getmtime(_HOOK_CONFIG_PATH)
        if _hook_config and mtime == _hook_config_mtime:
            return _hook_config
        with open(_HOOK_CONFIG_PATH, "r", encoding="utf-8") as fh:
            _hook_config = json.load(fh)
        _hook_config_mtime = mtime
        return _hook_config
    except (IOError, OSError, json.JSONDecodeError):
        if _hook_config:
            return _hook_config
        return {"detectors": {}, "features": {}}


def _is_hook_feature_enabled(name: str) -> bool:
    cfg = _load_hook_config()
    return cfg.get("features", {}).get(name, True)


def _is_hook_detector_enabled(name: str) -> bool:
    cfg = _load_hook_config()
    return cfg.get("detectors", {}).get(name, True)


def _load_hook_ai_monitor():
    """Lazy-load AIMonitor for hook scanning (same as existing _get_monitor)."""
    global _hook_ai_monitor, _hook_ai_monitor_loaded
    if _hook_ai_monitor_loaded:
        return _hook_ai_monitor
    _hook_ai_monitor_loaded = True
    # Re-use the existing monitor if already loaded for dialog
    existing = _get_monitor()
    if existing:
        _hook_ai_monitor = existing
        return _hook_ai_monitor
    return None


def _get_hook_anchor_system(session_state: Dict[str, Any]) -> Tuple[Optional[Any], Optional[Any]]:
    """Lazy-load anchor system for a session (persists in session state)."""
    if session_state.get("anchor_trigger") is not None:
        return session_state["anchor_trigger"], session_state["anchor_builder"]

    cfg = _load_hook_config()
    anchor_cfg = cfg.get("anchor_messages", {})
    if not anchor_cfg.get("enabled", True):
        return None, None

    try:
        base = Path(__file__).resolve().parent
        for sdk_dir in [base / "InsAIts-SDK", base]:
            te_path = sdk_dir / "insa_its" / "anchor" / "trigger_engine.py"
            mb_path = sdk_dir / "insa_its" / "anchor" / "message_builder.py"
            if te_path.exists() and mb_path.exists():
                # Ensure _types module is loaded first (dependency)
                types_path = sdk_dir / "insa_its" / "anchor" / "_types.py"
                if types_path.exists() and "insa_its.anchor._types" not in sys.modules:
                    ts_spec = importlib.util.spec_from_file_location("insa_its.anchor._types", str(types_path))
                    if ts_spec and ts_spec.loader:
                        ts_mod = importlib.util.module_from_spec(ts_spec)
                        sys.modules["insa_its.anchor._types"] = ts_mod
                        ts_spec.loader.exec_module(ts_mod)

                te_spec = importlib.util.spec_from_file_location("anchor_trigger", str(te_path))
                mb_spec = importlib.util.spec_from_file_location("anchor_builder", str(mb_path))
                if te_spec and te_spec.loader and mb_spec and mb_spec.loader:
                    te_mod = importlib.util.module_from_spec(te_spec)
                    te_spec.loader.exec_module(te_mod)
                    mb_mod = importlib.util.module_from_spec(mb_spec)
                    mb_spec.loader.exec_module(mb_mod)

                    trigger = te_mod.AnchorTriggerEngine(
                        opus_refresh_min=anchor_cfg.get("opus_refresh_min", 20),
                        opus_refresh_max=anchor_cfg.get("opus_refresh_max", 40),
                        subagent_refresh_min=anchor_cfg.get("subagent_refresh_min", 8),
                        subagent_refresh_max=anchor_cfg.get("subagent_refresh_max", 15),
                        subagent_periodic_enabled=anchor_cfg.get("subagent_periodic_enabled", False),
                        agent_refresh_min=anchor_cfg.get("agent_refresh_min", 15),
                        agent_refresh_max=anchor_cfg.get("agent_refresh_max", 30),
                        agent_time_interval_seconds=anchor_cfg.get("agent_time_interval_seconds", 180),
                        anomaly_delta_threshold=anchor_cfg.get("anomaly_delta_threshold", 25),
                        anomaly_delay_max_seconds=anchor_cfg.get("anomaly_delay_max_seconds", 45),
                        stealth_disables_injection=anchor_cfg.get("stealth_disables_injection", True),
                        layer1_enabled=anchor_cfg.get("layer1_enabled", True),
                        layer2_enabled=anchor_cfg.get("layer2_enabled", True),
                        layer3_enabled=anchor_cfg.get("layer3_enabled", True),
                    )
                    claude_md = anchor_cfg.get("claude_md_path", "CLAUDE.md")
                    templates_dir = str(sdk_dir / "insa_its" / "anchor" / "templates")
                    builder = mb_mod.AnchorMessageBuilder(
                        claude_md_path=claude_md,
                        templates_dir=templates_dir,
                    )
                    session_state["anchor_trigger"] = trigger
                    session_state["anchor_builder"] = builder
                    logger.info("Anchor system loaded for daemon hook processing")
                    return trigger, builder
                break
    except Exception as exc:
        logger.warning("Anchor system not available: %s", exc)
    return None, None


def _extract_hook_text(data: Dict[str, Any]) -> Tuple[str, str, str]:
    """Extract response text, task context, and agent ID from hook payload.

    Returns (response_text, task_context, agent_id).
    """
    tool_name = data.get("tool_name", "unknown")
    hook_event = data.get("hook_event_name", "")
    tool_input = data.get("tool_input", {})
    tool_response = data.get("tool_response", "")

    agent_id = "claude-main"
    response_text = ""
    task_context = ""

    # PostToolUse: extract response text
    if hook_event == "PostToolUse":
        if isinstance(tool_response, str):
            response_text = tool_response[:_MAX_SCAN_LENGTH]
        elif isinstance(tool_response, dict):
            # Try common response shapes
            for key in ("content", "output", "result", "text", "stdout"):
                if key in tool_response:
                    val = tool_response[key]
                    if isinstance(val, str):
                        response_text = val[:_MAX_SCAN_LENGTH]
                        break
                    elif isinstance(val, list):
                        parts = []
                        for item in val:
                            if isinstance(item, str):
                                parts.append(item)
                            elif isinstance(item, dict) and "text" in item:
                                parts.append(str(item["text"]))
                        response_text = "\n".join(parts)[:_MAX_SCAN_LENGTH]
                        break
            if not response_text:
                response_text = json.dumps(tool_response, default=str)[:_MAX_SCAN_LENGTH]

    # PreToolUse: extract input text for security scanning
    if hook_event == "PreToolUse":
        if isinstance(tool_input, dict):
            for key in ("command", "content", "prompt", "query", "file_path"):
                if key in tool_input:
                    response_text += " " + str(tool_input[key])[:2000]
            response_text = response_text.strip()[:_MAX_SCAN_LENGTH]

    # Task context from input
    if isinstance(tool_input, dict):
        task_context = str(tool_input.get("description", tool_input.get("prompt", "")))[:500]

    return response_text, task_context, agent_id


def _write_hook_audit(tool_name: str, model: str, anomalies: List[Dict[str, Any]],
                      session_id: str = "", resolved: bool = True,
                      stealth: bool = False, **extra) -> Dict[str, Any]:
    """Write audit entry to JSONL and ingest into collector."""
    import time as _time
    entry = {
        "ts": datetime.now(timezone.utc).isoformat(),
        "model": model,
        "tool": tool_name,
        "msg_id": f"daemon_{int(_time.time() * 1000)}",
        "anomalies": anomalies,
        "resolved": resolved,
        "retries": 0,
        "session_id": session_id,
    }
    entry.update(extra)

    # Write to local JSONL (same file the dashboard reads)
    try:
        with open(_HOOK_AUDIT_PATH, "a", encoding="utf-8") as fh:
            fh.write(json.dumps(entry, default=str) + "\n")
    except (IOError, OSError):
        pass

    # Also ingest into collector's in-memory event stream
    try:
        _ingest_event(entry)
    except Exception:
        pass

    return entry


def _process_hook(data: Dict[str, Any]) -> Dict[str, Any]:
    """Process a complete hook event. Returns response dict for the shim.

    This is the daemon equivalent of insaits_hook_runner.py's main().
    All state is in-memory — no file I/O for counters/state.

    Response format:
        {"deny": bool, "reason": str, "injection": str, "stderr": str}
    """
    tool_name = data.get("tool_name", "unknown")
    hook_event = data.get("hook_event_name", "")
    session_id = data.get("session_id", "")
    tool_use_id = data.get("tool_use_id", "")
    tool_input = data.get("tool_input", {})
    is_pre = hook_event == "PreToolUse"

    model_id = os.environ.get("INSAITS_MODEL", _DEFAULT_MODEL)
    stealth = os.environ.get("INSAITS_STEALTH", "").strip() == "1"

    response_text, task_context, agent_id = _extract_hook_text(data)
    audit_model = agent_id if agent_id != "claude-main" else f"claude:{tool_name}"

    # Get persistent session state (restore from snapshot if new session)
    sess = _get_hook_session(session_id)

    # PHASE_GUARDIAN: restore from snapshot if this is a fresh session
    _guardian_restored = False
    _is_new_session = False
    if sess["tool_call_count"] == 0 and sess["is_first_call"]:
        snapshot = _restore_session_snapshot(session_id)
        if snapshot:
            _apply_snapshot(sess, snapshot)
            _guardian_restored = True
            logger.info("Session %s restored from snapshot (tool_calls=%d)",
                        session_id[:12], sess["tool_call_count"])
        else:
            _is_new_session = True  # Truly new session, no snapshot

    sess["tool_call_count"] += 1

    # PHASE_GUARDIAN Phase 2: work journal
    _journal_entry(sess, tool_name, tool_input)

    # PHASE_GUARDIAN M15: milestone auto-save (every 25 tool calls)
    # Ensures work journal is persisted frequently for context recovery
    if sess["tool_call_count"] > 0 and sess["tool_call_count"] % 25 == 0:
        threading.Thread(
            target=_write_state_file,
            args=(session_id, sess, "MILESTONE"),
            daemon=True,
        ).start()

    # PHASE_GUARDIAN: replay detection
    fingerprint = _make_call_fingerprint(session_id, tool_use_id)
    is_replay = _is_replay(sess, fingerprint)

    injections: List[str] = []
    response = {"deny": False, "reason": "", "injection": "", "stderr": ""}

    # CRITICAL: Resume anchors fire BEFORE replay check — they're one-time injections
    # that help the agent recover context after snapshot restore, new session, or compression.
    # Without these, the agent re-reads everything and burns tokens.
    if is_pre and _guardian_restored:
        sess["restored_from_snapshot"] = False  # Only fire once
        sess["resume_source"] = "snapshot"
        resume_anchor = _build_resume_anchor(sess, source="snapshot")
        injections.append(resume_anchor)
        _write_hook_audit("SESSION_RESUMED", audit_model,
            [{"type": "session_resumed", "severity": "INFO",
              "description": (
                  f"Session resumed from snapshot | "
                  f"calls={sess['tool_call_count']} anomalies={sess['anomaly_count']} "
                  f"journal={len(sess.get('work_journal', []))} "
                  f"anchor_chars={len(resume_anchor)} anchor_tokens~{len(resume_anchor)//4}"
              )}],
            resolved=True, stealth=stealth, session_id=session_id)
        logger.info("SESSION_RESUMED: %s source=snapshot tokens~%d",
                     session_id[:12], len(resume_anchor) // 4)
        _guardian_restored = False

    if is_pre and _is_new_session:
        _prev_work = _load_previous_session_worklog()
        if _prev_work:
            prev_sess_overlay = dict(sess)
            prev_sess_overlay["tool_call_count"] = _prev_work.get("tool_call_count", 0)
            prev_sess_overlay["anomaly_count"] = _prev_work.get("anomaly_count", 0)
            prev_sess_overlay["replay_count"] = _prev_work.get("replay_count", 0)
            sess["resume_source"] = "state_file"
            resume_anchor = _build_resume_anchor(
                prev_sess_overlay, source="state_file", state_data=_prev_work
            )
            injections.append(resume_anchor)
            _write_hook_audit("SESSION_RESUMED", audit_model,
                [{"type": "session_resumed", "severity": "INFO",
                  "description": (
                      f"New session — previous work log injected | "
                      f"prev_calls={_prev_work.get('tool_call_count', 0)} "
                      f"anchor_chars={len(resume_anchor)} anchor_tokens~{len(resume_anchor)//4}"
                  )}],
                resolved=True, stealth=stealth, session_id=session_id)
            logger.info("SESSION_RESUMED: %s source=state_file prev=%s tokens~%d",
                         session_id[:12], _prev_work.get("session_id", "?")[:12],
                         len(resume_anchor) // 4)
        _is_new_session = False

    if is_replay:
        # Replayed calls: still count toward tool_call_count (for correct cadence)
        # but skip: regular anchor injection, pattern learning, dialog delivery, anomaly counting
        # BUT resume anchors (above) still fire through replays
        logger.debug("REPLAY_DETECTED: session=%s tool_use_id=%s count=%d",
                     session_id[:12], tool_use_id[:12] if tool_use_id else "?", sess["replay_count"])

        # Log replay to audit (every 5th replay to avoid flooding)
        if sess["replay_count"] % 5 == 1:
            _write_hook_audit("REPLAY_DETECTED", audit_model,
                [{"type": "replay_detected", "severity": "INFO",
                  "description": f"Replay #{sess['replay_count']}: tool_use_id={tool_use_id[:16] if tool_use_id else '?'}",
                  "is_replay": True}],
                resolved=True, session_id=session_id)

        # Check retry budget
        if sess["replay_count"] >= MAX_REPLAYS_BEFORE_BLOCK:
            response["injection"] = (
                "[InsAIts Guardian] WARNING: This session has been replayed "
                f"{sess['replay_count']} times. Consider starting a fresh session "
                "to avoid excessive token burn."
            )
            # Log budget breach once
            if sess["replay_count"] == MAX_REPLAYS_BEFORE_BLOCK:
                _write_hook_audit("RETRY_BUDGET_WARNING", audit_model,
                    [{"type": "retry_budget_exceeded", "severity": "HIGH",
                      "description": f"Replay budget exceeded: {sess['replay_count']} replays (max={MAX_REPLAYS_BEFORE_BLOCK})"}],
                    resolved=False, session_id=session_id)
        elif sess["replay_count"] >= MAX_REPLAYS_BEFORE_WARNING:
            response["stderr"] = (
                f"[InsAIts] Replay detected ({sess['replay_count']}x). "
                "Token cost is accumulating from replayed calls."
            )

        # PHASE_GUARDIAN M7.4: replay rate >50% operator alert (fires once)
        if (sess["tool_call_count"] >= 10
                and not sess.get("_replay_rate_alerted", False)):
            current_pct = sess["replay_count"] / max(sess["tool_call_count"], 1) * 100
            if current_pct > 50:
                sess["_replay_rate_alerted"] = True
                _write_hook_audit("REPLAY_RATE_HIGH", audit_model,
                    [{"type": "replay_rate_high", "severity": "HIGH",
                      "description": f"Replay rate {current_pct:.0f}% exceeds 50% threshold. "
                                     f"{sess['replay_count']}/{sess['tool_call_count']} calls are replays."}],
                    resolved=False, session_id=session_id)
                # Send operator notification via dialog bus
                try:
                    _dialog_send(
                        session_id="insaits_guardian",
                        session_name="InsAIts Guardian",
                        message_type="warning",
                        content=(f"HIGH REPLAY RATE: Session {session_id[:12]}... has "
                                 f"{current_pct:.0f}% replay rate ({sess['replay_count']}/{sess['tool_call_count']} calls). "
                                 "Consider starting a fresh session."),
                        to_session_id=HUMAN_SESSION_ID,
                    )
                except Exception:
                    pass  # dialog bus failure should not break hook

        # PHASE_GUARDIAN Phase 2: retry storm detection (check on every call, not just replays)
        _check_retry_storm(session_id, sess, data)

        # Update session tracking but skip all processing
        with _lock:
            if session_id in _sessions:
                _sessions[session_id]["last_seen"] = _now_iso()
                _sessions[session_id]["tool_calls"] = sess["tool_call_count"]
        # Include any resume anchor injections that were set before replay check
        if injections:
            response["injection"] = "\n\n".join(injections)
        return response

    # PHASE_GUARDIAN Phase 2: retry storm detection (check on every call, not just replays)
    _check_retry_storm(session_id, sess, data)

    # Register session with collector
    if session_id and session_id not in _sessions:
        session_name = os.environ.get("INSAITS_SESSION_NAME", "AI Session")
        with _lock:
            _sessions[session_id] = {
                "session_id": session_id,
                "session_name": session_name,
                "session_type": "ai_terminal",
                "first_seen": _now_iso(),
                "last_seen": _now_iso(),
                "tool_calls": 0,
                "anomaly_count": 0,
            }

    # Update session last_seen
    with _lock:
        if session_id in _sessions:
            _sessions[session_id]["last_seen"] = _now_iso()
            _sessions[session_id]["tool_calls"] = sess["tool_call_count"]

    # =================================================================
    # PHASE_GUARDIAN M15: Context compression detection
    # Detect when Claude's context was compressed (long gap between calls
    # in the same active session) and inject work log to restore context.
    # Fires at most once per 50 tool calls to avoid noise.
    # =================================================================
    if is_pre and sess["tool_call_count"] > 10:
        now_mono = time.monotonic()
        last_call_time = sess.get("last_tool_call_time", 0.0)
        last_injected_at = sess.get("work_log_injected_at", 0)
        gap_seconds = now_mono - last_call_time if last_call_time > 0 else 0.0
        calls_since_last_inject = sess["tool_call_count"] - last_injected_at

        # Large gap (>90s) + enough calls since last injection = likely compression
        if (gap_seconds > 90.0 and calls_since_last_inject > 50
                and not _guardian_restored):
            sess["resume_source"] = "context_compression"
            resume_anchor = _build_resume_anchor(sess, source="context_compression")
            injections.append(resume_anchor)
            sess["work_log_injected_at"] = sess["tool_call_count"]
            # Also save state file as checkpoint
            threading.Thread(
                target=_write_state_file,
                args=(session_id, sess, "CONTEXT_COMPRESSION"),
                daemon=True,
            ).start()
            _write_hook_audit("SESSION_RESUMED", audit_model,
                [{"type": "session_resumed", "severity": "INFO",
                  "description": (
                      f"Context compression detected | gap={gap_seconds:.0f}s "
                      f"calls={sess['tool_call_count']} "
                      f"anchor_chars={len(resume_anchor)} anchor_tokens~{len(resume_anchor)//4}"
                  )}],
                resolved=True, stealth=stealth, session_id=session_id)
            logger.info("CONTEXT_RESTORED: %s (gap=%.0fs, calls=%d, journal=%d)",
                         session_id[:12], gap_seconds, sess["tool_call_count"],
                         len(sess.get("work_journal", [])))

    # Update last tool call time for compression detection
    sess["last_tool_call_time"] = time.monotonic()

    # =================================================================
    # PreToolUse checks
    # =================================================================
    if is_pre:
        # 1. Urgent channel — drain in-memory queue
        uq = _get_urgent_queue(session_id)
        urgent_msgs = []
        while not uq.empty():
            try:
                urgent_msgs.append(uq.get_nowait())
            except queue.Empty:
                break
        if urgent_msgs:
            combined = "\n".join(
                f"[URGENT from {m.get('from_name', 'operator')}] {m.get('message', '')}"
                for m in urgent_msgs
            )
            _write_hook_audit(tool_name, audit_model,
                [{"type": "urgent_intervention", "severity": "CRITICAL",
                  "description": f"Human operator urgent: {combined[:200]}"}],
                session_id=session_id)
            response["deny"] = True
            response["reason"] = f"[InsAIts URGENT from operator] {combined}"
            return response

        # 1b. PHASE_GUARDIAN M16: Circuit breaker — block retries during storm
        # BUT allow resume anchors through (they help recover context and REDUCE token burn)
        breaker_msg = _check_circuit_breaker(session_id, sess)
        if breaker_msg:
            response["deny"] = True
            response["reason"] = breaker_msg
            # Still include resume anchor if it was set (before this check)
            if injections:
                response["injection"] = "\n\n".join(injections)
            return response

        # 2. Dialog messages — drain dialog queue, inject as context
        dq = _get_dialog_queue(session_id)
        dialog_msgs = []
        while not dq.empty():
            try:
                dialog_msgs.append(dq.get_nowait())
            except queue.Empty:
                break
        if dialog_msgs:
            lines = ["[InsAIts Inter-Session Dialog — new messages:]"]
            for msg in dialog_msgs[-5:]:
                sender = msg.get("from_name", msg.get("from", "?"))
                content = msg.get("message", "")[:300]
                priority = msg.get("priority", "normal")
                prefix = "URGENT" if priority == "urgent" else ""
                line = f"  [{prefix}{sender}]: {content}" if prefix else f"  [{sender}]: {content}"
                lines.append(line)
            lines.append("[End of dialog — respond via your next action or acknowledge]")
            injections.append("\n".join(lines))

        # 3. Subagent attribution — track Agent tool calls
        if tool_name == "Agent":
            sa_type = tool_input.get("subagent_type", tool_input.get("type", "unknown"))
            sa_desc = tool_input.get("description", tool_input.get("prompt", ""))[:50]
            sess["active_agents"][tool_use_id] = {
                "subagent_type": sa_type,
                "description": sa_desc,
                "start_ts": time.time(),
            }
            # Track in collector agent tree
            with _lock:
                _agent_tree[tool_use_id] = {
                    "agent_id": tool_use_id,
                    "name": sa_type,
                    "description": sa_desc,
                    "session_id": session_id,
                    "parent_id": session_id,
                    "start_ts": _now_iso(),
                    "tool_calls": 0,
                    "anomaly_count": 0,
                    "status": "running",
                }

        # Determine if inside a subagent window
        active_agent = None
        if tool_name != "Agent":
            # Find active agent (auto-expire after 5 min)
            now = time.time()
            expired = [k for k, v in sess["active_agents"].items()
                       if now - v.get("start_ts", 0) > 300]
            for k in expired:
                del sess["active_agents"][k]
            if sess["active_agents"]:
                # Use the most recent agent
                latest_key = max(sess["active_agents"],
                                 key=lambda k: sess["active_agents"][k].get("start_ts", 0))
                active_agent = sess["active_agents"][latest_key]

        if active_agent:
            sa_type_tag = active_agent.get("subagent_type", "unknown")
            audit_model = f"subagent:{sa_type_tag}"

        # 4. Loop detection
        if _is_hook_feature_enabled("loop_detection"):
            fingerprint = _hashlib.md5(
                json.dumps({"t": tool_name, "i": sorted(tool_input.items()) if isinstance(tool_input, dict) else str(tool_input)},
                           default=str).encode()
            ).hexdigest()[:12]
            sess["tool_history"].append(fingerprint)
            if len(sess["tool_history"]) > _TOOL_HISTORY_MAX:
                sess["tool_history"] = sess["tool_history"][-_TOOL_HISTORY_MAX:]
            # Check for consecutive repeats
            recent = sess["tool_history"]
            if len(recent) >= _LOOP_THRESHOLD:
                tail = recent[-_LOOP_THRESHOLD:]
                if all(f == tail[0] for f in tail):
                    _write_hook_audit("LOOP_DETECTED", audit_model,
                        [{"type": "loop_detected", "severity": "MEDIUM",
                          "description": f"Tool {tool_name} called {_LOOP_THRESHOLD}+ times with same params"}],
                        session_id=session_id)

        # 5. Sensitive file access
        if _is_hook_feature_enabled("sensitive_file_access"):
            file_path = ""
            if isinstance(tool_input, dict):
                file_path = str(tool_input.get("file_path", tool_input.get("path", "")))
                if not file_path and tool_name == "Bash":
                    file_path = str(tool_input.get("command", ""))
            for pattern in _SENSITIVE_PATTERNS:
                if pattern in file_path.lower():
                    _write_hook_audit("SENSITIVE_FILE_ACCESS", audit_model,
                        [{"type": "sensitive_file_access", "severity": "HIGH",
                          "description": f"Access to sensitive file pattern: {pattern} in {file_path[:100]}"}],
                        session_id=session_id)
                    break

        # 6. Unauthorized write detection (Bash file writes)
        # Only flag genuine file-creation redirects, NOT stderr (2>&1),
        # /dev/null, pipe chains, or safe CLI tools (git, pytest, etc.)
        if tool_name == "Bash" and isinstance(tool_input, dict):
            cmd = str(tool_input.get("command", ""))
            _safe_prefixes = ("git ", "pytest ", "python -m pytest", "pip ",
                              "npm ", "npx ", "ruff ", "black ", "mypy ",
                              "docker ", "kubectl ", "curl ", "wget ")
            if cmd.lstrip() and not cmd.lstrip().startswith(_safe_prefixes):
                import re as _re_unauth
                _benign = r'(?:2>&1|>&2|/dev/null|&>/dev/null|2>/dev/null)'
                _write_pats = [
                    r'(?:echo|cat|printf)\s+.*[^2]>\s*(?!' + _benign + r')\S+',
                    r'>>\s*(?!' + _benign + r')\S+\.\w+',
                    r'\btee\s+\S+\.\w+',
                    r'\bsed\s+-i\b',
                    r'\bdd\b.*\bof=',
                    r'\bmv\s+\S+\s+\S+',
                    r'\bcp\s+\S+\s+\S+',
                ]
                for pat in _write_pats:
                    if _re_unauth.search(pat, cmd):
                        _write_hook_audit("UNAUTHORIZED_WRITE", audit_model,
                            [{"type": "unauthorized_write", "severity": "MEDIUM",
                              "description": f"Bash command appears to write files: '{cmd[:100]}'"}],
                            session_id=session_id)
                        break

    # =================================================================
    # Anchor injection (3-tier, in-memory state)
    # The trigger engine counter must see ALL tool calls (not just PreToolUse)
    # so periodic refresh fires at the correct cadence. We tick the counter
    # on PostToolUse and do full evaluate+inject on PreToolUse.
    # =================================================================
    anchor_trigger, anchor_builder = _get_hook_anchor_system(sess)
    if anchor_trigger and anchor_builder:
        if is_pre:
            # Full evaluate + inject on PreToolUse
            triggers = anchor_trigger.evaluate(
                tool_name=tool_name,
                tool_input=tool_input,
                anomaly_count=sess.get("anomaly_count", 0),
                is_subagent=active_agent is not None,
                agent_id=tool_use_id or "",
                stealth=stealth,
                has_critical=False,
            )
            for trig in triggers:
                layers = trig["layers"]
                delay = trig.get("delay", 0.0)
                trigger_type = trig["trigger"]
                if delay > 0:
                    anchor_trigger.schedule_delayed(layers, trigger_type, delay)
                    continue
                injection_text = anchor_builder.build_for_layers(layers)
                if injection_text:
                    injections.append(injection_text)
                    layer_names = {1: "opus", 2: "agent", 3: "subagent"}
                    layer_str = "+".join(layer_names.get(l, str(l)) for l in layers)
                    _write_hook_audit("ANCHOR_INJECTION", audit_model,
                        [{"type": "anchor_injected", "severity": "INFO",
                          "description": f"Anchor injected: layers=[{layer_str}] trigger={trigger_type.value} tool={tool_name} len={len(injection_text)}",
                          "intervention": injection_text[:500]}],
                        resolved=True, stealth=stealth, session_id=session_id)
        else:
            # PostToolUse: just tick the counter so periodic cadence is correct
            anchor_trigger.tick()

    # =================================================================
    # PostToolUse checks
    # =================================================================
    if not is_pre:
        # End active agent if this is Agent PostToolUse
        if tool_name == "Agent":
            if tool_use_id in sess["active_agents"]:
                sa_info = sess["active_agents"].pop(tool_use_id)
                _write_hook_audit("SUBAGENT_SPAWNED", audit_model,
                    [{"type": "subagent_complete", "severity": "INFO",
                      "description": f"Subagent completed: {sa_info.get('subagent_type', 'unknown')}"}],
                    session_id=session_id)
                with _lock:
                    if tool_use_id in _agent_tree:
                        _agent_tree[tool_use_id]["status"] = "completed"

        # Determine if inside subagent for PostToolUse
        active_agent = None
        if tool_name != "Agent" and sess["active_agents"]:
            now = time.time()
            latest_key = max(sess["active_agents"],
                             key=lambda k: sess["active_agents"][k].get("start_ts", 0),
                             default=None)
            if latest_key:
                active_agent = sess["active_agents"][latest_key]
                audit_model = f"subagent:{active_agent.get('subagent_type', 'unknown')}"

        # Write tracking (file thrashing)
        if _is_hook_feature_enabled("write_tracking") and tool_name in ("Write", "Edit", "MultiEdit"):
            file_path = ""
            if isinstance(tool_input, dict):
                file_path = str(tool_input.get("file_path", ""))
            if file_path:
                now = time.time()
                if file_path not in sess["write_tracking"]:
                    sess["write_tracking"][file_path] = []
                sess["write_tracking"][file_path].append(now)
                # Keep only writes in window
                sess["write_tracking"][file_path] = [
                    t for t in sess["write_tracking"][file_path]
                    if now - t < _FILE_THRASHING_WINDOW
                ]
                count = len(sess["write_tracking"][file_path])
                if count == _FILE_THRASHING_THRESHOLD:
                    _write_hook_audit("FILE_THRASHING", audit_model,
                        [{"type": "file_thrashing", "severity": "MEDIUM",
                          "description": f"File written {count}x in {_FILE_THRASHING_WINDOW}s: {file_path[:100]}"}],
                        session_id=session_id)

        # Surrender / NPC / blame detection
        if response_text and _is_hook_feature_enabled("surrender_detection"):
            lower = response_text.lower()
            for phrase in _SURRENDER_PHRASES:
                if phrase in lower:
                    _write_hook_audit("AGENT_SURRENDER", audit_model,
                        [{"type": "agent_surrender", "severity": "LOW",
                          "description": f"Surrender phrase detected: '{phrase}'"}],
                        session_id=session_id)
                    break
            npc_count = sum(1 for p in _NPC_PHRASES if p in lower)
            if npc_count >= 2:
                _write_hook_audit("NPC_BEHAVIOR", audit_model,
                    [{"type": "npc_behavior", "severity": "MEDIUM",
                      "description": f"NPC behavior: {npc_count} deflection phrases detected"}],
                    session_id=session_id)

        # Response awareness (periodic context injection)
        if _is_hook_feature_enabled("response_awareness"):
            sess["ra_counter"] += 1
            triggered = False
            if response_text:
                for trigger_word, desc in _AWARENESS_TRIGGERS:
                    if trigger_word.lower() in response_text.lower():
                        triggered = True
                        break
            if triggered or sess["ra_counter"] >= _RESPONSE_AWARENESS_INTERVAL:
                sess["ra_counter"] = 0
                import random as _random
                msg = _random.choice(_AWARENESS_MESSAGES)
                injections.append(msg)
                _write_hook_audit("RESPONSE_AWARENESS", audit_model,
                    [{"type": "response_awareness", "severity": "INFO",
                      "description": "Periodic context awareness injection"}],
                    resolved=True, session_id=session_id)

        # Content filtering
        if not response_text or len(response_text) < _MIN_CONTENT_LENGTH:
            _write_hook_audit(tool_name, audit_model, [], session_id=session_id)
            response["injection"] = "\n".join(injections) if injections else ""
            return response

        # Run AIMonitor scan
        monitor = _load_hook_ai_monitor()
        issues = []
        if monitor:
            try:
                result = monitor.inspect(response_text[:_MAX_SCAN_LENGTH], prompt=task_context)
                raw_anomalies = getattr(result, "anomalies", []) if not isinstance(result, dict) else result.get("anomalies", [])
                for a in raw_anomalies:
                    if isinstance(a, dict):
                        atype = a.get("type", "unknown")
                        severity = a.get("severity", "LOW")
                        desc = a.get("description", "")
                    else:
                        atype = getattr(a, "anomaly_type", getattr(a, "type", "unknown"))
                        severity = getattr(a, "severity", "LOW")
                        desc = getattr(a, "description", "")
                    if isinstance(atype, str):
                        pass
                    else:
                        atype = getattr(atype, "value", str(atype))
                    if isinstance(severity, str):
                        pass
                    else:
                        severity = getattr(severity, "value", str(severity))
                    if _is_hook_detector_enabled(atype):
                        issues.append({
                            "type": atype,
                            "severity": severity,
                            "description": desc,
                        })
            except Exception as exc:
                logger.debug("AIMonitor scan error: %s", exc)

        # Update anomaly count in session
        if issues:
            sess["anomaly_count"] += len(issues)
            with _lock:
                if session_id in _sessions:
                    _sessions[session_id]["anomaly_count"] = sess["anomaly_count"]

        # Critical check
        has_critical = any(i["severity"] in _BLOCKING_SEVERITIES for i in issues)

        if issues:
            # Format feedback
            feedback_lines = [f"[InsAIts] {len(issues)} anomal{'y' if len(issues)==1 else 'ies'} detected:"]
            for i, issue in enumerate(issues, 1):
                feedback_lines.append(f"  {i}. [{issue['severity']}] {issue['type']}: {issue['description'][:200]}")
            feedback = "\n".join(feedback_lines)

            if has_critical:
                injections.append(feedback)
            else:
                response["stderr"] = feedback

        # Write audit entry
        _write_hook_audit(tool_name, audit_model, issues,
                         session_id=session_id, resolved=not has_critical,
                         stealth=stealth)

    # Compile injections
    response["injection"] = "\n".join(injections) if injections else ""

    # PHASE_GUARDIAN: periodic snapshot (build data in main thread, write in background)
    calls_since_snapshot = sess["tool_call_count"] - sess["last_snapshot_at"]
    if calls_since_snapshot >= SNAPSHOT_INTERVAL:
        snapshot_data = _build_snapshot_data(session_id, sess)
        sess["last_snapshot_at"] = sess["tool_call_count"]
        threading.Thread(
            target=_write_snapshot_to_disk,
            args=(snapshot_data,),
            daemon=True,
        ).start()
        # Also write full state file for Session Vault
        threading.Thread(
            target=_write_state_file,
            args=(session_id, sess, "SCHEDULED"),
            daemon=True,
        ).start()

    return response


# ---------------------------------------------------------------------------
# HTTP Handler
# ---------------------------------------------------------------------------
class CollectorHandler(BaseHTTPRequestHandler):

    def log_message(self, fmt: str, *args: Any) -> None:
        pass

    def _json(self, data: Any, status: int = 200) -> None:
        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("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def _err(self, status: int, msg: str) -> None:
        self._json({"error": msg}, status)

    def _body(self) -> bytes:
        n = int(self.headers.get("Content-Length", 0))
        return self.rfile.read(n) if n > 0 else b""

    def _params(self) -> Dict[str, str]:
        p = {}
        for k, v in parse_qs(urlparse(self.path).query).items():
            p[k] = v[0] if v else ""
        return p

    def do_OPTIONS(self) -> None:
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    def do_GET(self) -> None:
        path = urlparse(self.path).path
        params = self._params()

        if path == "/health":
            self._json({
                "status": "ok", "port": COLLECTOR_PORT,
                "uptime_seconds": round(time.time() - _start_time, 1),
                "events_count": len(_events), "agents_count": len(_agent_tree),
                "sessions_count": len(_sessions), "dialog_messages": len(_dialog_thread),
                "evidence_records": len(_evidence_chain),
                "paused_sessions": list(_paused_sessions),
                "file_locks": dict(_file_locks),
                "session_start": _session_start,
            })
        elif path == "/guardian":
            # PHASE_GUARDIAN: session health stats (M7 — full observability)
            guardian_stats = {}
            with _hook_lock:
                for sid, sess in _hook_sessions.items():
                    guardian_stats[sid[:12]] = {
                        "session_id": sid,
                        "tool_call_count": sess["tool_call_count"],
                        "replay_count": sess["replay_count"],
                        "fingerprint_count": len(sess["seen_fingerprints"]),
                        "last_snapshot_at": sess["last_snapshot_at"],
                        "anomaly_count": sess["anomaly_count"],
                        "replay_pct": round(sess["replay_count"] / max(sess["tool_call_count"], 1) * 100, 1),
                        "is_continued": sess.get("is_continued", False),
                        "parent_session_id": sess.get("parent_session_id", ""),
                        "restored_from_snapshot": sess.get("restored_from_snapshot", False),
                        "resume_source": sess.get("resume_source", ""),
                        "retry_storm_active": sess.get("retry_storm_active", False),
                        "retry_storm_count": sess.get("retry_storm_count", 0),
                        "storm_block_active": sess.get("storm_block_active", False),
                        "storm_block_tier": sess.get("storm_block_tier", 0),
                        "storm_block_remaining": max(0, int(sess.get("storm_block_until", 0) - time.monotonic())) if sess.get("storm_block_active") else 0,
                        "circuit_breaker_active": sess.get("circuit_breaker_active", False),
                        "circuit_breaker_blocked_count": sess.get("circuit_breaker_blocked_count", 0),
                        "work_journal_count": len(sess.get("work_journal", [])),
                        "files_touched_count": len(sess.get("files_touched", set())),
                        "calls_since_snapshot": sess["tool_call_count"] - sess["last_snapshot_at"],
                        "next_snapshot_in": max(0, SNAPSHOT_INTERVAL - (sess["tool_call_count"] - sess["last_snapshot_at"])),
                        "budget_status": (
                            "exceeded" if sess["replay_count"] >= MAX_REPLAYS_BEFORE_BLOCK
                            else "warning" if sess["replay_count"] >= MAX_REPLAYS_BEFORE_WARNING
                            else "ok"
                        ),
                    }
            snapshots = list(SNAPSHOTS_DIR.glob("*.json")) if SNAPSHOTS_DIR.exists() else []
            snapshot_details = []
            for sp in snapshots:
                try:
                    stat = sp.stat()
                    snapshot_details.append({
                        "session_id": sp.stem,
                        "size_bytes": stat.st_size,
                        "age_seconds": round(time.time() - stat.st_mtime),
                    })
                except OSError:
                    pass
            # State files (Session Vault) — read content for trigger/stats display
            state_files_list = []
            if SESSIONS_DIR.exists():
                for sf in sorted(SESSIONS_DIR.glob("session_*.json"), reverse=True)[:20]:
                    try:
                        stat = sf.stat()
                        with open(sf, "r", encoding="utf-8") as fh:
                            data = json.load(fh)
                        state_files_list.append({
                            "filename": sf.name,
                            "session_id": data.get("session_id", ""),
                            "saved_at": data.get("saved_at", ""),
                            "trigger": data.get("trigger", ""),
                            "tool_call_count": data.get("tool_call_count", 0),
                            "anomaly_count": data.get("anomaly_count", 0),
                            "files_touched_count": len(data.get("files_touched", [])),
                            "resume_hint": data.get("resume_hint", ""),
                            "size_bytes": stat.st_size,
                            "age_seconds": round(time.time() - stat.st_mtime),
                        })
                    except (json.JSONDecodeError, IOError, OSError):
                        pass
            # Emergency markers
            emergency_markers_list = []
            for em in sorted(INSAITS_DIR.glob("emergency_shutdown_*.json"), reverse=True)[:5]:
                try:
                    with open(em, "r", encoding="utf-8") as fh:
                        em_data = json.load(fh)
                    emergency_markers_list.append({
                        "filename": em.name,
                        "timestamp": em_data.get("timestamp", ""),
                        "reason": em_data.get("reason", ""),
                        "sessions_saved": em_data.get("sessions_saved", 0),
                    })
                except (json.JSONDecodeError, IOError):
                    pass
            self._json({
                "sessions": guardian_stats,
                "snapshots": snapshot_details,
                "snapshot_count": len(snapshots),
                "snapshot_dir": str(SNAPSHOTS_DIR),
                "state_files": state_files_list,
                "emergency_markers": emergency_markers_list,
                "config": {
                    "snapshot_interval": SNAPSHOT_INTERVAL,
                    "max_replays_warning": MAX_REPLAYS_BEFORE_WARNING,
                    "max_replays_block": MAX_REPLAYS_BEFORE_BLOCK,
                    "snapshot_max_age_hours": SNAPSHOT_MAX_AGE / 3600,
                    "retry_storm_window": RETRY_STORM_WINDOW,
                    "retry_storm_threshold": RETRY_STORM_THRESHOLD,
                },
            })
        elif path == "/stream":
            after = int(params.get("after", "0"))
            evts = [e for e in _events if e.get("collector_seq", 0) > after][-500:]
            self._json({"events": evts, "total": len(_events), "latest_seq": _sequence})
        elif path == "/agents":
            tree = _get_agent_tree()
            roots = [a for a, n in tree.items() if not n.get("parent_id") or n["parent_id"] not in tree]
            self._json({"agents": tree, "roots": roots, "total_agents": len(tree)})
        elif path == "/sessions":
            self._json(_get_sessions_serializable())
        elif path == "/dialog/read":
            self._json(_dialog_read(int(params.get("since_sequence", "0")),
                                    params.get("session_id", "")))
        elif path == "/dialog/thread":
            with _lock:
                self._json({"thread": _dialog_thread, "latest_sequence": _dialog_sequence,
                            "active_sessions": list(_sessions.keys()),
                            "file_locks": dict(_file_locks)})
        elif path == "/evidence":
            with _lock:
                self._json({"chain": _evidence_chain[-100:], "total": len(_evidence_chain),
                            "chain_valid": True})
        elif path == "/dialog/check":
            # Non-blocking check for urgent + dialog messages for a session.
            # Drains queues — messages are consumed on read (single delivery).
            sid = params.get("session_id", "")
            if not sid:
                self._err(400, "Missing session_id")
                return
            messages = []
            # Drain urgent queue first (higher priority)
            uq = _get_urgent_queue(sid)
            while not uq.empty():
                try:
                    messages.append(uq.get_nowait())
                except queue.Empty:
                    break
            # Then drain dialog queue
            dq = _get_dialog_queue(sid)
            while not dq.empty():
                try:
                    messages.append(dq.get_nowait())
                except queue.Empty:
                    break
            if messages:
                self._json({"has_message": True, "messages": messages, "count": len(messages)})
            else:
                self._json({"has_message": False})
        elif path.startswith("/guardian/journal/"):
            # GET /guardian/journal/{session_id} — work journal for a session
            target_sid = path.split("/guardian/journal/", 1)[1].strip("/")
            if not target_sid:
                self._err(400, "Missing session_id")
                return
            with _hook_lock:
                # Find session by prefix match
                found_sess = None
                found_sid = None
                for sid, sess in _hook_sessions.items():
                    if sid.startswith(target_sid) or target_sid in sid:
                        found_sess = sess
                        found_sid = sid
                        break
            if found_sess is None:
                self._err(404, f"Session not found: {target_sid}")
                return
            self._json({
                "session_id": found_sid,
                "journal": found_sess.get("work_journal", []),
                "files_touched": sorted(found_sess.get("files_touched", set())),
                "total_entries": len(found_sess.get("work_journal", [])),
            })
        elif path == "/guardian/worklog/latest":
            # GET /guardian/worklog/latest — most recent work log from any session
            # Used by hook runner to inject context at session start
            latest_summary = None
            latest_ts = ""

            # First try active sessions (in-memory, most current)
            with _hook_lock:
                for sid, sess in _hook_sessions.items():
                    journal = sess.get("work_journal", [])
                    if journal:
                        entry_ts = journal[-1].get("timestamp", "")
                        if entry_ts > latest_ts:
                            latest_ts = entry_ts
                            latest_summary = {
                                "session_id": sid,
                                "source": "active",
                                "tool_call_count": sess["tool_call_count"],
                                "anomaly_count": sess["anomaly_count"],
                                "work_summary": _build_work_log_summary(sess, max_entries=20),
                                "files_touched": sorted(sess.get("files_touched", set())),
                                "journal_count": len(journal),
                                "last_action": journal[-1].get("action", ""),
                                "last_timestamp": latest_ts,
                            }

            # Then check state files (disk, for when no active sessions)
            if latest_summary is None and SESSIONS_DIR.exists():
                state_files = sorted(
                    SESSIONS_DIR.glob("session_*.json"),
                    key=lambda f: f.stat().st_mtime,
                    reverse=True,
                )
                for sf in state_files[:3]:  # Check top 3 most recent
                    try:
                        state = json.loads(sf.read_text(encoding="utf-8"))
                        journal = state.get("work_journal", [])
                        if journal:
                            latest_summary = {
                                "session_id": state.get("session_id", ""),
                                "source": "state_file",
                                "tool_call_count": state.get("tool_call_count", 0),
                                "anomaly_count": state.get("anomaly_count", 0),
                                "work_summary": state.get("resume_hint", ""),
                                "files_touched": state.get("files_touched", []),
                                "journal_count": len(journal),
                                "last_action": journal[-1].get("action", "") if journal else "",
                                "last_timestamp": state.get("saved_at", ""),
                            }
                            break
                    except (json.JSONDecodeError, OSError):
                        continue

            if latest_summary:
                self._json(latest_summary)
            else:
                self._json({"session_id": "", "source": "none", "work_summary": "No work log found."})

        elif path == "/guardian/sessions":
            # GET /guardian/sessions — list state files in Session Vault
            state_files = []
            if SESSIONS_DIR.exists():
                for sf in sorted(SESSIONS_DIR.glob("session_*.json"), reverse=True)[:50]:
                    try:
                        stat = sf.stat()
                        # Read just the header fields
                        with open(sf, "r", encoding="utf-8") as fh:
                            data = json.load(fh)
                        state_files.append({
                            "filename": sf.name,
                            "session_id": data.get("session_id", ""),
                            "saved_at": data.get("saved_at", ""),
                            "trigger": data.get("trigger", ""),
                            "tool_call_count": data.get("tool_call_count", 0),
                            "anomaly_count": data.get("anomaly_count", 0),
                            "files_touched_count": len(data.get("files_touched", [])),
                            "resume_hint": data.get("resume_hint", ""),
                            "size_bytes": stat.st_size,
                        })
                    except (json.JSONDecodeError, IOError, OSError):
                        pass
            self._json({
                "state_files": state_files,
                "total": len(state_files),
                "sessions_dir": str(SESSIONS_DIR),
            })
        else:
            self._err(404, f"Unknown: {path}")

    def do_POST(self) -> None:
        path = urlparse(self.path).path
        raw_body = self._body()
        if not raw_body:
            self._err(400, "Empty body")
            return
        try:
            raw = json.loads(raw_body)
        except json.JSONDecodeError as exc:
            self._err(400, f"Bad JSON: {exc}")
            return

        if path == "/events":
            ev = _ingest_event(raw)
            self._json({"ok": True, "seq": ev.get("collector_seq", 0)})

        elif path == "/dialog/send":
            self._json(_dialog_send(
                session_id=raw.get("session_id", ""),
                session_name=raw.get("session_name", "unknown"),
                message_type=raw.get("message_type", "message"),
                content=raw.get("content", ""),
                to_session_id=raw.get("to_session_id"),
                files_involved=raw.get("files_involved"),
                action=raw.get("action"),
                urgent=raw.get("urgent", False),
            ))

        elif path == "/dialog/urgent":
            # Direct urgent message — goes to in-memory queue, runs monitoring
            target_sid = raw.get("session_id", raw.get("to_session_id", ""))
            content = raw.get("content", raw.get("message", ""))
            from_sid = raw.get("from_session", raw.get("from", HUMAN_SESSION_ID))
            from_name = raw.get("from_name", raw.get("session_name", HUMAN_SESSION_NAME))
            if not target_sid or not content:
                self._err(400, "Missing session_id and content")
                return
            # Send via _dialog_send with urgent=True (handles monitoring + evidence + queue)
            self._json(_dialog_send(
                session_id=from_sid,
                session_name=from_name,
                message_type="urgent",
                content=content,
                to_session_id=target_sid,
                urgent=True,
            ))

        elif path == "/dialog/coordinate":
            valid = {"STARTING_WORK", "FINISHED_WORK", "REQUESTING_REVIEW",
                     "SHARING_RESULT", "WARNING", "ASKING_QUESTION"}
            act = raw.get("action", "")
            if act not in valid:
                self._err(400, f"Invalid action: {act}")
                return
            self._json(_dialog_send(
                session_id=raw.get("from_session", raw.get("session_id", "")),
                session_name=raw.get("session_name", "unknown"),
                message_type="coordinate",
                content=raw.get("task_description", raw.get("content", "")),
                to_session_id=raw.get("to_session_id"),
                files_involved=raw.get("files_involved"),
                action=act,
            ))

        elif path == "/session/new":
            _archive_session()
            self._json({"ok": True, "message": "Session archived"})

        elif path == "/guardian/save":
            # POST /guardian/save — manual save all sessions
            reason = raw.get("reason", "MANUAL_SAVE")
            saved = _emergency_save_all(reason=reason)
            self._json({"ok": True, "sessions_saved": saved, "reason": reason})

        elif path == "/guardian/unblock":
            # POST /guardian/unblock — manual release storm block + circuit breaker
            target_sid = raw.get("session_id", "")
            result = _release_storm_block(target_sid)
            self._json({"ok": True, **result})

        elif path == "/guardian/emergency-pause":
            # POST /guardian/emergency-pause — manual trigger storm block
            target_sid = raw.get("session_id", "")
            duration = int(raw.get("duration", STORM_BLOCK_DURATION))
            duration = max(20, min(duration, 120))  # Clamp 20-120s
            result = _emergency_pause(target_sid, duration)
            self._json({"ok": True, **result})

        elif path == "/hook":
            # DAEMON HOOK ENDPOINT — processes full hook event in-memory
            # Called by insaits_hook_shim.py instead of spawning a new process
            try:
                result = _process_hook(raw)
                self._json(result)
            except Exception as exc:
                logger.error("Hook processing error: %s", exc)
                self._json({"deny": False, "reason": "", "injection": "", "stderr": f"Hook error: {exc}"})

        else:
            self._err(404, f"Unknown: {path}")


def _archive_session() -> None:
    global _sequence, _session_start, _dialog_sequence
    with _lock:
        if UNIFIED_LOG.exists():
            ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
            try:
                UNIFIED_LOG.rename(INSAITS_DIR / f"session_{ts}.jsonl")
            except (IOError, OSError) as exc:
                logger.error("Archive failed: %s", exc)
        _events.clear()
        _sequence = 0
        _agent_tree.clear()
        _dialog_thread.clear()
        _dialog_sequence = 0
        _file_locks.clear()
        _paused_sessions.clear()
        _session_start = _now_iso()


    def do_DELETE(self) -> None:
        path = urlparse(self.path).path
        if path.startswith("/guardian/sessions/"):
            filename = path.split("/guardian/sessions/", 1)[1]
            if not filename or ".." in filename or "/" in filename:
                self._err(400, "Invalid filename")
                return
            target = SESSIONS_DIR / filename
            if target.exists() and target.is_file():
                try:
                    target.unlink()
                    self._json({"deleted": True, "filename": filename})
                except OSError as exc:
                    self._err(500, str(exc))
            else:
                self._err(404, "State file not found")
        else:
            self._err(404, "Not found")


class ThreadedCollectorServer(ThreadingMixIn, HTTPServer):
    daemon_threads = True
    allow_reuse_address = True


def main() -> None:
    global _last_evidence_hash, _sequence, _dialog_sequence
    _ensure_dir()
    _load_dialog()

    # PHASE_GUARDIAN Phase 2: emergency crash save
    signal.signal(signal.SIGTERM, _signal_handler)
    signal.signal(signal.SIGINT, _signal_handler)
    atexit.register(_emergency_save_atexit)

    # Clean up legacy urgent files (replaced by in-memory queues)
    for p in INSAITS_DIR.glob("urgent_*.json"):
        try:
            p.unlink()
            logger.info("Cleaned up legacy urgent file: %s", p.name)
        except OSError:
            pass

    # PHASE_GUARDIAN M16: clean up orphaned storm blocks from previous crash
    _cleanup_orphaned_storm_blocks()

    # PHASE_GUARDIAN: clean up old snapshots (>24h)
    _cleanup_old_snapshots()

    # PHASE_GUARDIAN Phase 2: detect recovery from crash
    emergency_markers = sorted(INSAITS_DIR.glob("emergency_shutdown_*.json"), reverse=True)
    if emergency_markers:
        latest = emergency_markers[0]
        try:
            with open(latest, "r", encoding="utf-8") as fh:
                marker = json.load(fh)
            logger.info("RECOVERED_FROM_CRASH: %s — %d sessions were saved",
                       marker.get("timestamp", "?"), marker.get("sessions_saved", 0))
            # Clean up old markers (keep last 5)
            for old in emergency_markers[5:]:
                try:
                    old.unlink()
                except OSError:
                    pass
        except (json.JSONDecodeError, IOError):
            pass

    # Verify evidence chain
    ok, msg = _verify_evidence_chain()
    if ok:
        logger.info("Evidence chain: %s", msg)
        if _evidence_chain:
            _last_evidence_hash = _evidence_chain[-1].get("record_hash_sha256", "genesis")
    else:
        logger.error("EVIDENCE TAMPERING DETECTED: %s", msg)

    # Load existing session
    if UNIFIED_LOG.exists():
        n = 0
        try:
            with open(UNIFIED_LOG, "r", encoding="utf-8") as fh:
                for line in fh:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        ev = json.loads(line)
                        with _lock:
                            _sequence += 1
                            ev["collector_seq"] = _sequence
                            _events.append(ev)
                            _update_agent_tree(ev)
                            _update_session(ev)
                        n += 1
                    except json.JSONDecodeError:
                        continue
        except (IOError, OSError) as exc:
            logger.error("Load session failed: %s", exc)
        logger.info("Loaded %d events, %d agents, %d sessions", n, len(_agent_tree), len(_sessions))

    # Load existing dialog
    if DIALOG_FILE.exists():
        try:
            with open(DIALOG_FILE, "r", encoding="utf-8") as fh:
                data = json.load(fh)
                with _lock:
                    _dialog_thread.extend(data.get("thread", []))
                    _dialog_sequence = data.get("latest_sequence", len(_dialog_thread))
            logger.info("Loaded %d dialog messages", len(_dialog_thread))
        except (json.JSONDecodeError, IOError):
            pass

    server = ThreadedCollectorServer(("127.0.0.1", COLLECTOR_PORT), CollectorHandler)
    logger.info("=" * 60)
    logger.info("InsAIts Central Collector v1.0")
    logger.info("Listening on http://127.0.0.1:%d", COLLECTOR_PORT)
    logger.info("Data: %s", INSAITS_DIR)
    logger.info("=" * 60)
    logger.info("POST /hook              — DAEMON: full hook processing (replaces hook_runner.py)")
    logger.info("POST /events            — audit events from hook runners")
    logger.info("POST /dialog/send       — inter-session + human dialog")
    logger.info("POST /dialog/coordinate — task coordination (STARTING_WORK etc.)")
    logger.info("GET  /dialog/read       — poll messages for a session")
    logger.info("GET  /dialog/thread     — full dialog history")
    logger.info("GET  /stream            — unified event stream")
    logger.info("GET  /agents            — agent spawn tree")
    logger.info("GET  /sessions          — session registry")
    logger.info("GET  /evidence          — tamper-evident chain")
    logger.info("GET  /health            — collector status")
    logger.info("GET  /guardian          — PHASE_GUARDIAN session health + replay stats")
    logger.info("GET  /guardian/journal  — work journal for a session")
    logger.info("GET  /guardian/sessions — Session Vault state files")
    logger.info("POST /guardian/save    — manual save all sessions")
    logger.info("POST /guardian/unblock — manual release storm block")
    logger.info("POST /guardian/emergency-pause — manual trigger storm block")
    logger.info("=" * 60)

    try:
        server.serve_forever()
    except (KeyboardInterrupt, SystemExit):
        logger.info("Shutting down")
        server.shutdown()


if __name__ == "__main__":
    main()
