"""
Brynq Dashboard — Unified multi-project web dashboard.

Reads from ~/.brynq/registry.json to discover ALL broker directories
across projects. One dashboard shows every project, every channel, every agent.

Usage:
    python -m brynq.dashboard
    python -m brynq.dashboard --port 9999

Then open http://localhost:9999 in a browser.
"""

import json
import http.server
import os
import secrets
import socketserver
import subprocess
import argparse
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse, parse_qs

from brynq.server import get_registered_brokers, BROKER_DIR

# Optional imports — dashboard works without these modules.
try:
    from brynq.spawner import spawn_agent as _spawner_spawn_agent
    from brynq.spawner import spawn_swarm as _spawner_spawn_swarm
    from brynq.spawner import kill_agent as _spawner_kill_agent
    from brynq.spawner import list_agents as _spawner_list_agents
    _HAS_SPAWNER = True
except ImportError:
    _HAS_SPAWNER = False

try:
    from brynq.llm_router import discover_backends as _discover_backends
    _HAS_LLM_ROUTER = True
except ImportError:
    _HAS_LLM_ROUTER = False

# ── HTML Dashboard (inline) ──────────────────────────────────────────────
#
# Security note: All user-supplied content (message text, session names,
# channel names) is escaped through escapeHtml() which creates a temporary
# DOM text node to neutralize any HTML/script injection. This dashboard
# is served on localhost only for operator use.

DASHBOARD_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Brynq</title>
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
    background: #0A0F18;
    color: #E0E6ED;
    height: 100vh;
    margin: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
  }

  /* ── Header ─────────────────────────────────── */
  .header {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 16px 24px;
    border-bottom: 1px solid #1A2332;
    background: #0D1320;
    z-index: 100;
  }
  .header-left { display: flex; align-items: center; gap: 12px; }
  .header h1 {
    font-size: 18px;
    font-weight: 600;
    color: #FFFFFF;
    letter-spacing: 0.5px;
  }
  .live-badge {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 11px;
    font-weight: 600;
    color: #00D4AA;
    text-transform: uppercase;
    letter-spacing: 1px;
  }
  .live-dot {
    width: 8px;
    height: 8px;
    background: #00D4AA;
    border-radius: 50%;
    animation: pulse 2s ease-in-out infinite;
  }
  @keyframes pulse {
    0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(0,212,170,0.4); }
    50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(0,212,170,0); }
  }
  .header-right { display: flex; align-items: center; gap: 16px; }
  .header-stats {
    display: flex;
    align-items: center;
    gap: 12px;
  }
  .header-stat {
    font-size: 12px;
    color: #6B7B8D;
    background: #141C2B;
    padding: 4px 10px;
    border-radius: 12px;
    border: 1px solid #1A2332;
  }
  .header-stat span { color: #00D4AA; font-weight: 600; }
  .refresh-indicator {
    font-size: 11px;
    color: #3A4A5C;
    transition: color 0.3s;
  }
  .refresh-indicator.active { color: #00D4AA; }

  /* ── Layout ─────────────────────────────────── */
  .container {
    flex-grow: 1;
    display: grid;
    grid-template-columns: 280px 1fr;
    height: 0; /* Prevents flex children from stretching container */
  }

  /* ── Sidebar ────────────────────────────────── */
  .sidebar {
    background: #0D1320;
    border-right: 1px solid #1A2332;
    padding: 16px 0;
    overflow-y: auto;
    height: 100%;
  }
  .sidebar-section {
    padding: 0 16px;
    margin-bottom: 20px;
  }
  .sidebar-title {
    font-size: 10px;
    font-weight: 700;
    color: #3A4A5C;
    text-transform: uppercase;
    letter-spacing: 1.5px;
    margin-bottom: 8px;
  }

  /* Project filter buttons */
  .project-list { display: flex; flex-direction: column; gap: 2px; }
  .project-btn {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 7px 12px;
    background: transparent;
    border: none;
    border-radius: 6px;
    color: #8B9DB0;
    font-family: inherit;
    font-size: 12px;
    cursor: pointer;
    transition: all 0.15s;
    text-align: left;
    width: 100%;
  }
  .project-btn:hover { background: #141C2B; color: #E0E6ED; }
  .project-btn.active { background: #1A2A1A; color: #7AFF7A; }
  .project-btn .proj-count {
    font-size: 10px;
    background: #1A2332;
    color: #6B7B8D;
    padding: 1px 6px;
    border-radius: 8px;
  }
  .project-btn.active .proj-count { background: #1A3A1A; color: #7AFF7A; }

  /* Channel buttons */
  .channel-list { display: flex; flex-direction: column; gap: 2px; }
  .channel-btn {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 7px 12px;
    background: transparent;
    border: none;
    border-radius: 6px;
    color: #8B9DB0;
    font-family: inherit;
    font-size: 12px;
    cursor: pointer;
    transition: all 0.15s;
    text-align: left;
    width: 100%;
  }
  .channel-btn:hover { background: #141C2B; color: #E0E6ED; }
  .channel-btn.active { background: #142A3A; color: #00D4AA; }
  .channel-btn.hot { border-left: 2px solid #00D4AA; }
  .channel-btn .ch-name::before { content: '#'; opacity: 0.5; margin-right: 2px; }
  .channel-btn .ch-count {
    font-size: 10px;
    background: #1A2332;
    color: #6B7B8D;
    padding: 1px 6px;
    border-radius: 8px;
    min-width: 20px;
    text-align: center;
  }
  .channel-btn.active .ch-count { background: #1A3A3A; color: #00D4AA; }
  .channel-btn .ch-project {
    font-size: 9px;
    color: #4A5A6C;
    margin-left: 4px;
  }

  /* Session list */
  .session-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px;
    border-radius: 6px;
    margin-bottom: 2px;
  }
  .session-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    flex-shrink: 0;
  }
  .session-dot.active { background: #00D4AA; }
  .session-dot.stale { background: #4A3A2A; }
  .session-info { flex: 1; min-width: 0; }
  .session-name {
    font-size: 12px;
    color: #C0C8D0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .session-meta {
    font-size: 10px;
    color: #4A5A6C;
  }
  .session-project {
    font-size: 9px;
    color: #5A6A4C;
    background: #1A2A1A;
    padding: 0 4px;
    border-radius: 3px;
    margin-left: 4px;
  }

  /* ── Main content ───────────────────────────── */
  .main {
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  .main-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 24px;
    border-bottom: 1px solid #1A2332;
    background: #0A0F18;
  }
  .main-title {
    font-size: 14px;
    font-weight: 600;
    color: #FFFFFF;
  }
  .main-title::before { content: '#'; opacity: 0.4; margin-right: 4px; }
  .msg-count-label {
    font-size: 11px;
    color: #4A5A6C;
  }
  .mobile-toggle {
    display: none;
    align-items: center;
    justify-content: center;
    background: #141C2B;
    border: 1px solid #1A2332;
    border-radius: 6px;
    color: #8B9DB0;
    width: 32px;
    height: 32px;
    cursor: pointer;
    font-size: 16px;
  }

  /* ── Messages ───────────────────────────────── */
  .message-list {
    flex: 1;
    overflow-y: auto;
    padding: 8px 16px;
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .message-list::-webkit-scrollbar { width: 6px; }
  .message-list::-webkit-scrollbar-track { background: transparent; }
  .message-list::-webkit-scrollbar-thumb { background: #1A2332; border-radius: 3px; }

  .msg {
    padding: 8px 12px;
    border-radius: 6px;
    transition: background 0.3s;
  }
  .msg:hover { background: #0F1624; }
  .msg.new-msg { animation: fadeIn 0.4s ease-out; }
  @keyframes fadeIn {
    from { opacity: 0; transform: translateY(-4px); background: rgba(0,212,170,0.05); }
    to { opacity: 1; transform: translateY(0); background: transparent; }
  }

  .msg-header {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 3px;
    flex-wrap: wrap;
  }
  .msg-sender {
    font-size: 13px;
    font-weight: 600;
    color: #FFFFFF;
  }
  .msg-platform {
    font-size: 10px;
    font-weight: 600;
    padding: 1px 6px;
    border-radius: 3px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
  }
  .platform-claude   { background: #0D3A3A; color: #00D4AA; }
  .platform-gemini   { background: #0D2A4A; color: #4A9EFF; }
  .platform-cursor   { background: #2A1A4A; color: #B07AFF; }
  .platform-script   { background: #1A2332; color: #6B7B8D; }
  .platform-codex    { background: #2A2A1A; color: #D4AA00; }
  .platform-cli      { background: #1A2332; color: #8B9DB0; }
  .platform-human    { background: #4A3A0A; color: #FFD700; border: 1px solid #FFD700; font-weight: 700; }
  .platform-dashboard { background: #4A3A0A; color: #FFD700; border: 1px solid #FFD700; font-weight: 700; }
  .platform-unknown  { background: #1A2332; color: #4A5A6C; }
  .msg.from-human { border-left: 3px solid #FFD700; background: #0F1218; }
  .human-badge { color: #FFD700; font-size: 10px; font-weight: 700; margin-left: 4px; }

  .msg-project {
    font-size: 9px;
    color: #5A6A4C;
    background: #1A2A1A;
    padding: 1px 5px;
    border-radius: 3px;
    font-weight: 600;
  }
  .msg-channel {
    font-size: 10px;
    color: #3A6A5A;
    background: #0A1A18;
    padding: 1px 5px;
    border-radius: 3px;
  }
  .msg-type {
    font-size: 10px;
    color: #4A5A6C;
    font-style: italic;
  }
  .msg-time {
    font-size: 10px;
    color: #3A4A5C;
    margin-left: auto;
  }
  .msg-body {
    font-size: 13px;
    color: #B0BCC8;
    line-height: 1.5;
    word-break: break-word;
    white-space: pre-wrap;
  }

  /* Type-specific styling */
  .msg.type-alert { border-left: 2px solid #FF6B6B; }
  .msg.type-alert .msg-body { color: #FF9B9B; }
  .msg.type-question { border-left: 2px solid #4A9EFF; }
  .msg.type-request { border-left: 2px solid #B07AFF; }
  .msg.type-file_ready { border-left: 2px solid #00D4AA; }
  .msg.type-status { border-left: 2px solid #D4AA00; }

  /* ── Tabs ───────────────────────────────────── */
  .tab-bar {
    display: flex;
    gap: 0;
    border-bottom: 1px solid #1A2332;
    background: #0D1320;
    overflow-x: auto;
  }
  .tab-btn {
    padding: 10px 18px;
    background: transparent;
    border: none;
    border-bottom: 2px solid transparent;
    color: #6B7B8D;
    font-family: inherit;
    font-size: 12px;
    font-weight: 600;
    cursor: pointer;
    white-space: nowrap;
    transition: all 0.15s;
  }
  .tab-btn:hover { color: #E0E6ED; }
  .tab-btn.active { color: #00D4AA; border-bottom-color: #00D4AA; }
  .tab-btn .tab-count {
    font-size: 10px;
    background: #1A2332;
    color: #6B7B8D;
    padding: 1px 5px;
    border-radius: 8px;
    margin-left: 6px;
  }
  .tab-btn.active .tab-count { background: #1A3A3A; color: #00D4AA; }
  .tab-panel { display: none; flex: 1; overflow-y: auto; padding: 12px 16px; }
  .tab-panel.active { display: flex; flex-direction: column; gap: 6px; }
  #panel-messages { padding: 0; overflow: hidden; }

  /* ── Cards (tasks, capabilities, watches, memory, cron) ── */
  .card {
    background: #0F1624;
    border: 1px solid #1A2332;
    border-radius: 8px;
    padding: 10px 14px;
  }
  .card-header {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 4px;
    flex-wrap: wrap;
  }
  .card-title {
    font-size: 13px;
    font-weight: 600;
    color: #E0E6ED;
  }
  .card-body {
    font-size: 12px;
    color: #8B9DB0;
    line-height: 1.4;
  }
  .badge {
    font-size: 10px;
    font-weight: 600;
    padding: 1px 7px;
    border-radius: 3px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
  }
  .badge-pending { background: #2A2A1A; color: #D4AA00; }
  .badge-accepted { background: #0D2A4A; color: #4A9EFF; }
  .badge-completed { background: #0D3A3A; color: #00D4AA; }
  .badge-failed { background: #3A1A1A; color: #FF6B6B; }
  .badge-delegated { background: #2A1A4A; color: #B07AFF; }
  .badge-active { background: #0D3A3A; color: #00D4AA; }
  .badge-high { background: #3A2A1A; color: #FF9B6B; }
  .badge-urgent { background: #3A1A1A; color: #FF6B6B; }
  .badge-normal { background: #1A2332; color: #6B7B8D; }
  .badge-low { background: #1A2332; color: #4A5A6C; }
  .card-meta {
    font-size: 10px;
    color: #4A5A6C;
    margin-top: 4px;
  }

  /* ── Stats bar ──────────────────────────────── */
  .stats-bar {
    display: flex;
    gap: 24px;
    padding: 10px 24px;
    border-top: 1px solid #1A2332;
    background: #0D1320;
    font-size: 11px;
    color: #4A5A6C;
    flex-wrap: wrap;
  }
  .stat-item span { color: #8B9DB0; font-weight: 600; }

  /* ── Empty state ────────────────────────────── */
  .empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    flex: 1;
    color: #3A4A5C;
    gap: 8px;
    padding: 40px;
  }
  .empty-state .empty-icon { font-size: 32px; opacity: 0.3; }
  .empty-state .empty-text { font-size: 13px; }

  /* ── Launch buttons ────────────────────────── */
  .launch-btn {
    padding: 6px 14px;
    border: none;
    border-radius: 6px;
    font-family: inherit;
    font-size: 12px;
    font-weight: 600;
    cursor: pointer;
    letter-spacing: 0.3px;
    transition: all 0.15s;
  }
  .launch-btn:hover { filter: brightness(1.15); transform: translateY(-1px); }
  .launch-btn-agent { background: #00D4AA; color: #0A0F18; }
  .launch-btn-swarm { background: linear-gradient(135deg, #D4AA00, #FFD700); color: #0A0F18; }

  /* ── Modals ────────────────────────────────── */
  .modal-overlay {
    display: none;
    position: fixed;
    top: 0; left: 0;
    width: 100%; height: 100%;
    background: rgba(0,0,0,0.7);
    z-index: 1000;
    align-items: center;
    justify-content: center;
  }
  .modal-overlay.open { display: flex; }
  .modal-content {
    background: #111927;
    border: 1px solid #1A2332;
    border-radius: 12px;
    padding: 24px;
    width: 90%;
    max-width: 520px;
    box-shadow: 0 16px 48px rgba(0,0,0,0.5);
  }
  .modal-content h3 {
    color: #00D4AA;
    margin-bottom: 16px;
    font-size: 16px;
    font-weight: 700;
  }
  .modal-content label {
    font-size: 11px;
    color: #8B9DB0;
    display: block;
    margin-bottom: 4px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
  }
  .modal-content input,
  .modal-content select,
  .modal-content textarea {
    width: 100%;
    padding: 10px 12px;
    margin-bottom: 12px;
    background: #0A0F18;
    border: 1px solid #1A2332;
    border-radius: 6px;
    color: #E0E6ED;
    font-family: inherit;
    font-size: 13px;
    transition: border-color 0.15s;
  }
  .modal-content input:focus,
  .modal-content select:focus,
  .modal-content textarea:focus {
    outline: none;
    border-color: #00D4AA;
  }
  .modal-content textarea { resize: vertical; }
  .modal-actions {
    display: flex;
    gap: 8px;
    margin-top: 8px;
    justify-content: flex-end;
  }
  .modal-actions button {
    padding: 10px 20px;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-weight: 600;
    font-family: inherit;
    font-size: 13px;
    transition: all 0.15s;
  }
  .modal-actions .btn-primary { background: #00D4AA; color: #0A0F18; }
  .modal-actions .btn-primary:hover { filter: brightness(1.15); }
  .modal-actions .btn-secondary { background: #1A2332; color: #6B7B8D; }
  .modal-actions .btn-secondary:hover { background: #222E40; color: #8B9DB0; }
  .modal-actions .btn-swarm { background: linear-gradient(135deg, #D4AA00, #FFD700); color: #0A0F18; }
  .modal-actions .btn-swarm:hover { filter: brightness(1.15); }
  .platform-mix-row {
    display: flex;
    gap: 16px;
    margin-bottom: 12px;
  }
  .platform-mix-row > div { flex: 1; }
  .platform-mix-row label { margin-bottom: 4px; }
  .platform-mix-row input[type="number"] {
    width: 100%;
    text-align: center;
  }
  .modal-toast {
    position: fixed;
    bottom: 24px;
    right: 24px;
    background: #0D3A3A;
    color: #00D4AA;
    padding: 12px 20px;
    border-radius: 8px;
    border: 1px solid #00D4AA;
    font-family: inherit;
    font-size: 13px;
    font-weight: 600;
    z-index: 2000;
    animation: toastIn 0.3s ease-out;
    box-shadow: 0 4px 16px rgba(0,212,170,0.2);
  }
  @keyframes toastIn {
    from { opacity: 0; transform: translateY(12px); }
    to { opacity: 1; transform: translateY(0); }
  }

  /* ── Local Agents panel ──────────────────────── */
  .local-agent-item {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px;
    border-radius: 6px;
    margin-bottom: 2px;
    justify-content: space-between;
  }
  .local-agent-left {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
    flex: 1;
  }
  .local-agent-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    flex-shrink: 0;
  }
  .local-agent-dot.alive { background: #00D4AA; }
  .local-agent-dot.dead { background: #FF6B6B; }
  .local-agent-info { flex: 1; min-width: 0; }
  .local-agent-name {
    font-size: 12px;
    color: #C0C8D0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .local-agent-meta {
    font-size: 10px;
    color: #4A5A6C;
  }
  .local-agent-kill {
    background: #3A1A1A;
    color: #FF6B6B;
    border: 1px solid #FF6B6B;
    border-radius: 4px;
    padding: 1px 6px;
    font-size: 9px;
    cursor: pointer;
    font-weight: bold;
    font-family: inherit;
    flex-shrink: 0;
  }
  .local-agent-kill:hover { background: #5A2A2A; }

  /* ── Backend detection ───────────────────────── */
  .backend-status {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin-bottom: 12px;
    padding: 8px 12px;
    background: #0A0F18;
    border: 1px solid #1A2332;
    border-radius: 6px;
  }
  .backend-row {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 12px;
  }
  .backend-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    flex-shrink: 0;
  }
  .backend-dot.up { background: #00D4AA; }
  .backend-dot.down { background: #3A4A5C; }
  .backend-name { color: #8B9DB0; font-weight: 600; }
  .backend-detail { color: #4A5A6C; font-size: 11px; }

    /* -- BRYNQ CEO OVERRIDE: STICKY UI -- */
    html, body {
        height: 100vh !important;
        overflow: hidden !important;
        margin: 0 !important;
    }
    .container {
        height: calc(100vh - 57px) !important;
        overflow: hidden !important;
    }
    .main {
        display: flex !important;
        flex-direction: column !important;
        height: 100% !important;
        overflow: hidden !important;
    }
    #messageList, .message-list {
        flex-grow: 1 !important;
        overflow-y: auto !important;
        /* height: 0 allows flex-grow to manage the scrolling properly without stretching the parent */
        height: 0 !important; 
    }
    /* Target the immediate sibling after the message list (the chat input box/form) */
    #messageList + *, .message-list + * {
        position: sticky !important;
        bottom: 0 !important;
        flex-shrink: 0 !important;
        z-index: 100 !important;
        background-color: var(--bg-color, inherit) !important;
    }
    

/* UI STRIKE TEAM: STICKY INPUT ENFORCEMENT */
.main {
    height: 100% !important;
    overflow: hidden !important;
}
.message-list {
    flex-grow: 1 !important;
    overflow-y: auto !important;
    height: 0 !important; 
}
.input-area, .chat-input, form {
    position: sticky !important;
    bottom: 0 !important;
    z-index: 100 !important;
    background: #0A0F18 !important;
    flex-shrink: 0 !important;
}

</style>
</head>
<body>

<div class="header">
  <div class="header-left">
    <button class="mobile-toggle" onclick="toggleSidebar()">&#9776;</button>
    <h1>Brynq</h1>
    <div class="live-badge">
      <div class="live-dot"></div>
      LIVE
    </div>
  </div>
  <div class="header-right">
    <button class="launch-btn launch-btn-agent" onclick="openModal('launchAgentModal')">Launch Agent</button>
    <button class="launch-btn launch-btn-swarm" onclick="openModal('launchSwarmModal')">Launch Swarm</button>
    <button class="launch-btn launch-btn-local" onclick="openModal('launchLocalModal')">Launch Local Agent</button>
    <button class="launch-btn" style="background:#1A2332;color:#6B7B8D;border:1px solid #2A3342;" onclick="openModal('addProjectModal')">+ Project</button>
    <div class="header-stats">
      <div class="header-stat">Projects: <span id="projectCount">0</span></div>
      <div class="header-stat">Sessions: <span id="sessionCount">0</span></div>
    </div>
    <div class="refresh-indicator" id="refreshDot">&#x25CF; refreshing</div>
  </div>
</div>

<div class="container">
  <div class="sidebar" id="sidebar">
    <div class="sidebar-section">
      <div class="sidebar-title">Projects</div>
      <div class="project-list" id="projectList"></div>
    </div>
    <div class="sidebar-section">
      <div class="sidebar-title">Channels</div>
      <div class="channel-list" id="channelList"></div>
    </div>
    <div class="sidebar-section">
      <div class="sidebar-title">Sessions</div>
      <div id="sessionList"></div>
    </div>
    <div class="sidebar-section">
      <div class="sidebar-title">Local Agents</div>
      <div id="localAgentList"></div>
    </div>
  </div>

  <div class="main">
    <div class="tab-bar" id="tabBar">
      <button class="tab-btn active" data-tab="messages" onclick="switchTab('messages')">Messages <span class="tab-count" id="tabMsgCount">0</span></button>
      <button class="tab-btn" data-tab="agents" onclick="switchTab('agents')">Agents <span class="tab-count" id="tabAgentCount">0</span></button>
      <button class="tab-btn" data-tab="tasks" onclick="switchTab('tasks')">Tasks <span class="tab-count" id="tabTaskCount">0</span></button>
      <button class="tab-btn" data-tab="watches" onclick="switchTab('watches')">Watchers <span class="tab-count" id="tabWatchCount">0</span></button>
      <button class="tab-btn" data-tab="memory" onclick="switchTab('memory')">Memory <span class="tab-count" id="tabMemCount">0</span></button>
      <button class="tab-btn" data-tab="cron" onclick="switchTab('cron')">Cron <span class="tab-count" id="tabCronCount">0</span></button>
      <button class="tab-btn" data-tab="daemon" onclick="switchTab('daemon')">Daemons <span class="tab-count" id="tabDaemonCount">0</span></button>
      <button class="tab-btn" data-tab="compute" onclick="switchTab('compute')">Compute <span class="tab-count" id="tabComputeCount">0</span></button>
      <button class="tab-btn" data-tab="settings" onclick="switchTab('settings')">Settings <span class="tab-count" id="tabSetCount">0</span></button>
    </div>
    <div class="tab-panel active" id="panel-messages">
      <div class="filter-bar" style="padding: 8px 16px; border-bottom: 1px solid #1A2332; display: flex; gap: 12px; background: #0D1320; flex-shrink: 0; justify-content: space-between;">
        <div style="display: flex; gap: 12px;">
          <label style="font-size: 11px; color: #8B9DB0; display: flex; align-items: center; gap: 6px; cursor: pointer;">
            <input type="checkbox" id="hideDiscordToggle" onchange="fetchMessages()" checked>
            Mute Discord Spam
          </label>
          <label style="font-size: 11px; color: #8B9DB0; display: flex; align-items: center; gap: 6px; cursor: pointer;">
            <input type="checkbox" id="hideSystemToggle" onchange="fetchMessages()">
            Hide System Events
          </label>
        </div>
        <button onclick="killDiscordBot()" style="background: #3A1A1A; color: #FF6B6B; border: 1px solid #FF6B6B; border-radius: 4px; padding: 2px 8px; font-size: 10px; cursor: pointer; font-weight: bold; font-family: inherit;">KILL MOCK BOT</button>
      </div>
      <div class="message-list" id="messageList" style="flex:1;overflow-y:auto;">
        <div class="empty-state">
          <div class="empty-icon">&#x2637;</div>
          <div class="empty-text">No messages yet</div>
        </div>
      </div>
      <div style="padding:8px 16px;border-top:1px solid #1A2332;background:#0D1320;position:sticky;bottom:0;z-index:100;flex-shrink:0;display:flex;gap:8px;flex-shrink:0;">
        <select id="composeSender" style="background:#141C2B;border:1px solid #FFD700;border-radius:6px;padding:8px;color:#FFD700;font-family:inherit;font-size:12px;width:130px;font-weight:600;">
          <option value="human-operator|human" style="color:#FFD700;">You (Human)</option>
          <option value="claude-opus|claude">Claude</option>
          <option value="Gemini|gemini">Gemini</option>
          <option value="cursor-ai|cursor">Cursor</option>
          <option value="codex-ai|codex">Codex</option>
        </select>
        <select id="composeChannel" style="width:140px;background:#141C2B;border:1px solid #1A2332;border-radius:6px;padding:8px 12px;color:#E0E6ED;font-family:inherit;font-size:12px;">
          <!-- Options populated dynamically, default to general -->
          <option value="general">#general</option>
        </select>
        <input id="composeMsg" placeholder="Type a message..." style="flex:1;background:#141C2B;border:1px solid #1A2332;border-radius:6px;padding:8px 12px;color:#E0E6ED;font-family:inherit;font-size:12px;" onkeydown="if(event.key==='Enter')sendMessage()">
        <button onclick="sendMessage()" style="background:#00D4AA;border:none;border-radius:6px;padding:8px 16px;color:#0A0F18;font-family:inherit;font-size:12px;font-weight:600;cursor:pointer;">Send</button>
      </div>
    </div>
    <div class="tab-panel" id="panel-agents"></div>
    <div class="tab-panel" id="panel-tasks">
      <div style="padding:12px 16px;background:#0F1624;border:1px solid #1A2332;border-radius:8px;margin-bottom:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
        <span style="font-size:13px;font-weight:600;color:#E0E6ED;width:100%;">Create New Task</span>
        <input id="taskTitle" placeholder="Task title/description..." style="flex:1;background:#141C2B;border:1px solid #1A2332;border-radius:6px;padding:8px 12px;color:#E0E6ED;font-family:inherit;font-size:12px;">
        <input id="taskAssign" placeholder="@agent1, @agent2, or empty" style="width:200px;background:#141C2B;border:1px solid #1A2332;border-radius:6px;padding:8px 12px;color:#E0E6ED;font-family:inherit;font-size:12px;">
        <input id="taskCapability" placeholder="Required Skill (e.g. code_review)" style="width:200px;background:#141C2B;border:1px solid #1A2332;border-radius:6px;padding:8px 12px;color:#E0E6ED;font-family:inherit;font-size:12px;">
        <select id="taskPriority" style="background:#141C2B;border:1px solid #1A2332;border-radius:6px;padding:8px;color:#E0E6ED;font-family:inherit;font-size:12px;">
          <option value="normal">Normal</option>
          <option value="low">Low</option>
          <option value="high">High</option>
          <option value="urgent">Urgent</option>
        </select>
        <button onclick="createTask()" style="background:#00D4AA;border:none;border-radius:6px;padding:8px 16px;color:#0A0F18;font-family:inherit;font-size:12px;font-weight:600;cursor:pointer;">Assign Task</button>
      </div>
      <div id="taskList" style="flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:6px;"></div>
    </div>
    <div class="tab-panel" id="panel-watches"></div>
    <div class="tab-panel" id="panel-memory"></div>
    <div class="tab-panel" id="panel-cron">
      <div style="padding:12px 16px;background:#0F1624;border:1px solid #1A2332;border-radius:8px;margin-bottom:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
        <span style="font-size:13px;font-weight:600;color:#E0E6ED;width:100%;">Create Scheduled Job</span>
        <input id="cronName" placeholder="Job Name" style="width:120px;background:#141C2B;border:1px solid #1A2332;border-radius:6px;padding:6px 10px;color:#E0E6ED;font-size:12px;">
        <input id="cronChannel" placeholder="#channel" value="general" style="width:100px;background:#141C2B;border:1px solid #1A2332;border-radius:6px;padding:6px 10px;color:#E0E6ED;font-size:12px;">
        <input id="cronInterval" placeholder="Seconds (e.g. 300)" style="width:140px;background:#141C2B;border:1px solid #1A2332;border-radius:6px;padding:6px 10px;color:#E0E6ED;font-size:12px;">
        <input id="cronMsg" placeholder="Task description to post..." style="flex:1;background:#141C2B;border:1px solid #1A2332;border-radius:6px;padding:6px 10px;color:#E0E6ED;font-size:12px;">
        <button onclick="createCronJob()" style="background:#4A9EFF;border:none;border-radius:6px;padding:6px 16px;color:#0A0F18;font-size:12px;font-weight:600;cursor:pointer;">Schedule</button>
      </div>
      <div id="cronListContainer" style="display:flex;flex-direction:column;gap:6px;"></div>
    </div>
    <div class="tab-panel" id="panel-compute">
      <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin-bottom:14px;" id="computeStatsGrid">
        <div class="card" style="padding:10px;text-align:center;"><div style="font-size:22px;font-weight:700;color:#4A9EFF;" id="csTotal">0</div><div style="font-size:11px;color:#8892A4;">Total Nodes</div></div>
        <div class="card" style="padding:10px;text-align:center;"><div style="font-size:22px;font-weight:700;color:#22C55E;" id="csIdle">0</div><div style="font-size:11px;color:#8892A4;">Idle</div></div>
        <div class="card" style="padding:10px;text-align:center;"><div style="font-size:22px;font-weight:700;color:#F59E0B;" id="csBusy">0</div><div style="font-size:11px;color:#8892A4;">Busy</div></div>
        <div class="card" style="padding:10px;text-align:center;"><div style="font-size:22px;font-weight:700;color:#E0E6ED;" id="csCapacity">0</div><div style="font-size:11px;color:#8892A4;">Total Capacity</div></div>
        <div class="card" style="padding:10px;text-align:center;"><div style="font-size:22px;font-weight:700;color:#A78BFA;" id="csJobs">0</div><div style="font-size:11px;color:#8892A4;">Jobs Completed</div></div>
      </div>
      <div style="display:flex;gap:12px;flex-wrap:wrap;">
        <div style="flex:1;min-width:300px;">
          <div style="font-size:13px;font-weight:600;color:#E0E6ED;margin-bottom:8px;">Compute Nodes</div>
          <div id="computeNodeList" style="display:flex;flex-direction:column;gap:6px;"></div>
        </div>
        <div style="flex:1;min-width:300px;">
          <div style="font-size:13px;font-weight:600;color:#E0E6ED;margin-bottom:8px;">Active Jobs</div>
          <div id="computeJobList" style="display:flex;flex-direction:column;gap:6px;"></div>
        </div>
      </div>
    </div>
    <div class="tab-panel" id="panel-settings"></div>
    <div class="stats-bar" id="statsBar">
      <div class="stat-item">Projects: <span id="statProjects">0</span></div>
      <div class="stat-item">Channels: <span id="statChannels">0</span></div>
      <div class="stat-item">Sessions: <span id="statSessions">0</span></div>
      <div class="stat-item">Messages: <span id="statMessages">0</span></div>
      <div class="stat-item">Tools: <span>32</span></div>
      <div class="stat-item">Last refresh: <span id="statRefresh">--</span></div>
    </div>
  </div>
</div>

<!-- Launch Agent Modal -->
<div class="modal-overlay" id="launchAgentModal">
  <div class="modal-content">
    <h3>Launch Agent</h3>
    <label>Agent Name</label>
    <input id="agentName" placeholder="e.g. backend-worker-1">
    <label>Platform</label>
    <select id="agentPlatform">
      <option value="claude">Claude</option>
      <option value="gemini">Gemini</option>
    </select>
    <label>Task / Instructions</label>
    <textarea id="agentTask" placeholder="What should this agent work on?" rows="4"></textarea>
    <label>Project</label>
    <select id="agentProject" onchange="updateAgentCwd()">
      <option value="">Loading projects...</option>
    </select>
    <input id="agentCwd" type="hidden">
    <div class="modal-actions">
      <button class="btn-secondary" onclick="closeModal('launchAgentModal')">Cancel</button>
      <button class="btn-primary" onclick="launchAgent()">Launch</button>
    </div>
  </div>
</div>

<!-- Launch Swarm Modal -->
<div class="modal-overlay" id="launchSwarmModal">
  <div class="modal-content">
    <h3>Launch Swarm</h3>
    <label>Swarm Name</label>
    <input id="swarmName" placeholder="e.g. auth-backend-team">
    <label>Task Description</label>
    <textarea id="swarmTask" placeholder="Describe the task for the swarm..." rows="4"></textarea>
    <div class="platform-mix-row">
      <div>
        <label>Claude Agents</label>
        <input type="number" id="swarmClaude" value="2" min="0" max="10">
      </div>
      <div>
        <label>Gemini Agents</label>
        <input type="number" id="swarmGemini" value="1" min="0" max="10">
      </div>
    </div>
    <div class="modal-actions">
      <button class="btn-secondary" onclick="closeModal('launchSwarmModal')">Cancel</button>
      <button class="btn-swarm" onclick="launchSwarm()">Launch Swarm</button>
    </div>
  </div>
</div>

<!-- Launch Local Agent Modal -->
<div class="modal-overlay" id="launchLocalModal">
  <div class="modal-content">
    <h3>Launch Local Agent</h3>
    <label>Detected Backends</label>
    <div class="backend-status" id="backendStatus">
      <div class="backend-row"><span class="backend-detail">Scanning backends...</span></div>
    </div>
    <label>Agent Name</label>
    <input id="localName" placeholder="e.g. local-worker-1">
    <label>Backend</label>
    <select id="localAdapter">
      <option value="ollama">Ollama (localhost:11434)</option>
      <option value="lmstudio">LM Studio (localhost:1234)</option>
      <option value="openai">OpenAI-Compatible</option>
      <option value="vllm">vLLM (localhost:8000)</option>
      <option value="llamacpp">llama.cpp (localhost:8080)</option>
    </select>
    <label>Model</label>
    <select id="localModelSelect" style="display:none;">
    </select>
    <input id="localModel" placeholder="e.g. llama3, mistral">
    <label>Channel</label>
    <input id="localChannel" value="general">
    <div class="modal-actions">
      <button class="btn-secondary" onclick="closeModal('launchLocalModal')">Cancel</button>
      <button class="btn-primary" style="background:#B07AFF; color:#0A0F18;" onclick="launchLocalAgent()">Launch (Terminal)</button>
      <button class="btn-primary" style="background:#00D4AA; color:#0A0F18;" onclick="spawnLocalAgent()">Spawn (Background)</button>
    </div>
  </div>
</div>

<!-- Add Project Modal -->
<div class="modal-overlay" id="addProjectModal">
  <div class="modal-content">
    <h3 style="color:#6B7B8D;">Add Project</h3>
    <p style="color:#4A5A6C;font-size:12px;margin-bottom:16px;">Register a project so its agents and channels appear in this dashboard.</p>
    <label style="font-size:11px;color:#6B7B8D;text-transform:uppercase;">Project Name</label>
    <input id="projName" placeholder="e.g. ZDtE, my-app, backend">
    <label style="font-size:11px;color:#6B7B8D;text-transform:uppercase;">Project Path (full path to project root)</label>
    <input id="projPath" placeholder="e.g. C:/Users/you/Documents/GitHub/my-project">
    <p style="color:#4A5A6C;font-size:11px;margin-top:4px;">A <code style="background:#0A0F18;padding:2px 6px;border-radius:3px;">state/broker/</code> directory will be created in this path if it doesn't exist.</p>
    <div class="modal-actions">
      <button class="btn-secondary" onclick="closeModal('addProjectModal')">Cancel</button>
      <button class="btn-primary" onclick="addProject()">Add Project</button>
    </div>
  </div>
</div>

<script>
(function() {
  "use strict";

  var currentProject = 'all';
  var currentChannel = 'general';
  var knownIds = {};
  var firstLoad = true;

  // -- Safe DOM helpers (no raw innerHTML with user data) --

  function escapeHtml(s) {
    var d = document.createElement('div');
    d.appendChild(document.createTextNode(s || ''));
    return d.innerHTML;
  }

  function el(tag, attrs, children) {
    var node = document.createElement(tag);
    if (attrs) {
      Object.keys(attrs).forEach(function(k) {
        if (k === 'className') node.className = attrs[k];
        else if (k === 'textContent') node.textContent = attrs[k];
        else if (k.indexOf('on') === 0) node.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
        else node.setAttribute(k, attrs[k]);
      });
    }
    if (typeof children === 'string') {
      node.textContent = children;
    } else if (Array.isArray(children)) {
      children.forEach(function(c) { if (c) node.appendChild(c); });
    }
    return node;
  }

  function clearChildren(node) {
    while (node.firstChild) node.removeChild(node.firstChild);
  }

  function selectProject(proj) {
    currentProject = proj;
    currentChannel = 'all';
    document.querySelectorAll('.project-btn').forEach(function(b) { b.classList.remove('active'); });
    var btn = document.querySelector('[data-project="' + CSS.escape(proj) + '"]');
    if (btn) btn.classList.add('active');
    document.getElementById('mainTitle').textContent = proj === 'all' ? 'all projects' : proj;
    knownIds = {};
    firstLoad = true;
    refreshAll();
    document.getElementById('sidebar').classList.remove('open');
  }
  window.selectProject = selectProject;

  function selectChannel(ch) {
    currentChannel = ch;
    document.querySelectorAll('.channel-btn').forEach(function(b) { b.classList.remove('active'); });
    var btn = document.querySelector('[data-channel="' + CSS.escape(ch) + '"]');
    if (btn) btn.classList.add('active');
    document.getElementById('mainTitle').textContent = ch;
    // Sync compose bar channel so Send posts to the channel you're viewing
    var composeInput = document.getElementById('composeChannel');
    if (composeInput) composeInput.value = ch;
    knownIds = {};
    firstLoad = true;
    fetchMessages();
    document.getElementById('sidebar').classList.remove('open');
  }
  window.selectChannel = selectChannel;

  function toggleSidebar() {
    document.getElementById('sidebar').classList.toggle('open');
  }
  window.toggleSidebar = toggleSidebar;

  function platformClass(plat) {
    var p = (plat || 'unknown').toLowerCase();
    var map = { claude: 'platform-claude', gemini: 'platform-gemini', cursor: 'platform-cursor', script: 'platform-script', codex: 'platform-codex', cli: 'platform-cli', human: 'platform-human', dashboard: 'platform-dashboard' };
    return map[p] || 'platform-unknown';
  }

  function formatTime(ts) {
    if (!ts) return '';
    try {
      var d = new Date(ts);
      return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
    } catch(e) { return (ts || '').substring(11, 19); }
  }

  // Build a message DOM node safely
  function buildMessageNode(msg, isNew) {
    var type = msg.msg_type || 'info';
    var isHuman = (msg.platform === 'human' || msg.platform === 'dashboard');
    var row = el('div', { className: 'msg type-' + escapeHtml(type) + (isNew ? ' new-msg' : '') + (isHuman ? ' from-human' : '') });

    var headerChildren = [
      el('span', { className: 'msg-sender' }, msg.session_name || 'unknown'),
      el('span', { className: 'msg-platform ' + platformClass(msg.platform) }, (msg.platform || '?').toUpperCase())
    ];
    if (msg.project) {
      headerChildren.push(el('span', { className: 'msg-project' }, msg.project));
    }
    headerChildren.push(el('span', { className: 'msg-channel' }, '#' + (msg.channel || '?')));
    headerChildren.push(el('span', { className: 'msg-type' }, type));
    headerChildren.push(el('span', { className: 'msg-time' }, formatTime(msg.timestamp)));

    row.appendChild(el('div', { className: 'msg-header' }, headerChildren));
    row.appendChild(el('div', { className: 'msg-body' }, msg.message || ''));
    return row;
  }

  function buildProjectButton(name, count, isActive) {
    return el('button', {
      className: 'project-btn' + (isActive ? ' active' : ''),
      'data-project': name,
      onClick: function() { selectProject(name); }
    }, [
      el('span', {}, name),
      el('span', { className: 'proj-count' }, String(count))
    ]);
  }

  function buildChannelButton(name, count, project, isActive, lastActivity, lastSender) {
    var isHot = false;
    if (lastActivity) {
      var actTime = new Date(lastActivity).getTime();
      isHot = (Date.now() - actTime) < 300000; // 5 min
    }
    var left = el('span', { style: 'display:flex;flex-direction:column;min-width:0;' }, [
      el('span', { className: 'ch-name' }, name),
      lastSender ? el('span', { style: 'font-size:10px;color:#4A5A6C;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px;' }, lastSender) : null
    ]);
    var right = el('span', { style: 'display:flex;align-items:center;gap:4px;' }, [
      project ? el('span', { className: 'ch-project' }, project) : null,
      isHot ? el('span', { style: 'width:6px;height:6px;border-radius:50%;background:#00D4AA;flex-shrink:0;' }) : null,
      el('span', { className: 'ch-count' }, String(count))
    ]);
    return el('button', {
      className: 'channel-btn' + (isActive ? ' active' : '') + (isHot && !isActive ? ' hot' : ''),
      'data-channel': name,
      onClick: function() { selectChannel(name); }
    }, [left, right]);
  }

  function buildSessionNode(s) {
    var isActive = s.age_seconds < 300;
    var metaChildren = [
      el('span', { className: 'msg-platform ' + platformClass(s.platform) }, (s.platform || '?').toUpperCase()),
      document.createTextNode(' PID ' + (s.pid || '?'))
    ];
    if (s.project) {
      metaChildren.push(el('span', { className: 'session-project' }, s.project));
    }
    return el('div', { className: 'session-item' }, [
      el('div', { className: 'session-dot ' + (isActive ? 'active' : 'stale') }),
      el('div', { className: 'session-info' }, [
        el('div', { className: 'session-name' }, (s.avatar || '\uD83E\uDD16') + ' ' + (s.name || 'unknown')),
        el('div', { className: 'session-meta' }, metaChildren)
      ])
    ]);
  }

  // -- Fetch functions --

  function apiParams() {
    var p = '';
    if (currentProject !== 'all') p += '&project=' + encodeURIComponent(currentProject);
    return p;
  }

  function fetchMessages() {
    var list = document.getElementById('messageList');
    var wasAtBottom = list ? (list.scrollHeight - list.scrollTop <= list.clientHeight + 50) : true;
    var indicator = document.getElementById('refreshDot');
    indicator.classList.add('active');

    var hideDiscord = document.getElementById('hideDiscordToggle') ? document.getElementById('hideDiscordToggle').checked : false;
    var hideSystem = document.getElementById('hideSystemToggle') ? document.getElementById('hideSystemToggle').checked : false;

    var url = '/api/messages?channel=' + encodeURIComponent(currentChannel) + '&limit=100' + apiParams();
    fetch(url).then(function(r) { return r.json(); }).then(function(msgs) {
      
      // Apply frontend filters
      if (hideDiscord) msgs = msgs.filter(function(m) { return (m.platform || '').toLowerCase() !== 'discord'; });
      if (hideSystem) msgs = msgs.filter(function(m) { return m.channel === '_system' ? false : (m.msg_type !== 'status'); });

      var list = document.getElementById('messageList');
      var countLabel = document.getElementById('tabMsgCount');
      if (countLabel) countLabel.textContent = msgs.length;

      clearChildren(list);

      if (msgs.length === 0) {
        list.appendChild(el('div', { className: 'empty-state' }, [
          el('div', { className: 'empty-icon', textContent: '\u2637' }),
          el('div', { className: 'empty-text' }, 'No messages in #' + currentChannel)
        ]));
      } else {
        for (var i = 0; i < msgs.length; i++) {
          var m = msgs[i];
          var id = m.id || (m.timestamp + m.session_id);
          var isNew = !firstLoad && !knownIds[id];
          list.appendChild(buildMessageNode(m, isNew));
          knownIds[id] = true;
        }
      }
      if (firstLoad || wasAtBottom) {
        list.scrollTop = list.scrollHeight;
      }
      firstLoad = false;
      setTimeout(function() { indicator.classList.remove('active'); }, 500);
    }).catch(function(e) {
      console.error('Fetch messages failed:', e);
      setTimeout(function() { indicator.classList.remove('active'); }, 500);
    });
  }

  function fetchChannels() {
    fetch('/api/channels?' + apiParams()).then(function(r) { return r.json(); }).then(function(channels) {
      var container = document.getElementById('channelList');
      clearChildren(container);

      // Sort by last activity (most recent first), then alphabetical
      channels.sort(function(a, b) {
        var aTime = a.last_activity || '';
        var bTime = b.last_activity || '';
        if (bTime > aTime) return 1;
        if (aTime > bTime) return -1;
        return a.name.localeCompare(b.name);
      });

      var total = 0;
      channels.forEach(function(c) { total += c.count; });

      container.appendChild(buildChannelButton('all', total, null, currentChannel === 'all', null, null));
      
      var composeSel = document.getElementById('composeChannel');
      if (composeSel) {
          var currentVal = composeSel.value;
          clearChildren(composeSel);
      }

      channels.forEach(function(c) {
        var showProject = currentProject === 'all' ? c.project : null;
        container.appendChild(buildChannelButton(c.name, c.count, showProject, currentChannel === c.name, c.last_activity, c.last_sender));
        
        if (composeSel) {
            var opt = document.createElement('option');
            opt.value = c.name;
            opt.textContent = '#' + c.name;
            if (c.project && currentProject === 'all') opt.textContent += ' (' + c.project + ')';
            composeSel.appendChild(opt);
        }
      });
      
      if (composeSel && currentVal) {
          // Try to restore the selected value if it still exists
          for (var i=0; i<composeSel.options.length; i++) {
              if (composeSel.options[i].value === currentVal) {
                  composeSel.selectedIndex = i;
                  break;
              }
          }
      }

      document.getElementById('statChannels').textContent = channels.length;
      document.getElementById('statMessages').textContent = total;
    }).catch(function(e) {
      console.error('Fetch channels failed:', e);
    });
  }

  function fetchSessions() {
    fetch('/api/sessions?' + apiParams()).then(function(r) { return r.json(); }).then(function(sessions) {
      var container = document.getElementById('sessionList');
      clearChildren(container);
      document.getElementById('sessionCount').textContent = sessions.length;
      document.getElementById('statSessions').textContent = sessions.length;

      if (sessions.length === 0) {
        container.appendChild(el('div', { style: 'font-size:11px;color:#3A4A5C;padding:0 12px;' }, 'No sessions'));
        return;
      }

      sessions.forEach(function(s) {
        container.appendChild(buildSessionNode(s));
      });
    }).catch(function(e) {
      console.error('Fetch sessions failed:', e);
    });
  }

  function fetchProjects() {
    fetch('/api/projects').then(function(r) { return r.json(); }).then(function(projects) {
      var container = document.getElementById('projectList');
      clearChildren(container);
      var totalMsgs = 0;
      projects.forEach(function(p) { totalMsgs += p.message_count; });

      container.appendChild(buildProjectButton('all', totalMsgs, currentProject === 'all'));
      projects.forEach(function(p) {
        container.appendChild(buildProjectButton(p.project, p.message_count, currentProject === p.project));
      });

      document.getElementById('projectCount').textContent = projects.length;
      document.getElementById('statProjects').textContent = projects.length;
    }).catch(function(e) {
      console.error('Fetch projects failed:', e);
    });
  }

  // -- Tab switching --
  var currentTab = 'messages';
  function switchTab(tab) {
    currentTab = tab;
    document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
    document.querySelector('[data-tab="' + tab + '"]').classList.add('active');
    document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
    document.getElementById('panel-' + tab).classList.add('active');
    refreshAll();
  }
  window.switchTab = switchTab;
  
  function killDiscordBot() {
    fetch('/api/kill-discord', { method: 'POST' })
      .then(function(r) { return r.json(); })
      .then(function(res) {
        if (res.success) {
           alert("Discord mock bot killed successfully.");
           refreshAll();
        } else {
           alert("Failed to kill bot: " + res.message);
        }
      });
  }
  window.killDiscordBot = killDiscordBot;

  // -- v2 panel fetchers --

  function fetchCapabilities() {
    fetch('/api/capabilities').then(function(r) { return r.json(); }).then(function(caps) {
      var container = document.getElementById('panel-agents');
      clearChildren(container);
      document.getElementById('tabAgentCount').textContent = caps.length;
      if (caps.length === 0) {
        container.appendChild(el('div', { className: 'empty-state' }, [
          el('div', { className: 'empty-text' }, 'No agent capabilities registered')
        ]));
        return;
      }
      // Group by session
      var bySession = {};
      caps.forEach(function(c) {
        var key = c.session_name + '@' + c.platform;
        if (!bySession[key]) bySession[key] = { info: c, caps: [] };
        bySession[key].caps.push(c);
      });
      Object.keys(bySession).forEach(function(key) {
        var group = bySession[key];
        var info = group.info;
        var card = el('div', { className: 'card' });
        var headerChildren = [
          el('span', { className: 'card-title' }, info.session_name),
          el('span', { className: 'msg-platform ' + platformClass(info.platform) }, (info.platform || '?').toUpperCase()),
          el('span', { className: 'badge ' + (info.active ? 'badge-active' : 'badge-pending') }, info.active ? 'ONLINE' : 'OFFLINE')
        ];
        if (info.project) headerChildren.push(el('span', { className: 'msg-project' }, info.project));
        card.appendChild(el('div', { className: 'card-header' }, headerChildren));
        var skillList = group.caps.map(function(c) { return c.capability + ': ' + c.description; }).join('\n');
        card.appendChild(el('div', { className: 'card-body' }, skillList));
        container.appendChild(card);
      });
    }).catch(function(e) { console.error('Fetch capabilities failed:', e); });
  }

  function fetchTasks() {
    fetch('/api/tasks').then(function(r) { return r.json(); }).then(function(tasks) {
      var container = document.getElementById('panel-tasks');
      clearChildren(container);
      document.getElementById('tabTaskCount').textContent = tasks.length;
      if (tasks.length === 0) {
        container.appendChild(el('div', { className: 'empty-state' }, [
          el('div', { className: 'empty-text' }, 'No tasks')
        ]));
        return;
      }
      tasks.forEach(function(t) {
        var status = t.status || 'pending';
        var priority = t.priority || 'normal';
        var card = el('div', { className: 'card' });
        card.appendChild(el('div', { className: 'card-header' }, [
          el('span', { className: 'badge badge-' + status }, status),
          el('span', { className: 'badge badge-' + priority }, priority),
          el('span', { className: 'card-title' }, t.title || t.task_id || '?')
        ]));
        card.appendChild(el('div', { className: 'card-body' }, t.description || ''));
        var meta = [];
        if (t.assigned_to) meta.push('Assigned: ' + t.assigned_to);
        if (t.channel) meta.push('#' + t.channel);
        if (t.created) meta.push(formatTime(t.created));
        if (meta.length) card.appendChild(el('div', { className: 'card-meta' }, meta.join(' | ')));
        container.appendChild(card);
      });
    }).catch(function(e) { console.error('Fetch tasks failed:', e); });
  }

  function fetchWatches() {
    fetch('/api/watches').then(function(r) { return r.json(); }).then(function(watches) {
      var container = document.getElementById('panel-watches');
      clearChildren(container);
      document.getElementById('tabWatchCount').textContent = watches.length;
      if (watches.length === 0) {
        container.appendChild(el('div', { className: 'empty-state' }, [
          el('div', { className: 'empty-text' }, 'No active file watchers')
        ]));
        return;
      }
      watches.forEach(function(w) {
        var card = el('div', { className: 'card' });
        card.appendChild(el('div', { className: 'card-header' }, [
          el('span', { className: 'badge badge-active' }, 'WATCHING'),
          el('span', { className: 'card-title' }, w.pattern || '*'),
          el('span', { className: 'msg-channel' }, '#' + (w.channel || 'file-events'))
        ]));
        card.appendChild(el('div', { className: 'card-body' }, w.directory || ''));
        var meta = ['ID: ' + (w.watch_id || '?')];
        if (w.recursive) meta.push('recursive');
        if (w.project) meta.push(w.project);
        card.appendChild(el('div', { className: 'card-meta' }, meta.join(' | ')));
        container.appendChild(card);
      });
    }).catch(function(e) { console.error('Fetch watches failed:', e); });
  }

  function fetchMemory() {
    fetch('/api/memory').then(function(r) { return r.json(); }).then(function(entries) {
      var container = document.getElementById('panel-memory');
      clearChildren(container);
      document.getElementById('tabMemCount').textContent = entries.length;
      if (entries.length === 0) {
        container.appendChild(el('div', { className: 'empty-state' }, [
          el('div', { className: 'empty-text' }, 'No memory entries')
        ]));
        return;
      }
      entries.forEach(function(m) {
        var card = el('div', { className: 'card' });
        var ns = m.namespace || 'default';
        card.appendChild(el('div', { className: 'card-header' }, [
          el('span', { className: 'badge badge-active' }, ns),
          el('span', { className: 'card-title' }, m.key || '?')
        ]));
        var val = m.value_preview || (typeof m.value === 'string' ? m.value : JSON.stringify(m.value));
        card.appendChild(el('div', { className: 'card-body' }, String(val).substring(0, 300)));
        var meta = [];
        if (m.tags && m.tags.length) meta.push('tags: ' + m.tags.join(', '));
        if (m.updated) meta.push('updated: ' + formatTime(m.updated));
        if (m.expires) meta.push('expires: ' + formatTime(m.expires));
        if (meta.length) card.appendChild(el('div', { className: 'card-meta' }, meta.join(' | ')));
        container.appendChild(card);
      });
    }).catch(function(e) { console.error('Fetch memory failed:', e); });
  }

  function createCronJob() {
    var name = document.getElementById('cronName').value.trim();
    var channel = document.getElementById('cronChannel').value.trim();
    var interval = document.getElementById('cronInterval').value.trim();
    var msg = document.getElementById('cronMsg').value.trim();
    if (!name || !interval || !msg) { alert('Name, Interval, and Message are required.'); return; }
    
    fetch('/api/create-cron', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: name, channel: channel, cron_expr: interval, message: msg })
    }).then(function(r) { return r.json(); }).then(function(res) {
      if (res.error) alert(res.error);
      else {
        document.getElementById('cronName').value = '';
        document.getElementById('cronMsg').value = '';
        refreshAll();
      }
    });
  }
  window.createCronJob = createCronJob;

  function fetchCron() {
    fetch('/api/cron').then(function(r) { return r.json(); }).then(function(jobs) {
      var container = document.getElementById('cronListContainer');
      if (!container) return;
      clearChildren(container);
      var tabCount = document.getElementById('tabCronCount');
      if (tabCount) tabCount.textContent = jobs.length;
      if (jobs.length === 0) {
        container.appendChild(el('div', { className: 'empty-state' }, [
          el('div', { className: 'empty-text' }, 'No scheduled cron jobs')
        ]));
        return;
      }
      jobs.forEach(function(j) {
        var card = el('div', { className: 'card' });
        card.appendChild(el('div', { className: 'card-header' }, [
          el('span', { className: 'badge badge-active' }, 'SCHEDULED'),
          el('span', { className: 'card-title' }, j.name || j.job_id || '?'),
          el('span', { className: 'msg-channel' }, '#' + (j.channel || '?'))
        ]));
        card.appendChild(el('div', { className: 'card-body' }, j.message || ''));
        var meta = ['interval: ' + (j.cron_expr || j.interval_seconds || '?') + 's'];
        if (j.last_run) meta.push('last: ' + formatTime(j.last_run));
        if (j.project) meta.push(j.project);
        card.appendChild(el('div', { className: 'card-meta' }, meta.join(' | ')));
        container.appendChild(card);
      });
    }).catch(function(e) { console.error('Fetch cron failed:', e); });
  }

  function fetchSettings() {
    fetch('/api/settings').then(function(r) { return r.json(); }).then(function(platforms) {
      var container = document.getElementById('panel-settings');
      if (!container) return;
      clearChildren(container);
      var tabCount = document.getElementById('tabSetCount');
      if (tabCount) tabCount.textContent = platforms.length;
      
      container.appendChild(el('div', { style: 'padding:12px 16px;background:#0F1624;border:1px solid #1A2332;border-radius:8px;margin-bottom:12px;' }, [
        el('span', { style: 'font-size:13px;font-weight:600;color:#E0E6ED;' }, 'Connected AI Platforms')
      ]));

      if (platforms.length === 0) {
        container.appendChild(el('div', { className: 'empty-state' }, [
          el('div', { className: 'empty-text' }, 'No API keys configured in the Vault.')
        ]));
        return;
      }
      platforms.forEach(function(p) {
        var card = el('div', { className: 'card' });
        card.appendChild(el('div', { className: 'card-header' }, [
          el('span', { className: 'badge badge-active' }, 'CONNECTED'),
          el('span', { className: 'card-title' }, p.toUpperCase())
        ]));
        card.appendChild(el('div', { className: 'card-body' }, 'Securely connected and available for agent operations.'));
        container.appendChild(card);
      });
    }).catch(function(e) { console.error('Fetch settings failed:', e); });
  }

  function fetchCompute() {
    // Fetch grid stats
    fetch('/api/compute_stats').then(function(r) { return r.json(); }).then(function(s) {
      var e = document.getElementById;
      document.getElementById('csTotal').textContent = s.total_nodes || 0;
      document.getElementById('csIdle').textContent = s.idle_nodes || 0;
      document.getElementById('csBusy').textContent = s.busy_nodes || 0;
      document.getElementById('csCapacity').textContent = s.total_capacity || 0;
      document.getElementById('csJobs').textContent = s.jobs_completed || 0;
      document.getElementById('tabComputeCount').textContent = s.total_nodes || 0;
    }).catch(function(){});

    // Fetch nodes
    fetch('/api/compute_nodes').then(function(r) { return r.json(); }).then(function(nodes) {
      var container = document.getElementById('computeNodeList');
      if (!container) return;
      clearChildren(container);
      if (nodes.length === 0) {
        container.appendChild(el('div', { className: 'empty-state' }, [
          el('div', { className: 'empty-text' }, 'No compute nodes in pool. Agents can join via ComputeGrid.join().')
        ]));
        return;
      }
      nodes.forEach(function(n) {
        var statusClass = n.status === 'idle' ? 'badge-active' : n.status === 'busy' ? 'badge-warning' : 'badge-offline';
        var card = el('div', { className: 'card' });
        card.appendChild(el('div', { className: 'card-header' }, [
          el('span', { className: 'badge ' + statusClass }, n.status.toUpperCase()),
          el('span', { className: 'card-title' }, n.session_id || '?'),
          el('span', { style: 'font-size:11px;color:#8892A4;margin-left:auto;' }, (n.active_tasks || 0) + '/' + (n.max_concurrent || 4) + ' slots')
        ]));
        var caps = (n.capabilities || []).join(', ') || 'general';
        var stats = 'Completed: ' + (n.completed_tasks || 0) + ' | Failed: ' + (n.failed_tasks || 0);
        card.appendChild(el('div', { className: 'card-body' }, caps + '  ·  ' + stats));
        container.appendChild(card);
      });
    }).catch(function(e) { console.error('Fetch compute nodes failed:', e); });

    // Fetch jobs
    fetch('/api/compute_jobs').then(function(r) { return r.json(); }).then(function(jobs) {
      var container = document.getElementById('computeJobList');
      if (!container) return;
      clearChildren(container);
      if (jobs.length === 0) {
        container.appendChild(el('div', { className: 'empty-state' }, [
          el('div', { className: 'empty-text' }, 'No active MapReduce jobs.')
        ]));
        return;
      }
      jobs.forEach(function(j) {
        var statusClass = j.status === 'completed' ? 'badge-active' : j.status === 'failed' ? 'badge-offline' : 'badge-warning';
        var card = el('div', { className: 'card' });
        card.appendChild(el('div', { className: 'card-header' }, [
          el('span', { className: 'badge ' + statusClass }, j.status.toUpperCase()),
          el('span', { className: 'card-title' }, j.description || j.job_id || '?'),
          el('span', { style: 'font-size:11px;color:#8892A4;margin-left:auto;' }, j.mapped + '/' + j.chunk_count + ' mapped')
        ]));
        var progress = Math.round((j.mapped / Math.max(j.chunk_count, 1)) * 100);
        card.appendChild(el('div', { style: 'margin:6px 12px;background:#1A2332;border-radius:4px;height:6px;overflow:hidden;' }, [
          el('div', { style: 'background:#4A9EFF;height:100%;width:' + progress + '%;border-radius:4px;transition:width 0.3s;' })
        ]));
        card.appendChild(el('div', { className: 'card-body' }, 'Failed: ' + j.failed + ' | Created: ' + (j.created_at || '').slice(0, 19).replace('T', ' ')));
        container.appendChild(card);
      });
    }).catch(function(e) { console.error('Fetch compute jobs failed:', e); });
  }

  // -- Modal helpers --

  function openModal(id) {
    document.getElementById(id).classList.add('open');
    if (id === 'launchLocalModal') fetchBackends();
  }
  window.openModal = openModal;

  function closeModal(id) {
    document.getElementById(id).classList.remove('open');
  }
  window.closeModal = closeModal;

  function showToast(msg) {
    var toast = document.createElement('div');
    toast.className = 'modal-toast';
    toast.textContent = msg;
    document.body.appendChild(toast);
    setTimeout(function() { toast.remove(); }, 3500);
  }

  // Close modals on overlay click
  document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
    overlay.addEventListener('click', function(e) {
      if (e.target === overlay) overlay.classList.remove('open');
    });
  });

  // -- Project dropdown for Launch Agent --
  function loadProjectDropdown() {
    fetch('/api/projects').then(function(r) { return r.json(); }).then(function(projects) {
      var sel = document.getElementById('agentProject');
      if (!sel) return;
      while (sel.firstChild) sel.removeChild(sel.firstChild);
      if (projects.length === 0) {
        var empty = document.createElement('option');
        empty.value = '';
        empty.textContent = 'No projects registered';
        sel.appendChild(empty);
        return;
      }
      projects.forEach(function(p) {
        var opt = document.createElement('option');
        var projDir = p.broker_dir.replace(/[\\\/]state[\\\/]broker\/?$/, '');
        opt.value = projDir;
        opt.textContent = p.project + ' - ' + projDir;
        sel.appendChild(opt);
      });
      updateAgentCwd();
    }).catch(function() {});
  }
  window.loadProjectDropdown = loadProjectDropdown;

  function updateAgentCwd() {
    var sel = document.getElementById('agentProject');
    var cwd = document.getElementById('agentCwd');
    if (sel && cwd) cwd.value = sel.value;
  }
  window.updateAgentCwd = updateAgentCwd;

  // Load projects when Launch Agent modal opens
  var origOpenModal = window.openModal;
  window.openModal = function(id) {
    origOpenModal(id);
    if (id === 'launchAgentModal') loadProjectDropdown();
  };

  // -- Launch Agent --
  function launchAgent() {
    var name = document.getElementById('agentName').value.trim();
    var platform = document.getElementById('agentPlatform').value;
    var task = document.getElementById('agentTask').value.trim();
    var cwd = document.getElementById('agentCwd').value.trim();
    if (!name) { alert('Agent name is required.'); return; }
    if (!task) { alert('Task/instructions are required.'); return; }
    if (!cwd) { alert('Select a project first.'); return; }
    fetch('/api/launch-agent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: name, platform: platform, task: task, cwd: cwd })
    }).then(function(r) { return r.json(); }).then(function(d) {
      if (d.error) { alert('Error: ' + d.error); return; }
      closeModal('launchAgentModal');
      showToast('Agent ' + name + ' launched');
      document.getElementById('agentName').value = '';
      document.getElementById('agentTask').value = '';
      fetchSessions();
    }).catch(function(e) { alert('Launch failed: ' + e); });
  }
  window.launchAgent = launchAgent;

  // -- Launch Swarm --
  function launchSwarm() {
    var name = document.getElementById('swarmName').value.trim();
    var task = document.getElementById('swarmTask').value.trim();
    var claudeCount = parseInt(document.getElementById('swarmClaude').value) || 0;
    var geminiCount = parseInt(document.getElementById('swarmGemini').value) || 0;
    if (!name) { alert('Swarm name is required.'); return; }
    if (!task) { alert('Task description is required.'); return; }
    if (claudeCount + geminiCount < 1) { alert('At least 1 agent is required.'); return; }
    var platforms = [];
    for (var i = 0; i < claudeCount; i++) platforms.push('claude');
    for (var i = 0; i < geminiCount; i++) platforms.push('gemini');
    fetch('/api/launch-swarm', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: name, task: task, count: platforms.length, platforms: platforms })
    }).then(function(r) { return r.json(); }).then(function(d) {
      if (d.error) { alert('Error: ' + d.error); return; }
      closeModal('launchSwarmModal');
      showToast('Swarm ' + name + ' launched with ' + d.agents.length + ' agents');
      document.getElementById('swarmName').value = '';
      document.getElementById('swarmTask').value = '';
      fetchSessions();
    }).catch(function(e) { alert('Launch failed: ' + e); });
  }
  window.launchSwarm = launchSwarm;

  // -- Backend Detection + Local Agent Management --

  var cachedBackends = [];

  function fetchBackends() {
    var container = document.getElementById('backendStatus');
    if (!container) return;
    clearChildren(container);
    container.appendChild(el('div', { className: 'backend-row' }, [
      el('span', { className: 'backend-detail' }, 'Scanning backends...')
    ]));
    fetch('/api/backends').then(function(r) { return r.json(); }).then(function(backends) {
      cachedBackends = backends;
      clearChildren(container);
      if (!backends || backends.length === 0) {
        container.appendChild(el('div', { className: 'backend-row' }, [
          el('span', { className: 'backend-detail' }, 'No backends configured')
        ]));
        return;
      }
      backends.forEach(function(b) {
        var isUp = b.status === 'running' || b.status === 'configured';
        var detail = '';
        if (b.status === 'running') {
          detail = b.models.length + ' model' + (b.models.length !== 1 ? 's' : '');
        } else if (b.status === 'configured') {
          detail = 'API key set';
        } else if (b.status === 'not_configured') {
          detail = 'no API key';
        } else {
          detail = 'not detected';
        }
        var row = el('div', { className: 'backend-row' }, [
          el('span', { className: 'backend-dot ' + (isUp ? 'up' : 'down') }),
          el('span', { className: 'backend-name' }, b.name),
          el('span', { className: 'backend-detail' }, detail)
        ]);
        container.appendChild(row);
      });
      updateModelDropdown();
    }).catch(function(e) {
      clearChildren(container);
      container.appendChild(el('div', { className: 'backend-row' }, [
        el('span', { className: 'backend-detail' }, 'Backend scan failed')
      ]));
    });
  }

  function updateModelDropdown() {
    var sel = document.getElementById('localAdapter');
    var modelSelect = document.getElementById('localModelSelect');
    var modelInput = document.getElementById('localModel');
    if (!sel || !modelSelect || !modelInput) return;
    var chosen = sel.value;
    var found = null;
    for (var i = 0; i < cachedBackends.length; i++) {
      if (cachedBackends[i].name === chosen) { found = cachedBackends[i]; break; }
    }
    clearChildren(modelSelect);
    var hasModels = found && (found.status === 'running' || found.status === 'configured') && found.models && found.models.length > 0;
    if (hasModels) {
      modelSelect.style.display = '';
      modelInput.style.display = 'none';
      found.models.forEach(function(m) {
        modelSelect.appendChild(el('option', { value: m }, m));
      });
    } else {
      modelSelect.style.display = 'none';
      modelInput.style.display = '';
    }
  }

  // Wire adapter change to model dropdown
  (function() {
    var adapterSel = document.getElementById('localAdapter');
    if (adapterSel) adapterSel.addEventListener('change', updateModelDropdown);
  })();

  function buildLocalAgentNode(agent) {
    var alive = agent.status === 'running';
    var uptime = '';
    if (alive && agent.uptime) {
      var mins = Math.floor(agent.uptime / 60);
      var hrs = Math.floor(mins / 60);
      if (hrs > 0) uptime = hrs + 'h ' + (mins % 60) + 'm';
      else uptime = mins + 'm';
    }
    var metaText = agent.backend + '/' + agent.model + ' #' + agent.channel;
    if (uptime) metaText += ' | ' + uptime;
    metaText += ' | PID ' + agent.pid;

    var killBtn = alive ? el('button', {
      className: 'local-agent-kill',
      onClick: function() { killLocalAgent(agent.name); }
    }, 'KILL') : null;

    return el('div', { className: 'local-agent-item' }, [
      el('div', { className: 'local-agent-left' }, [
        el('div', { className: 'local-agent-dot ' + (alive ? 'alive' : 'dead') }),
        el('div', { className: 'local-agent-info' }, [
          el('div', { className: 'local-agent-name' }, agent.name),
          el('div', { className: 'local-agent-meta' }, metaText)
        ])
      ]),
      killBtn
    ]);
  }

  function fetchLocalAgents() {
    fetch('/api/local-agents').then(function(r) { return r.json(); }).then(function(agents) {
      var container = document.getElementById('localAgentList');
      if (!container) return;
      clearChildren(container);
      if (!agents || agents.length === 0) {
        container.appendChild(el('div', { style: 'font-size:11px;color:#3A4A5C;padding:0 12px;' }, 'No local agents'));
        return;
      }
      agents.forEach(function(a) {
        container.appendChild(buildLocalAgentNode(a));
      });
    }).catch(function(e) {
      console.error('Fetch local agents failed:', e);
    });
  }

  function spawnLocalAgent() {
    var name = document.getElementById('localName').value.trim();
    var backend = document.getElementById('localAdapter').value;
    var modelSelect = document.getElementById('localModelSelect');
    var modelInput = document.getElementById('localModel');
    var model = (modelSelect && modelSelect.style.display !== 'none')
      ? modelSelect.value
      : modelInput.value.trim();
    var channel = document.getElementById('localChannel').value.trim();
    if (!name) { alert('Agent name is required.'); return; }
    if (!model) { alert('Model name is required.'); return; }
    fetch('/api/spawn-agent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: name, backend: backend, model: model, channel: channel })
    }).then(function(r) { return r.json(); }).then(function(d) {
      if (d.error) { alert('Error: ' + d.error); return; }
      closeModal('launchLocalModal');
      showToast('Agent ' + name + ' spawned (background, PID ' + (d.pid || '?') + ')');
      document.getElementById('localName').value = '';
      fetchLocalAgents();
    }).catch(function(e) { alert('Spawn failed: ' + e); });
  }
  window.spawnLocalAgent = spawnLocalAgent;

  function killLocalAgent(name) {
    if (!confirm('Kill agent "' + name + '"?')) return;
    fetch('/api/kill-agent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: name })
    }).then(function(r) { return r.json(); }).then(function(d) {
      if (d.error) { alert('Error: ' + d.error); return; }
      showToast('Agent ' + name + ' killed');
      fetchLocalAgents();
    }).catch(function(e) { alert('Kill failed: ' + e); });
  }
  window.killLocalAgent = killLocalAgent;

  // -- Launch Local Agent (terminal mode -- original behavior) --
  function launchLocalAgent() {
    var name = document.getElementById('localName').value.trim();
    var adapter = document.getElementById('localAdapter').value;
    var modelSelect = document.getElementById('localModelSelect');
    var modelInput = document.getElementById('localModel');
    var model = (modelSelect && modelSelect.style.display !== 'none')
      ? modelSelect.value
      : modelInput.value.trim();
    var channel = document.getElementById('localChannel').value.trim();
    if (!name) { alert('Agent name is required.'); return; }
    if (!model) { alert('Model name is required.'); return; }
    fetch('/api/launch-local', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: name, adapter: adapter, model: model, channel: channel })
    }).then(function(r) { return r.json(); }).then(function(d) {
      if (d.error) { alert('Error: ' + d.error); return; }
      closeModal('launchLocalModal');
      showToast('Local Agent ' + name + ' launched (terminal)');
      document.getElementById('localName').value = '';
      fetchSessions();
      fetchLocalAgents();
    }).catch(function(e) { alert('Launch failed: ' + e); });
  }
  window.launchLocalAgent = launchLocalAgent;

  // -- Add Project --
  function addProject() {
    var name = document.getElementById('projName').value.trim();
    var path = document.getElementById('projPath').value.trim();
    if (!name) { alert('Project name is required.'); return; }
    if (!path) { alert('Project path is required.'); return; }
    fetch('/api/add-project', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: name, path: path })
    }).then(function(r) { return r.json(); }).then(function(d) {
      if (d.error) { alert('Error: ' + d.error); return; }
      closeModal('addProjectModal');
      showToast('Project ' + name + ' added!');
      document.getElementById('projName').value = '';
      document.getElementById('projPath').value = '';
      fetchProjects();
      fetchChannels();
    }).catch(function(e) { alert('Failed: ' + e); });
  }
  window.addProject = addProject;

  function refreshAll() {
    fetchProjects();
    fetchMessages();
    fetchChannels();
    fetchSessions();
    fetchLocalAgents();
    // Only fetch active tab's v2 data to reduce load
    if (currentTab === 'agents') fetchCapabilities();
    else if (currentTab === 'tasks') fetchTasks();
    else if (currentTab === 'watches') fetchWatches();
    else if (currentTab === 'memory') fetchMemory();
    else if (currentTab === 'cron') fetchCron();
    else if (currentTab === 'compute') fetchCompute();
    else if (currentTab === 'settings') fetchSettings();
    // Always fetch counts for tab badges
    fetch('/api/capabilities').then(function(r) { return r.json(); }).then(function(d) { document.getElementById('tabAgentCount').textContent = d.length; }).catch(function(){});
    fetch('/api/tasks').then(function(r) { return r.json(); }).then(function(d) { document.getElementById('tabTaskCount').textContent = d.length; }).catch(function(){});
    fetch('/api/watches').then(function(r) { return r.json(); }).then(function(d) { document.getElementById('tabWatchCount').textContent = d.length; }).catch(function(){});
    fetch('/api/memory').then(function(r) { return r.json(); }).then(function(d) { document.getElementById('tabMemCount').textContent = d.length; }).catch(function(){});
    fetch('/api/cron').then(function(r) { return r.json(); }).then(function(d) { document.getElementById('tabCronCount').textContent = d.length; }).catch(function(){});
    fetch('/api/compute_nodes').then(function(r) { return r.json(); }).then(function(d) { document.getElementById('tabComputeCount').textContent = d.length; }).catch(function(){});
    fetch('/api/settings').then(function(r) { return r.json(); }).then(function(d) { document.getElementById('tabSetCount').textContent = d.length; }).catch(function(){});
    document.getElementById('statRefresh').textContent = new Date().toLocaleTimeString('en-US', { hour12: false });
  }

  // -- Interactive: send messages and create tasks --

  function sendMessage() {
    var input = document.getElementById('composeMsg');
    var msg = input.value.trim();
    if (!msg) return;
    var channel = document.getElementById('composeChannel').value.trim() || 'general';
    var senderVal = document.getElementById('composeSender').value.split('|');
    var isHuman = senderVal[1] === 'human';

    // Parse @mentions for targeted routing (e.g. @claude, @gemini)
    var mentions = [];
    var mentionRegex = /@(claude|gemini|cursor|codex|all)/gi;
    var match;
    while ((match = mentionRegex.exec(msg)) !== null) {
      mentions.push(match[1].toLowerCase());
    }

    // Build payload with mention routing metadata
    var payload = {
      message: msg,
      channel: channel,
      sender: senderVal[0],
      platform: senderVal[1],
      msg_type: isHuman ? 'request' : 'info'
    };
    if (mentions.length > 0 && mentions.indexOf('all') === -1) {
      payload.mentions = mentions;
    }

    fetch('/api/post', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    }).then(function(r) { return r.json(); }).then(function() {
      input.value = '';
      fetchMessages();
    }).catch(function(e) { console.error('Send failed:', e); });
  }
  window.sendMessage = sendMessage;

  function createTask() {
    var title = document.getElementById('taskTitle').value.trim();
    if (!title) return;
    var assign = document.getElementById('taskAssign').value.trim();
    var priority = document.getElementById('taskPriority').value;
    var capability = document.getElementById('taskCapability').value.trim();
    
    // Support assigning to multiple agents separated by commas
    var assignees = assign ? assign.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [""];
    
    var promises = assignees.map(function(assigned_to) {
        return fetch('/api/create-task', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ 
              title: title, 
              description: '', 
              channel: 'tasks', 
              priority: priority, 
              assigned_to: assigned_to, 
              required_capability: capability 
          })
        }).then(function(r) { return r.json(); });
    });
    
    Promise.all(promises).then(function() {
      document.getElementById('taskTitle').value = '';
      document.getElementById('taskAssign').value = '';
      document.getElementById('taskCapability').value = '';
      fetchTasks();
    }).catch(function(e) { console.error('Create task failed:', e); });
  }
  window.createTask = createTask;

  // Initial load + auto-refresh every 5 seconds
  refreshAll();
  setInterval(refreshAll, 5000);
})();
</script>
</body>
</html>
"""


# ── Multi-Broker Helpers ─────────────────────────────────────────────────


def _get_all_broker_dirs() -> list[dict]:
    """Get all broker directories from the registry, with fallback to local."""
    brokers = get_registered_brokers()
    if not brokers:
        # Fallback: use the current project's broker dir
        return [{"project": "local", "broker_dir": str(BROKER_DIR)}]
    return brokers


def _read_jsonl_file(path: Path) -> list[dict]:
    """Read a JSONL file and return list of dicts, skipping bad lines."""
    if not path.exists():
        return []
    entries: list[dict] = []
    try:
        for line in path.read_text("utf-8").strip().splitlines():
            try:
                entries.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    except OSError:
        pass
    return entries


# ── HTTP Handler ──────────────────────────────────────────────────────────


class DashboardHandler(http.server.BaseHTTPRequestHandler):
    """Serves the multi-broker dashboard and JSON API endpoints."""

    # Hide Python/server version from response headers
    server_version = "brynq"
    sys_version = ""

    # Class-level auth config (set by run_dashboard before serving)
    _auth_enabled: bool = False
    _auth_token: str = ""

    def log_message(self, fmt, *args):
        pass

    def _check_auth(self) -> bool:
        """Validate session token if auth is enabled. Returns True if OK."""
        if not self._auth_enabled:
            return True
        # Check query parameter ?token=xxx
        parsed = urlparse(self.path)
        params = parse_qs(parsed.query)
        token_param = params.get("token", [None])[0]
        if token_param == self._auth_token:
            return True
        # Check cookie
        cookies = self.headers.get("Cookie", "")
        for part in cookies.split(";"):
            part = part.strip()
            if part.startswith("brynq_token="):
                if part[len("brynq_token="):] == self._auth_token:
                    return True
        return False

    def _send_unauthorized(self) -> None:
        body = b'{"error": "unauthorized", "message": "Valid token required. Pass ?token=xxx or set brynq_token cookie."}'
        self.send_response(401)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self._send_security_headers()
        self.end_headers()
        self.wfile.write(body)

    def _send_security_headers(self) -> None:
        """Add security headers to every response."""
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("X-Frame-Options", "DENY")
        self.send_header("Cache-Control", "no-store")

    def _send_json(self, data: list | dict, 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._send_security_headers()
        self.end_headers()
        self.wfile.write(body)

    def _send_html(self, html: str, status: int = 200) -> None:
        body = html.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self._send_security_headers()
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self) -> None:
        if not self._check_auth():
            self._send_unauthorized()
            return
        parsed = urlparse(self.path)
        ppath = parsed.path.rstrip("/")
        length = int(self.headers.get("Content-Length", 0))
        try:
            body = json.loads(self.rfile.read(length)) if length else {}
        except json.JSONDecodeError:
            body = {}
            
        if ppath == "/api/post":
            self._post_message(body)
        elif ppath == "/api/create-task":
            self._post_create_task(body)
        elif ppath == "/api/create-cron":
            self._post_create_cron(body)
        elif ppath == "/api/kill-discord":
            self._handle_kill_discord()
        elif ppath == "/api/launch-agent":
            self._handle_launch_agent(body)
        elif ppath == "/api/launch-swarm":
            self._handle_launch_swarm(body)
        elif ppath == "/api/launch-local":
            self._handle_launch_local(body)
        elif ppath == "/api/add-project":
            self._handle_add_project(body)
        elif ppath == "/api/spawn-agent":
            self._handle_spawn_agent(body)
        elif ppath == "/api/spawn-swarm":
            self._handle_spawn_swarm(body)
        elif ppath == "/api/kill-agent":
            self._handle_kill_agent(body)
        else:
            self.send_error(404, "Not found")

    def _post_create_cron(self, body: dict) -> None:
        import uuid as _uuid
        name = body.get("name", "")
        channel = body.get("channel", "general")
        cron_expr = body.get("cron_expr", "300")
        message = body.get("message", "")
        if not name or not message:
            self._send_json({"error": "name and message required"}, 400)
            return
        try:
            interval = int(cron_expr)
        except ValueError:
            self._send_json({"error": "cron_expr must be integer seconds"}, 400)
            return
            
        brokers = _get_all_broker_dirs()
        if not brokers:
            self._send_json({"error": "no brokers"}, 500)
            return
        broker_dir = Path(brokers[0]["broker_dir"])
        cron_dir = broker_dir / "scheduler"
        cron_dir.mkdir(parents=True, exist_ok=True)
        job_id = _uuid.uuid4().hex[:12]
        
        job = {
            "job_id": job_id,
            "name": name,
            "channel": channel,
            "interval_secs": interval,
            "message": message,
            "msg_type": "request",
            "session_id": "dashboard",
            "session_name": "dashboard-user",
            "platform": "dashboard",
            "created_at": datetime.now(timezone.utc).isoformat(),
            "last_run": None,
            "active": True
        }
        
        path = cron_dir / f"{job_id}.json"
        path.write_text(json.dumps(job, indent=2))
        self._send_json({"success": True, "job_id": job_id})

    def _post_message(self, body: dict) -> None:
        import uuid as _uuid
        channel = body.get("channel", "general")
        message = body.get("message", "")
        sender = body.get("sender", "dashboard-user")
        platform = body.get("platform", "dashboard")
        mentions = body.get("mentions", [])
        if not message:
            self._send_json({"error": "message required"}, 400)
            return
        brokers = _get_all_broker_dirs()
        if not brokers:
            self._send_json({"error": "no brokers"}, 500)
            return
        channels_dir = Path(brokers[0]["broker_dir"]) / "channels"
        channels_dir.mkdir(parents=True, exist_ok=True)
        is_human = platform in ("human", "dashboard")
        entry = {
            "id": _uuid.uuid4().hex[:12],
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "session_id": "dashboard",
            "session_name": sender,
            "platform": platform,
            "channel": channel,
            "msg_type": body.get("msg_type", "request" if is_human else "info"),
            "message": message,
        }
        if mentions:
            entry["mentions"] = mentions
        ch_path = channels_dir / f"{channel}.jsonl"
        with ch_path.open("a", encoding="utf-8") as f:
            f.write(json.dumps(entry, default=str) + "\n")
        # When a human posts, notify all agents via #_system
        if is_human:
            target = ""
            if mentions:
                target = " addressed to @" + ", @".join(mentions)
            notify = {
                "id": _uuid.uuid4().hex[:12],
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "session_id": "dashboard",
                "session_name": "brynq-system",
                "platform": "system",
                "channel": "_system",
                "msg_type": "alert",
                "message": f"HUMAN MESSAGE in #{channel}{target}: {message[:200]}",
            }
            sys_path = channels_dir / "_system.jsonl"
            with sys_path.open("a", encoding="utf-8") as f:
                f.write(json.dumps(notify, default=str) + "\n")
        self._send_json({"ok": True, "id": entry["id"]})

    def _post_create_task(self, body: dict) -> None:
        import uuid as _uuid
        title = body.get("title", "")
        description = body.get("description", "")
        channel = body.get("channel", "tasks")
        priority = body.get("priority", "normal")
        assigned_to = body.get("assigned_to", "")
        req_cap = body.get("required_capability", "")
        if not title:
            self._send_json({"error": "title required"}, 400)
            return
        brokers = _get_all_broker_dirs()
        if not brokers:
            self._send_json({"error": "no brokers"}, 500)
            return
            
        # Auto-dispatch: find an agent, or launch one, or escalate
        if req_cap and not assigned_to:
            try:
                from brynq.auto_dispatch import create_and_dispatch
                dispatch_result = create_and_dispatch(
                    title=title,
                    description=description or title,
                    required_skills=[s.strip() for s in req_cap.split(",") if s.strip()],
                    priority=priority,
                    channel=channel,
                )
                action = dispatch_result.get("action", "")
                if action in ("delegated", "launched_and_delegated"):
                    assigned_to = dispatch_result.get("agent_id", "")
                    if assigned_to:
                        title = f"[@{assigned_to}] {title}"
                    # Task already created by autodispatch — return early
                    self._send_json({"ok": True, "task_id": dispatch_result.get("task_id", ""), "autodispatch": action})
                    return
            except Exception:
                # Fallback to basic capability routing
                try:
                    from brynq.capability import route_task
                    target_agent = route_task(title, req_cap)
                    if target_agent:
                        assigned_to = target_agent
                        title = f"[@{target_agent}] {title}"
                except Exception:
                    pass
                
        broker_dir = Path(brokers[0]["broker_dir"])
        tasks_dir = broker_dir / "tasks"
        tasks_dir.mkdir(parents=True, exist_ok=True)
        channels_dir = broker_dir / "channels"
        task_id = _uuid.uuid4().hex[:12]
        task = {
            "task_id": task_id, "title": title, "description": description,
            "channel": channel, "priority": priority, "status": "pending",
            "assigned_to": assigned_to or None,
            "created": datetime.now(timezone.utc).isoformat(),
            "created_by": "dashboard-user",
            "required_capability": req_cap or None
        }
        (tasks_dir / f"{task_id}.json").write_text(json.dumps(task, indent=2, default=str))
        assign_text = f" -> {assigned_to}" if assigned_to else ""
        if req_cap and not assigned_to:
             assign_text = f" (routing for capability: {req_cap})"
        entry = {
            "id": _uuid.uuid4().hex[:12],
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "session_id": "dashboard", "session_name": "dashboard-user",
            "platform": "dashboard", "channel": channel, "msg_type": "request",
            "message": f"[TASK:{task_id}] [{priority.upper()}] {title}{assign_text}\n{description}",
        }
        ch_path = channels_dir / f"{channel}.jsonl"
        with ch_path.open("a", encoding="utf-8") as f:
            f.write(json.dumps(entry, default=str) + "\n")
        self._send_json({"ok": True, "task_id": task_id})

    def do_GET(self) -> None:
        if not self._check_auth():
            self._send_unauthorized()
            return
        parsed = urlparse(self.path)
        path = parsed.path.rstrip("/") or "/"
        params = parse_qs(parsed.query)

        if path == "/":
            html = DASHBOARD_HTML
            try:
                from brynq.enterprise_integration import patch_dashboard_html
                html = patch_dashboard_html(html)
            except Exception:
                pass  # Serve base dashboard if enterprise patch fails
            self._send_html(html)
        elif path == "/api/projects":
            self._handle_projects()
        elif path == "/api/messages":
            self._handle_messages(params)
        elif path == "/api/sessions":
            self._handle_sessions(params)
        elif path == "/api/channels":
            self._handle_channels(params)
        elif path == "/api/projects":
            self._send_json(_get_all_broker_dirs())
        elif path == "/api/stats":
            self._handle_stats()
        elif path == "/api/capabilities":
            self._handle_capabilities()
        elif path == "/api/tasks":
            self._handle_tasks(params)
        elif path == "/api/watches":
            self._handle_watches()
        elif path == "/api/memory":
            self._handle_memory(params)
        elif path == "/api/cron":
            self._handle_cron()
        elif path == "/api/settings":
            self._handle_settings()
        elif path == "/api/compute_nodes":
            self._handle_compute_nodes()
        elif path == "/api/compute_jobs":
            self._handle_compute_jobs()
        elif path == "/api/compute_stats":
            self._handle_compute_stats()
        elif path == "/api/local-agents":
            self._handle_local_agents()
        elif path == "/api/backends":
            self._handle_backends()
        else:
            self.send_error(404, "Not found")

    # ── API Endpoints ─────────────────────────────────────────────────

    def _handle_projects(self) -> None:
        """List all registered projects with message counts."""
        projects = []
        for broker in _get_all_broker_dirs():
            broker_dir = Path(broker["broker_dir"])
            channels_dir = broker_dir / "channels"
            sessions_dir = broker_dir / "sessions"
            msg_count = 0
            session_count = 0
            if channels_dir.exists():
                for f in channels_dir.glob("*.jsonl"):
                    try:
                        msg_count += len(f.read_text("utf-8").strip().splitlines())
                    except OSError:
                        continue
            if sessions_dir.exists():
                session_count = len(list(sessions_dir.glob("*.json")))
            projects.append({
                "project": broker.get("project", "unknown"),
                "broker_dir": broker["broker_dir"],
                "message_count": msg_count,
                "session_count": session_count,
            })
        self._send_json(projects)

    def _handle_messages(self, params: dict) -> None:
        channel = params.get("channel", ["all"])[0]
        project_filter = params.get("project", [None])[0]
        limit = int(params.get("limit", ["100"])[0])

        messages: list[dict] = []
        for broker in _get_all_broker_dirs():
            if project_filter and broker.get("project") != project_filter:
                continue
            channels_dir = Path(broker["broker_dir"]) / "channels"
            if not channels_dir.exists():
                continue
            proj = broker.get("project", "unknown")
            if channel == "all":
                for f in sorted(channels_dir.glob("*.jsonl")):
                    for msg in _read_jsonl_file(f):
                        msg["project"] = proj
                        messages.append(msg)
            else:
                path = channels_dir / f"{channel}.jsonl"
                for msg in _read_jsonl_file(path):
                    msg["project"] = proj
                    messages.append(msg)

        messages.sort(key=lambda m: m.get("timestamp", ""))
        messages = messages[-limit:]
        self._send_json(messages)

    def _handle_sessions(self, params: dict) -> None:
        project_filter = params.get("project", [None])[0]
        sessions: list[dict] = []
        now = datetime.now(timezone.utc)

        for broker in _get_all_broker_dirs():
            if project_filter and broker.get("project") != project_filter:
                continue
            sessions_dir = Path(broker["broker_dir"]) / "sessions"
            if not sessions_dir.exists():
                continue
            proj = broker.get("project", "unknown")
            for f in sorted(sessions_dir.glob("*.json")):
                try:
                    data = json.loads(f.read_text("utf-8"))
                except (json.JSONDecodeError, OSError):
                    continue

                age_seconds = 9999.0
                try:
                    hb = datetime.fromisoformat(data.get("heartbeat", ""))
                    age_seconds = (now - hb).total_seconds()
                except (ValueError, TypeError):
                    pass

                sid = data.get("session_id", "?")
                avatar = "\U0001F916"
                try:
                    assigned_path = Path(broker["broker_dir"]) / "personalities" / "assigned" / f"{sid}.json"
                    if assigned_path.exists():
                        p_data = json.loads(assigned_path.read_text("utf-8"))
                        avatar = p_data.get("personality", {}).get("avatar", "\U0001F916")
                except Exception:
                    pass

                sessions.append({
                    "session_id": sid,
                    "name": data.get("name", "unknown"),
                    "platform": data.get("platform", "unknown"),
                    "pid": data.get("pid", "?"),
                    "heartbeat": data.get("heartbeat", ""),
                    "started": data.get("started", ""),
                    "age_seconds": round(age_seconds),
                    "project": proj,
                    "avatar": avatar,
                })

        self._send_json(sessions)

    def _handle_channels(self, params: dict) -> None:
        project_filter = params.get("project", [None])[0]
        channels: list[dict] = []

        for broker in _get_all_broker_dirs():
            if project_filter and broker.get("project") != project_filter:
                continue
            channels_dir = Path(broker["broker_dir"]) / "channels"
            if not channels_dir.exists():
                continue
            proj = broker.get("project", "unknown")
            for f in sorted(channels_dir.glob("*.jsonl")):
                try:
                    lines = f.read_text("utf-8").strip().splitlines()
                except OSError:
                    continue

                count = len(lines)
                last_activity = ""
                last_sender = ""
                if lines:
                    try:
                        last_msg = json.loads(lines[-1])
                        last_activity = last_msg.get("timestamp", "")
                        last_sender = last_msg.get("session_name", "")
                    except json.JSONDecodeError:
                        pass

                channels.append({
                    "name": f.stem,
                    "count": count,
                    "last_activity": last_activity,
                    "last_sender": last_sender,
                    "project": proj,
                })

        self._send_json(channels)

    def _handle_stats(self) -> None:
        total_messages = 0
        channel_count = 0
        session_count = 0
        active_sessions = 0
        project_count = 0
        now = datetime.now(timezone.utc)

        for broker in _get_all_broker_dirs():
            project_count += 1
            channels_dir = Path(broker["broker_dir"]) / "channels"
            sessions_dir = Path(broker["broker_dir"]) / "sessions"

            if channels_dir.exists():
                for f in channels_dir.glob("*.jsonl"):
                    try:
                        total_messages += len(f.read_text("utf-8").strip().splitlines())
                        channel_count += 1
                    except OSError:
                        continue

            if sessions_dir.exists():
                for f in sessions_dir.glob("*.json"):
                    session_count += 1
                    try:
                        data = json.loads(f.read_text("utf-8"))
                        hb = datetime.fromisoformat(data.get("heartbeat", ""))
                        if (now - hb).total_seconds() < 300:
                            active_sessions += 1
                    except (json.JSONDecodeError, ValueError, TypeError, OSError):
                        continue

        self._send_json({
            "total_messages": total_messages,
            "channel_count": channel_count,
            "session_count": session_count,
            "active_sessions": active_sessions,
            "project_count": project_count,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        })


    # ── v2 API Endpoints ──────────────────────────────────────────────

    def _handle_capabilities(self) -> None:
        """List all registered capabilities across all brokers."""
        caps = []
        now = datetime.now(timezone.utc)
        for broker in _get_all_broker_dirs():
            cap_dir = Path(broker["broker_dir"]) / "capabilities"
            sessions_dir = Path(broker["broker_dir"]) / "sessions"
            if not cap_dir.exists():
                continue
            proj = broker.get("project", "unknown")
            active_sids = set()
            if sessions_dir.exists():
                for sf in sessions_dir.glob("*.json"):
                    active_sids.add(sf.stem)
            for f in sorted(cap_dir.glob("*.json")):
                if f.name.startswith("_"):
                    continue
                try:
                    data = json.loads(f.read_text("utf-8"))
                    data["project"] = proj
                    data["active"] = data.get("session_id", "") in active_sids
                    caps.append(data)
                except (json.JSONDecodeError, OSError):
                    continue
        self._send_json(caps)

    def _handle_tasks(self, params: dict) -> None:
        """List tasks from the tasks directory across all brokers."""
        tasks = []
        status_filter = params.get("status", [None])[0]
        for broker in _get_all_broker_dirs():
            tasks_dir = Path(broker["broker_dir"]) / "tasks"
            if not tasks_dir.exists():
                continue
            proj = broker.get("project", "unknown")
            for f in sorted(tasks_dir.glob("*.json")):
                try:
                    data = json.loads(f.read_text("utf-8"))
                    if status_filter and data.get("status") != status_filter:
                        continue
                    data["project"] = proj
                    tasks.append(data)
                except (json.JSONDecodeError, OSError):
                    continue
        tasks.sort(key=lambda t: t.get("created", ""), reverse=True)
        self._send_json(tasks)

    def _handle_watches(self) -> None:
        """List active file watchers across all brokers."""
        watches = []
        for broker in _get_all_broker_dirs():
            watches_dir = Path(broker["broker_dir"]) / "watches"
            if not watches_dir.exists():
                continue
            proj = broker.get("project", "unknown")
            for f in sorted(watches_dir.glob("*.json")):
                if f.name.startswith("."):
                    continue
                try:
                    data = json.loads(f.read_text("utf-8"))
                    if data.get("active", True):
                        data["project"] = proj
                        watches.append(data)
                except (json.JSONDecodeError, OSError):
                    continue
        self._send_json(watches)

    def _handle_memory(self, params: dict) -> None:
        """List memory entries across all brokers."""
        entries = []
        namespace_filter = params.get("namespace", [None])[0]
        for broker in _get_all_broker_dirs():
            mem_dir = Path(broker["broker_dir"]) / "memory"
            if not mem_dir.exists():
                continue
            proj = broker.get("project", "unknown")
            for session_dir in sorted(mem_dir.iterdir()):
                if not session_dir.is_dir():
                    continue
                for f in sorted(session_dir.glob("*.json")):
                    if f.name == "_index.json":
                        continue
                    try:
                        data = json.loads(f.read_text("utf-8"))
                        if namespace_filter and data.get("namespace") != namespace_filter:
                            continue
                        data["project"] = proj
                        # Truncate large values for the API
                        val = data.get("value", "")
                        if isinstance(val, str) and len(val) > 200:
                            data["value"] = val[:200] + "..."
                        elif isinstance(val, (dict, list)):
                            s = json.dumps(val, default=str)
                            if len(s) > 200:
                                data["value_preview"] = s[:200] + "..."
                        entries.append(data)
                    except (json.JSONDecodeError, OSError):
                        continue
        self._send_json(entries)

    def _handle_cron(self) -> None:
        """List scheduled cron jobs across all brokers."""
        jobs = []
        for broker in _get_all_broker_dirs():
            cron_dir = Path(broker["broker_dir"]) / "scheduler"
            if not cron_dir.exists():
                continue
            proj = broker.get("project", "unknown")
            for f in sorted(cron_dir.glob("*.json")):
                try:
                    data = json.loads(f.read_text("utf-8"))
                    data["project"] = proj
                    jobs.append(data)
                except (json.JSONDecodeError, OSError):
                    continue
        self._send_json(jobs)

    def _handle_compute_nodes(self) -> None:
        try:
            from brynq.compute_grid import ComputeGrid
            nodes = ComputeGrid.list_nodes()
            self._send_json(nodes)
        except Exception:
            self._send_json([])

    def _handle_compute_jobs(self) -> None:
        try:
            from brynq.compute_grid import list_jobs
            jobs = list_jobs()
            self._send_json(jobs)
        except Exception:
            self._send_json([])

    def _handle_compute_stats(self) -> None:
        try:
            from brynq.compute_grid import ComputeGrid
            stats = ComputeGrid.stats()
            self._send_json(stats)
        except Exception:
            self._send_json({"total_nodes": 0, "idle_nodes": 0, "busy_nodes": 0, "total_capacity": 0, "active_tasks": 0, "available_capacity": 0, "jobs_completed": 0})

    def _handle_settings(self) -> None:
        """List configured platform credentials from the Vault."""
        try:
            from brynq.vault import vault_list
            platforms = vault_list()
            self._send_json(platforms)
        except ImportError:
            self._send_json([])

    # ── Local Agent / Backend API Endpoints ─────────────────────────

    def _handle_local_agents(self) -> None:
        """GET /api/local-agents — list running local LLM agents."""
        if not _HAS_SPAWNER:
            self._send_json([])
            return
        try:
            agents = _spawner_list_agents()
            self._send_json(agents)
        except Exception as e:
            self._send_json({"error": str(e)}, 500)

    def _handle_backends(self) -> None:
        """GET /api/backends — list available LLM backends with models."""
        if not _HAS_LLM_ROUTER:
            self._send_json([])
            return
        try:
            backends = _discover_backends()
            self._send_json(backends)
        except Exception as e:
            self._send_json({"error": str(e)}, 500)

    def _handle_spawn_agent(self, body: dict) -> None:
        """POST /api/spawn-agent — spawn a background local LLM agent."""
        if not _HAS_SPAWNER:
            self._send_json({"error": "spawner module not available"}, 501)
            return
        name = body.get("name", "").strip()
        backend = body.get("backend", "ollama").strip()
        model = body.get("model", "").strip()
        channel = body.get("channel", "general").strip()
        if not name:
            self._send_json({"error": "name is required"}, 400)
            return
        if not model:
            self._send_json({"error": "model is required"}, 400)
            return
        try:
            result = _spawner_spawn_agent(name=name, backend=backend, model=model, channel=channel)
            self._send_json(result)
        except (RuntimeError, ValueError) as e:
            self._send_json({"error": str(e)}, 409)
        except Exception as e:
            self._send_json({"error": str(e)}, 500)

    def _handle_spawn_swarm(self, body: dict) -> None:
        """POST /api/spawn-swarm — spawn a swarm of background local LLM agents."""
        if not _HAS_SPAWNER:
            self._send_json({"error": "spawner module not available"}, 501)
            return
        name = body.get("name", "").strip()
        task = body.get("task", "").strip()
        agents = body.get("agents", [])
        if not name:
            self._send_json({"error": "name is required"}, 400)
            return
        if not task:
            self._send_json({"error": "task is required"}, 400)
            return
        if not agents or not isinstance(agents, list):
            self._send_json({"error": "agents list is required"}, 400)
            return
        try:
            result = _spawner_spawn_swarm(swarm_name=name, task=task, agents=agents)
            self._send_json(result)
        except Exception as e:
            self._send_json({"error": str(e)}, 500)

    def _handle_kill_agent(self, body: dict) -> None:
        """POST /api/kill-agent — kill a running local LLM agent."""
        if not _HAS_SPAWNER:
            self._send_json({"error": "spawner module not available"}, 501)
            return
        name = body.get("name", "").strip()
        if not name:
            self._send_json({"error": "name is required"}, 400)
            return
        try:
            killed = _spawner_kill_agent(name)
            self._send_json({"name": name, "killed": killed})
        except Exception as e:
            self._send_json({"error": str(e)}, 500)

    def _handle_kill_discord(self) -> None:
        try:
            cmd = "Get-CimInstance Win32_Process -Filter \"Name = 'python.exe' OR Name = 'pwsh.exe' OR Name = 'powershell.exe'\" | Where-Object {$_.CommandLine -match 'discord_bot'} | Invoke-CimMethod -MethodName Terminate"
            subprocess.run(["powershell", "-Command", cmd], check=False)
            self._send_json({"success": True, "message": "Bot killed"})
        except Exception as e:
            self._send_json({"success": False, "message": str(e)})

    # ── Launch Agent / Swarm ─────────────────────────────────────────

    def _handle_launch_agent(self, body: dict) -> None:
        """Launch a single AI agent in a new terminal window."""
        import uuid as _uuid
        name = body.get("name", "").strip()
        platform = body.get("platform", "claude").strip().lower()
        task = body.get("task", "").strip()
        cwd = body.get("cwd", "").strip()

        if not name:
            self._send_json({"error": "name is required"}, 400)
            return
        if not task:
            self._send_json({"error": "task is required"}, 400)
            return
        if platform not in ("claude", "gemini"):
            self._send_json({"error": "platform must be claude or gemini"}, 400)
            return
        if not cwd:
            cwd = os.getcwd()

        bat_path = _create_agent_bat(name, platform, task, cwd)

        # Launch in a new console window
        try:
            CREATE_NEW_CONSOLE = 0x00000010
            subprocess.Popen(
                ["cmd", "/c", "start", "", str(bat_path)],
                creationflags=CREATE_NEW_CONSOLE,
            )
        except Exception as e:
            self._send_json({"error": f"Failed to launch process: {e}"}, 500)
            return

        # Post launch notification to broker channel
        brokers = _get_all_broker_dirs()
        if brokers:
            channels_dir = Path(brokers[0]["broker_dir"]) / "channels"
            channels_dir.mkdir(parents=True, exist_ok=True)
            entry = {
                "id": _uuid.uuid4().hex[:12],
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "session_id": "dashboard",
                "session_name": "brynq-launcher",
                "platform": "dashboard",
                "channel": "general",
                "msg_type": "status",
                "message": f"{name}@{platform} launched with task: {task[:200]}",
            }
            ch_path = channels_dir / "general.jsonl"
            with ch_path.open("a", encoding="utf-8") as f:
                f.write(json.dumps(entry, default=str) + "\n")

        self._send_json({
            "status": "launched",
            "name": name,
            "bat_path": str(bat_path),
        })

    def _handle_launch_swarm(self, body: dict) -> None:
        """Launch multiple AI agents as a coordinated swarm."""
        import uuid as _uuid
        name = body.get("name", "").strip()
        task = body.get("task", "").strip()
        count = body.get("count", 0)
        platforms = body.get("platforms", [])

        if not name:
            self._send_json({"error": "name is required"}, 400)
            return
        if not task:
            self._send_json({"error": "task is required"}, 400)
            return
        if not platforms or len(platforms) < 1:
            self._send_json({"error": "at least 1 platform required"}, 400)
            return
        if len(platforms) > 10:
            self._send_json({"error": "max 10 agents per swarm"}, 400)
            return

        cwd = body.get("cwd", os.getcwd())
        swarm_channel = f"swarm-{name}"
        agent_names = []

        CREATE_NEW_CONSOLE = 0x00000010
        for i, plat in enumerate(platforms):
            plat = plat.strip().lower()
            if plat not in ("claude", "gemini"):
                plat = "claude"
            agent_name = f"{name}-{plat}-{i+1}"
            agent_task = (
                f"You are part of swarm '{name}' (agent {i+1}/{len(platforms)}). "
                f"Coordinate with other agents via #{swarm_channel}. "
                f"Task: {task}"
            )
            bat_path = _create_agent_bat(agent_name, plat, agent_task, cwd)
            try:
                subprocess.Popen(
                    ["cmd", "/c", "start", "", str(bat_path)],
                    creationflags=CREATE_NEW_CONSOLE,
                )
            except Exception:
                pass  # Continue launching remaining agents
            agent_names.append(agent_name)

        # Post swarm launch notification to broker
        brokers = _get_all_broker_dirs()
        if brokers:
            channels_dir = Path(brokers[0]["broker_dir"]) / "channels"
            channels_dir.mkdir(parents=True, exist_ok=True)

            # Post to main channel
            entry = {
                "id": _uuid.uuid4().hex[:12],
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "session_id": "dashboard",
                "session_name": "brynq-launcher",
                "platform": "dashboard",
                "channel": "general",
                "msg_type": "status",
                "message": (
                    f"Swarm '{name}' launched with {len(agent_names)} agents: "
                    f"{', '.join(agent_names)}. Task: {task[:200]}"
                ),
            }
            ch_path = channels_dir / "general.jsonl"
            with ch_path.open("a", encoding="utf-8") as f:
                f.write(json.dumps(entry, default=str) + "\n")

            # Create the swarm coordination channel with the task
            swarm_entry = {
                "id": _uuid.uuid4().hex[:12],
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "session_id": "dashboard",
                "session_name": "brynq-launcher",
                "platform": "dashboard",
                "channel": swarm_channel,
                "msg_type": "request",
                "message": (
                    f"SWARM TASK for '{name}' ({len(agent_names)} agents):\n\n"
                    f"{task}\n\n"
                    f"Agents: {', '.join(agent_names)}\n"
                    f"Coordinate here. Claim files before editing (broker_claim_file)."
                ),
            }
            swarm_path = channels_dir / f"{swarm_channel}.jsonl"
            with swarm_path.open("a", encoding="utf-8") as f:
                f.write(json.dumps(swarm_entry, default=str) + "\n")

        self._send_json({
            "status": "launched",
            "swarm": name,
            "agents": agent_names,
            "channel": swarm_channel,
        })

    def _handle_launch_local(self, body: dict) -> None:
        """Launch a local open-source LLM agent via an adapter in a new terminal."""
        import uuid as _uuid
        name = body.get("name", "").strip()
        adapter = body.get("adapter", "ollama").strip().lower()
        model = body.get("model", "").strip()
        channel = body.get("channel", "general").strip()
        cwd = body.get("cwd", "").strip()

        if not name or not model:
            self._send_json({"error": "name and model are required"}, 400)
            return
        if not cwd:
            cwd = os.getcwd()

        bat_path = _create_local_agent_bat(name, adapter, model, channel, cwd)

        # Launch in a new console window
        try:
            CREATE_NEW_CONSOLE = 0x00000010
            subprocess.Popen(
                ["cmd", "/c", "start", "", str(bat_path)],
                creationflags=CREATE_NEW_CONSOLE,
            )
        except Exception as e:
            self._send_json({"error": f"Failed to launch process: {e}"}, 500)
            return

        # Post launch notification to broker channel
        brokers = _get_all_broker_dirs()
        if brokers:
            channels_dir = Path(brokers[0]["broker_dir"]) / "channels"
            channels_dir.mkdir(parents=True, exist_ok=True)
            entry = {
                "id": _uuid.uuid4().hex[:12],
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "session_id": "dashboard",
                "session_name": "brynq-launcher",
                "platform": "dashboard",
                "channel": "general",
                "msg_type": "status",
                "message": f"Local agent '{name}' launched using {adapter} with model '{model}' listening on #{channel}",
            }
            ch_path = channels_dir / "general.jsonl"
            with ch_path.open("a", encoding="utf-8") as f:
                f.write(json.dumps(entry, default=str) + "\n")

        self._send_json({
            "status": "launched",
            "name": name,
            "bat_path": str(bat_path),
        })

    def _handle_add_project(self, body: dict) -> None:
        """Register a new project in the broker registry."""
        name = body.get("name", "").strip()
        proj_path = body.get("path", "").strip()

        if not name or not proj_path:
            self._send_json({"error": "name and path are required"}, 400)
            return

        proj_dir = Path(proj_path)
        if not proj_dir.exists():
            self._send_json({"error": f"Path does not exist: {proj_path}"}, 400)
            return

        # Create broker directories
        broker_dir = proj_dir / "state" / "broker"
        for sub in ("channels", "sessions", "cursors"):
            (broker_dir / sub).mkdir(parents=True, exist_ok=True)

        # Register in ~/.brynq/registry.json
        registry_dir = Path.home() / ".brynq"
        registry_dir.mkdir(parents=True, exist_ok=True)
        registry_file = registry_dir / "registry.json"

        registry: dict = {}
        try:
            if registry_file.exists():
                registry = json.loads(registry_file.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            registry = {}

        brokers = registry.get("brokers", {})
        broker_key = str(broker_dir.resolve())
        brokers[broker_key] = {
            "project": name,
            "broker_dir": broker_key,
            "registered": datetime.now(timezone.utc).isoformat(),
            "pid": 0,
        }
        registry["brokers"] = brokers
        registry_file.write_text(json.dumps(registry, indent=2, default=str))

        self._send_json({
            "status": "added",
            "project": name,
            "broker_dir": broker_key,
            "total_projects": len(brokers),
        })


# ── Agent .bat Creator ────────────────────────────────────────────────────


def _create_local_agent_bat(name: str, adapter: str, model: str, channel: str, cwd: str) -> Path:
    """Create a .bat launcher file for a local open-source agent."""
    brokers = _get_all_broker_dirs()
    if brokers:
        launchers_dir = Path(brokers[0]["broker_dir"]) / "launchers"
    else:
        launchers_dir = Path(tempfile.gettempdir()) / "brynq_launchers"
    launchers_dir.mkdir(parents=True, exist_ok=True)

    adapter_module = "brynq.ollama_adapter" if adapter == "ollama" else "brynq.openai_adapter"
    
    bat_content = (
        f"@echo off\n"
        f"title Brynq Local Agent: {name}\n"
        f"cd /d \"{cwd}\"\n"
        f"set PYTHONPATH=src\n"
        f"set BROKER_SESSION_NAME={name}\n"
        f"set BROKER_PLATFORM={adapter}\n"
        f"python -m {adapter_module} --model \"{model}\" --channel \"{channel}\"\n"
        f"pause\n"
    )

    bat_path = launchers_dir / f"{name}_local.bat"
    bat_path.write_text(bat_content, encoding="utf-8")
    return bat_path


def _create_agent_bat(name: str, platform: str, task: str, cwd: str) -> Path:
    """Create a .bat launcher file for an agent and return its path."""
    brokers = _get_all_broker_dirs()
    if brokers:
        launchers_dir = Path(brokers[0]["broker_dir"]) / "launchers"
    else:
        launchers_dir = Path(tempfile.gettempdir()) / "brynq_launchers"
    launchers_dir.mkdir(parents=True, exist_ok=True)

    # Escape double quotes in the task text for batch file embedding
    safe_task = task.replace('"', "'").replace("\n", " ").replace("\r", "")

    # Load project context from the working directory
    try:
        from .launcher import _load_project_context, _safe_bat_string
        project_context = _safe_bat_string(_load_project_context(cwd, max_chars=800))
    except Exception:
        project_context = f"Project at {cwd}"

    system_prompt = (
        f"You are agent '{name}'. "
        f"CONTEXT: {project_context} "
        f"YOUR TASK: {safe_task} "
        f"RULES: Check broker_inbox first. Post status to #general. "
        f"Claim files with broker_claim_file before editing. "
        f"Read files before modifying them. Never overwrite without reading first."
    )

    # Build the CLI command based on platform
    if platform == "gemini":
        cli_line = f'gemini --yolo -i "{system_prompt}"'
    else:
        cli_line = f'claude --yes --append-system-prompt "{system_prompt}"'

    bat_content = (
        f"@echo off\n"
        f"title Brynq Agent: {name}\n"
        f"cd /d \"{cwd}\"\n"
        f"set PYTHONPATH=src\n"
        f"set BROKER_SESSION_NAME={name}\n"
        f"set BROKER_PLATFORM={platform}\n"
        f"{cli_line}\n"
        f"pause\n"
    )

    bat_path = launchers_dir / f"{name}.bat"
    bat_path.write_text(bat_content, encoding="utf-8")
    return bat_path


# ── Server ────────────────────────────────────────────────────────────────

def run_dashboard(port: int = 9999) -> None:
    """Start the dashboard HTTP server (localhost-only)."""
    brokers = _get_all_broker_dirs()

    # Auth: controlled by BRYNQ_DASHBOARD_AUTH env var (default: false)
    auth_enabled = os.environ.get("BRYNQ_DASHBOARD_AUTH", "false").lower() in ("true", "1", "yes")
    token = secrets.token_urlsafe(32)

    handler = DashboardHandler
    handler._auth_enabled = auth_enabled
    handler._auth_token = token

    socketserver.TCPServer.allow_reuse_address = True
    # Bind to 127.0.0.1 only — never expose on all interfaces
    with socketserver.TCPServer(("127.0.0.1", port), handler) as httpd:
        print(f"Brynq Dashboard running at http://127.0.0.1:{port}")
        if auth_enabled:
            print(f"Auth ENABLED — use token: {token}")
            print(f"  URL: http://127.0.0.1:{port}/?token={token}")
        else:
            print("Auth disabled (set BRYNQ_DASHBOARD_AUTH=true to enable)")
        print(f"Monitoring {len(brokers)} project(s):")
        for b in brokers:
            print(f"  {b.get('project', '?')} -> {b['broker_dir']}")
        print("Press Ctrl+C to stop.")
        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            print("\nDashboard stopped.")


# ── CLI Entry ─────────────────────────────────────────────────────────────


def main() -> None:
    parser = argparse.ArgumentParser(description="Brynq Dashboard")
    default_port = int(os.environ.get("BROKER_DASHBOARD_PORT", "9999"))
    parser.add_argument("--port", "-p", type=int, default=default_port, help=f"Port (default: {default_port})")
    args = parser.parse_args()
    run_dashboard(port=args.port)


if __name__ == "__main__":
    main()
