Ctrl K
ComunidadGuías…

dashboard.html

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Squidbrake</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23FFF8F2'/%3E%3Cpath fill='%231E2A23' d='M16 30V21.5C16 12.4 23.2 5 32 5C40.8 5 48 12.4 48 21.5V30Z'/%3E%3Cg fill='none' stroke='%231E2A23' stroke-width='5' stroke-linecap='round'%3E%3Cpath d='M23 34V48.2C23 51.4 21.7 53.9 19.6 55.8'/%3E%3Cpath d='M32 34V44.6'/%3E%3Cpath d='M41 34V48.2C41 51.4 42.3 53.9 44.4 55.8'/%3E%3C/g%3E%3Ccircle fill='%231E2A23' cx='32' cy='52.6' r='2.9'/%3E%3Crect fill='%23F2622E' x='11' y='24.5' width='42' height='11' rx='5.5'/%3E%3Ccircle fill='%232FD574' cx='41.2' cy='30' r='2.5'/%3E%3C/svg%3E">
<style>
:root {
  color-scheme: light;
  --page: #f4f3f0;
  --surface-1: #fcfcfb;
  --surface-2: #f0efec;
  --border: #e2e1dc;
  --grid: #e8e7e3;
  --text-primary: #0b0b0b;
  --text-secondary: #52514e;
  --text-muted: #7a7974;
  --accent: #2a78d6;
  --series-1: #2a78d6;
  --hover-col: rgba(42, 120, 214, 0.08);
  --good: #0ca30c;
  --warning: #fab219;
  --serious: #ec835a;
  --critical: #d03b3b;
  --neutral: #8a8984;
  --shadow: 0 1px 2px rgba(0,0,0,.04), 0 4px 16px rgba(0,0,0,.04);
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:where(:not([data-theme="light"])) {
    color-scheme: dark;
    --page: #121211;
    --surface-1: #1a1a19;
    --surface-2: #242422;
    --border: #2f2f2c;
    --grid: #2a2a28;
    --text-primary: #ffffff;
    --text-secondary: #c3c2b7;
    --text-muted: #8f8e86;
    --accent: #3987e5;
    --series-1: #3987e5;
    --hover-col: rgba(57, 135, 229, 0.12);
    --neutral: #8f8e86;
    --shadow: none;
  }
}
:root[data-theme="dark"] {
  color-scheme: dark;
  --page: #121211;
  --surface-1: #1a1a19;
  --surface-2: #242422;
  --border: #2f2f2c;
  --grid: #2a2a28;
  --text-primary: #ffffff;
  --text-secondary: #c3c2b7;
  --text-muted: #8f8e86;
  --accent: #3987e5;
  --series-1: #3987e5;
  --hover-col: rgba(57, 135, 229, 0.12);
  --neutral: #8f8e86;
  --shadow: none;
}

* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; }
body {
  background:
    radial-gradient(900px 320px at 0% -8%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%),
    var(--page);
  color: var(--text-primary);
  font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  -webkit-font-smoothing: antialiased;
}
button, input, select { font: inherit; color: inherit; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 28px 24px 56px; }
@media (max-width: 640px) { .wrap { padding: 14px 16px 40px; } }

/* header */
header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
header h1 { display: flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 680; margin: 0; letter-spacing: -0.03em; }
header h1 .mark { flex: none; color: var(--text-primary); }
header h1 .name { display: flex; flex-direction: column; line-height: 1.05; }
header h1 .name a { color: inherit; text-decoration: none; }
header h1 #ver { font-size: 11px; font-weight: 500; letter-spacing: 0; color: var(--text-secondary); text-decoration: none; }
header h1 #ver:hover, header h1 .newver:hover { text-decoration: underline; }
header h1 .newver { font-size: 11px; font-weight: 600; letter-spacing: 0; color: var(--accent); text-decoration: none; }
.conn { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 12.5px; }
.conn .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--neutral); }
.conn.ok .dot { background: var(--good); }
.conn.err .dot { background: var(--critical); }
header .spacer { flex: 1; }
.btn {
  border: 1px solid var(--border); background: var(--surface-1); border-radius: 8px;
  padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
}
.btn:hover { background: var(--surface-2); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn:focus-visible, input:focus-visible, select:focus-visible, .seg button:focus-visible, tr:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 1px;
}
.toggle { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 13px; cursor: pointer; user-select: none; }

/* filter row */
.filters {
  display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 16px;
  background: var(--surface-1); border: 1px solid var(--border); border-radius: 12px; padding: 8px;
  box-shadow: var(--shadow);
}
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface-1); }
.seg button { border: 0; background: none; padding: 6px 12px; cursor: pointer; color: var(--text-secondary); }
.seg button + button { border-left: 1px solid var(--border); }
.seg button[aria-pressed="true"] { background: var(--surface-2); color: var(--text-primary); font-weight: 600; }
.filters input, .filters select {
  border: 1px solid var(--border); background: var(--surface-1); border-radius: 8px; padding: 6px 10px; min-width: 0;
}
.filters input[type=search] { flex: 1 1 220px; }
.filters .f-sm { flex: 0 1 150px; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
  display: inline-flex; align-items: center; gap: 6px; background: var(--surface-2); border: 1px solid var(--border);
  border-radius: 999px; padding: 2px 4px 2px 10px; font-size: 12.5px; color: var(--text-secondary);
}
.chip b { color: var(--text-primary); font-weight: 600; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip button { border: 0; background: none; cursor: pointer; color: var(--text-muted); padding: 0 4px; font-size: 14px; line-height: 1; }

/* cards */
.card { background: var(--surface-1); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); }
.card h2 { font-size: 13px; font-weight: 600; margin: 0; color: var(--text-secondary); }
.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 14px 16px 6px; }
.card-head .sub { font-size: 12px; color: var(--text-muted); }
.linkish { border: 0; background: none; color: var(--accent); cursor: pointer; padding: 0; font-size: 12.5px; }

.tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
@media (max-width: 860px) { .tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 520px) { .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.tile { padding: 12px 14px; cursor: pointer; text-align: left; font: inherit; color: inherit; }
.tile:hover { background: var(--surface-2); }
.tile[aria-pressed="true"] { border-color: var(--accent); }
.tile .lbl { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-secondary); }
.tile .val { font-size: 26px; font-weight: 650; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; margin-top: 2px; }
.tile .pct { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }

.row2 { display: grid; grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr); gap: 12px; margin-bottom: 12px; }
@media (max-width: 960px) { .row2 { grid-template-columns: minmax(0, 1fr); } }

/* status glyph */
.st { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary); white-space: nowrap; }
.st i {
  font-style: normal; width: 16px; height: 16px; border-radius: 50%; display: inline-grid; place-items: center;
  font-size: 10px; font-weight: 700; color: #fff; background: var(--c, var(--neutral)); flex: none;
}
.st.completed { --c: var(--good); }
.st.failed { --c: var(--serious); }
.st.denied { --c: var(--critical); }
.st.pending { --c: var(--neutral); }
.st.awaiting_approval { --c: var(--warning); }
.st.awaiting_approval i { color: #3d2c00; }

/* approval queue */
.approvals { margin-bottom: 12px; border-color: var(--warning); border-width: 1px 1px 1px 4px; }
.approvals .card-head h2 { color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
.approvals .count { background: var(--warning); color: #3d2c00; border-radius: 999px; padding: 0 8px; font-size: 12px; font-weight: 700; }
.aq { list-style: none; margin: 0; padding: 0 8px 8px; max-height: 420px; overflow: auto; }
.aq li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 16px; padding: 10px 8px; border-top: 1px solid var(--grid); }
.aq li:first-child { border-top: 0; }
.aq li.shortcut-selected { background: var(--hover-col); outline: 2px solid var(--accent); outline-offset: -2px; }
.aq .arm { grid-column: 1 / -1; font-size: 13px; font-weight: 600; color: var(--text-primary); background: var(--surface-2);
  border-left: 3px solid var(--warning); border-radius: 6px; padding: 6px 10px; }
.aq .who { font-size: 12.5px; color: var(--text-secondary); }
.aq .why { font-size: 13px; }
.aq .inp { grid-column: 1 / -1; font: 12px/1.4 var(--mono); color: var(--text-secondary); background: var(--surface-2);
  border-radius: 6px; padding: 6px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.aq .side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.aq .acts, .appr-acts { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.aq .acts input, .appr-acts input { border: 1px solid var(--border); background: var(--surface-1); border-radius: 8px; padding: 5px 8px; width: 180px; min-width: 0; }
.btn.approve { background: var(--good); border-color: var(--good); color: #fff; font-weight: 600; }
.btn.reject { color: var(--critical); border-color: var(--critical); font-weight: 600; }
.btn:disabled { opacity: .5; cursor: default; }
.clock { font-size: 12.5px; color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; }
.clock.soon { color: var(--critical); font-weight: 600; }
.hint { font-size: 12px; color: var(--text-muted); }
#slackSetup summary, #emailSetup summary { cursor: pointer; font-weight: 500; }
.sub-h { font-size: 13px; font-weight: 600; margin: 18px 0 6px; }
.packs { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin-bottom: 8px; }
.packs .pack, .suggest { border: 1px solid var(--border, rgba(127,127,127,.25)); border-radius: 8px; padding: 10px 12px; }
.packs .pack p { margin: 4px 0 8px; font-size: 12px; color: var(--text-muted); }
.suggest { display: flex; gap: 10px; align-items: center; margin: 6px 0; }
.suggest .grow { flex: 1; }
#slackSetup .steps { margin: 10px 0 12px; padding-left: 18px; display: grid; gap: 6px; }
#slackSetup .manifest { max-height: 180px; overflow: auto; margin: 6px 0; padding: 10px; font-size: 11px; border-radius: 6px;
  background: var(--bg-subtle, rgba(127,127,127,.08)); white-space: pre; }
@media (max-width: 640px) {
  .aq li { grid-template-columns: minmax(0, 1fr); }
  .aq .side { align-items: stretch; }
  .aq .acts { justify-content: stretch; }
  .aq .acts input { flex: 1 1 100%; width: auto; }
  .aq .acts .btn { flex: 1; justify-content: center; }
}
.toast {
  position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: var(--text-primary); color: var(--page);
  padding: 10px 16px; border-radius: 8px; font-size: 13px; z-index: 40; max-width: calc(100vw - 32px); box-shadow: 0 6px 24px rgba(0,0,0,.2);
}

/* chart */
.chart-box { position: relative; padding: 4px 12px 10px; }
#chart svg { display: block; width: 100%; height: 200px; overflow: visible; }
#chart .axis { fill: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
#chart .gridline { stroke: var(--grid); stroke-width: 1; }
#chart .baseline { stroke: var(--border); stroke-width: 1; }
#chart .bar { fill: var(--series-1); }
#chart .hovercol { fill: transparent; }
#chart .hovercol.on { fill: var(--hover-col); }
.empty { color: var(--text-muted); text-align: center; padding: 40px 12px; font-size: 13px; }
#noRows.empty {
  background: var(--surface-1); border: 1px dashed var(--border); border-radius: 16px;
  margin: 4px 0 8px; padding: 48px 20px;
}
#noRows .empty-title { color: var(--text-primary); font-size: 16px; font-weight: 650; letter-spacing: -0.02em; margin-bottom: 4px; }
.tip {
  position: absolute; pointer-events: none; background: var(--surface-1); border: 1px solid var(--border);
  border-radius: 8px; padding: 8px 10px; font-size: 12.5px; box-shadow: 0 4px 16px rgba(0,0,0,.12);
  white-space: nowrap; z-index: 5; display: none;
}
.tip .t { color: var(--text-secondary); margin-bottom: 4px; }
.tip .n { font-weight: 650; font-variant-numeric: tabular-nums; }
.tip .r { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.tip .r .sw { width: 10px; height: 10px; border-radius: 3px; background: var(--series-1); }
.mini-table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
.mini-table th, .mini-table td { text-align: right; padding: 4px 8px; border-bottom: 1px solid var(--grid); }
.mini-table th:first-child, .mini-table td:first-child { text-align: left; }
.mini-wrap { max-height: 220px; overflow: auto; margin: 0 12px 12px; }

/* top tools */
.toplist { list-style: none; margin: 0; padding: 4px 8px 12px; max-height: 250px; overflow: auto; }
.toplist li button {
  display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 10px; width: 100%; text-align: left;
  border: 0; background: none; padding: 6px 8px; border-radius: 6px; cursor: pointer;
}
.toplist li button:hover { background: var(--surface-2); }
.toplist .nm { font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toplist .ct { font-size: 12.5px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.toplist .track { grid-column: 1 / -1; height: 4px; }
.toplist .fill { height: 4px; border-radius: 0 4px 4px 0; background: var(--series-1); min-width: 2px; }

/* events table */
.tbl-wrap { overflow-x: auto; }
table.events { width: 100%; border-collapse: collapse; font-size: 13px; }
table.events th {
  text-align: left; font-weight: 600; font-size: 12px; color: var(--text-muted); padding: 8px 12px;
  border-bottom: 1px solid var(--border); white-space: nowrap; position: sticky; top: 0; background: var(--surface-1);
}
table.events td { padding: 8px 12px; border-bottom: 1px solid var(--grid); vertical-align: middle; white-space: nowrap; }
table.events tbody tr { cursor: pointer; }
table.events tbody tr:hover { background: var(--surface-2); }
table.events tbody tr.sel { background: var(--hover-col); }
table.events tr.fresh td { animation: flash 1.6s ease-out; }
@keyframes flash { from { background: var(--hover-col); } to { background: transparent; } }
.mono { font-family: var(--mono); font-size: 12.5px; }
.muted { color: var(--text-muted); }
.sec { color: var(--text-secondary); }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.trunc { max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
.pill-link { border: 0; background: none; padding: 0; cursor: pointer; color: var(--text-secondary); text-decoration: underline dotted; text-underline-offset: 3px; }
.pill-link:hover { color: var(--accent); }
.foot { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; color: var(--text-muted); font-size: 12.5px; gap: 8px; flex-wrap: wrap; }

/* agents + chains */
.chain-title { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.chain-title h2 { color: var(--text-primary); font-size: 15px; letter-spacing: -0.02em; }
.agent-strip { display: flex; gap: 8px; overflow-x: auto; padding: 0 2px 12px; }
.agent-chip {
  display: inline-flex; align-items: center; gap: 8px; border: 1px solid var(--border); background: var(--surface-1);
  border-radius: 999px; padding: 4px 12px 4px 4px; cursor: pointer; white-space: nowrap; color: var(--text-primary);
  box-shadow: var(--shadow);
}
.agent-chip:hover { background: var(--surface-2); }
.agent-chip[aria-pressed="true"] { border-color: var(--accent); background: var(--hover-col); }
.agent-chip .ct { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.avatar {
  --h: 210; --hl: 38%;
  width: 36px; height: 36px; border-radius: 12px; flex: none; display: inline-grid; place-items: center;
  background: linear-gradient(160deg, hsl(var(--h) 62% calc(var(--hl) + 8%)), hsl(var(--h) 52% var(--hl)));
  color: #fff; font-weight: 700; font-size: 13px; letter-spacing: .02em;
  font-family: system-ui, sans-serif; vertical-align: middle;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.28);
}
.avatar.sm { width: 24px; height: 24px; border-radius: 8px; font-size: 10px; }
#eventsCard { background: transparent; border: 0; box-shadow: none; }
#eventsCard .card-head { padding: 2px 2px 10px; flex-wrap: wrap; align-items: center; }
#eventsCard .foot { padding-left: 2px; padding-right: 2px; }
#callsWrap { background: var(--surface-1); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow); }
.chains { display: flex; flex-direction: column; gap: 12px; padding: 0 0 4px; }
.chain {
  --h: 210; --chain-bg: var(--surface-1);
  min-width: 0;
  border: 1px solid var(--border); border-radius: 16px; background: var(--chain-bg);
  box-shadow: var(--shadow);
  border-left: 4px solid hsl(var(--h) 52% 46%); padding: 16px 16px 12px;
}
.chain.fresh { animation: flash 1.6s ease-out; }
.chain.loose { border-left-style: dashed; }
.chain.awaiting_approval { --chain-bg: color-mix(in srgb, var(--warning) 12%, var(--surface-1)); }
.chain.denied { --chain-bg: color-mix(in srgb, var(--critical) 8%, var(--surface-1)); }
.chain.failed { --chain-bg: color-mix(in srgb, var(--serious) 9%, var(--surface-1)); }
.chain-head { display: flex; gap: 12px; align-items: flex-start; }
.chain-id { min-width: 0; flex: 1; }
.chain-name { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-weight: 650; font-size: 15px; letter-spacing: -0.02em; }
.chain-name .agent-name { border: 0; background: none; padding: 0; cursor: pointer; font: inherit; font-weight: 650; color: inherit; }
.chain-name .agent-name:hover { color: var(--accent); }
.chain-meta { font-size: 12.5px; color: var(--text-secondary); margin-top: 3px; display: flex; flex-wrap: wrap; gap: 0; align-items: center; }
.chain-side { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; align-items: center; }
.tally {
  display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-variant-numeric: tabular-nums;
  color: var(--text-secondary); background: color-mix(in srgb, var(--chain-bg) 70%, var(--surface-2));
  border-radius: 999px; padding: 2px 8px 2px 3px;
}
.tally i {
  font-style: normal; width: 16px; height: 16px; border-radius: 50%; display: inline-grid; place-items: center;
  font-size: 9px; font-weight: 700; color: #fff; background: var(--c, var(--neutral));
}
.tally.completed { --c: var(--good); }
.tally.failed { --c: var(--serious); }
.tally.denied { --c: var(--critical); }
.tally.pending { --c: var(--neutral); }
.tally.awaiting_approval { --c: var(--warning); }
.tally.awaiting_approval i { color: #3d2c00; }
.loose-note { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
.flow { display: flex; align-items: flex-start; gap: 0; overflow-x: auto; padding: 14px 2px 4px; }
.flow .edge { width: 36px; flex: none; align-self: flex-start; height: 2px; margin-top: 10px; background: var(--border); }
.flow .edge::after { content: none; }
.node {
  position: relative; flex: 0 0 124px; min-width: 104px; border: 0; background: transparent;
  border-radius: 10px; padding: 26px 6px 4px; text-align: center; cursor: pointer;
}
.node::before {
  content: ""; position: absolute; top: 2px; left: 50%; transform: translateX(-50%);
  width: 16px; height: 16px; border-radius: 50%; background: var(--c, var(--neutral));
  box-shadow: 0 0 0 4px var(--chain-bg); z-index: 1;
}
.node:hover .nm { color: var(--accent); }
.node.sel::before { outline: 2px solid var(--accent); outline-offset: 3px; }
.node .nm { display: block; font-family: var(--mono); font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.node .meta { display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.node.more { flex-basis: 76px; min-width: 72px; }
.node.more::before { background: var(--surface-2); box-shadow: 0 0 0 4px var(--chain-bg), inset 0 0 0 1.5px var(--border); }
.node.completed { --c: var(--good); }
.node.failed { --c: var(--serious); }
.node.denied { --c: var(--critical); }
.node.pending { --c: var(--neutral); }
.node.awaiting_approval { --c: var(--warning); }
.node.awaiting_approval::before { box-shadow: 0 0 0 4px var(--chain-bg), 0 0 0 7px color-mix(in srgb, var(--warning) 35%, transparent); }
.spark { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; margin-top: 12px; padding-top: 2px; }
.spark-dot {
  width: 11px; height: 11px; border-radius: 50%; border: 0; padding: 0; cursor: pointer; background: var(--c, var(--neutral));
  box-shadow: 0 0 0 2px var(--chain-bg);
}
.spark-dot.sel { outline: 2px solid var(--accent); outline-offset: 2px; }
.spark-dot.completed { --c: var(--good); }
.spark-dot.failed { --c: var(--serious); }
.spark-dot.denied { --c: var(--critical); }
.spark-dot.pending { --c: var(--neutral); }
.spark-dot.awaiting_approval { --c: var(--warning); border-radius: 3px; }
.chain-focus {
  margin-top: 12px; padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--chain-bg) 55%, var(--surface-2));
  border: 1px solid var(--border); font-size: 13px;
}
.chain-focus .st-in { margin-top: 4px; }
.chain-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.aq-main { display: flex; gap: 10px; align-items: flex-start; min-width: 0; }
.aq-copy { min-width: 0; }
@media (max-width: 640px) {
  .chain-head { flex-wrap: wrap; }
  .chain-side { justify-content: flex-start; }
}

/* drawer */
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.28); z-index: 20; display: none; }
.drawer {
  position: fixed; top: 0; right: 0; bottom: 0; width: min(720px, 100vw); background: var(--surface-1);
  border-left: 1px solid var(--border); z-index: 21; transform: translateX(100%); transition: transform .18s ease;
  display: flex; flex-direction: column;
}
.drawer.open { transform: none; }
.drawer.open + .backdrop, .backdrop.on { display: block; }
.dr-head { display: flex; align-items: flex-start; gap: 10px; padding: 16px 18px 12px; border-bottom: 1px solid var(--border); }
.dr-head h3 { margin: 0; font-family: var(--mono); font-size: 15px; display: flex; align-items: center; gap: 8px; }
.dr-head h3 span { word-break: break-all; }
.dr-head .x { margin-left: auto; }
.dr-body { overflow: auto; padding: 14px 18px 24px; }
.kv { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 6px 12px; font-size: 13px; margin-bottom: 16px; }
.kv dt { color: var(--text-muted); }
.kv dd { margin: 0; word-break: break-word; }
.blk h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); margin: 16px 0 6px; display: flex; justify-content: space-between; }
.blk pre {
  margin: 0; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px;
  font: 12.5px/1.5 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 360px; overflow: auto;
}
.banner { border-radius: 8px; padding: 8px 12px; font-size: 13px; margin-bottom: 14px; border: 1px solid var(--border); background: var(--surface-2); }
.banner.denied { border-left: 3px solid var(--critical); }
.banner.failed { border-left: 3px solid var(--serious); }
.banner.awaiting { border-left: 3px solid var(--warning); }
.banner.approved { border-left: 3px solid var(--good); }
.banner .appr-acts { margin-top: 10px; justify-content: flex-start; }

/* key dialog */
dialog { border: 1px solid var(--border); border-radius: 12px; background: var(--surface-1); color: var(--text-primary); padding: 20px; width: min(420px, calc(100vw - 32px)); }
dialog::backdrop { background: rgba(0,0,0,.35); }
dialog h3 { margin: 0 0 6px; font-size: 16px; }
dialog p { margin: 0 0 14px; color: var(--text-secondary); font-size: 13px; }
dialog input { width: 100%; border: 1px solid var(--border); background: var(--page); border-radius: 8px; padding: 8px 10px; }
.shortcut-help .mini-table td { text-align: left; }
.shortcut-help kbd { display: inline-block; min-width: 28px; padding: 2px 7px; border: 1px solid var(--border); border-bottom-width: 2px;
  border-radius: 5px; background: var(--surface-2); font: 12px var(--mono); text-align: center; }
dialog .acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.err-msg { color: var(--critical); font-size: 12.5px; margin-top: 8px; min-height: 1em; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }

/* story + history warnings */
.signals { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; margin: 2px 0 10px; }
.brief { border-radius: 8px; padding: 8px 10px; font-size: 13px; border: 1px solid var(--border); border-left: 4px solid var(--accent, #2a78d6);
         background: var(--surface-2); white-space: pre-wrap; margin-top: 6px; }
.brief .who { font-size: 11.5px; color: var(--text-muted); margin-top: 4px; }
.proof-ok { color: var(--good); font-weight: 600; } .proof-bad { color: var(--critical); font-weight: 600; }
.meter { height: 6px; border-radius: 999px; background: var(--surface-2); overflow: hidden; margin-top: 4px; min-width: 80px; }
.meter > i { display: block; height: 100%; background: var(--good); }
.meter.over > i { background: var(--critical); }
.signal { border-radius: 8px; padding: 8px 10px; font-size: 13px; border: 1px solid var(--border); border-left-width: 4px; background: var(--surface-2); }
.signal.block { border-left-color: var(--critical); }
.signal.review { border-left-color: var(--serious); }
.signal.warn { border-left-color: var(--warning); }
.story-d { grid-column: 1 / -1; font-size: 13px; }
.story-d summary { cursor: pointer; color: var(--accent); font-size: 12.5px; }
.rail { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; }
.rail li { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; }
.rail .tick { display: flex; flex-direction: column; align-items: center; }
.rail .tick .line { flex: 1; width: 2px; background: var(--border); min-height: 10px; }
.rail li:last-child .line { visibility: hidden; }
.rail .body { padding-bottom: 12px; min-width: 0; }
.rail li.on .body { background: var(--hover-col); border-radius: 8px; padding: 6px 8px 8px; margin: -6px -8px 0; }
.rail .st-line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
.st-in { color: var(--text-secondary); font: 12px/1.4 var(--mono); margin-top: 2px; word-break: break-word; }
.st-out { color: var(--text-primary); font-size: 12.5px; margin-top: 2px; background: var(--surface-2); border-radius: 6px; padding: 5px 8px; word-break: break-word; }

/* tabs + header actions */
.tabs { display: flex; gap: 2px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 9px; padding: 2px; }
.tabs a { color: var(--text-secondary); text-decoration: none; padding: 5px 12px; border-radius: 7px; font-size: 13.5px; }
.tabs a:hover { color: var(--text-primary); }
.tabs a[aria-current="page"] { background: var(--surface-1); color: var(--text-primary); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
.btn.danger { background: var(--critical); border-color: var(--critical); color: #fff; font-weight: 600; }
.btn.danger:hover { background: var(--critical); filter: brightness(1.08); }
.stop-banner { background: var(--critical); color: #fff; border-radius: 10px; padding: 12px 16px; margin-bottom: 14px;
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.stop-banner b { font-size: 15px; }
.shadow-banner { background: var(--surface-1); border: 1px dashed var(--serious); color: var(--text-primary);
  border-radius: 10px; padding: 10px 16px; margin-bottom: 14px; font-size: 13.5px; }
.tag.would { border-color: var(--serious); color: var(--serious); margin-left: 6px; }
.stop-banner .btn { background: #fff; color: var(--critical); border-color: #fff; margin-left: auto; font-weight: 600; }
.lede { color: var(--text-secondary); margin: 0 16px 12px; font-size: 13.5px; max-width: 900px; }
.saved { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 0 0 14px; padding: 12px 16px;
  border: 1px solid var(--border); border-left: 4px solid #16803c; border-radius: 0 12px 12px 0; background: var(--surface-1); }
.saved b { font-size: 15px; } .saved span { color: var(--text-secondary); font-size: 13px; }
.saved a { margin-left: auto; }
.saved.plan { border-left-color: #b45309; }
code { font-family: var(--mono); font-size: .92em; background: var(--surface-2); padding: 1px 5px; border-radius: 5px; }

/* forms */
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px 16px; padding: 0 16px 16px; align-items: end; }
.form-grid.wide { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.form-grid label { display: flex; flex-direction: column; gap: 5px; font-size: 12.5px; font-weight: 600; color: var(--text-secondary); }
.form-grid label.full, .form-grid .full { grid-column: 1 / -1; }
.form-grid label.check { flex-direction: row; align-items: center; font-weight: 400; color: var(--text-primary); font-size: 13.5px; }
.form-grid input:not([type=checkbox]), .form-grid select { border: 1px solid var(--border); background: var(--surface-1); border-radius: 8px;
  padding: 7px 10px; font-weight: 400; color: var(--text-primary); min-width: 0; width: 100%; }
.form-grid .hint { font-weight: 400; }
.inline { display: flex; gap: 8px; }
.inline input { flex: 1; }
.form-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.newkey { margin: 0 16px 16px; padding: 12px 14px; border: 1px solid var(--good); border-radius: 10px; background: var(--surface-2); font-size: 13.5px; }
.newkey code { user-select: all; font-size: 13px; word-break: break-all; }
.access { display: inline-flex; gap: 6px; align-items: center; }
.tag { display: inline-block; font-size: 11.5px; padding: 1px 8px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-secondary); margin-right: 4px; }
.tag.admin { border-color: var(--accent); color: var(--accent); }

/* reports */
.report-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.report-bar .spacer { flex: 1; }
.row2.even { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
@media (max-width: 960px) { .row2.even { grid-template-columns: minmax(0, 1fr); } }
.audit-ok { display: flex; gap: 12px; align-items: flex-start; padding: 4px 16px 16px; }
.audit-ok .big { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; color: #fff; font-size: 20px; flex: none; }
.audit-ok.ok .big { background: var(--good); }
.audit-ok.bad .big { background: var(--critical); }
.audit-ok b { display: block; font-size: 15px; }
.fp { font-family: var(--mono); font-size: 12px; color: var(--text-muted); word-break: break-all; }
.print-head { display: none; }
td.wrap { white-space: normal; max-width: 420px; }

/* dialogs */
dialog.wide-dlg { width: min(460px, calc(100vw - 32px)); }
.qr { display: grid; place-items: center; background: #fff; border-radius: 12px; padding: 12px; margin: 8px 0 10px; }
.qr img { width: 240px; height: 240px; image-rendering: pixelated; }
.warn { color: var(--critical); font-size: 13px; }
dialog input#stopReason { width: 100%; border: 1px solid var(--border); background: var(--page); border-radius: 8px; padding: 8px 10px; }

@media print {
  header, .filters, .report-bar, .stop-banner, .drawer, .backdrop, #toast, .view:not(#view-reports) { display: none !important; }
  body { background: #fff; color: #000; }
  .card { box-shadow: none; break-inside: avoid; }
  .print-head { display: block; margin-bottom: 12px; }
  .print-head h1 { font-size: 20px; margin: 0 0 4px; }
  td button, th:last-child:empty { display: none; }
}
</style>
</head>
<body>
<div class="wrap">
  <header>
    <h1><svg class="mark" viewBox="0 0 64 64" width="28" height="28" aria-hidden="true"><path fill="currentColor" d="M16 30V21.5C16 12.4 23.2 5 32 5C40.8 5 48 12.4 48 21.5V30Z"/><g fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"><path d="M23 34V48.2C23 51.4 21.7 53.9 19.6 55.8"/><path d="M32 34V44.6"/><path d="M41 34V48.2C41 51.4 42.3 53.9 44.4 55.8"/></g><circle fill="currentColor" cx="32" cy="52.6" r="2.9"/><rect fill="#F2622E" x="11" y="24.5" width="42" height="11" rx="5.5"/><circle fill="#2FD574" cx="41.2" cy="30" r="2.5"/></svg><span class="name"><a href="#activity" id="home" title="Activity">Squidbrake</a>
      <a id="ver" target="_blank" rel="noopener" href="https://github.com/batrapulkit/squidbrake/releases"
         title="Latest release"></a></span></h1>
    <span class="conn" id="conn"><span class="dot"></span><span id="connText">Connecting…</span></span>
    <nav class="tabs" id="tabs" aria-label="Sections">
      <a href="#activity" data-view="activity">Activity</a>
      <a href="#team" data-view="team">Team</a>
      <a href="#reports" data-view="reports">Reports</a>
      <a href="#settings" data-view="settings" data-admin hidden>Settings</a>
    </nav>
    <span class="spacer"></span>
    <label class="toggle"><input type="checkbox" id="auto" checked> Live</label>
    <button class="btn" id="refresh" title="Refresh">↻</button>
    <button class="btn" id="phoneBtn" title="Approve from your phone">📱 Phone</button>
    <button class="btn" id="notifyBtn" aria-pressed="false">🔔 <span id="notifyText">Alerts off</span></button>
    <button class="btn" id="keyBtn" title="Sign in with a different key">⚿ <span id="meName">Key</span></button>
    <button class="btn" id="modeBtn" data-admin hidden aria-pressed="false"
      title="Observe: nothing is blocked or held, only recorded for a report later. Enforce: rules block and hold.">👁 <span id="modeText">Observe off</span></button>
    <button class="btn danger" id="stopBtn" title="Block every agent action right now" hidden>⏹ Stop agents</button>
  </header>

  <div class="stop-banner" id="stopBanner" role="alert" hidden></div>
  <div class="shadow-banner" id="shadowBanner" hidden></div>

  <div id="view-activity" class="view">
  <div class="saved" id="savedCard" hidden><div><b id="savedTitle"></b><br><span>Each one was stopped before it ran.</span></div>
    <a class="btn" id="savedShare" target="_blank" rel="noopener">Share on LinkedIn</a></div>
  <div class="saved plan" id="planCard" hidden><div><b id="planTitle"></b><br><span id="planText"></span></div>
    <a class="btn" id="planLink" target="_blank" rel="noopener">Start a Team trial</a></div>
  <div class="filters" role="search">
    <div class="seg" id="range" aria-label="Time range">
      <button data-r="1h">1h</button><button data-r="24h">24h</button><button data-r="7d">7d</button><button data-r="30d">30d</button><button data-r="1y">1y</button>
    </div>
    <input type="search" id="q" placeholder="Search tool, agent, chain…" aria-label="Search">
    <select id="status" class="f-sm" aria-label="Status">
      <option value="">All statuses</option>
      <option value="completed">Completed</option>
      <option value="failed">Failed</option>
      <option value="denied">Denied</option>
      <option value="pending">Pending</option>
      <option value="awaiting_approval">Awaiting approval</option>
    </select>
    <input id="kind" class="f-sm" placeholder="Kind" aria-label="Kind" list="kinds">
    <datalist id="kinds"><option value="tool_call"><option value="action"><option value="http_request"></datalist>
    <input id="source" class="f-sm" placeholder="Agent" aria-label="Agent">
    <input id="session_id" class="f-sm" placeholder="Chain" aria-label="Chain">
    <div class="chips" id="chips"></div>
  </div>

  <section class="card approvals" id="approvals" aria-labelledby="aqTitle" hidden>
    <div class="card-head">
      <h2 id="aqTitle">Needs approval <span class="count" id="aqCount"></span></h2>
      <span class="hint" id="aqHint"></span>
    </div>
    <ul class="aq" id="aq"></ul>
  </section>

  <section class="tiles" id="tiles" aria-label="Totals"></section>

  <div class="row2">
    <section class="card">
      <div class="card-head">
        <h2 id="chartTitle">Calls per hour</h2>
        <button class="linkish" id="tableToggle" aria-expanded="false">View as table</button>
      </div>
      <div class="chart-box">
        <div id="chart"></div>
        <div class="tip" id="tip"></div>
      </div>
      <div class="mini-wrap" id="chartTable" hidden></div>
    </section>
    <section class="card">
      <div class="card-head"><h2>Top tools</h2><span class="sub">click to filter</span></div>
      <ul class="toplist" id="top"></ul>
    </section>
  </div>

  <section class="card" id="eventsCard">
    <div class="card-head">
      <div class="chain-title"><h2 id="evTitle">Chains</h2><span class="sub" id="evCount"></span></div>
      <div class="seg" id="evView" role="group" aria-label="Activity layout">
        <button type="button" data-v="chains" aria-pressed="true">Chains</button>
        <button type="button" data-v="calls">Calls</button>
      </div>
    </div>
    <div class="agent-strip" id="agentStrip" aria-label="Agents" hidden></div>
    <div id="chains" class="chains"></div>
    <div class="tbl-wrap" id="callsWrap" hidden>
      <table class="events">
        <thead><tr>
          <th>Time</th><th>Status</th><th>Name</th><th>Kind</th><th>Agent</th><th>Chain</th><th class="num">Duration</th><th>Rule</th>
        </tr></thead>
        <tbody id="rows"></tbody>
      </table>
    </div>
    <div class="empty" id="noRows" hidden>No events match these filters.</div>
    <div class="foot">
      <span id="footInfo"></span>
      <button class="btn" id="more" hidden>Load older</button>
    </div>
  </section>
  </div>

  <!-- ============================================================ TEAM -->
  <div id="view-team" class="view" hidden>
    <section class="card">
      <div class="card-head"><h2>People and agents</h2><span class="sub" id="teamSub"></span></div>
      <p class="lede">Each person and each AI agent gets its own key. <b>Viewers</b> can look at everything and change
        nothing; <b>approvers</b> also approve held actions and can stop agents; <b>admins</b> manage the team and settings;
        <b>owners</b> also decide who is an admin or owner. Agents can only ask permission and report what they did.
        Roles such as <code>finance</code> let rules require a specific team to approve.</p>
      <p class="lede">Want your teammates to approve from anywhere, on one shared dashboard, their phone or Slack?
        <a href="https://squidbrake.com/signup?plan=team&amp;from=invite" target="_blank" rel="noopener"><b>Start a 14-day Team
        trial</b></a>: we host it for your team, no card to start. Or
        <a href="https://calendly.com/pulkitbatra2024/new-meeting-1" target="_blank" rel="noopener">book 15 minutes with the founder</a>.</p>
      <div class="tbl-wrap">
        <table class="events team">
          <thead><tr><th>Name</th><th>Type</th><th>Access</th><th>Roles</th><th>Added</th><th></th></tr></thead>
          <tbody id="teamRows"></tbody>
        </table>
      </div>
    </section>
    <section class="card" id="addMemberCard" data-admin hidden>
      <div class="card-head"><h2>Add someone</h2></div>
      <form class="form-grid" id="addMember">
        <label>Name <input name="name" required maxlength="64" placeholder="e.g. priya or billing-agent" pattern="[A-Za-z0-9][A-Za-z0-9_.@-]{0,63}"></label>
        <label>Type
          <select name="kind"><option value="person">Person</option><option value="agent">AI agent</option></select></label>
        <label class="person-only">Access
          <select name="access"><option value="approver">Approver: can approve</option><option value="viewer">Viewer: can view only</option>
            <option value="admin">Admin: manages team and settings</option><option value="owner">Owner: also manages admins</option></select></label>
        <label class="person-only">Teams (optional) <input name="roles" placeholder="e.g. finance, ops"></label>
        <label class="agent-only" hidden>Works for
          <select name="owner" id="ownerSel"><option value="">nobody in particular</option></select>
          <span class="hint">With second-person approval on, this person can't approve what their own agent asks for.</span></label>
        <div class="form-actions"><button class="btn primary" type="submit">Add and create key</button></div>
      </form>
      <div class="newkey" id="newKey" hidden></div>
    </section>
  </div>

  <!-- ============================================================ REPORTS -->
  <div id="view-reports" class="view" hidden>
    <div class="report-bar">
      <div class="seg" id="repRange" aria-label="Report period">
        <button data-d="1">Today</button><button data-d="7">7 days</button><button data-d="30">30 days</button><button data-d="90">90 days</button>
      </div>
      <span class="spacer"></span>
      <button class="btn" id="csvBtn">⬇ Download audit CSV</button>
      <button class="btn" id="jsonlBtn">⬇ Download JSONL</button>
      <button class="btn" id="evidenceBtn" title="A printable page for your auditor: controls in place, what happened, and the requirements it speaks to">📄 Evidence pack</button>
      <button class="btn" id="printBtn">🖨 Print / save PDF</button>
    </div>
    <div class="print-head" id="printHead"></div>
    <section class="tiles" id="repTiles" aria-label="Report totals"></section>
    <section class="card">
      <div class="card-head"><h2>What it caught</h2><span class="sub" id="caughtSub"></span>
        <span class="spacer"></span><button class="btn" id="copyWeekly" type="button">Copy weekly report</button>
        <button class="btn" id="shareWeek" type="button" title="A public card with these counts only, to post">Share my week</button></div>
      <div class="tbl-wrap"><table class="events"><thead><tr><th>Outcome</th><th>What the agent tried</th><th>Why</th>
        <th>What it would have done</th></tr></thead><tbody id="caughtList"></tbody></table></div>
      <p class="hint">Also sent every Monday to Slack, Teams or email when they're set up in Settings.</p>
    </section>
    <div class="row2 even">
      <section class="card" id="auditCard"></section>
      <section class="card">
        <div class="card-head"><h2>Approvals by person</h2></div>
        <div class="tbl-wrap"><table class="events"><thead><tr><th>Approver</th><th class="num">Approved</th><th class="num">Rejected</th></tr></thead>
          <tbody id="repApprovers"></tbody></table></div>
      </section>
    </div>
    <section class="card">
      <div class="card-head"><h2>Agents</h2><span class="sub">stop a single agent without touching the others</span></div>
      <div class="tbl-wrap"><table class="events"><thead><tr>
        <th>Agent</th><th class="num">Actions</th><th class="num">Blocked</th><th class="num">Held</th><th class="num">Approved</th>
        <th class="num">Rejected</th><th class="num">Failed</th><th>Last seen</th><th></th></tr></thead>
        <tbody id="repAgents"></tbody></table></div>
    </section>
    <section class="card" id="spendCard" hidden>
      <div class="card-head"><h2>Spend limits</h2><span class="sub">model tokens this period; an agent over its limit is paused until it resets</span></div>
      <div class="tbl-wrap"><table class="events"><thead><tr><th>Limit</th><th>Agent</th><th>Used</th><th></th><th>Resets</th></tr></thead>
        <tbody id="spendRows"></tbody></table></div>
      <p class="hint">Set in rules.yaml under <code>budgets:</code>. Claude Code reports its tokens by itself; other agents send them to <code>POST /v1/usage</code>.</p>
    </section>
    <div class="row2 even">
      <section class="card">
        <div class="card-head"><h2>Blocked by rules</h2></div>
        <div class="tbl-wrap"><table class="events"><thead><tr><th>Rule</th><th>Why</th><th class="num">Times</th></tr></thead>
          <tbody id="repBlocked"></tbody></table></div>
      </section>
      <section class="card">
        <div class="card-head"><h2>Most used tools</h2></div>
        <div class="tbl-wrap"><table class="events"><thead><tr><th>Tool</th><th class="num">Calls</th></tr></thead>
          <tbody id="repTools"></tbody></table></div>
      </section>
    </div>
    <section class="card">
      <div class="card-head"><h2>Audit trail</h2><span class="sub">every decision, change and export, newest first</span></div>
      <div class="tbl-wrap"><table class="events"><thead><tr><th>When</th><th>Who</th><th>What</th><th>Details</th></tr></thead>
        <tbody id="repAudit"></tbody></table></div>
    </section>
  </div>

  <!-- ============================================================ SETTINGS -->
  <div id="view-settings" class="view" hidden>
    <section class="card" id="teamAgents" hidden>
      <div class="card-head"><h2>Agents across the team</h2><span class="sub" id="teamAgentsSub"></span></div>
      <p class="lede">Each computer's <code>squidbrake doctor</code> says which agents it has. Anything marked
        <b>not connected</b> works without Squidbrake: nothing it does is checked or recorded.</p>
      <div class="tbl-wrap"><table class="events"><thead><tr><th>Computer</th><th>Connected</th><th>Not connected</th>
        <th>MCP servers not guarded</th><th>Reported</th></tr></thead><tbody id="teamAgentsRows"></tbody></table></div>
    </section>
    <section class="card" id="agentsHere" hidden>
      <div class="card-head"><h2>Agents on this computer</h2></div>
      <p class="lede">Choose which agents go through Squidbrake. Turning one off takes Squidbrake out of that agent only;
        the others stay as they are. Quit and reopen the agent afterwards so it picks the change up.</p>
      <div id="agentsList"></div>
    </section>
    <section class="card">
      <div class="card-head"><h2>Notifications</h2></div>
      <p class="lede">When an agent needs approval, the gateway can ping you on your phone and in Slack. Each message has a
        secure one-tap link to approve or reject that one action; the link stops working once it's decided.</p>
      <form class="form-grid wide" id="settingsForm">
        <label class="full">Public address of this gateway
          <span class="hint">Links in notifications point here, so it must open from your phone (your cloud URL or tunnel link).</span>
          <span class="inline"><input name="public_url" placeholder="https://gateway.yourcompany.com">
            <button type="button" class="btn" id="useOrigin">Use this page's address</button></span></label>
        <label class="full">Phone notifications (free ntfy app)
          <span class="hint">Install “ntfy” from the App Store / Google Play, tap +, and subscribe to this private topic name.
            Anyone who knows the topic can see the notifications, so keep it random.</span>
          <span class="inline"><input name="ntfy_topic" placeholder="leave empty to turn off" pattern="[A-Za-z0-9_-]{0,64}">
            <button type="button" class="btn" id="genTopic">Generate private topic</button></span></label>
        <label class="full">Slack or Discord incoming webhook
          <span class="hint">Paste a Slack or Discord webhook URL. Discord formatting is detected automatically. Leave empty to turn off.</span>
          <input name="slack_webhook" placeholder="Webhook URL"></label>
        <label class="full">Microsoft Teams
          <span class="hint">In the channel: Workflows → "Post to a channel when a webhook request is received" → copy its URL here.
            Each approval arrives as a card with a button to the one-tap page.</span>
          <input name="teams_webhook" placeholder="https://…logic.azure.com/workflows/… (leave empty to turn off)"></label>
        <details class="full" id="emailSetup">
          <summary>Email (approvals and the weekly report)</summary>
          <div class="form-grid wide" style="margin-top:10px">
            <label class="full">Send to <input name="email_to" placeholder="ops@yourcompany.com, cto@yourcompany.com"></label>
            <label>SMTP server <input name="smtp_host" placeholder="smtp.gmail.com"></label>
            <label>Port <input name="smtp_port" type="number" min="1" max="65535" placeholder="587"></label>
            <label>Username <input name="smtp_user" autocomplete="off"></label>
            <label>Password <input name="smtp_password" type="password" autocomplete="off"></label>
            <label class="full">From <input name="smtp_from" placeholder="squidbrake@yourcompany.com"></label>
            <p class="hint full">An email carries the link to the review page only. Mail scanners open links, so nothing
              in an email approves or rejects by being opened.</p>
          </div>
        </details>
        <details class="full" id="slackSetup">
          <summary>Approve and Reject right in Slack (buttons)</summary>
          <ol class="hint steps">
            <li>Set the public address above first: Slack sends each click there.</li>
            <li><a href="https://api.slack.com/apps?new_app=1" target="_blank" rel="noopener">Create a Slack app</a>
              → From a manifest → pick your workspace → paste this:
              <pre id="slackManifest" class="manifest"></pre> <button type="button" class="btn" id="copyManifest">Copy manifest</button></li>
            <li>Install it to your workspace and pick the channel for approvals. Paste the webhook URL it gives you above.</li>
            <li>From the app's Basic Information page, copy the Signing Secret here:</li>
          </ol>
          <label>Slack app signing secret <input name="slack_signing_secret" type="password" autocomplete="off" placeholder="from Basic Information"></label>
        </details>
        <label>One-tap links approve as
          <select name="notify_as" id="notifyAs"></select>
          <span class="hint">Whoever can see the notification can use its link, so pick the person who owns that phone / channel.</span></label>
        <label class="check"><input type="checkbox" name="weekly_digest"> Post a weekly summary to Slack (Mondays 9:00)</label>
        <label class="check full"><input type="checkbox" name="second_person"> Second-person approval: nobody approves what their own agent asked for
          <span class="hint">Set who each agent works for in Team. Approvals then always come from someone else, which is what auditors look for.
            Rolling it out to a team? <a href="https://pilots.squidbrake.com/team?from=dashboard" target="_blank" rel="noopener">We'll help</a>.</span></label>
        <div class="form-actions full">
          <button class="btn primary" type="submit">Save</button>
          <button class="btn" type="button" id="testNotify">Send a test notification</button>
          <span class="hint" id="settingsMsg"></span>
        </div>
      </form>
    </section>
    <section class="card">
      <div class="card-head"><h2>Approve with Face ID or fingerprint</h2><span class="sub" id="devSub"></span></div>
      <p class="lede">Register your phone or laptop as a passkey. Each approval you make with it is signed by the device
        and kept in the audit trail as proof of who approved what; anyone with an export can check the signature.</p>
      <div class="tbl-wrap"><table class="events"><thead><tr><th>Device</th><th>Person</th><th>Added</th><th>Last used</th><th></th></tr></thead>
        <tbody id="devRows"></tbody></table></div>
      <div class="form-actions" style="margin-top:10px">
        <button class="btn primary" type="button" id="devAdd">Set up Face ID / fingerprint on this device</button>
        <span class="hint" id="devMsg"></span></div>
      <label class="check full" style="margin-top:10px" data-admin hidden><input type="checkbox" id="devRequired"> Approvals need Face ID or fingerprint
        <span class="hint">Approvals from Slack buttons, phone notification buttons and plain links are refused; people approve on the page,
          on a registered device. Rejecting never needs it. Register your own device first.</span></label>
    </section>
    <section class="card">
      <div class="card-head"><h2>AI approval briefs</h2></div>
      <p class="lede">A short, plain-English risk note on each action waiting for a person, written with your team's own
        Anthropic key. It only advises: the rules decide what's held and people decide what runs. What it reads is what this
        dashboard shows (redacted, previews capped); with no key, nothing is sent anywhere.</p>
      <form class="form-grid wide" id="aiForm">
        <label class="check full"><input type="checkbox" name="ai_briefs"> Write a brief for each held action</label>
        <label class="full">Anthropic API key <input name="ai_key" type="password" autocomplete="off" placeholder="sk-ant-…">
          <span class="hint" id="aiKeyHint">Kept on this gateway and never shown again.</span></label>
        <label>Model <input name="ai_model" placeholder="claude-opus-5-5"></label>
        <div class="form-actions full"><button class="btn primary" type="submit">Save</button>
          <button class="btn" type="button" id="aiForget">Remove key</button><span class="hint" id="aiMsg"></span></div>
      </form>
    </section>
    <section class="card">
      <div class="card-head"><h2>Send to your SIEM</h2></div>
      <p class="lede">Every decision, as it happens: who, which tool, allowed, held or blocked and why, who approved. In batches,
        in the background, so a slow SIEM never slows an agent.</p>
      <form class="form-grid wide" id="siemForm">
        <label>Format <select name="siem_format"><option value="json">Any HTTP endpoint (JSON)</option>
          <option value="splunk">Splunk (HTTP Event Collector)</option><option value="datadog">Datadog (logs)</option>
          <option value="otlp">OpenTelemetry collector (OTLP logs, its /v1/logs URL)</option></select></label>
        <label class="full">URL <input name="siem_url" placeholder="https://splunk.example.com:8088/services/collector/event"></label>
        <label class="full">Token <input name="siem_token" type="password" autocomplete="off"
          placeholder="HEC token, Datadog API key, or a Bearer token"></label>
        <div class="form-actions full"><button class="btn primary" type="submit">Save</button>
          <button class="btn" type="button" id="siemTest">Send a test record</button><span class="hint" id="siemMsg"></span></div>
      </form>
    </section>
    <section class="card">
      <div class="card-head"><h2>Customers</h2><span class="sub" id="custCount"></span></div>
      <p class="lede">Agents copy what they read: a ticket's customer ends up in a pull request description or a public issue.
        When an agent puts text where others can read it (a PR, an issue or comment, a gist, a chat post) and it names a
        customer on this list, it waits for a person. Card numbers, social security numbers, IBANs and lists of people's
        emails or phone numbers are held there too, with no list needed.</p>
      <form class="form-grid wide" id="custForm">
        <label class="full">Customer names, one per line
          <textarea name="customers" rows="6" placeholder="Acme Corp&#10;Globex&#10;initech.com"></textarea>
          <span class="hint">Whole names, any case. Add the forms people write: the company, its domain, a product name.
            Kept on this gateway; only admins see the list.</span></label>
        <div class="form-actions full"><button class="btn primary" type="submit">Save list</button>
          <span class="hint" id="custMsg"></span></div>
      </form>
    </section>
    <section class="card">
      <div class="card-head"><h2>Agents that connect by URL</h2><span class="sub">MCP</span></div>
      <p class="lede">ChatGPT and claude.ai connectors, Devin, n8n and other cloud agents use MCP servers by URL. Add the app's
        MCP server here and give the agent its Squidbrake URL instead: every call is checked like any other and recorded as
        the agent that made it.</p>
      <div class="tbl-wrap"><table class="events"><thead><tr><th>Name</th><th>URL for your agents</th><th>The app's server</th>
        <th>Status</th><th></th></tr></thead><tbody id="mcpList"></tbody></table></div>
      <p class="hint">The agent signs in with one of its keys (Team, add an AI agent): <code>Authorization: Bearer gw_…</code>.
        If the app only takes a URL, add <code>?key=gw_…</code> to it, and treat that URL like a password.</p>
      <form class="form-grid wide" id="mcpForm">
        <label class="full">App <select name="app" id="mcpApp"><option value="">Another MCP server (fill in below)</option></select>
          <span class="hint" id="mcpAppHint">Pick an app and only its token is left to paste.</span></label>
        <label>Name <input name="name" placeholder="github" pattern="[a-z0-9][a-z0-9\-]{0,39}" required>
          <span class="hint">Lowercase, used in the URL and in rules, e.g. github.create_issue</span></label>
        <label>The app's MCP server URL <input name="url" placeholder="https://api.githubcopilot.com/mcp/" required></label>
        <label>How it signs in <select name="auth" id="mcpAuth">
            <option value="header">A token in a header</option>
            <option value="oauth">Sign in to the app (OAuth)</option></select></label>
        <label data-auth="header">Header it needs (optional) <input name="hname" placeholder="Authorization"></label>
        <label data-auth="header">Header value <input name="hvalue" type="password" placeholder="Bearer ghp_…" autocomplete="off">
          <span class="hint">Kept on this gateway and never shown again.</span></label>
        <p class="hint full" data-auth="oauth" hidden>After adding it, click <b>Connect</b> and sign in to the app. Redirect URL, if the
          app asks for one: <code id="mcpRedirect"></code></p>
        <label data-auth="oauth" hidden>Your OAuth app's client ID (optional) <input name="client_id" autocomplete="off"
          placeholder="only if the app needs its own OAuth app"></label>
        <label data-auth="oauth" hidden>Client secret <input name="client_secret" type="password" autocomplete="off"></label>
        <label data-auth="oauth" hidden>Scopes (optional) <input name="scope" placeholder="as the app's docs list them"></label>
        <div class="form-actions full"><button class="btn primary" type="submit">Add server</button>
          <span class="hint" id="mcpMsg"></span></div>
      </form>
    </section>
    <section class="card">
      <div class="card-head"><h2>Rules</h2></div>
      <p class="lede">What agents may do is decided by <code>rules.yaml</code> on the gateway: allow, block, or hold for a person
        (optionally a specific role). Edits apply within seconds. Use <b>Test a rule</b> to check what would happen.</p>
      <div id="ruleSuggest"></div>
      <h3 class="sub-h">Starter packs</h3>
      <p class="hint">Rules for a kind of agent, added to your rules.yaml after the read rule, so blocks above still win. A backup is kept.</p>
      <div id="rulePacks" class="packs"></div>
      <h3 class="sub-h">History</h3>
      <p class="hint">Every version of rules.yaml that decided something, newest first. <b>Changes</b> shows what's different
        today; <b>Put back</b> restores that version (a backup of today's is kept, and it's in the audit trail).</p>
      <div id="ruleHistory"></div>
      <pre id="ruleDiff" class="mono" hidden style="max-height:320px;overflow:auto;font-size:12px;background:var(--page);border:1px solid var(--border);border-radius:8px;padding:8px"></pre>
      <h3 class="sub-h">Test a rule</h3>
      <form class="form-grid" id="ruleTest">
        <label>Tool name <input name="name" placeholder="e.g. stripe.create_refund" required></label>
        <label>Agent (source) <input name="source" placeholder="optional"></label>
        <label class="full">Input (JSON) <input name="input" placeholder='{"amount": 250}'></label>
        <div class="form-actions full"><button class="btn" type="submit">Test a rule</button><span class="hint" id="ruleResult"></span></div>
      </form>
    </section>
  </div>
</div>

<dialog id="phoneDlg" class="wide-dlg">
  <form method="dialog">
    <h3>Approve from your phone</h3>
    <p>Scan with your phone's camera. It opens your approvals inbox, signed in as <b id="phoneAs"></b>.
      Anyone who scans this can act as you, so don't share it.</p>
    <p class="warn" id="phoneWarn" hidden>No public address is set, so this only works on the same Wi-Fi/PC. An admin can set it in Settings.</p>
    <div class="qr"><img id="phoneQr" alt="QR code for your phone approvals page"></div>
    <p class="hint">For instant pop-ups, also turn on phone notifications in Settings. On this computer, 🔔 Alerts gives you a desktop pop-up.</p>
    <div class="acts"><button class="btn primary" value="close">Done</button></div>
  </form>
</dialog>

<dialog id="stopDlg">
  <form method="dialog" id="stopForm">
    <h3>Stop agents?</h3>
    <p id="stopWhat">Every action any agent tries will be blocked immediately, until an admin resumes them. Actions already
      running aren't interrupted.</p>
    <input id="stopReason" placeholder="Reason (shown to the agents and in the audit trail)" maxlength="500" aria-label="Reason">
    <label style="display:block;margin-top:10px">For how long
      <select id="stopFor" aria-label="For how long">
        <option value="">Until someone turns it off</option>
        <option value="15">15 minutes</option>
        <option value="60">1 hour</option>
        <option value="240">4 hours</option>
      </select></label>
    <div class="acts">
      <button class="btn" type="button" id="stopCancel">Cancel</button>
      <button class="btn danger" type="submit">⏹ Stop now</button>
    </div>
  </form>
</dialog>

<dialog id="noteDlg">
  <form method="dialog" id="noteForm">
    <h3>Send a note</h3>
    <p id="noteWhat">The agent gets it with its next action, and it's kept in the audit trail. It steers; it can't approve
      anything.</p>
    <input id="noteText" placeholder="e.g. use the staging database, not prod" maxlength="2000" aria-label="Note" required
      style="width:100%;border:1px solid var(--border);background:var(--page);border-radius:8px;padding:8px 10px">
    <div class="acts">
      <button class="btn" type="button" id="noteCancel">Cancel</button>
      <button class="btn primary" type="submit">✉ Send</button>
    </div>
  </form>
</dialog>

<aside class="drawer" id="drawer" aria-hidden="true" aria-labelledby="drTitle">
  <div class="dr-head">
    <div>
      <h3 id="drTitle"></h3>
      <div id="drSub" class="muted" style="font-size:12.5px;margin-top:4px"></div>
    </div>
    <button class="btn x" id="drClose" aria-label="Close">✕</button>
  </div>
  <div class="dr-body" id="drBody"></div>
</aside>
<div class="backdrop" id="backdrop"></div>

<dialog id="shortcutDlg" class="shortcut-help">
  <h3>Approval queue shortcuts</h3>
  <table class="mini-table">
    <tbody>
      <tr><td><kbd>j</kbd></td><td>Select the next waiting request (nothing is selected until you choose)</td></tr>
      <tr><td><kbd>k</kbd></td><td>Previous waiting request</td></tr>
      <tr><td><kbd>a</kbd></td><td>Approve the selected request: press <kbd>a</kbd> twice within 3 seconds</td></tr>
      <tr><td><kbd>r</kbd></td><td>Reject: focus the note field, then press Enter to reject (Esc to cancel)</td></tr>
      <tr><td><kbd>?</kbd></td><td>Show this list</td></tr>
    </tbody>
  </table>
  <div class="acts"><button class="btn primary" type="button" id="shortcutDone">Done</button></div>
</dialog>

<dialog id="keyDlg">
  <form method="dialog" id="keyForm">
    <h3>Gateway API key</h3>
    <p>Paste your key (it starts with <code>gw_</code>). The admin key was printed when the server first started; anyone with access to the server can make a new one with <code>python server.py add-key NAME --approver</code>. It's kept in this browser only.</p>
    <input type="password" id="keyInput" autocomplete="off" placeholder="secret" aria-label="API key">
    <div class="err-msg" id="keyErr"></div>
    <div class="acts">
      <a class="btn" href="/sso/login" id="ssoBtn" hidden>Sign in with SSO</a>
      <button class="btn" type="button" id="keyCancel">Cancel</button>
      <button class="btn primary" type="submit">Connect</button>
    </div>
  </form>
</dialog>

<script>
(() => {
  "use strict";
  const $ = (s) => document.querySelector(s);
  const RANGES = {
    "1h":  { hours: 1,    bucket: "hour", label: "Calls per hour" },
    "24h": { hours: 24,   bucket: "hour", label: "Calls per hour" },
    "7d":  { hours: 168,  bucket: "hour", label: "Calls per hour" },
    "30d": { hours: 720,  bucket: "day",  label: "Calls per day" },
    "1y":  { hours: 8760, bucket: "day",  label: "Calls per day" },
  };
  const FILTERS = ["q", "status", "kind", "source", "session_id", "name", "client"];
  const STATUS = {
    completed: { label: "Completed", icon: "✓" },
    failed:    { label: "Failed",    icon: "!" },
    denied:    { label: "Denied",    icon: "✕" },
    pending:   { label: "Pending",   icon: "…" },
    awaiting_approval: { label: "Awaiting approval", icon: "?" },
  };

  // ---------- state (mirrored into the URL so views are linkable)
  const params = new URLSearchParams(location.search);
  const state = { range: RANGES[params.get("range")] ? params.get("range") : "24h", f: {} };
  FILTERS.forEach((k) => { if (params.get(k)) state.f[k] = params.get(k); });
  let rows = [], nextBefore = null, selectedId = null, lastStats = null, gen = 0, liveBusy = false;
  let listMode = "chains";
  try { if (localStorage.getItem("gw.list") === "calls") listMode = "calls"; } catch {}
  const openChains = new Set();
  const closedChains = new Set();
  let me = null, queue = [], drawerEvent = null, view = "activity";
  let shortcutQueueId = null, shortcutIndexHint = 0, approveArm = null, queueIds = [];   // approveArm: {id, timer} while waiting for the second `a`
  const linkedEvent = params.get("event");  // /dashboard?event=<id> (webhook links) opens that event

  const store = {
    get() { try { return localStorage.getItem("gw.key") || ""; } catch { return ""; } },
    set(v) { try { v ? localStorage.setItem("gw.key", v) : localStorage.removeItem("gw.key"); } catch {} },
  };
  let apiKey = store.get();
  // /dashboard#key=... signs this browser in (the fragment never reaches the server) and is then removed.
  const frag = new URLSearchParams(location.hash.slice(1)), fragKey = frag.get("key");
  if (fragKey) {
    apiKey = fragKey.trim();
    store.set(apiKey);
  }
  // #connected=cursor,codex: opened by Squidbrake itself right after it connected agents installed since setup
  const joined = (frag.get("connected") || "").split(",").filter((a) => /^[a-z0-9-]{1,40}$/.test(a));
  if (fragKey || joined.length) history.replaceState(null, "", location.pathname + location.search);
  if (joined.length) {
    const names = { "claude-code": "Claude Code", "cursor": "Cursor", "codex": "Codex", "gemini-cli": "Gemini CLI",
      "vscode": "VS Code Copilot", "antigravity": "Antigravity", "qwen-code": "Qwen Code", "factory": "Factory Droid", "kiro": "Kiro" };
    const list = joined.map((a) => names[a] || a).join(", ");
    setTimeout(() => toast(`${list} ${joined.length > 1 ? "are" : "is"} now checked by Squidbrake. Restart ${joined.length > 1 ? "them" : "it"} once to load the hook.`, 15000));
  }
  try { $("#auto").checked = localStorage.getItem("gw.auto") !== "0"; } catch {}

  function syncUrl() {
    const p = new URLSearchParams();
    if (state.range !== "24h") p.set("range", state.range);
    FILTERS.forEach((k) => state.f[k] && p.set(k, state.f[k]));
    const qs = p.toString();
    history.replaceState(null, "", location.pathname + (qs ? "?" + qs : ""));
  }

  // ---------- api
  class AuthError extends Error {}
  async function api(path, query = {}) {
    const url = new URL(path, location.origin);
    Object.entries(query).forEach(([k, v]) => v != null && v !== "" && url.searchParams.set(k, v));
    const res = await fetch(url, { headers: apiKey ? { "X-Gateway-Key": apiKey } : {} });
    if (res.status === 401) throw new AuthError("unauthorized");
    if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
    return res.json();
  }
  async function apiSend(method, path, body) {
    const res = await fetch(new URL(path, location.origin), {
      method,
      headers: { "Content-Type": "application/json", ...(apiKey ? { "X-Gateway-Key": apiKey } : {}) },
      body: method === "DELETE" ? undefined : JSON.stringify(body || {}),
    });
    let data = null;
    try { data = await res.json(); } catch {}
    if (!res.ok) {
      const d = data && data.detail;
      throw new Error(Array.isArray(d) ? d.map((x) => x.msg).join("; ") : d || `${res.status} ${res.statusText}`);
    }
    return data;
  }
  const apiPost = (path, body) => apiSend("POST", path, body);

  // ---------- passkeys (Face ID, fingerprint): sign approvals with this device
  const fromB64u = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
  const toB64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  const passkeysWork = () => !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
  let myDevices = [], devicesRequired = false;
  async function loadDevices() {
    try { const r = await api("/v1/devices"); myDevices = r.devices.filter((d) => d.person === (me && me.client)); devicesRequired = r.required; return r; }
    catch { myDevices = []; return null; }
  }
  // The device's signature over exactly this decision, or null when this browser has no registered passkey.
  async function deviceProof(eventId, outcome) {
    if (!myDevices.length || !passkeysWork()) {
      if (devicesRequired && outcome === "approve") throw new Error("Approvals need Face ID or fingerprint: set it up in Settings on this device, or approve from your registered phone.");
      return null;
    }
    const ch = await apiPost(`/v1/events/${encodeURIComponent(eventId)}/challenge`, { outcome });
    const cred = await navigator.credentials.get({ publicKey: { challenge: fromB64u(ch.challenge), rpId: ch.rp_id,
      userVerification: "required", timeout: 60000, allowCredentials: ch.allow_credentials.map((id) => ({ type: "public-key", id: fromB64u(id) })) } });
    return { credential_id: toB64u(cred.rawId), statement: ch.statement, authenticator_data: toB64u(cred.response.authenticatorData),
             client_data_json: toB64u(cred.response.clientDataJSON), signature: toB64u(cred.response.signature) };
  }
  async function registerDevice(name) {
    const o = await apiPost("/v1/devices/register/start");
    const cred = await navigator.credentials.create({ publicKey: { ...o, challenge: fromB64u(o.challenge),
      user: { ...o.user, id: fromB64u(o.user.id) }, excludeCredentials: o.excludeCredentials.map((c) => ({ ...c, id: fromB64u(c.id) })) } });
    return apiPost("/v1/devices/register/finish", { name, challenge: o.challenge, credential_id: toB64u(cred.rawId),
      client_data_json: toB64u(cred.response.clientDataJSON), attestation_object: toB64u(cred.response.attestationObject) });
  }
  let toastTimer;
  function toast(msg, ms = 3500) {
    let t = $("#toast");
    if (!t) { t = el("div", { id: "toast", class: "toast", role: "status" }); document.body.append(t); }
    t.textContent = msg; t.hidden = false;
    clearTimeout(toastTimer); toastTimer = setTimeout(() => (t.hidden = true), ms);
  }
  function sinceIso() {
    return new Date(Date.now() - RANGES[state.range].hours * 3600e3).toISOString();
  }
  function setConn(kind, text) {
    $("#conn").className = "conn " + kind;
    $("#connText").textContent = text;
  }

  // ---------- formatting
  const nf = new Intl.NumberFormat();
  const pad = (n) => String(n).padStart(2, "0");
  function fmtTime(iso) {
    const d = new Date(iso), now = new Date();
    const t = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
    if (d.toDateString() === now.toDateString()) return t;
    return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + t;
  }
  function fmtAgo(iso) {
    const s = Math.round((Date.now() - new Date(iso)) / 1000);
    if (s < 60) return `${Math.max(s, 0)}s ago`;
    if (s < 3600) return `${Math.floor(s / 60)}m ago`;
    if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
    return `${Math.floor(s / 86400)}d ago`;
  }
  function fmtDur(ms) {
    if (ms == null) return "";
    if (ms < 1) return "<1 ms";
    if (ms < 1000) return `${Math.round(ms)} ms`;
    if (ms < 60000) return `${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)} s`;
    return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
  }
  function el(tag, attrs = {}, ...kids) {
    const n = document.createElement(tag);
    for (const [k, v] of Object.entries(attrs)) {
      if (v == null || v === false) continue;
      if (k === "class") n.className = v;
      else if (k === "text") n.textContent = v;
      else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
      else n.setAttribute(k, v);
    }
    kids.flat().forEach((c) => c != null && n.append(c.nodeType ? c : document.createTextNode(String(c))));
    return n;
  }
  function statusEl(s) {
    const m = STATUS[s] || { label: s || "unknown", icon: "?" };
    return el("span", { class: "st " + s }, el("i", { "aria-hidden": "true", text: m.icon }), m.label);
  }

  // ---------- agent identity + chains
  // A chain is one conversation (session). Calls with no session are grouped under the agent and marked
  // with a dashed border so they are not read as one conversation. The same agent name always gets the same color.
  function agentOf(e) {
    return ((e && (e.source || e.client)) || "unknown").trim() || "unknown";
  }
  function agentField(e) { return e && e.source ? "source" : "client"; }
  function agentHue(name) {
    let h = 2166136261;
    for (let i = 0; i < name.length; i++) h = Math.imul(h ^ name.charCodeAt(i), 16777619);
    return (Math.abs(h) % 24) * 15;
  }
  function monogram(name) {
    const parts = String(name).split(/[^A-Za-z0-9]+/).filter(Boolean);
    const raw = parts.length >= 2 ? parts[0][0] + parts[1][0] : String(name).slice(0, 2);
    return raw.toUpperCase();
  }
  function avatarEl(name, size) {
    const n = name || "unknown";
    const h = agentHue(n);
    return el("span", {
      class: "avatar" + (size === "sm" ? " sm" : ""),
      style: `--h:${h};--hl:${h > 40 && h < 90 ? 32 : 38}%`,
      title: n, "aria-hidden": "true", text: monogram(n),
    });
  }
  function shortId(id) {
    if (!id) return "";
    return id.length <= 14 ? id : id.slice(0, 6) + "…" + id.slice(-4);
  }
  function chainTone(steps) {
    for (const s of ["awaiting_approval", "denied", "failed", "pending"]) {
      if (steps.some((e) => e.status === s)) return s;
    }
    return "completed";
  }
  function groupChains(list) {
    const sessions = new Map(), loose = new Map();
    for (const e of list) {
      if (e.session_id) {
        const g = sessions.get(e.session_id) || { key: "s:" + e.session_id, session: e.session_id, loose: false, steps: [] };
        g.steps.push(e);
        sessions.set(e.session_id, g);
      } else {
        const name = agentOf(e);
        const g = loose.get(name) || { key: "loose:" + name, session: null, loose: true, steps: [] };
        g.steps.push(e);
        loose.set(name, g);
      }
    }
    const chains = [...sessions.values(), ...loose.values()];
    for (const c of chains) {
      c.steps.sort((a, b) => a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.id < b.id ? -1 : 1);
      c.latest = c.steps[c.steps.length - 1];
      c.started = c.steps[0].created_at;
      c.agent = agentOf(c.latest);
      c.field = agentField(c.latest);
      c.client = c.latest.client;
      c.tone = chainTone(c.steps);
      c.also = [...new Set(c.steps.map(agentOf))].filter((n) => n !== c.agent);
    }
    chains.sort((a, b) => a.latest.created_at < b.latest.created_at ? 1 : a.latest.created_at > b.latest.created_at ? -1 : 0);
    return chains;
  }
  function setAgentFilter(name, field) {
    const on = state.f[field] === name;
    delete state.f.source; delete state.f.client;
    setFilter(field, on ? "" : name);
  }
  function toggleChain(key, openNow) {
    if (openNow) { openChains.delete(key); closedChains.add(key); }
    else { closedChains.delete(key); openChains.add(key); }
    renderRows();
  }
  function edgeEl() { return el("span", { class: "edge", "aria-hidden": "true" }); }
  function stepButton(s) {
    const m = STATUS[s.status] || { label: s.status || "unknown", icon: "?" };
    return el("button", {
      type: "button",
      class: "node " + (s.status || "") + (s.id === selectedId ? " sel" : ""),
      "data-id": s.id,
      title: `${s.name} · ${m.label}`,
      "aria-label": `${s.name}, ${m.label}`,
      onclick: (ev) => { ev.stopPropagation(); openDrawer(s.id); },
    },
      el("span", { class: "nm", text: s.name }),
      el("span", { class: "meta", text: fmtTime(s.created_at) + (s.would ? (s.would === "deny" ? " · would block" : " · would hold") : "") }),
    );
  }
  function flowEl(steps, chainKey) {
    const cap = 8;
    const truncated = steps.length > cap;
    const shown = truncated ? [steps[0], ...steps.slice(-(cap - 1))] : steps;
    const skipped = steps.length - shown.length;
    const box = el("div", { class: "flow", role: "list", "aria-label": "Calls in this chain, oldest first" });
    const add = (node) => { if (box.childNodes.length) box.append(edgeEl()); box.append(node); };
    shown.forEach((s, i) => {
      if (i === 1 && skipped > 0) {
        add(el("button", {
          type: "button", class: "node more", title: "Show every step",
          onclick: (ev) => { ev.stopPropagation(); toggleChain(chainKey, false); },
        }, el("span", { class: "nm", text: `+${skipped}` }), el("span", { class: "meta", text: "earlier" })));
      }
      add(stepButton(s));
    });
    return box;
  }
  function sparkEl(steps) {
    return el("div", { class: "spark", role: "list", "aria-label": "Whole chain" }, steps.map((s, i) => {
      const m = STATUS[s.status] || { label: s.status || "" };
      return el("button", {
        type: "button",
        class: "spark-dot " + (s.status || "") + (s.id === selectedId ? " sel" : ""),
        "data-id": s.id,
        title: `${i + 1}. ${s.name} · ${m.label}`,
        "aria-label": `Step ${i + 1} of ${steps.length}: ${s.name}, ${m.label}`,
        onclick: (ev) => { ev.stopPropagation(); openDrawer(s.id); },
      });
    }));
  }
  function tallyEls(steps) {
    const order = ["awaiting_approval", "denied", "failed", "pending", "completed"];
    const counts = {};
    for (const s of steps) counts[s.status] = (counts[s.status] || 0) + 1;
    return order.filter((k) => counts[k]).map((k) => {
      const m = STATUS[k] || { label: k, icon: "?" };
      return el("span", { class: "tally " + k, title: m.label }, el("i", { "aria-hidden": "true", text: m.icon }), String(counts[k]));
    });
  }
  function chainMeta(c) {
    const bits = [];
    if (c.loose) bits.push(el("span", { text: "No session" }));
    else bits.push(el("button", {
      type: "button", class: "pill-link", title: c.session, text: "chain " + shortId(c.session),
      onclick: () => setFilter("session_id", state.f.session_id === c.session ? "" : c.session),
    }));
    if (c.client && c.client !== c.agent) bits.push(el("span", { text: "key " + c.client }));
    bits.push(el("span", { text: `${c.steps.length} call${c.steps.length === 1 ? "" : "s"}` }));
    if (c.steps.length > 1) {
      const ms = new Date(c.latest.created_at) - new Date(c.started);
      if (ms >= 1000) bits.push(el("span", { text: "ran " + fmtDur(ms) }));
    }
    bits.push(el("span", { text: fmtAgo(c.latest.created_at) }));
    const wrap = el("div", { class: "chain-meta" });
    bits.forEach((b, i) => { if (i) wrap.append(" · "); wrap.append(b); });
    return wrap;
  }
  function focusStep(steps) {
    return steps.find((s) => s.status === "awaiting_approval")
      || [...steps].reverse().find((s) => s.status === "denied" || s.status === "failed")
      || null;
  }
  function railEl(steps, currentId) {
    return el("ol", { class: "rail" }, steps.map((s, i) => el("li", { class: (s.id === currentId ? "on " : "") + (s.status || ""), "data-id": s.id },
      el("div", { class: "tick", "aria-hidden": "true" },
        el("span", { class: "st " + (s.status || "") }, el("i", { text: (STATUS[s.status] || { icon: "?" }).icon })),
        el("span", { class: "line" })),
      el("div", { class: "body" },
        el("div", { class: "st-line" },
          el("button", { type: "button", class: "pill-link mono step-name", text: s.name, onclick: () => openDrawer(s.id) }),
          el("span", { class: "muted", text: `${i + 1}/${steps.length}` }),
          el("span", { class: "muted", text: (STATUS[s.status] || {}).label || s.status || "" }),
          el("span", { class: "muted", text: fmtTime(s.created_at) }),
          s.would ? el("span", { class: "tag would", text: s.would === "deny" ? "would block" : "would hold" }) : null,
          s.decided_by && s.decided_by !== "timeout" ? el("span", { class: "muted", text: `${s.decision === "allow" ? "approved" : "rejected"} by ${s.decided_by}${s.decision_note ? ": “" + s.decision_note + "”" : ""}` }) : null),
        summarizeInput(s.input) ? el("div", { class: "st-in", text: summarizeInput(s.input) }) : null,
        summarizeOutput(s.output) ? el("div", { class: "st-out", text: summarizeOutput(s.output) }) : null,
        s.status === "denied" && s.reason ? el("div", { class: "st-in", text: s.reason }) : null,
        s.error ? el("div", { class: "st-in", text: s.error }) : null,
      ),
    )));
  }
  function chainCard(c, freshIds) {
    const open = !closedChains.has(c.key) && (openChains.has(c.key) || (!!state.f.session_id && c.session === state.f.session_id));
    const hue = agentHue(c.agent);
    const fresh = c.steps.some((s) => freshIds.has(s.id));
    const oldestLoaded = rows.reduce((a, e) => e.created_at < a ? e.created_at : a, rows[0].created_at);
    const maybeMore = !c.loose && nextBefore && c.steps[0].created_at === oldestLoaded;
    const focus = open ? null : focusStep(c.steps);
    const kids = [
      el("div", { class: "chain-head" },
        avatarEl(c.agent),
        el("div", { class: "chain-id" },
          el("div", { class: "chain-name" },
            el("button", {
              type: "button", class: "agent-name", title: `Filter by ${c.agent}`, text: c.agent,
              onclick: () => setAgentFilter(c.agent, c.field),
            }),
            c.also.length ? el("span", { class: "muted", style: "font-weight:400;font-size:12px", text: "with " + c.also.join(", ") }) : null),
          chainMeta(c),
          c.loose ? el("div", { class: "loose-note", text: c.steps.length > 1
            ? "These calls have no session, so they are not one conversation. Grouped under the agent."
            : "This call has no session, so it isn’t part of a chain." }) : null,
        ),
        el("div", { class: "chain-side" }, tallyEls(c.steps), c.session ? sessionControl(c.session, true) : null),
      ),
    ];
    if (c.steps.length > 8) kids.push(sparkEl(c.steps));
    if (!open) kids.push(flowEl(c.steps, c.key));
    if (focus) {
      const m = STATUS[focus.status] || { label: focus.status, icon: "?" };
      kids.push(el("div", { class: "chain-focus" },
        el("span", { class: "st " + focus.status }, el("i", { "aria-hidden": "true", text: m.icon }), m.label),
        " ",
        el("button", { type: "button", class: "pill-link mono", text: focus.name, onclick: () => openDrawer(focus.id) }),
        summarizeInput(focus.input) ? el("div", { class: "st-in", text: summarizeInput(focus.input) }) : null,
        focus.reason ? el("div", { class: "sec", style: "margin-top:4px", text: focus.reason }) : null,
      ));
    }
    if (open && c.steps.length > 1) kids.push(railEl(c.steps, selectedId));
    if (c.steps.length > 1 || maybeMore) {
      kids.push(el("div", { class: "chain-foot" },
        c.steps.length > 1 ? el("button", {
          type: "button", class: "linkish", text: open ? "Hide details" : "Show details",
          onclick: () => toggleChain(c.key, open),
        }) : el("span"),
        maybeMore ? el("span", { class: "hint", text: "Earlier calls in this chain may still be on the next page." }) : null,
      ));
    }
    return el("article", {
      class: ["chain", c.tone, c.loose ? "loose" : "", fresh ? "fresh" : ""].filter(Boolean).join(" "),
      style: `--h:${hue}`,
      "data-chain": c.key,
      "aria-label": `${c.agent}, ${c.loose ? "loose calls" : "chain " + (c.session || "")}, ${c.steps.length} calls`,
    }, kids);
  }
  function renderAgentStrip(chains) {
    const box = $("#agentStrip");
    const map = new Map();
    for (const c of chains) {
      const key = c.field + ":" + c.agent;
      const a = map.get(key) || { name: c.agent, field: c.field, chains: 0, calls: 0 };
      a.chains += 1; a.calls += c.steps.length;
      map.set(key, a);
    }
    const agents = [...map.values()].sort((a, b) => b.calls - a.calls);
    box.hidden = agents.length === 0;
    box.replaceChildren(...agents.map((a) => {
      const on = state.f[a.field] === a.name;
      return el("button", {
        type: "button", class: "agent-chip", "aria-pressed": String(on),
        title: on ? `Clear ${a.name}` : `Show only ${a.name}`,
        onclick: () => setAgentFilter(a.name, a.field),
      }, avatarEl(a.name, "sm"), el("span", { text: a.name }),
        el("span", { class: "ct", text: a.chains > 1 ? `${a.chains} chains · ${a.calls} calls` : `${a.calls} call${a.calls === 1 ? "" : "s"}` }));
    }));
  }
  function applyListMode() {
    const chainsOn = listMode === "chains";
    $("#chains").hidden = !chainsOn;
    $("#callsWrap").hidden = chainsOn;
    $("#evTitle").textContent = chainsOn ? "Chains" : "Calls";
    document.querySelectorAll("#evView button").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.v === listMode)));
  }

  // ---------- filters UI
  function renderFilters() {
    document.querySelectorAll("#range button").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.r === state.range)));
    ["q", "status", "kind", "source", "session_id"].forEach((k) => {
      const input = $("#" + k);
      if (document.activeElement !== input) input.value = state.f[k] || "";
    });
    // exact-match filters without their own input show as removable chips
    const chips = $("#chips");
    chips.replaceChildren();
    ["name", "client"].forEach((k) => {
      if (!state.f[k]) return;
      chips.append(el("span", { class: "chip" }, `${k}: `, el("b", { text: state.f[k] }),
        el("button", { "aria-label": `Remove ${k} filter`, text: "×", onclick: () => setFilter(k, "") })));
    });
    $("#chartTitle").textContent = RANGES[state.range].label;
  }
  function setFilter(k, v) {
    if (v) state.f[k] = v; else delete state.f[k];
    syncUrl(); renderFilters(); reload();
  }

  // ---------- tiles
  function renderTiles(stats) {
    const by = stats.by_status || {};
    const total = Object.values(by).reduce((a, b) => a + b, 0);
    const tiles = $("#tiles");
    tiles.replaceChildren();
    const mk = (key, label, value, lblNode) => el("button", {
      class: "card tile", "aria-pressed": String((state.f.status || "") === key),
      onclick: () => setFilter("status", state.f.status === key ? "" : key),
      title: key ? `Show only ${label.toLowerCase()}` : "Show all statuses",
    },
      el("div", { class: "lbl" }, lblNode || label),
      el("div", { class: "val", text: nf.format(value) }),
      el("div", { class: "pct", text: key && total ? `${((value / total) * 100).toFixed(value / total < 0.01 && value ? 2 : 1)}%` : `last ${state.range}` }),
    );
    tiles.append(mk("", "Total calls", total));
    for (const s of ["awaiting_approval", "completed", "failed", "denied", "pending"]) tiles.append(mk(s, STATUS[s].label, by[s] || 0, statusEl(s)));
  }

  // ---------- timeline chart
  function buildBuckets(range) {
    const { hours, bucket } = RANGES[range];
    const out = [];
    if (bucket === "hour") {
      const end = new Date(); end.setUTCMinutes(0, 0, 0);
      for (let i = hours - 1; i >= 0; i--) {
        const d = new Date(end.getTime() - i * 3600e3);
        out.push({ key: d.toISOString().slice(0, 13), start: d, ms: 3600e3 });
      }
    } else {
      const end = new Date(); end.setUTCHours(0, 0, 0, 0);
      for (let i = Math.ceil(hours / 24) - 1; i >= 0; i--) {
        const d = new Date(end.getTime() - i * 86400e3);
        out.push({ key: d.toISOString().slice(0, 10), start: d, ms: 86400e3 });
      }
    }
    return out;
  }
  function niceTicks(max) {
    if (max <= 0) return [0, 1];
    const raw = max / 4, mag = 10 ** Math.floor(Math.log10(raw)), f = raw / mag;
    const step = Math.max(1, (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10) * mag);
    const top = Math.ceil(max / step) * step, ticks = [];
    for (let v = 0; v <= top + 1e-9; v += step) ticks.push(v);
    return ticks;
  }
  function bucketLabel(b, bucket, long) {
    const d = b.start;
    if (bucket === "day") return d.toLocaleDateString(undefined, long ? { weekday: "short", month: "short", day: "numeric" } : { month: "short", day: "numeric" });
    const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
    if (!long) return hm;
    const e = new Date(d.getTime() + b.ms);
    return `${d.toLocaleDateString(undefined, { month: "short", day: "numeric" })}, ${hm}–${pad(e.getHours())}:${pad(e.getMinutes())}`;
  }

  function renderChart(stats) {
    const { bucket } = RANGES[state.range];
    const buckets = buildBuckets(state.range);
    const byKey = Object.fromEntries((stats.timeline || []).map((t) => [t.bucket, t]));
    buckets.forEach((b) => Object.assign(b, byKey[b.key] || { count: 0, denied: 0, failed: 0 }));
    const box = $("#chart");
    const total = buckets.reduce((a, b) => a + b.count, 0);
    renderChartTable(buckets, bucket);
    if (!total) { box.replaceChildren(el("div", { class: "empty", text: "No calls in this period." })); return; }

    const W = Math.max(box.clientWidth, 280), H = 200;
    const m = { t: 10, r: 4, b: 24, l: 40 };
    const pw = W - m.l - m.r, ph = H - m.t - m.b;
    const ticks = niceTicks(Math.max(...buckets.map((b) => b.count)));
    const top = ticks[ticks.length - 1];
    const y = (v) => m.t + ph - (v / top) * ph;
    const colW = pw / buckets.length;
    const gap = colW > 6 ? 2 : colW > 3 ? 1 : 0;
    const bw = Math.max(colW - gap, 1);

    const NS = "http://www.w3.org/2000/svg";
    const s = (tag, a) => { const n = document.createElementNS(NS, tag); for (const k in a) n.setAttribute(k, a[k]); return n; };
    const svg = s("svg", { viewBox: `0 0 ${W} ${H}`, role: "img",
      "aria-label": `${RANGES[state.range].label}: ${nf.format(total)} calls in the last ${state.range}. Use "View as table" for exact values.` });
    for (const t of ticks) {
      svg.append(s("line", { class: t === 0 ? "baseline" : "gridline", x1: m.l, x2: W - m.r, y1: y(t), y2: y(t) }));
      const lbl = s("text", { class: "axis", x: m.l - 8, y: y(t) + 4, "text-anchor": "end" });
      lbl.textContent = nf.format(t);
      svg.append(lbl);
    }
    const every = Math.max(1, Math.ceil(buckets.length / Math.max(2, Math.floor(pw / 70))));
    const hovers = [];
    buckets.forEach((b, i) => {
      const x = m.l + i * colW + gap / 2;
      const hc = s("rect", { class: "hovercol", x: m.l + i * colW, y: m.t, width: colW, height: ph });
      hovers.push(hc);
      svg.append(hc);
      if (b.count > 0) {
        const h = Math.max(ph - (y(b.count) - m.t), 1.5), yt = m.t + ph - h;
        const r = Math.min(4, bw / 2, h);
        svg.append(s("path", { class: "bar",
          d: `M${x},${yt + h}V${yt + r}A${r},${r} 0 0 1 ${x + r},${yt}H${x + bw - r}A${r},${r} 0 0 1 ${x + bw},${yt + r}V${yt + h}Z` }));
      }
      if (i % every === 0 && (i + every <= buckets.length || buckets.length <= 2)) {
        const lbl = s("text", { class: "axis", x: m.l + i * colW + colW / 2, y: H - 6, "text-anchor": "middle" });
        lbl.textContent = bucketLabel(b, bucket, false);
        svg.append(lbl);
      }
    });
    // one hit layer for the whole plot: bigger than any bar, works for 1px bars too
    const hit = s("rect", { x: m.l, y: m.t, width: pw, height: ph, fill: "transparent" });
    const tip = $("#tip");
    let cur = -1;
    const hide = () => { tip.style.display = "none"; if (cur >= 0) hovers[cur].classList.remove("on"); cur = -1; };
    hit.addEventListener("mousemove", (ev) => {
      const rect = svg.getBoundingClientRect(), sx = W / rect.width;
      const i = Math.min(buckets.length - 1, Math.max(0, Math.floor(((ev.clientX - rect.left) * sx - m.l) / colW)));
      if (i !== cur) {
        if (cur >= 0) hovers[cur].classList.remove("on");
        hovers[i].classList.add("on"); cur = i;
        const b = buckets[i];
        tip.replaceChildren(
          el("div", { class: "t", text: bucketLabel(b, bucket, true) }),
          el("div", { class: "r" }, el("span", { class: "sw" }), el("span", { class: "n", text: nf.format(b.count) }), "calls"),
          b.denied ? el("div", { class: "r" }, statusEl("denied"), el("span", { class: "n", text: nf.format(b.denied) })) : null,
          b.failed ? el("div", { class: "r" }, statusEl("failed"), el("span", { class: "n", text: nf.format(b.failed) })) : null,
        );
        tip.style.display = "block";
      }
      const boxRect = box.parentElement.getBoundingClientRect();
      let left = ev.clientX - boxRect.left + 14;
      if (left + tip.offsetWidth > boxRect.width - 4) left = ev.clientX - boxRect.left - tip.offsetWidth - 14;
      tip.style.left = Math.max(4, left) + "px";
      tip.style.top = Math.max(0, ev.clientY - boxRect.top - tip.offsetHeight - 10) + "px";
    });
    hit.addEventListener("mouseleave", hide);
    hit.addEventListener("click", () => {
      if (cur < 0) return;
      const b = buckets[cur];
      // Zoom the event list to that bucket.
      state.window = { since: b.start.toISOString(), before: new Date(b.start.getTime() + b.ms).toISOString(), label: bucketLabel(b, bucket, true) };
      reloadEvents();
    });
    svg.style.cursor = "pointer";
    svg.append(hit);
    box.replaceChildren(svg);
  }
  function renderChartTable(buckets, bucket) {
    const wrap = $("#chartTable");
    const tbl = el("table", { class: "mini-table" },
      el("thead", {}, el("tr", {}, el("th", { text: bucket === "day" ? "Day" : "Hour" }), el("th", { text: "Calls" }), el("th", { text: "Denied" }), el("th", { text: "Failed" }))),
      el("tbody", {}, buckets.slice().reverse().filter((b) => b.count).map((b) =>
        el("tr", {}, el("td", { text: bucketLabel(b, bucket, true) }), el("td", { text: nf.format(b.count) }), el("td", { text: nf.format(b.denied) }), el("td", { text: nf.format(b.failed) })))),
    );
    wrap.replaceChildren(tbl);
  }

  // ---------- top tools
  function renderTop(stats) {
    const list = $("#top"), top = stats.top_names || [];
    list.replaceChildren();
    if (!top.length) { list.append(el("li", { class: "empty", text: "Nothing yet." })); return; }
    const max = top[0].count;
    top.forEach((t) => list.append(el("li", {},
      el("button", { title: `Filter by ${t.name}`, onclick: () => setFilter("name", state.f.name === t.name ? "" : t.name) },
        el("span", { class: "nm", text: t.name }),
        el("span", { class: "ct", text: nf.format(t.count) }),
        el("span", { class: "track" }, el("span", { class: "fill", style: `display:block;width:${(t.count / max) * 100}%` })),
      ))));
  }

  // ---------- events table
  function rowEl(e, fresh) {
    const link = (k, v) => v ? el("button", { class: "pill-link trunc", title: `Filter by ${k}: ${v}`, text: v,
      onclick: (ev) => { ev.stopPropagation(); setFilter(k, v); } }) : el("span", { class: "muted", text: "—" });
    const tr = el("tr", { tabindex: "0", "data-id": e.id, class: [fresh ? "fresh" : "", e.id === selectedId ? "sel" : ""].join(" ").trim() || null,
      onclick: () => openDrawer(e.id), onkeydown: (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); openDrawer(e.id); } } },
      el("td", { title: new Date(e.created_at).toLocaleString() }, el("span", { text: fmtTime(e.created_at) }), " ", el("span", { class: "muted", text: fmtAgo(e.created_at) })),
      el("td", {}, statusEl(e.status), e.would ? el("span", { class: "tag would", text: e.would === "deny" ? "would block" : "would hold" }) : null),
      el("td", { class: "mono trunc", title: e.name, text: e.name }),
      el("td", { class: "sec", text: e.kind }),
      el("td", {}, avatarEl(agentOf(e), "sm"), " ", link(agentField(e), agentOf(e))),
      el("td", { class: "mono" }, link("session_id", e.session_id)),
      el("td", { class: "num sec", text: fmtDur(e.duration_ms) }),
      el("td", { class: "mono sec trunc", title: e.reason || "", text: e.rule_id || "" }),
    );
    return tr;
  }
  function renderRows(freshIds = new Set()) {
    const tbody = $("#rows");
    tbody.replaceChildren(...rows.map((e) => rowEl(e, freshIds.has(e.id))));
    const chains = groupChains(rows);
    $("#chains").replaceChildren(...chains.map((c) => chainCard(c, freshIds)));
    renderAgentStrip(chains);
    applyListMode();
    $("#noRows").hidden = rows.length > 0;
    const narrowed = Object.keys(state.f).some((k) => state.f[k]) || state.window;
    $("#noRows").replaceChildren(
      el("div", { class: "empty-title", text: narrowed ? "Nothing matches" : (listMode === "chains" ? "No chains yet" : "No calls yet") }),
      el("div", { text: narrowed
        ? "Try a wider time range, or clear a filter."
        : "When an agent works through a session, the whole run shows up here as one chain." }),
    );
    $("#more").hidden = !nextBefore;
    const chainN = chains.length;
    $("#evCount").textContent = rows.length
      ? (listMode === "chains"
        ? `${nf.format(chainN)} chain${chainN === 1 ? "" : "s"} · ${nf.format(rows.length)} call${rows.length === 1 ? "" : "s"}${nextBefore ? "" : " · all loaded"}`
        : `${nf.format(rows.length)} shown${nextBefore ? "" : " · all loaded"}`)
      : "";
    const info = $("#footInfo");
    info.replaceChildren();
    if (state.window) {
      info.append(`Showing ${state.window.label} `,
        el("button", { class: "linkish", text: "× back to full range", onclick: () => { state.window = null; reloadEvents(); } }));
    } else info.append(listMode === "chains" ? `Chains newest first · range ${state.range}` : `Newest first · range ${state.range}`);
  }
  function eventQuery(before) {
    const w = state.window;
    return { ...state.f, since: w ? w.since : sinceIso(), before: before || (w ? w.before : null), limit: 100 };
  }
  // Every load carries the generation it started in; a response from an older generation
  // (filters changed while it was in flight) is dropped instead of overwriting the new view.
  async function loadEvents(append, g) {
    const data = await api("/v1/events", eventQuery(append ? nextBefore : null));
    if (g !== gen) return;
    rows = append ? rows.concat(data.events) : data.events;
    nextBefore = data.next_before;
    renderRows();
  }
  // Live refresh: merge the newest page into what's loaded instead of resetting scroll/paging.
  async function refreshEvents(g) {
    if (state.window) return;
    const data = await api("/v1/events", eventQuery(null));
    if (g !== gen) return;
    const byId = new Map(rows.map((r, i) => [r.id, i]));
    const fresh = [];
    for (const e of data.events) {
      if (byId.has(e.id)) rows[byId.get(e.id)] = e;
      else fresh.push(e);
    }
    const cutoff = sinceIso();
    rows = fresh.concat(rows).filter((r) => r.created_at >= cutoff.slice(0, 23));
    if (!rows.length) nextBefore = data.next_before;
    renderRows(new Set(rows.length === fresh.length ? [] : fresh.map((e) => e.id)));
  }

  async function loadStats(g) {
    const { hours, bucket } = RANGES[state.range];
    // Tiles always show every status (they are the status filter); chart + top tools follow all filters.
    const { status, ...rest } = state.f;
    const [all, filtered] = await Promise.all([
      api("/v1/stats", { ...rest, hours, bucket }),
      status ? api("/v1/stats", { ...state.f, hours, bucket }) : null,
    ]);
    if (g !== gen) return;
    lastStats = filtered || all;
    renderTiles(all); renderChart(lastStats); renderTop(lastStats);
  }

  async function run(g, fn) {
    try {
      await fn();
      if (g === gen) setConn("ok", "Connected");
    } catch (e) {
      if (g !== gen) return;
      if (e instanceof AuthError) { setConn("err", "API key required"); openKeyDialog(!!apiKey); }
      else setConn("err", e.message.startsWith("403") ? "Not allowed with this key" : "Can't reach gateway");
    }
  }
  // User actions start a new generation and always run.
  // What Squidbrake stopped in the last 7 days, worth showing (and sharing): only when it stopped something
  async function loadSaved() {
    const a = (await api("/v1/reports/week")).actions || {}, n = (a.blocked || 0) + (a.rejected || 0);
    const card = $("#savedCard");
    if (!card) return;
    card.hidden = !n;
    if (!n) return;
    $("#savedTitle").textContent = `Squidbrake stopped ${n} risky agent action${n === 1 ? "" : "s"} in the last 7 days`;
    const text = `My AI coding agents tried ${n} risky action${n === 1 ? "" : "s"} this week (deleting files, force-pushing, `
      + `reading secrets). Squidbrake stopped ${n === 1 ? "it" : "each one"} before it ran. `
      + `https://squidbrake.com/go/share`;
    $("#savedShare").href = `https://www.linkedin.com/feed/?shareActive=true&text=${encodeURIComponent(text)}`;
  }
  const reload = () => {
    state.window = null; const g = ++gen;
    loadSaved().catch(() => {});
    return run(g, () => Promise.all([loadMe().then(() => Promise.all([loadQueue(), loadControls(), loadView()])),
                                     loadStats(g), loadEvents(false, g)]));
  };
  const reloadEvents = () => { const g = ++gen; return run(g, () => loadEvents(false, g)); };
  // Live ticks never overlap each other and yield to user actions.
  const live = async () => {
    if (liveBusy) return;
    liveBusy = true;
    const g = gen;
    try {
      await run(g, () => Promise.all([loadQueue(), loadControls(), loadStats(g), refreshEvents(g),
                                      view === "reports" ? loadReports() : null]));
    } finally { liveBusy = false; }
  };

  // ---------- approvals
  async function loadMe() {
    me = await api("/v1/me");
    if (me.kind === "agent") {   // it gets in, but every read is 403: say why, not "can't reach gateway"
      openKeyDialog(true, `"${me.client}" is an agent's key: agents send actions, they can't open the dashboard. ` +
        "Paste a person's key (make one with: squidbrake add-key YOUR-NAME --approver).");
      throw new AuthError("agent key");
    }
    // the Team plan (license.py): what blocks and holds is free; a team's features need a license after the grace date
    const lic = me.license, card = $("#planCard");
    if (lic && card) {
      card.hidden = lic.licensed;
      const list = lic.features.join(", ");
      $("#planTitle").textContent = lic.team_features ? `From ${lic.grace_until}, these are part of the Team plan` : "Free plan";
      $("#planText").textContent = lic.team_features
        ? `${list[0].toUpperCase()}${list.slice(1)}. Blocking and holding stay free.`
        : `${list[0].toUpperCase()}${list.slice(1)} are part of the Team plan. Blocking and holding are on.`;
      $("#planLink").href = lic.upgrade_url;
    }
    const ver = $("#ver");
    if (ver && me.version) {
      // this version's release page on GitHub: what changed, and how to install it (or any older one) again
      const page = (v) => `https://github.com/batrapulkit/squidbrake/releases/tag/v${encodeURIComponent(v)}`;
      ver.textContent = `v${me.version}`;
      ver.href = page(me.version);
      ver.title = "This version on GitHub: what changed, and how to install it or an older one";
      ver.parentElement.querySelector(".newver")?.remove();
      if (me.latest) ver.after(el("a", { class: "newver", href: page(me.latest), target: "_blank", rel: "noopener",
        title: `Version ${me.latest} is out: run the install line again, or pipx upgrade squidbrake`, text: `new: v${me.latest}` }));
    }
    $("#meName").textContent = me.auth_enabled === false ? "Local" : me.client;
    $("#keyBtn").hidden = me.auth_enabled === false;
    document.querySelectorAll("[data-admin]").forEach((n) => (n.hidden = !me.is_admin));
    if (me.can_approve) loadDevices();
    $("#stopBtn").hidden = !(me.can_approve || me.is_admin);
    const shadowAll = me.mode === "shadow", shadowSome = (me.shadow_agents || []).length > 0;
    $("#modeBtn").setAttribute("aria-pressed", String(shadowAll));
    $("#modeText").textContent = shadowAll ? "Observe on" : "Observe off";
    $("#shadowBanner").hidden = !(shadowAll || shadowSome);
    $("#shadowBanner").replaceChildren(el("b", { text: "👁 Shadow mode" }), " ", el("span", { text: shadowAll
      ? "is on: nothing is blocked or held. Squidbrake records what it would have done (see Reports). Click 👁 Observe on (top right) to enforce again."
      : `for ${me.shadow_agents.join(", ")}: their actions aren't blocked or held, only recorded. Everyone else is enforced.` }));
    if (me.plan === "free") {
      const left = Math.max(0, Math.ceil((new Date(me.trial_ends) - Date.now()) / 86400000));
      $("#shadowBanner").hidden = false;
      $("#shadowBanner").replaceChildren(el("b", { text: me.trial_over ? "Your free 14 days have ended" : `Free plan: ${left} day${left === 1 ? "" : "s"} left` }),
        " ", el("span", { text: me.trial_over ? "Everything is still recorded, but nothing is blocked or held. " : "One person; agents unlimited. " }),
        el("a", { href: me.upgrade_url, target: "_blank", rel: "noopener", text: me.trial_over ? "Keep it on" : "Add your team" }));
    }
    if (!me.is_admin && view === "settings") showView("activity");
  }
  // Why this viewer can't decide on e, or null if they can.
  function cannotDecide(e) {
    if (!me) return "Checking permissions…";
    if (!me.can_approve) return `Key "${me.client}" can't approve. Use an approver key.`;
    if (me.auth_enabled && e.client === me.client) return "This request came from your own key; someone else must decide.";
    if ((e.approvals || []).some((a) => a.by === me.client))
      return `You approved. Waiting for ${(e.required_approvals || 1) - e.approvals.length} more approval(s) from someone else.`;
    if (me.auth_enabled && e.approvers && e.approvers.length && !e.approvers.includes(me.client))
      return `Only ${e.approvers.join(", ")} can decide this one.`;
    return null;
  }
  function clockEl(e) {
    return el("span", { class: "clock", "data-deadline": e.approval_deadline, "data-then": e.approval_on_timeout || "deny" });
  }
  function tickClocks() {
    const now = Date.now();
    document.querySelectorAll(".clock[data-deadline]").forEach((c) => {
      const s = Math.round((new Date(c.dataset.deadline) - now) / 1000);
      const then = c.dataset.then === "allow" ? "auto-allows" : "auto-denies";
      c.textContent = s > 0 ? `${then} in ${Math.floor(s / 60)}:${pad(s % 60)}` : `timed out, ${then.replace("auto-", "")}…`;
      c.classList.toggle("soon", s <= 60);
    });
  }
  function decisionControls(e, compact) {
    const why = cannotDecide(e);
    if (why) return el("span", { class: "hint", text: why });
    const isQ = e.kind === "question";   // ask_user: the note is the answer the agent gets
    const note = el("input", { placeholder: isQ ? "Your answer (the agent gets this)" : "Note (optional)",
      "aria-label": isQ ? `Answer to ${agentOf(e)}` : `Note for ${e.name}`, maxlength: "2000",
      onkeydown: (ev) => {
        if (ev.key === "Escape") { ev.stopPropagation(); note.blur(); }
        else if (ev.key === "Enter" && !ev.isComposing && compact) { ev.preventDefault(); note.closest("div").querySelector(isQ ? ".btn.approve" : ".btn.reject").click(); }
      } });
    const go = (outcome) => async (ev) => {
      if (outcome === "approve" && e.cannot_undo &&
          !confirm(`This can't be undone once it runs:\n\n${e.reason || e.name}\n\nApprove anyway?`)) return;
      const btns = ev.target.parentElement.querySelectorAll("button");
      btns.forEach((b) => (b.disabled = true));
      try {
        const device_proof = await deviceProof(e.id, outcome);
        const r = await apiPost(`/v1/events/${encodeURIComponent(e.id)}/${outcome}`, { note: note.value.trim() || null, device_proof });
        toast(isQ ? (outcome === "approve" ? "Answer sent" : "Declined")
          : r && r.decision === "review" ? `Your approval is in: ${e.name} needs someone else too`
          : `${outcome === "approve" ? "Approved" : "Rejected"} ${e.name}`);
        shortcutIndexHint = Math.max(0, queue.findIndex((q) => q.id === e.id));   // the next `j` lands on the request that moved up
        queue = queue.filter((q) => q.id !== e.id);
        shortcutQueueId = null; cancelApprove();   // never carry a selection over to the next request
        renderQueue();
        if (drawerEvent && drawerEvent.id === e.id) refreshDrawer();
        live();
      } catch (err) {
        toast(String(err.message || err));
        btns.forEach((b) => (b.disabled = false));
        loadQueue().catch(() => {});
      }
    };
    return el("div", { class: compact ? "acts" : "appr-acts" }, note,
      el("button", { class: "btn reject", onclick: go("reject") }, isQ ? "✕ Decline" : "✕ Reject"),
      el("button", { class: "btn approve", onclick: go("approve") }, isQ ? "✓ Send answer" : "✓ Approve"));
  }
  function preview(v) {
    if (v == null) return "(no input)";
    const s = typeof v === "string" ? v : JSON.stringify(v);
    return s.length > 240 ? s.slice(0, 240) + "…" : s;
  }
  // ---------- approval queue keyboard shortcuts
  function isTypingTarget(t) {
    return !!t && (t.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(t.tagName));
  }
  function queueRow(id) { return id ? $("#aq").querySelector(`li[data-id="${CSS.escape(id)}"]`) : null; }
  function updateShortcutSelection() {
    document.querySelectorAll("#aq li[data-id]").forEach((li) => {
      const on = li.dataset.id === shortcutQueueId;
      li.classList.toggle("shortcut-selected", on);
      li.setAttribute("aria-selected", String(on));
    });
  }
  // Only the person picks a request (j/k or a click); the dashboard never selects one for them.
  function setQueueSelection(id) {
    if (approveArm && approveArm.id !== id) cancelApprove();
    shortcutQueueId = id;
    updateShortcutSelection();
  }
  function selectQueueIndex(i) {
    if (!queue.length) { setQueueSelection(null); return; }
    setQueueSelection(queue[Math.max(0, Math.min(i, queue.length - 1))].id);
    shortcutIndexHint = 0;
    const li = queueRow(shortcutQueueId);
    if (li) li.scrollIntoView({ block: "nearest" });
  }
  function moveQueueSelection(dir) {
    const i = queue.findIndex((q) => q.id === shortcutQueueId);
    if (i >= 0) selectQueueIndex(Math.max(0, Math.min(i + dir, queue.length - 1)));
    else selectQueueIndex(dir > 0 ? shortcutIndexHint : shortcutIndexHint - 1);   // nothing selected: start at the top, or just after the last decision
  }
  // Approving takes two presses of `a` on the same request within APPROVE_CONFIRM_MS.
  const APPROVE_CONFIRM_MS = 3000;
  function cancelApprove() {
    if (!approveArm) return;
    clearTimeout(approveArm.timer);
    const li = queueRow(approveArm.id);
    if (li) li.querySelectorAll(".arm").forEach((n) => n.remove());
    approveArm = null;
  }
  function approveSelected() {
    const id = shortcutQueueId, li = queueRow(id);
    const btn = li && li.querySelector(".btn.approve");
    if (!btn || btn.disabled) return;
    if (approveArm && approveArm.id === id) { cancelApprove(); btn.click(); return; }   // second press: approve
    cancelApprove();
    const q = queue.find((x) => x.id === id);
    li.append(el("div", { class: "arm", role: "status", text: `Press a again to approve ${q ? q.name : "this request"}` }));
    approveArm = { id, timer: setTimeout(cancelApprove, APPROVE_CONFIRM_MS) };
  }
  function focusRejectNote() {
    const note = queueRow(shortcutQueueId) && queueRow(shortcutQueueId).querySelector(".acts input");
    if (note) { note.scrollIntoView({ block: "nearest" }); note.focus(); }
  }
  function shortcutsBlocked(ev) {
    return ev.ctrlKey || ev.metaKey || ev.altKey || ev.isComposing || isTypingTarget(ev.target) ||
      !!document.querySelector("dialog[open]") || selectedId;   // a dialog or the detail drawer has the user's attention
  }

  async function loadQueue() {
    const data = await api("/v1/events", { status: "awaiting_approval", limit: 200 });
    queue = data.events.sort((a, b) => (a.approval_deadline || "").localeCompare(b.approval_deadline || ""));
    renderQueue();
    notifyQueue();
    // Someone else (or the timeout) decided the event that's open in the drawer.
    if (drawerEvent && drawerEvent.status === "awaiting_approval" && !queue.some((q) => q.id === drawerEvent.id)) refreshDrawer();
  }
  // Reconcile by id so a live refresh never wipes a note someone is typing.
  function renderQueue() {
    const box = $("#approvals"), list = $("#aq");
    box.hidden = queue.length === 0;
    $("#aqCount").textContent = queue.length;
    const keys = " · j/k move · a approve · r reject · ? shortcuts";
    $("#aqHint").textContent = me && !me.can_approve ? `Viewing as "${me.client}" (not an approver)` + keys.replace(" · a approve · r reject", "") : "oldest deadline first" + keys;
    // No automatic selection. If the selected request is gone (someone else decided it, or it timed out), just deselect.
    if (shortcutQueueId && !queue.some((q) => q.id === shortcutQueueId)) {
      shortcutIndexHint = Math.max(0, queueIds.indexOf(shortcutQueueId));
      shortcutQueueId = null;
    }
    const ids = queue.map((q) => q.id);
    if (ids.join("|") !== queueIds.join("|")) cancelApprove();   // any change to the queue cancels a pending approval
    queueIds = ids;
    if (approveArm && (approveArm.id !== shortcutQueueId || !queueRow(approveArm.id))) cancelApprove();
    document.title = queue.length ? `(${queue.length}) Needs approval · Squidbrake` : "Squidbrake";
    // Rows are reused across refreshes, but rebuilt when the viewer (API key) changes,
    // because the approve/reject controls depend on who is looking.
    const viewer = me ? me.client : "";
    const keep = new Set(queue.map((q) => q.id));
    list.querySelectorAll("li[data-id]").forEach((li) => { if (!keep.has(li.dataset.id) || li.dataset.viewer !== viewer) li.remove(); });
    let prev = null;
    for (const e of queue) {
      let li = list.querySelector(`li[data-id="${CSS.escape(e.id)}"]`);
      if (!li) {
        li = el("li", { "data-id": e.id, "data-viewer": viewer, "aria-selected": "false",
            onclick: (ev) => { if (!ev.target.closest("button, input, select, textarea, a")) setQueueSelection(e.id); } },
          el("div", { class: "aq-main" },
            avatarEl(agentOf(e), "sm"),
            el("div", { class: "aq-copy" },
              el("div", {}, el("button", { class: "pill-link mono", text: e.name, title: "Open details", onclick: () => openDrawer(e.id) })),
              el("div", { class: "why" }, e.reason || "", e.rule_id ? el("span", { class: "muted mono", text: `  · ${e.rule_id}` }) : null),
              el("div", { class: "who" }, [agentOf(e), e.session_id && `chain ${e.session_id}`, e.client && e.client !== agentOf(e) ? `key ${e.client}` : null, `asked ${fmtAgo(e.created_at)}`].filter(Boolean).join("  ·  ")),
              e.session_id ? el("button", { type: "button", class: "linkish", text: "Show chain", onclick: (ev) => {
                ev.stopPropagation();
                listMode = "chains";
                try { localStorage.setItem("gw.list", "chains"); } catch {}
                setFilter("session_id", e.session_id);
              } }) : null,
            ),
          ),
          el("div", { class: "side" }, clockEl(e), decisionControls(e, true)),
          e.kind === "question" && e.input && e.input.question
            ? el("div", { class: "why", style: "font-size:14px", text: `❓ ${e.input.question}` })
            : el("div", { class: "inp", title: preview(e.input), text: preview(e.input) }),
          e.justification ? el("div", { class: "why" }, el("b", { text: "Why, says the agent: " }), e.justification) : null,
          (e.required_approvals || 1) > 1 ? el("div", { class: "why" }, el("b", { text: `${(e.approvals || []).length} of ${e.required_approvals} approvals` }),
            (e.approvals || []).length ? `: ${e.approvals.map((a) => a.by).join(", ")}` : ": needs different people; one rejection stops it") : null,
          briefEl(e),
          signalsEl(e.signals),
          storyDetails(e),
        );
      }
      if (prev ? prev.nextSibling !== li : list.firstChild !== li) prev ? prev.after(li) : list.prepend(li);
      prev = li;
    }
    tickClocks();
    updateShortcutSelection();
  }

  // ---------- desktop alerts: a pop-up when a new request joins the queue, so nobody has to keep this tab in view.
  // The queue is polled every 5s even in a background tab (see the wiring below), which is what feeds this.
  const deskPref = {
    get() { try { return localStorage.getItem("gw.notify") === "1"; } catch { return false; } },
    set(v) { try { localStorage.setItem("gw.notify", v ? "1" : "0"); } catch {} },
  };
  let notifySeen = null, notifySeenFor = null;   // ids already shown to this viewer; null until the first load, which never pops up
  let notifyOpen = [];   // [{n, ids}]: closed once none of its requests is still waiting
  function deskState() {
    if (!("Notification" in window)) return "unsupported";
    if (!window.isSecureContext) return "insecure";
    if (Notification.permission === "denied") return "blocked";
    return Notification.permission === "granted" && deskPref.get() ? "on" : "off";
  }
  const DESK_TITLES = {
    on: "You get a desktop pop-up when an action needs approval. Click to turn it off.",
    off: "Get a desktop pop-up when an action needs approval, even while you work in another app",
    blocked: "This browser blocks notifications from this page. Allow them in the site settings (left of the address bar), then click again.",
    insecure: "Browsers only show notifications on https or localhost. Open the dashboard at http://localhost:<port> or your https address.",
    unsupported: "This browser can't show desktop notifications. Phone notifications in Settings still work.",
  };
  function renderNotifyBtn() {
    const s = deskState(), b = $("#notifyBtn");
    b.setAttribute("aria-pressed", String(s === "on"));
    b.title = DESK_TITLES[s];
    $("#notifyText").textContent = s === "on" ? "Alerts on" : "Alerts off";
  }
  $("#notifyBtn").addEventListener("click", async () => {
    const s = deskState();
    if (s === "on") { deskPref.set(false); renderNotifyBtn(); toast("Desktop alerts off."); return; }
    if (s !== "off") { renderNotifyBtn(); toast(DESK_TITLES[s], 8000); return; }
    const p = Notification.permission === "granted" ? "granted" : await Notification.requestPermission();
    deskPref.set(p === "granted");
    renderNotifyBtn();
    toast(p === "granted" ? "Desktop alerts on. Keep this tab open; it can stay in the background." : DESK_TITLES[deskState()], 8000);
  });
  renderNotifyBtn();
  function notifyQueue() {
    const viewer = me ? me.client : "", waiting = new Set(queueIds);
    notifyOpen = notifyOpen.filter((o) => o.ids.some((id) => waiting.has(id)) || (o.n.close(), false));   // decided elsewhere: take it down
    if (notifySeen === null || notifySeenFor !== viewer) { notifySeen = waiting; notifySeenFor = viewer; return; }   // first look: no pop-up for what was already there
    const fresh = queue.filter((q) => !notifySeen.has(q.id));
    notifySeen = waiting;
    if (!fresh.length || !(me && me.can_approve)) return;
    if (document.visibilityState === "visible" && document.hasFocus()) return;   // they're looking at the page already
    if (deskState() !== "on") { renderNotifyBtn(); return; }   // permission may have been revoked since
    const one = fresh.length === 1 ? fresh[0] : null;
    let n;
    try {
      n = new Notification(one ? `Needs approval: ${one.name}` : `${fresh.length} actions need approval`, {
        body: one ? [agentOf(one), one.reason].filter(Boolean).join(" · ") : fresh.slice(0, 5).map((q) => `${q.name} (${agentOf(q)})`).join("\n"),
        tag: one ? `sb-${one.id}` : "sb-batch",
        requireInteraction: true,   // stays up until you act on it, or until it's decided somewhere else
      });
    } catch { return; }   // Android Chrome only allows notifications from a service worker
    n.onclick = () => {
      window.focus(); n.close();
      showView("activity");
      if (one && queueRow(one.id)) { setQueueSelection(one.id); queueRow(one.id).scrollIntoView({ block: "nearest" }); }
      else $("#approvals").scrollIntoView({ block: "start" });
    };
    notifyOpen.push({ n, ids: fresh.map((q) => q.id) });
  }

  // ---------- drawer
  async function openDrawer(id) {
    cancelApprove();
    selectedId = id;
    document.querySelectorAll("#rows tr, .node, .spark-dot").forEach((n) => n.classList.toggle("sel", n.dataset.id === id));
    document.querySelectorAll(".rail li").forEach((n) => n.classList.toggle("on", n.dataset.id === id));
    const d = $("#drawer");
    d.classList.add("open"); d.setAttribute("aria-hidden", "false"); $("#backdrop").classList.add("on");
    $("#drTitle").textContent = "Loading…"; $("#drSub").textContent = ""; $("#drBody").replaceChildren();
    try {
      const e = await api(`/v1/events/${encodeURIComponent(id)}`);
      if (selectedId !== id) return;
      renderDrawer(e);
    } catch (err) {
      $("#drTitle").textContent = "Couldn't load event";
      $("#drBody").replaceChildren(el("p", { class: "muted", text: String(err.message || err) }));
    }
    $("#drClose").focus();
  }
  async function refreshDrawer() {
    const id = drawerEvent && drawerEvent.id;
    if (!id) return;
    try {
      const e = await api(`/v1/events/${encodeURIComponent(id)}`);
      if (selectedId === id) renderDrawer(e);
    } catch {}
  }
  function closeDrawer() {
    const d = $("#drawer");
    d.classList.remove("open"); d.setAttribute("aria-hidden", "true"); $("#backdrop").classList.remove("on");
    const tr = selectedId && (
      document.querySelector(`#rows tr[data-id="${CSS.escape(selectedId)}"]`) ||
      document.querySelector(`.node[data-id="${CSS.escape(selectedId)}"]`));
    selectedId = null; drawerEvent = null;
    document.querySelectorAll("#rows tr.sel, .node.sel, .spark-dot.sel").forEach((t) => t.classList.remove("sel"));
    document.querySelectorAll(".rail li.on").forEach((n) => n.classList.remove("on"));
    if (tr) tr.focus();
  }
  function approvalBanner(e) {
    const when = e.decided_at ? `  ·  ${new Date(e.decided_at).toLocaleString()}` : "";
    const note = e.decision_note && e.decided_by !== "timeout" ? el("div", { class: "sec", style: "margin-top:4px" }, "“", e.decision_note, "”") : null;
    if (e.status === "awaiting_approval") {
      return el("div", { class: "banner awaiting" },
        el("div", {}, el("b", { text: "Waiting for a human: " }), e.reason || "", e.rule_id ? el("span", { class: "muted mono", text: `  (${e.rule_id})` }) : null),
        el("div", { style: "margin-top:4px" }, clockEl(e),
          e.approvers && e.approvers.length ? el("span", { class: "hint", text: `  ·  approvers: ${e.approvers.join(", ")}` }) : null),
        decisionControls(e, false));
    }
    if (!e.decided_by) return null;
    if (e.decided_by === "timeout") {
      return el("div", { class: "banner " + (e.decision === "allow" ? "approved" : "denied") },
        el("b", { text: `Approval timed out, ${e.decision === "allow" ? "allowed" : "denied"} automatically` }), when,
        el("div", { class: "muted", style: "margin-top:4px", text: `Rule: ${e.reason || e.rule_id || ""}` }));
    }
    return el("div", { class: "banner " + (e.decision === "allow" ? "approved" : "denied") },
      el("b", { text: `${e.decision === "allow" ? "Approved" : "Rejected"} by ${e.decided_by}` }), when,
      e.decided_with ? el("span", { class: "tag", style: "margin-left:6px", title: "Signed by the device; the signature is in the audit trail", text: `✓ ${e.decided_with}` }) : null, note,
      el("div", { class: "muted", style: "margin-top:4px", text: `Held by: ${e.reason || ""}${e.rule_id ? " (" + e.rule_id + ")" : ""}` }));
  }
  // A person's verdict on a hold or block: "this was legit" counts as a false positive in the weekly report
  function feedbackEl(e) {
    const stopped = e.would || (e.approval_deadline && e.status !== "awaiting_approval") || (e.status === "denied" && !e.decided_by);
    if (!stopped) return null;
    const box = el("div", { class: "hint", style: "margin:6px 0" });
    const mark = (verdict) => async () => {
      try {
        await apiPost(`/v1/events/${encodeURIComponent(e.id)}/feedback`, { verdict });
        e.feedback = verdict; box.replaceWith(feedbackEl(e) || el("span"));
        toast(verdict === "legit" ? "Marked: this should have run" : "Marked: stopping it was right");
      } catch (err) { toast(err.message || String(err)); }
    };
    box.append(e.feedback ? `You said: ${e.feedback === "legit" ? "this was legit (a false positive)" : "stopping it was right"}. ` : "Was stopping this right? ",
      el("button", { class: "linkish", text: "This was legit", onclick: mark("legit") }), " · ",
      el("button", { class: "linkish", text: "Stopping it was right", onclick: mark("right") }));
    return box;
  }
  // ---------- the story behind an action: history-check warnings + what the agent did just before
  const SIGNAL_TITLES = { effect: "What it changes",
    impersonation: "Possible scam", payment_request_in_message: "Payment requested in a message",
    repeat_of_rejected: "Already rejected before", duplicate_change: "Done before",
    catastrophic_command: "Destructive command", irreversible_command: "Can't be undone", hidden_command: "Hidden code", secret_command: "Shows a secret", rename_command: "Renames or moves", written_then_run_command: "Script written, then run", edited_then_run_command: "Script changed, then run", sequence: "Pattern", untrusted_destination: "Address from outside content", after_untrusted: "After reading outside content",
    after_untrusted_setup_edit: "Tainted setup change: after reading outside content",
  };
  // The AI brief: advice for the person deciding, written with the team's own key. Never part of the decision.
  function briefEl(e) {
    if (!e.brief) return null;
    if (e.brief.error) return el("div", { class: "hint", text: `AI brief unavailable: ${e.brief.error}` });
    return el("div", { class: "brief" }, el("b", { text: "AI brief (advice only): " }), e.brief.text,
      el("div", { class: "who", text: `${e.brief.model || ""} · the rules held it; you decide` }));
  }
  function snapshotEl(e) {
    const s = e.snapshot;
    if (!s || !(s.id || s.skipped || s.want)) return null;
    if (s.skipped) return el("div", { class: "banner failed" }, el("b", { text: "No undo copy: " }), s.skipped);
    if (!s.id) return e.status === "awaiting_approval" ? el("div", { class: "hint", text: `A copy will be kept before it runs (${s.want}).` }) : null;
    const kids = [el("b", { text: "Undo copy kept before it ran: " }), s.summary || s.ref || ""];
    if (s.kind === "local") kids.push(el("div", { class: "hint", text: `On ${agentOf(e)}'s computer: squidbrake undo ${s.ref}${e.name === "Edit" || e.name === "Write" ? " --force" : ""}` }));
    else kids.push(" ", el("button", { class: "linkish", text: "download the copy", onclick: async () => {
      try {
        const r = await api(`/v1/events/${encodeURIComponent(e.id)}/snapshots`);
        const url = URL.createObjectURL(new Blob([JSON.stringify(r.snapshots, null, 2)], { type: "application/json" }));
        el("a", { href: url, download: `squidbrake-undo-${e.id}.json` }).click();
        setTimeout(() => URL.revokeObjectURL(url), 5000);
      } catch (err) { toast(err.message); }
    } }));
    return el("div", { class: "banner approved" }, ...kids);
  }
  function proofEl(e) {
    if (!e.proof_status) return null;
    const ok = e.proof_status === "confirmed";
    return el("div", { class: "banner " + (ok ? "approved" : "denied") },
      el("b", { class: ok ? "proof-ok" : "proof-bad", text: ok ? "✓ Checked: it really happened. " : e.proof_status === "mismatch" ? "✕ Not actually done. " : "! Couldn't check. " }),
      (e.proof && e.proof.message) || "", " ",
      el("button", { class: "linkish", text: "check again", onclick: async () => {
        try { await apiPost(`/v1/events/${encodeURIComponent(e.id)}/proof`); refreshDrawer(); } catch (err) { toast(err.message); }
      } }));
  }
  function signalsEl(signals) {
    if (!signals || !signals.length) return null;
    return el("div", { class: "signals" }, signals.map((s) => el("div", { class: "signal " + s.effect },
      el("b", { text: "⚠ " + (SIGNAL_TITLES[s.check] || s.check) + ": " }), s.message)));
  }
  function summarizeInput(v) {
    if (v == null) return "";
    if (typeof v !== "object") return String(v).slice(0, 140);
    return Object.entries(v).slice(0, 4).map(([k, x]) => `${k}: ${typeof x === "object" ? JSON.stringify(x) : x}`).join(" · ").slice(0, 160);
  }
  function summarizeOutput(t) {
    if (!t) return null;
    const from = t.match(/"from"\s*:\s*"([^"]+)"/g), subject = t.match(/"subject"\s*:\s*"([^"]+)"/);
    if (from && from.length === 1) {
      const body = t.match(/"body"\s*:\s*"([^"]{0,220})/);
      return `✉ from ${from[0].replace(/.*"from"\s*:\s*"/, "").replace(/"$/, "")}${subject ? ` — “${subject[1]}”` : ""}${body ? `: ${body[1]}…` : ""}`;
    }
    if (from && from.length > 1) return "Listed several messages";
    return t.length > 180 ? t.slice(0, 180) + "…" : t;
  }
  function mergeChain(current, prior) {
    const byId = new Map();
    for (const s of prior || []) byId.set(s.id, s);
    if (current && current.session_id) {
      for (const r of rows) if (r.session_id === current.session_id) byId.set(r.id, { ...byId.get(r.id), ...r });
    }
    if (current) byId.set(current.id, { ...byId.get(current.id), ...current });
    return [...byId.values()].sort((a, b) => a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.id < b.id ? -1 : 1);
  }
  async function loadStory(evOrId, box) {
    const id = typeof evOrId === "string" ? evOrId : evOrId.id;
    const current = typeof evOrId === "string" ? (rows.find((r) => r.id === id) || null) : evOrId;
    const head = box.querySelector("h4, summary") || box.firstChild;
    try {
      const r = await api(`/v1/events/${encodeURIComponent(id)}/context`);
      const steps = mergeChain(current, r.steps);
      const parts = [head, steps.length > 1 ? railEl(steps, id) : el("div", { class: "muted", text: "Nothing else in this chain yet." })];
      if (r.steps.length >= 15) parts.push(el("div", { class: "hint", text: "Showing the latest steps of this chain." }));
      box.replaceChildren(...parts);
      if (drawerEvent && drawerEvent.id === id && steps.length) {
        const i = Math.max(0, steps.findIndex((s) => s.id === id));
        $("#drSub").replaceChildren(statusEl(drawerEvent.status),
          `  ·  step ${i + 1} of ${steps.length}  ·  ${agentOf(drawerEvent)}  ·  ${new Date(drawerEvent.created_at).toLocaleString()}`);
      }
    } catch { box.replaceChildren(head, el("div", { class: "muted", text: "Couldn't load the chain." })); }
  }
  function storyDetails(evOrId) {
    const d = el("details", { class: "story-d" }, el("summary", { text: "Chain" }), el("div", { class: "muted", text: "Loading…" }));
    d.addEventListener("toggle", () => { if (d.open && !d.dataset.loaded) { d.dataset.loaded = "1"; loadStory(evOrId, d); } });
    return d;
  }

  function sessionControl(session, compact) {
    if (!session || !me) return null;
    const stopped = (controlsState.sessions || {})[session];
    if (stopped) return el("span", {}, compact ? "" : " ", el("span", { class: "tag", style: "border-color:var(--critical);color:var(--critical)", text: "stopped" }),
      me.is_admin ? el("button", { class: "linkish", text: "resume", onclick: () => resume(null, session) }) : null);
    return me.can_approve || me.is_admin
      ? el("span", {}, compact ? "" : " ", el("button", { class: "linkish", style: "color:var(--critical)", text: compact ? "Stop" : "⏹ stop this session", onclick: () => askStop(null, session) }),
          " ", el("button", { class: "linkish", text: compact ? "Note" : "✉ send a note", onclick: () => askNote(null, session) }))
      : null;
  }

  function renderDrawer(e) {
    drawerEvent = e;
    $("#drTitle").replaceChildren(avatarEl(agentOf(e), "sm"), el("span", { text: e.name }));
    $("#drSub").replaceChildren(statusEl(e.status), `  ·  ${agentOf(e)}  ·  ${new Date(e.created_at).toLocaleString()}`);
    const body = $("#drBody");
    const kids = [approvalBanner(e), feedbackEl(e)];
    if (e.status === "denied" && !e.decided_by) kids.push(el("div", { class: "banner denied" }, el("b", { text: "Blocked: " }), e.reason || "", e.rule_id ? el("span", { class: "muted mono", text: `  (${e.rule_id})` }) : null));
    kids.push(briefEl(e), snapshotEl(e), proofEl(e));
    if (e.status === "awaiting_approval" && !e.brief && me && me.can_approve)
      kids.push(el("button", { class: "linkish", text: "Ask AI for a brief", onclick: async () => {
        try { await apiPost(`/v1/events/${encodeURIComponent(e.id)}/brief`); refreshDrawer(); } catch (err) { toast(err.message); }
      } }));
    kids.push(signalsEl(e.signals));
    const story = el("div", { class: "blk" }, el("h4", { text: "Chain" }), el("div", { class: "muted", text: "Loading…" }));
    kids.push(story);
    loadStory(e.id, story);
    if (e.status === "failed" && e.error) kids.push(el("div", { class: "banner failed" }, el("b", { text: "Error: " }), e.error));
    const filt = (k, v) => v ? el("span", {}, el("span", { class: "mono", text: v }), " ",
      el("button", { class: "linkish", text: "filter", onclick: () => { closeDrawer(); setFilter(k, v); } })) : el("span", { class: "muted", text: "—" });
    const kv = [
      ["Event ID", el("span", {}, el("span", { class: "mono", text: e.id }), " ", el("button", { class: "linkish", text: "copy", onclick: (ev) => copy(e.id, ev.target) }))],
      ["Kind", e.kind], ["Decision", `${e.decision}${e.reason ? " — " + e.reason : ""}`], ["Rule", e.rule_id || "—"],
      ["Agent", filt("source", e.source)], ["Chain", el("span", {}, filt("session_id", e.session_id), sessionControl(e.session_id))],
      ["Client", filt("client", e.client)], ["Client IP", e.client_ip || "—"],
      ["Created", new Date(e.created_at).toLocaleString() + "  (" + fmtAgo(e.created_at) + ")"],
      ["Completed", e.completed_at ? new Date(e.completed_at).toLocaleString() : "—"],
      ["Duration", fmtDur(e.duration_ms) || "—"],
    ];
    kids.push(el("dl", { class: "kv" }, kv.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", {}, v)])));
    const block = (title, val) => {
      if (val == null || (typeof val === "object" && !Array.isArray(val) && !Object.keys(val).length)) return null;
      const txt = typeof val === "string" ? val : JSON.stringify(val, null, 2);
      return el("div", { class: "blk" },
        el("h4", {}, title, el("button", { class: "linkish", text: "copy", onclick: (ev) => copy(txt, ev.target) })),
        el("pre", { text: txt }));
    };
    kids.push(block("Input", e.input), block("Output", e.output), e.status !== "failed" ? block("Error", e.error) : null, block("Metadata", e.metadata));
    body.replaceChildren(...kids.filter(Boolean));
    tickClocks();
  }
  async function copy(text, btn) {
    try { await navigator.clipboard.writeText(text); const o = btn.textContent; btn.textContent = "copied"; setTimeout(() => (btn.textContent = o), 1200); } catch {}
  }

  // ---------- key dialog
  function openKeyDialog(wasWrong, why) {
    const dlg = $("#keyDlg");
    if (dlg.open) return;
    $("#keyErr").textContent = why || (wasWrong ? "That key was rejected." : "");
    $("#keyInput").value = "";
    dlg.showModal();
    $("#keyInput").focus();
  }
  $("#keyForm").addEventListener("submit", () => {
    apiKey = $("#keyInput").value.trim();
    store.set(apiKey);
    me = null;
    reload();
  });
  $("#keyCancel").addEventListener("click", () => $("#keyDlg").close());
  fetch("/sso/status").then((r) => r.json()).then((s) => { $("#ssoBtn").hidden = !s.enabled; }).catch(() => {});
  $("#keyBtn").addEventListener("click", () => openKeyDialog(false));

  // ---------- wiring
  document.querySelectorAll("#range button").forEach((b) => b.addEventListener("click", () => {
    state.range = b.dataset.r; syncUrl(); renderFilters(); reload();
  }));
  let debounce;
  ["q", "kind", "source", "session_id"].forEach((k) => $("#" + k).addEventListener("input", (ev) => {
    clearTimeout(debounce);
    debounce = setTimeout(() => setFilter(k, ev.target.value.trim()), 300);
  }));
  $("#status").addEventListener("change", (ev) => setFilter("status", ev.target.value));
  $("#refresh").addEventListener("click", reload);
  $("#more").addEventListener("click", () => { const g = gen; run(g, () => loadEvents(true, g)); });
  $("#drClose").addEventListener("click", closeDrawer);
  $("#backdrop").addEventListener("click", closeDrawer);
  document.addEventListener("keydown", (ev) => {
    if (ev.key === "Escape" && selectedId && !$("#keyDlg").open) closeDrawer();
    if (ev.key === "/" && document.activeElement === document.body) { ev.preventDefault(); $("#q").focus(); }
    if (approveArm && !ev.repeat && (ev.key !== "a" || shortcutsBlocked(ev))) cancelApprove();   // any other key cancels a pending approval
    if (ev.repeat || shortcutsBlocked(ev)) return;   // never while typing, in a dialog, or with Ctrl/Cmd/Alt held
    if (ev.key === "?") { ev.preventDefault(); $("#shortcutDlg").showModal(); return; }
    if (view !== "activity" || !queue.length) return;
    if (ev.key === "j") { ev.preventDefault(); moveQueueSelection(1); }
    else if (ev.key === "k") { ev.preventDefault(); moveQueueSelection(-1); }
    else if (ev.key === "a") { ev.preventDefault(); approveSelected(); }
    else if (ev.key === "r") { ev.preventDefault(); focusRejectNote(); }
  });
  $("#shortcutDone").addEventListener("click", () => $("#shortcutDlg").close());
  document.querySelectorAll("#evView button").forEach((b) => b.addEventListener("click", () => {
    if (listMode === b.dataset.v) return;
    listMode = b.dataset.v;
    try { localStorage.setItem("gw.list", listMode); } catch {}
    renderRows();
  }));
  applyListMode();
  $("#tableToggle").addEventListener("click", (ev) => {
    const t = $("#chartTable"), open = t.hidden;
    t.hidden = !open;
    ev.target.setAttribute("aria-expanded", String(open));
    ev.target.textContent = open ? "Hide table" : "View as table";
  });
  $("#auto").addEventListener("change", (ev) => { try { localStorage.setItem("gw.auto", ev.target.checked ? "1" : "0"); } catch {} });
  setInterval(() => {
    if ($("#keyDlg").open) return;
    if ($("#auto").checked && !document.hidden) live();
    // The approval queue refreshes even when Live is off or the tab is in the background,
    // so the "(n) Needs approval" tab title stays accurate.
    else loadQueue().catch(() => {});
  }, 5000);
  setInterval(tickClocks, 1000);
  let rz;
  window.addEventListener("resize", () => { clearTimeout(rz); rz = setTimeout(() => lastStats && renderChart(lastStats), 120); });

  // ---------- views (tabs)
  $("#home").addEventListener("click", (ev) => { ev.preventDefault(); showView("activity"); });
  function showView(name) {
    if (!["activity", "team", "reports", "settings"].includes(name)) name = "activity";
    view = name;
    document.querySelectorAll(".view").forEach((v) => (v.hidden = v.id !== "view-" + name));
    document.querySelectorAll("#tabs a").forEach((a) => { if (a.dataset.view === name) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); });
    if (location.hash !== "#" + name) history.replaceState(null, "", location.pathname + location.search + (name === "activity" ? "" : "#" + name));
    loadView().catch(() => {});
  }
  function loadView() {
    if (!me) return Promise.resolve();  // reload() calls this again once we know who's signed in
    if (view === "team") return loadTeam();
    if (view === "reports") return loadReports();
    if (view === "settings") return loadSettings();
    return Promise.resolve();
  }
  window.addEventListener("hashchange", () => showView(location.hash.slice(1)));
  const fmtDate = (iso) => iso ? new Date(iso).toLocaleString([], { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—";

  // ---------- emergency stop
  let controlsState = { all: null, agents: {} };
  async function loadControls() {
    controlsState = await api("/v1/controls");
    const b = $("#stopBanner"), s = controlsState.all;
    const agentsStopped = Object.keys(controlsState.agents || {});
    const sessionsStopped = Object.keys(controlsState.sessions || {});
    const live = (x) => x && !(x.until && new Date(x.until) <= new Date());
    const hhmm = (t) => new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
    if (live(s)) {
      b.hidden = false;
      const long = Date.now() - new Date(s.at) > 30 * 60 * 1000;
      b.replaceChildren(el("b", { text: `⏹ Emergency stop ON since ${hhmm(s.at)}` }),
        el("span", { text: `By ${s.by}${s.reason ? " — " + s.reason : ""}${s.until ? `, until ${hhmm(s.until)}` : ""}. ` +
          "Every action any agent tries is blocked, and the agent is told how to turn this off." +
          (long ? " Still stopped: did you mean to leave this on?" : "") }),
        me && me.is_admin ? el("button", { class: "btn", text: "Turn off", onclick: () => resume(null) }) : null);
    } else if (agentsStopped.length || sessionsStopped.length) {
      b.hidden = false;
      const what = [...agentsStopped, ...sessionsStopped.map((x) => "session " + x)];
      b.replaceChildren(el("b", { text: `⏹ Stopped: ${what.join(", ")}` }),
        el("span", { text: "Everything else is running normally." }),
        ...(me && me.is_admin ? sessionsStopped.map((x) => el("button", { class: "btn", text: `Resume session ${x.slice(0, 18)}`, onclick: () => resume(null, x) })) : []));
    } else b.hidden = true;
    $("#stopBtn").textContent = live(s) ? "⏹ Agents stopped" : "⏹ Stop agents";
    $("#stopBtn").disabled = !!live(s);
  }
  let stopTarget = null, stopSession = null;
  function askStop(agent, session = null) {
    stopTarget = agent; stopSession = session;
    $("#stopWhat").textContent = session
      ? `This conversation (${session}) is stopped: its next action is blocked, Claude Code ends its turn, and anything it's waiting on is rejected. Other sessions keep working.`
      : agent
      ? `Every action “${agent}” tries will be blocked until an admin resumes it. Other agents keep working.`
      : "Every action any agent tries will be blocked immediately, until an admin resumes them. Actions already running aren't interrupted.";
    $("#stopReason").value = "";
    $("#stopDlg").showModal();
    $("#stopReason").focus();
  }
  $("#stopBtn").addEventListener("click", () => askStop(null));
  $("#modeBtn").addEventListener("click", async () => {
    const observe = me.mode !== "shadow";
    if (!observe && !confirm("Switch back to enforce? Risky actions will be blocked or wait for approval again.")) return;
    try {
      await apiSend("PUT", "/v1/policy/mode", { mode: observe ? "observe" : "enforce" });
      toast(observe ? "Observe mode on: nothing is blocked or held, see Reports for what would have been."
                    : "Enforce mode on: rules block and hold again.");
      await loadMe();
    } catch (e) { toast(`Couldn't switch mode: ${e.message}`); }
  });
  $("#stopCancel").addEventListener("click", () => $("#stopDlg").close());
  $("#stopForm").addEventListener("submit", async () => {
    try {
      await apiPost("/v1/controls/stop", { agent: stopTarget, session: stopSession, reason: $("#stopReason").value.trim() || null,
                                           minutes: Number($("#stopFor").value) || null });
      toast(stopSession ? "Session stopped" : stopTarget ? `Stopped ${stopTarget}` : "All agents stopped");
      await loadControls(); if (view === "reports") loadReports();
      if (drawerEvent && stopSession) renderDrawer(drawerEvent);
    } catch (err) { toast(err.message); }
  });
  async function resume(agent, session = null) {
    try {
      await apiPost("/v1/controls/resume", { agent, session });
      toast(session ? "Session resumed" : agent ? `Resumed ${agent}` : "All agents resumed");
      await loadControls(); if (view === "reports") loadReports();
      if (drawerEvent && session) renderDrawer(drawerEvent);
    } catch (err) { toast(err.message); }
  }

  let noteTarget = null, noteSession = null;
  function askNote(agent, session = null) {
    noteTarget = agent; noteSession = session;
    $("#noteWhat").textContent = `${session ? "This conversation (" + session + ")" : "“" + agent + "”"} gets it with its next ` +
      "action (Claude Code and the MCP connectors pass it on), and it's kept in the audit trail. It steers; it can't approve anything.";
    $("#noteText").value = "";
    $("#noteDlg").showModal();
    $("#noteText").focus();
  }
  $("#noteCancel").addEventListener("click", () => $("#noteDlg").close());
  $("#noteForm").addEventListener("submit", async () => {
    const text = $("#noteText").value.trim();
    if (!text) return;
    try {
      await apiPost("/v1/notes", noteSession ? { session: noteSession, text } : { agent: noteTarget, text });
      toast("Note sent: the agent gets it with its next action");
    } catch (err) { toast(err.message); }
  });

  async function loadTeamAgents() {
    try {
      const r = await api("/v1/agents/inventory");
      $("#teamAgents").hidden = !r.machines.length;
      $("#teamAgentsSub").textContent = r.not_connected.length
        ? `${r.not_connected.length} agent${r.not_connected.length === 1 ? "" : "s"} not connected` : "every agent reported is connected";
      cells("#teamAgentsRows", r.machines.map((m) => el("tr", {},
        el("td", {}, el("b", { text: m.machine }), m.stale ? el("span", { class: "tag", style: "margin-left:6px", text: "old report" }) : null),
        el("td", { class: "sec", text: m.agents.filter((a) => a.connected).map((a) => a.label).join(", ") || "none" }),
        el("td", { style: "color:var(--critical)", text: m.agents.filter((a) => !a.connected).map((a) => a.label).join(", ") }),
        el("td", { style: "color:var(--critical)", text: (m.mcp || []).filter((s) => !s.guarded).map((s) => `${s.name} (${s.agent})`).join(", ") }),
        el("td", { class: "sec", text: fmtAgo(m.at) }))), "No computer has reported yet.");
    } catch { $("#teamAgents").hidden = true; }
  }

  async function downloadEvidence() {
    try {
      const res = await fetch("/v1/audit/export.json", { headers: { "X-Gateway-Key": apiKey } });
      if (!res.ok) throw new Error(`export failed (${res.status})`);
      const name = (res.headers.get("content-disposition") || "").match(/filename="([^"]+)"/)?.[1] || "squidbrake-evidence.json";
      const url = URL.createObjectURL(await res.blob());
      el("a", { href: url, download: name }).click();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
    } catch (err) { toast(err.message); }
  }

  // ---------- team
  async function loadTeam() {
    const data = await api("/v1/team");
    $("#teamSub").textContent = data.managed_by_env ? "managed by the GATEWAY_API_KEYS setting" : `${data.members.length} keys`;
    const admin = me && me.is_admin && !data.managed_by_env;
    $("#addMemberCard").hidden = !admin;
    const LEVEL = { owner: "Owner", admin: "Admin", approver: "Approver", viewer: "Viewer (view only)" };
    const access = (m) => m.kind === "agent" ? (m.owner ? `Asks permission · works for ${m.owner}` : "Asks permission")
      : LEVEL[m.access] || (m.roles.includes("admin") ? "Admin" : m.approver ? "Approver" : "Viewer (view only)");
    $("#ownerSel").replaceChildren(el("option", { value: "", text: "nobody in particular" }),
      ...data.members.filter((m) => m.kind === "person").map((m) => el("option", { value: m.name, text: m.name })));
    $("#teamRows").replaceChildren(...data.members.map((m) => el("tr", {},
      el("td", {}, m.kind === "agent" ? avatarEl(m.name, "sm") : null, m.kind === "agent" ? " " : null, el("b", { text: m.name }), m.name === me.client ? el("span", { class: "muted", text: "  (you)" }) : null),
      el("td", { class: "sec", text: m.kind === "agent" ? "🤖 AI agent" : "👤 Person" }),
      el("td", { text: access(m) }),
      el("td", {}, m.roles.filter((r) => r !== "admin" && r !== "owner").length ? m.roles.filter((r) => r !== "admin" && r !== "owner").map((r) => el("span", { class: "tag", text: r })) : el("span", { class: "muted", text: "—" })),
      el("td", { class: "sec", text: fmtDate(m.created_at) }),
      el("td", { class: "num" }, admin && m.name !== me.client ? [
        m.kind === "person" ? el("button", { class: "linkish", text: "Edit", onclick: () => editMember(m) }) : null, " ",
        el("button", { class: "linkish", style: "color:var(--critical)", text: "Remove", onclick: () => removeMember(m) }),
      ] : null),
    )));
  }
  function editMember(m) {
    const levels = ["viewer", "approver", "admin", "owner"];
    const access = prompt(`Access for ${m.name}: viewer, approver, admin or owner\n(only owners make admins and owners)`, m.access || "approver");
    if (access === null) return;
    if (!levels.includes(access.trim().toLowerCase())) { toast("Use viewer, approver, admin or owner"); return; }
    const teams = prompt(`Teams for ${m.name}, for rules that need a team to approve (comma separated, e.g. finance):`,
      m.roles.filter((r) => r !== "admin" && r !== "owner").join(", "));
    if (teams === null) return;
    apiSend("PATCH", `/v1/team/${encodeURIComponent(m.name)}`, { access: access.trim().toLowerCase(),
      roles: teams.split(",").map((r) => r.trim()).filter(Boolean) })
      .then(() => { toast(`Updated ${m.name}`); loadTeam(); }).catch((err) => toast(err.message));
  }
  function removeMember(m) {
    if (!confirm(`Remove ${m.name}? Their key stops working immediately.`)) return;
    apiSend("DELETE", `/v1/team/${encodeURIComponent(m.name)}`).then(() => { toast(`Removed ${m.name}`); loadTeam(); }).catch((err) => toast(err.message));
  }
  const addForm = $("#addMember");
  const syncKind = () => {
    addForm.querySelectorAll(".person-only").forEach((n) => (n.hidden = addForm.kind.value === "agent"));
    addForm.querySelectorAll(".agent-only").forEach((n) => (n.hidden = addForm.kind.value !== "agent"));
  };
  addForm.kind.addEventListener("change", syncKind);
  addForm.addEventListener("submit", async (ev) => {
    ev.preventDefault();
    const f = addForm, person = f.kind.value === "person";
    try {
      const r = await apiPost("/v1/team", { name: f.name.value.trim(), kind: f.kind.value, access: person ? f.access.value : null,
        roles: person ? f.roles.value.split(",").map((x) => x.trim()).filter(Boolean) : [],
        owner: person ? null : f.owner.value || null });
      const box = $("#newKey");
      box.hidden = false;
      box.replaceChildren(el("b", { text: `Key for ${r.name} — copy it now, it won't be shown again:` }), el("br"), el("code", { text: r.key }), " ",
        el("button", { class: "linkish", text: "copy", onclick: (e) => copy(r.key, e.target) }),
        el("div", { class: "muted", style: "margin-top:6px", text: person
          ? "Send it to them privately. They paste it into this dashboard, or use 📱 Phone after signing in."
          : `For an agent: python connect.py wrap --agent ${r.name} --key <this key> … (see README)` }));
      f.reset(); syncKind(); loadTeam();
    } catch (err) { toast(err.message); }
  });

  // ---------- reports
  let repDays = 7;
  try { repDays = +localStorage.getItem("gw.repDays") || 7; } catch {}
  document.querySelectorAll("#repRange button").forEach((b) => b.addEventListener("click", () => {
    repDays = +b.dataset.d; try { localStorage.setItem("gw.repDays", repDays); } catch {} loadReports();
  }));
  const dur = (s) => s == null ? "—" : s < 60 ? `${Math.round(s)}s` : s < 3600 ? `${Math.round(s / 60)} min` : `${(s / 3600).toFixed(1)} h`;
  const cells = (tbody, rows, empty) => $(tbody).replaceChildren(...(rows.length ? rows : [el("tr", {}, el("td", { colspan: "9", class: "muted", text: empty }))]));
  let weeklyText = "";
  async function loadCaught() {
    let w;
    try { w = await api("/v1/report/weekly", { days: repDays }); } catch { return; }
    weeklyText = w.text;
    $("#caughtSub").textContent = w.caught.length ? `last ${repDays === 1 ? "24 hours" : repDays + " days"}` : "nothing stopped in this period";
    $("#caughtList").replaceChildren(...(w.caught.length ? w.caught.map((c) => el("tr", {},
      el("td", {}, el("b", { text: c.outcome.charAt(0).toUpperCase() + c.outcome.slice(1) }),
        ...(c.by ? [el("div", { class: "sub", text: `by ${c.by}${c.note ? ': "' + c.note + '"' : ""}` })] : []),
        ...(c.share ? [el("button", { class: "linkish", type: "button", text: "Share", onclick: () => shareCaught(c) })] : [])),
      el("td", {}, el("code", { text: c.what }), el("div", { class: "sub", text: c.agent || "" })),
      el("td", { class: "sub", text: c.why }),
      el("td", { class: "sub", text: c.effect || "—" })))
      : [el("tr", {}, el("td", { colspan: 4, class: "sub", text: "Nothing was blocked, rejected, or (in shadow mode) would have been." }))]));
  }
  $("#copyWeekly").addEventListener("click", (e) => copy(weeklyText, e.target));
  // Share cards on squidbrake.com, made from the link alone: words and counts, never a command's arguments
  const SHARE = "https://squidbrake.com";
  const SHARE_OUTCOME = { "blocked": "blocked", "rejected": "rejected", "would have been blocked": "would-block",
                          "would have waited for a person": "would-hold" };
  function shareCaught(c) {
    const s = c.share, q = new URLSearchParams({ what: s.what, outcome: SHARE_OUTCOME[c.outcome] });
    if (s.agent) q.set("agent", s.agent);
    if (s.rule) q.set("rule", s.rule);
    if (s.decide_s != null) q.set("s", s.decide_s);
    if (!confirm(`This opens a public page anyone can see:

  ${s.what}${s.agent ? " · " + s.agent : ""}${s.rule ? " · rule " + s.rule : ""} · ${c.outcome}

` +
                 "Only the program's name, never its arguments, files or who approved. Open it?")) return;
    window.open(`${SHARE}/caught?${q}`, "_blank", "noopener");
  }
  let lastSummary = null;
  $("#shareWeek").addEventListener("click", () => {
    const r = lastSummary;
    if (!r) return;
    const rejected = r.approvals.rejected, q = new URLSearchParams({ e: r.total, h: r.approvals.held, r: rejected,
      b: Math.max(0, (r.by_status.denied || 0) - rejected), d: repDays });
    if (!confirm(`This opens a public page with your counts only (${r.total} actions, ${r.approvals.held} held). Open it?`)) return;
    window.open(`${SHARE}/week?${q}`, "_blank", "noopener");
  });
  async function loadReports() {
    loadCaught();
    document.querySelectorAll("#repRange button").forEach((b) => b.setAttribute("aria-pressed", String(+b.dataset.d === repDays)));
    const [r, log] = await Promise.all([api("/v1/reports/summary", { days: repDays }), api("/v1/audit/log", { limit: 40 })]);
    lastSummary = r;
    const s = r.by_status, a = r.approvals;
    $("#printHead").replaceChildren(el("h1", { text: `AI agent activity report — last ${repDays === 1 ? "24 hours" : repDays + " days"}` }),
      el("div", { class: "muted", text: `Generated ${fmtDate(r.generated_at)} by ${me.client} · audit trail ${r.audit.ok ? "verified" : "BROKEN"} · fingerprint ${r.audit.head_hash || "—"}` }));
    // Status icon + this report's own wording (the tiles count different things than the status names).
    const iconLabel = (status, text) => el("span", { class: "st " + status }, el("i", { "aria-hidden": "true", text: STATUS[status].icon }), text);
    const tile = (label, value, sub, status) => el("div", { class: "card tile", style: "cursor:default" },
      el("div", { class: "lbl" }, status ? iconLabel(status, label) : label), el("div", { class: "val", text: value }), el("div", { class: "pct", text: sub || "" }));
    $("#repTiles").replaceChildren(
      tile("Agent actions", nf.format(r.total), `${r.agents.length} agent${r.agents.length === 1 ? "" : "s"}`),
      tile("Blocked", nf.format(s.denied || 0), "by rules, people or stop", "denied"),
      tile("Held for a person", nf.format(a.held), `${a.waiting_now} waiting now`, "awaiting_approval"),
      tile("Approved by a person", nf.format(a.approved), `${a.rejected} rejected · ${a.timed_out} timed out`, "completed"),
      tile("Time to decide", dur(a.median_seconds_to_decide), "median"),
      tile("Failed", nf.format(s.failed || 0), "errors from tools", "failed"),
      ...(r.shadow && (r.shadow.mode === "shadow" || r.shadow.agents.length || r.shadow.would_block || r.shadow.would_hold)
        ? [tile("Shadow mode: would block", nf.format(r.shadow.would_block), "let through, only recorded", "denied"),
           tile("Shadow mode: would hold", nf.format(r.shadow.would_hold), "would have asked a person", "awaiting_approval")] : []),
      ...(r.week ? [
        tile("Decision time", r.week.latency_ms.p50 == null ? "—" : `${r.week.latency_ms.p50} ms`,
             r.week.latency_ms.p95 == null ? "p50" : `p50 · p95 ${r.week.latency_ms.p95} ms · p99 ${r.week.latency_ms.p99} ms`),
        tile("False positives", r.week.false_positives.rate == null ? "—" : `${(r.week.false_positives.rate * 100).toFixed(1)}%`,
             `${r.week.false_positives.legit} of ${r.week.false_positives.stopped} marked "this was legit"`)] : []));
    const au = r.audit;
    const ig = r.integrity && r.integrity.changes && r.integrity.changes.length ? r.integrity : null;
    $("#auditCard").replaceChildren(el("div", { class: "card-head" }, el("h2", { text: "Tamper check" }),
        el("button", { class: "linkish", text: "Re-check now", onclick: loadReports })),
      el("div", { class: "audit-ok " + (au.ok ? "ok" : "bad") }, el("div", { class: "big", text: au.ok ? "✓" : "!" }),
        el("div", {}, el("b", { text: au.ok ? "Audit trail intact" : au.events_changed ? "Recorded actions were edited afterwards"
          : `Audit trail broken at entry #${au.first_bad_seq}` }),
          el("div", { class: "sec", text: `${nf.format(au.entries)} entries. ${au.message}.` }),
          el("div", { class: "muted", style: "margin-top:6px;font-size:12.5px", text: "Each entry is chained to the one before by a fingerprint, so any edit or deletion shows up here. Keep this fingerprint to prove later that today's history hasn't changed:" }),
          el("div", { class: "fp", text: au.head_hash || "—" }),
          ig ? el("div", { class: "sec", style: "margin-top:8px;color:var(--bad, #b91c1c)",
            text: `Squidbrake's own files changed: ${ig.changes.slice(0, 5).map((c) => `${c.what} ${c.change}`).join("; ")}. ` +
                  `If you did this, run: squidbrake integrity accept.` + (ig.mode === "fail_closed" ? " Until then, risky actions are blocked." : "") }) : null,
          au.checkpoints ? el("div", { class: "sec", style: "margin-top:8px", text: au.checkpoints.count
            ? `${au.checkpoints.ok ? "✓" : "✕"} ${au.checkpoints.message}. Signed every 50 entries with this gateway's ledger key (public key: GET /v1/audit/public-key), so an export can be proven unedited.`
            : "Signed checkpoints start at entry 50." }) : null,
          el("div", { style: "margin-top:10px" }, el("button", { class: "btn", text: "⬇ Download evidence", onclick: downloadEvidence }),
            el("span", { class: "muted", style: "margin-left:8px;font-size:12.5px", text: "Anyone can check it offline: python verify.py <file>" })))));
    const stoppedAgents = controlsState.agents || {};
    cells("#repAgents", r.agents.map((g) => el("tr", {},
      el("td", {}, avatarEl(g.agent, "sm"), " ", el("b", { text: g.agent }), stoppedAgents[g.agent] ? el("span", { class: "tag", style: "border-color:var(--critical);color:var(--critical);margin-left:6px", text: "stopped" }) : null),
      ...["total", "denied", "held", "approved", "rejected", "failed"].map((k) => el("td", { class: "num", text: nf.format(g[k] || 0) })),
      el("td", { class: "sec", text: fmtAgo(g.last_seen) }),
      el("td", { class: "num" }, me.is_admin && stoppedAgents[g.agent] ? el("button", { class: "linkish", text: "Resume", onclick: () => resume(g.agent) })
        : (me.can_approve || me.is_admin) && !stoppedAgents[g.agent] ? el("span", {},
            el("button", { class: "linkish", text: "Note", onclick: () => askNote(g.agent) }), " ",
            el("button", { class: "linkish", style: "color:var(--critical)", text: "Stop", onclick: () => askStop(g.agent) })) : null),
    )), "No agent activity in this period.");
    api("/v1/budgets").then((b) => {
      $("#spendCard").hidden = !b.budgets.length;
      const pct = (s) => Math.min(100, Math.round(100 * (s.token_limit ? s.tokens / s.token_limit : s.usd / s.usd_limit)));
      cells("#spendRows", b.spend.map((s) => el("tr", {},
        el("td", { class: "mono", text: `${s.budget} (per ${s.period})` }),
        el("td", {}, s.who === "together" ? "all its agents together" : el("b", { text: s.who })),
        el("td", { text: s.token_limit ? `${nf.format(s.tokens)} / ${nf.format(s.token_limit)} tokens` : `$${s.usd.toFixed(2)} / $${s.usd_limit.toFixed(2)}` }),
        el("td", {}, el("div", { class: "meter" + (s.paused ? " over" : "") }, el("i", { style: `width:${pct(s)}%` })),
          s.paused ? el("span", { class: "tag", style: "border-color:var(--critical);color:var(--critical)", text: "paused" }) : null),
        el("td", { class: "sec", text: fmtDate(s.resets_at) }))), "No spend reported this period.");
    }).catch(() => { $("#spendCard").hidden = true; });
    cells("#repApprovers", a.by_approver.map((p) => el("tr", {}, el("td", { text: p.approver }),
      el("td", { class: "num", text: p.approved }), el("td", { class: "num", text: p.rejected }))), "No decisions yet.");
    cells("#repBlocked", r.blocked_by_rule.map((b) => el("tr", {}, el("td", { class: "mono", text: b.rule_id || "—" }),
      el("td", { class: "wrap sec", text: b.reason || "" }), el("td", { class: "num", text: b.count }))), "Nothing blocked.");
    cells("#repTools", r.top_tools.map((t) => el("tr", {}, el("td", { class: "mono", text: t.name }), el("td", { class: "num", text: nf.format(t.count) }))), "No calls.");
    const describe = (e) => {
      const d = e.detail || {};
      const map = {
        "event.created": () => `${d.source || "?"} asked to run ${d.name} → ${d.status}${d.rule_id ? " (" + d.rule_id + ")" : ""}`,
        "event.approved": () => `approved${d.via === "link" ? " from a notification link" : ""}${d.decided_with ? " (" + d.decided_with + ", signed)" : ""}${d.note ? ": “" + d.note + "”" : ""}`,
        "event.rejected": () => `rejected${d.via === "link" ? " from a notification link" : ""}${d.note ? ": “" + d.note + "”" : ""}`,
        "event.expired": () => `nobody decided in time → ${d.outcome}`,
        "event.completed": () => `finished${d.duration_ms != null ? " in " + fmtDur(d.duration_ms) : ""}`,
        "event.failed": () => `failed: ${d.error || ""}`,
        "controls.stopped": () => `${e.target === "*" ? "stopped ALL agents" : "stopped " + e.target.replace("session:", "session ")}${d.reason ? ": " + d.reason : ""}`,
        "controls.resumed": () => e.target === "*" ? "resumed all agents" : `resumed ${e.target}`,
        "team.added": () => `added ${e.target} (${d.kind}${d.approver ? ", can approve" : ""}${d.roles && d.roles.length ? ", " + d.roles.join("/") : ""})`,
        "team.updated": () => `changed ${e.target}: ${d.approver ? "can approve" : "view only"}${d.roles && d.roles.length ? ", " + d.roles.join("/") : ""}`,
        "team.removed": () => `removed ${e.target}`,
        "settings.updated": () => `changed settings: ${(d.changed || []).join(", ")}`,
        "audit.exported": () => `downloaded the audit CSV (${d.days} days)`,
        "phone.link_shown": () => "opened the phone link QR code",
        "event.snapshot": () => d.skipped ? `no undo copy: ${d.skipped}` : `kept an undo copy (${d.kind}${d.ref ? " " + d.ref : ""})`,
        "event.proof": () => `${d.status === "confirmed" ? "checked: it really happened" : d.status === "mismatch" ? "NOT actually done" : "couldn't check"}: ${d.message || ""}`,
        "device.added": () => `added a Face ID / fingerprint device: ${e.target}`,
        "device.removed": () => `removed the device ${e.target}`,
      };
      return (map[e.action] || (() => e.action))();
    };
    cells("#repAudit", log.entries.map((e) => el("tr", {},
      el("td", { class: "sec", title: e.at, text: fmtDate(e.at) }),
      el("td", { text: e.actor || "system" }),
      el("td", { class: "mono sec", text: e.action }),
      el("td", { class: "wrap", text: describe(e) }))), "Nothing recorded yet.");
  }
  $("#printBtn").addEventListener("click", () => window.print());
  $("#csvBtn").addEventListener("click", async () => {
    try {
      const res = await fetch(`/v1/audit/export.csv?days=${repDays}`, { headers: { "X-Gateway-Key": apiKey } });
      if (!res.ok) throw new Error(`export failed (${res.status})`);
      const url = URL.createObjectURL(await res.blob());
      el("a", { href: url, download: `squidbrake-audit-${new Date().toISOString().slice(0, 10)}.csv` }).click();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
      loadReports();
    } catch (err) { toast(err.message); }
  });
  $("#jsonlBtn").addEventListener("click", async () => {
    try {
      const res = await fetch(`/v1/audit/export.jsonl?days=${repDays}`, { headers: { "X-Gateway-Key": apiKey } });
      if (!res.ok) throw new Error(`export failed (${res.status})`);
      const url = URL.createObjectURL(await res.blob());
      el("a", { href: url, download: `squidbrake-audit-${new Date().toISOString().slice(0, 10)}.jsonl` }).click();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
      loadReports();
    } catch (err) { toast(err.message); }
  });

  $("#evidenceBtn").addEventListener("click", async () => {
    try {
      const res = await fetch(`/v1/audit/evidence-pack?days=${Math.max(repDays, 30)}`, { headers: { "X-Gateway-Key": apiKey } });
      if (!res.ok) throw new Error(`evidence pack failed (${res.status})`);
      const url = URL.createObjectURL(await res.blob());
      el("a", { href: url, download: `squidbrake-evidence-pack-${new Date().toISOString().slice(0, 10)}.html` }).click();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
      loadReports();
    } catch (err) { toast(err.message); }
  });

  // ---------- settings
  const sForm = $("#settingsForm");
  // one switch per agent on this computer (admins, and only when the gateway runs on this computer)
  function renderAgentsHere(list) {
    const box = $("#agentsList");
    box.replaceChildren(...list.map((a) => {
      const sw = el("input", { type: "checkbox", "aria-label": `Send ${a.label} through Squidbrake` });
      sw.checked = a.connected;
      sw.disabled = !a.present && !a.connected;
      sw.onchange = async () => {
        sw.disabled = true;
        try {
          const r = await apiPost(`/v1/agents/local/${encodeURIComponent(a.name)}`, { on: sw.checked });
          renderAgentsHere(r.agents);
          toast(`${a.label}: ${sw.checked ? "connected" : "taken out"}. Quit and reopen ${a.label} to apply.`);
        } catch (err) { toast(String(err.message || err)); sw.checked = !sw.checked; sw.disabled = false; }
      };
      return el("label", { class: "toggle-row", style: "display:flex;align-items:center;gap:10px;padding:6px 0" }, sw,
        el("b", { text: a.label }),
        el("span", { class: "hint", text: a.connected ? "goes through Squidbrake" : a.present ? "not connected" : "not installed here",
          title: a.connected && a.checks ? `Checks: ${a.checks}. Not checked: ${a.not_checked}` : "" }),
        a.connected && a.not_checked && a.not_checked !== "nothing known"
          ? el("span", { class: "hint", text: `not checked: ${a.not_checked}` }) : null);
    }));
  }
  async function loadAgentsHere() {
    if (!(me && me.is_admin)) return;
    try { const r = await api("/v1/agents/local"); $("#agentsHere").hidden = false; renderAgentsHere(r.agents); }
    catch { $("#agentsHere").hidden = true; }          // a gateway on a server: nothing to switch here
  }
  async function loadSettings() {
    loadAgentsHere();
    loadTeamAgents();
    const [s, team] = await Promise.all([api("/v1/settings"), api("/v1/team")]);
    for (const k of ["public_url", "ntfy_topic", "slack_webhook", "slack_signing_secret", "teams_webhook", "email_to",
                     "smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from"]) sForm[k].value = s[k] || "";
    if (s.smtp_host) $("#emailSetup").open = true;
    for (const k of ["siem_url", "siem_token"]) $("#siemForm")[k].value = s[k] || "";
    $("#siemForm").siem_format.value = s.siem_format || "json";
    loadRuleHelp();
    if (s.slack_signing_secret) $("#slackSetup").open = true;
    api("/v1/slack/manifest").then((m) => { $("#slackManifest").textContent = JSON.stringify(m.manifest, null, 2); }).catch(() => {});
    sForm.weekly_digest.checked = !!s.weekly_digest;
    sForm.second_person.checked = !!s.second_person;
    const approvers = team.members.filter((m) => m.approver).map((m) => m.name);
    $("#notifyAs").replaceChildren(...approvers.map((n) => el("option", { value: n, text: n, selected: n === s.notify_as ? "" : null })));
    $("#custForm").customers.value = (s.customers || []).join("\n");
    $("#custCount").textContent = s.customers && s.customers.length ? `${s.customers.length} on the list` : "";
    const ai = $("#aiForm");
    ai.ai_briefs.checked = !!s.ai_briefs; ai.ai_model.value = s.ai_model || ""; ai.ai_key.value = "";
    $("#aiKeyHint").textContent = s.ai_key_set ? `A key ending ${s.ai_key_hint || "…"} is set. Paste a new one to replace it.` : "Kept on this gateway and never shown again.";
    $("#devRequired").checked = !!s.require_device_signature;
    renderDevices();
    loadMcp();
  }
  async function renderDevices() {
    const r = await loadDevices();
    if (!r) return;
    $("#devSub").textContent = r.required ? "required for approvals" : "";
    cells("#devRows", r.devices.map((d) => el("tr", {}, el("td", {}, el("b", { text: d.name || "device" })), el("td", { text: d.person }),
      el("td", { class: "sec", text: fmtDate(d.created_at) }), el("td", { class: "sec", text: d.last_used_at ? fmtAgo(d.last_used_at) : "never" }),
      el("td", { class: "num" }, el("button", { class: "linkish", style: "color:var(--critical)", text: "Remove", onclick: async () => {
        if (!confirm(`Remove ${d.name}? Approvals from it stop working.`)) return;
        try { await apiSend("DELETE", `/v1/devices/${encodeURIComponent(d.credential_id)}`); renderDevices(); } catch (err) { toast(err.message); }
      } })))), "No devices yet.");
  }
  $("#devAdd").addEventListener("click", async () => {
    if (!passkeysWork()) { $("#devMsg").textContent = "This browser can't use passkeys here (it needs https, or localhost)."; return; }
    const name = prompt("Name this device (e.g. Pat's iPhone):", /iPhone|iPad|Android|Mac|Windows/.exec(navigator.userAgent)?.[0] || "My device");
    if (!name) return;
    try { await registerDevice(name.trim()); $("#devMsg").textContent = "Added ✓ Your approvals from this device are now signed."; renderDevices(); }
    catch (err) { $("#devMsg").textContent = err.name === "NotAllowedError" ? "Cancelled." : err.message; }
  });
  $("#devRequired").addEventListener("change", async (ev) => {
    try { await apiSend("PUT", "/v1/settings", { require_device_signature: ev.target.checked }); renderDevices(); toast(ev.target.checked ? "Approvals now need Face ID or fingerprint" : "Plain approvals allowed again"); }
    catch (err) { ev.target.checked = !ev.target.checked; toast(err.message); }
  });
  $("#aiForm").addEventListener("submit", async (ev) => {
    ev.preventDefault();
    const f = ev.target, body = { ai_briefs: f.ai_briefs.checked, ai_model: f.ai_model.value.trim() || null };
    if (f.ai_key.value.trim()) body.ai_key = f.ai_key.value.trim();
    try { await apiSend("PUT", "/v1/settings", body); $("#aiMsg").textContent = "Saved ✓"; loadSettings(); }
    catch (err) { $("#aiMsg").textContent = err.message; }
  });
  $("#aiForget").addEventListener("click", async () => {
    try { await apiSend("PUT", "/v1/settings", { ai_key: "", ai_briefs: false }); $("#aiMsg").textContent = "Key removed"; loadSettings(); }
    catch (err) { $("#aiMsg").textContent = err.message; }
  });
  $("#custForm").addEventListener("submit", async (ev) => {
    ev.preventDefault();
    try {
      const s = await apiSend("PUT", "/v1/settings", { customers: ev.target.customers.value.split("\n") });
      ev.target.customers.value = s.customers.join("\n");
      $("#custCount").textContent = s.customers.length ? `${s.customers.length} on the list` : "";
      $("#custMsg").textContent = "Saved ✓"; setTimeout(() => ($("#custMsg").textContent = ""), 3000);
    } catch (err) { $("#custMsg").textContent = err.message; }
  });

  // ---------- MCP servers by URL
  let mcpCatalog = [];
  function showAuth(kind) {
    $("#mcpForm").auth.value = kind;
    document.querySelectorAll("#mcpForm [data-auth]").forEach((n) => { n.hidden = n.dataset.auth !== kind; });
  }
  $("#mcpAuth").addEventListener("change", (e) => showAuth(e.target.value));
  async function loadMcpCatalog() {
    if (mcpCatalog.length) return;
    let r;
    try { r = await api("/v1/mcp-catalog"); } catch { return; }
    mcpCatalog = r.apps;
    $("#mcpRedirect").textContent = r.redirect_uri.includes("<") ? location.origin + "/v1/mcp-oauth/callback" : r.redirect_uri;
    const groups = {};
    for (const a of mcpCatalog) (groups[a.category] = groups[a.category] || []).push(a);
    $("#mcpApp").append(...Object.keys(groups).sort().map((cat) => el("optgroup", { label: cat },
      ...groups[cat].map((a) => el("option", { value: a.id, text: a.name })))));
  }
  $("#mcpApp").addEventListener("change", () => {
    const f = $("#mcpForm"), a = mcpCatalog.find((x) => x.id === f.app.value);
    if (!a) { $("#mcpAppHint").textContent = "Pick an app and only its token is left to paste."; return; }
    f.name.value = a.id; f.url.value = a.url;
    const docs = el("a", { href: a.source, target: "_blank", rel: "noopener", text: "Docs" });
    if (a.auth === "oauth") {
      showAuth("oauth");
      $("#mcpAppHint").replaceChildren("Add it, then click Connect and sign in to ", a.name, ". ",
        ...(a.note ? [a.note + " "] : []), docs);
      (a.own_app ? f.client_id : f.querySelector("button[type=submit]")).focus();
      return;
    }
    showAuth("header");
    f.hname.value = a.header; f.hvalue.value = ""; f.hvalue.placeholder = a.value.replace("<TOKEN>", "your token");
    $("#mcpAppHint").replaceChildren(`Token: ${a.token}. Paste it as `, el("code", { text: a.value.replace("<TOKEN>", "…") }), ". ",
      ...(a.note ? [a.note + " "] : []), docs);
    f.hvalue.focus();
  });
  async function connectMcp(name, btn) {
    btn.disabled = true; btn.textContent = "Starting…";
    try {
      const r = await apiSend("POST", `/v1/mcp-servers/${encodeURIComponent(name)}/connect`);
      if (r.authorize_url) window.open(r.authorize_url, "_blank", "noopener");
      btn.textContent = "Waiting for sign-in…";
      for (let i = 0; i < 180; i++) {                    // up to 6 minutes for the person to sign in
        await new Promise((res) => setTimeout(res, 2000));
        const m = (await api("/v1/mcp-servers")).servers.find((s) => s.name === name);
        if (m && (m.connected && String(m.sign_in || "").startsWith("connected") || String(m.sign_in || "").startsWith("sign-in failed"))) break;
      }
    } catch (err) { toast(err.message); }
    loadMcp();
  }
  async function loadMcp() {
    loadMcpCatalog();
    let r;
    try { r = await api("/v1/mcp-servers"); } catch { return; }
    $("#mcpList").replaceChildren(...(r.servers.length ? r.servers.map((m) => {
      const copyBtn = el("button", { class: "btn", type: "button", text: "Copy", onclick: (e) => copy(m.endpoint, e.target) });
      const del = el("button", { class: "btn", type: "button", text: "Remove", onclick: async () => {
        if (!confirm(`Remove ${m.name}? Agents using its URL stop reaching the app.`)) return;
        try { await apiSend("DELETE", `/v1/mcp-servers/${encodeURIComponent(m.name)}`); loadMcp(); } catch (err) { toast(err.message); }
      } });
      const status = m.auth === "oauth"
        ? el("td", {}, el("span", { text: m.connected ? "signed in" : "not signed in yet" }),
            ...(m.sign_in && !String(m.sign_in).startsWith("connected") ? [el("div", { class: "sub", text: m.sign_in })] : []), " ",
            el("button", { class: "btn", type: "button", text: m.connected ? "Reconnect" : "Connect",
                           onclick: (e) => connectMcp(m.name, e.target) }))
        : el("td", { text: m.running ? "running" : "starts on first use" });
      return el("tr", {}, el("td", {}, el("b", { text: m.name })),
        el("td", {}, el("code", { text: m.endpoint }), " ", copyBtn),
        el("td", { class: "sub", text: (m.url || m.command || "") + (m.headers.length ? ` (+ ${m.headers.join(", ")})` : "")
                                      + (m.auth === "oauth" ? ` (OAuth${m.own_app ? ", your app" : ""})` : "") }),
        status, el("td", {}, del));
    }) : [el("tr", {}, el("td", { colspan: 5, class: "sub", text: "None yet. Add one below." }))]));
  }
  $("#mcpForm").addEventListener("submit", async (ev) => {
    ev.preventDefault();
    const f = ev.target, headers = {}, oauth = f.auth.value === "oauth";
    if (!oauth && f.hname.value.trim() && f.hvalue.value) headers[f.hname.value.trim()] = f.hvalue.value;
    const body = oauth ? { url: f.url.value.trim(), auth: "oauth", client_id: f.client_id.value.trim() || null,
                           client_secret: f.client_secret.value || null, scope: f.scope.value.trim() || null }
                       : { url: f.url.value.trim(), headers };
    try {
      const m = await apiSend("PUT", `/v1/mcp-servers/${encodeURIComponent(f.name.value.trim())}`, body);
      $("#mcpMsg").textContent = oauth ? `Added. Now click Connect next to ${m.name} and sign in.`
                                       : `Added. Give your agent ${m.endpoint}`;
      f.reset(); showAuth("header"); loadMcp();
    } catch (err) { $("#mcpMsg").textContent = err.message; }
  });
  $("#useOrigin").addEventListener("click", () => { sForm.public_url.value = location.origin; });
  $("#copyManifest").addEventListener("click", (e) => copy($("#slackManifest").textContent, e.target));
  $("#siemForm").addEventListener("submit", async (ev) => {
    ev.preventDefault();
    const f = ev.target;
    try {
      await apiSend("PUT", "/v1/settings", { siem_url: f.siem_url.value.trim(), siem_token: f.siem_token.value,
                                             siem_format: f.siem_format.value });
      $("#siemMsg").textContent = f.siem_url.value.trim() ? "Saved ✓ Decisions now go there." : "Saved ✓ (off)";
    } catch (err) { $("#siemMsg").textContent = err.message; }
  });
  $("#siemTest").addEventListener("click", async () => {
    try { const r = await apiPost("/v1/settings/siem-test"); $("#siemMsg").textContent = r.ok ? "Delivered ✓" : r.error; }
    catch (err) { $("#siemMsg").textContent = err.message; }
  });
  async function loadRuleHistory() {
    let v;
    try { v = await api("/v1/policy/versions"); } catch { return; }
    $("#ruleHistory").replaceChildren(...v.versions.slice(0, 15).map((x) => el("div", { class: "suggest" },
      el("div", { class: "grow" }, el("code", { text: x.fingerprint }), x.current ? el("span", { class: "tag", style: "margin-left:6px", text: "in use" }) : null,
        el("div", { class: "sub", text: `first used ${fmtAgo(x.first_seen)}` })),
      x.current ? null : el("button", { class: "btn", type: "button", text: "Changes", onclick: async () => {
        try { const d = await api(`/v1/policy/versions/${x.fingerprint}`); $("#ruleDiff").hidden = false; $("#ruleDiff").textContent = d.diff_to_current || "(no differences)"; }
        catch (err) { toast(err.message); }
      } }),
      x.current || !(me && me.is_admin) ? null : el("button", { class: "btn", type: "button", text: "Put back", onclick: async () => {
        if (!confirm(`Put rules version ${x.fingerprint} back? Today's rules.yaml is kept as a backup.`)) return;
        try { const r = await apiPost(`/v1/policy/versions/${x.fingerprint}/restore`); toast(`Rules put back (backup: ${r.backup})`); loadRuleHistory(); }
        catch (err) { toast(err.message); }
      } }))));
  }
  async function loadRuleHelp() {
    loadRuleHistory();
    let s, p;
    try { [s, p] = await Promise.all([api("/v1/policy/suggestions"), api("/v1/policy/packs")]); } catch { return; }
    $("#ruleSuggest").replaceChildren(...(s.suggestions.length ? [el("h3", { class: "sub-h", text: "Suggested: stop asking about these" }),
      ...s.suggestions.map((x) => el("div", { class: "suggest" },
        el("div", { class: "grow" }, el("code", { text: x.what }),
          el("div", { class: "sub", text: `approved or marked legit ${x.approved} times in 30 days, never rejected${x.held_by ? " · held by " + x.held_by : ""}` })),
        el("button", { class: "btn", type: "button", text: "Allow it", title: x.yaml, onclick: async (e) => {
          if (!confirm(`Add this rule to rules.yaml?\n\n${x.yaml}`)) return;
          try { await apiPost(`/v1/policy/suggestions/${encodeURIComponent(x.id)}/apply`); toast("Rule added"); loadRuleHelp(); }
          catch (err) { toast(err.message); }
        } })))] : []));
    $("#rulePacks").replaceChildren(...p.packs.map((x) => el("div", { class: "pack" }, el("b", { text: x.title }),
      el("p", { text: x.about }),
      x.added ? el("span", { class: "sub", text: "Added ✓" }) : el("button", { class: "btn", type: "button", text: "Add pack",
        title: x.yaml, onclick: async () => {
          if (!confirm(`Add the ${x.title} rules to rules.yaml?\n\n${x.yaml}`)) return;
          try { await apiPost(`/v1/policy/packs/${x.id}/apply`); toast(`${x.title} rules added`); loadRuleHelp(); }
          catch (err) { toast(err.message); }
        } }))));
  }
  $("#genTopic").addEventListener("click", () => {
    const b = new Uint8Array(9); crypto.getRandomValues(b);
    sForm.ntfy_topic.value = "gw-" + Array.from(b, (x) => x.toString(36).padStart(2, "0")).join("").slice(0, 16);
  });
  sForm.addEventListener("submit", async (ev) => {
    ev.preventDefault();
    try {
      await apiSend("PUT", "/v1/settings", { public_url: sForm.public_url.value.trim(), ntfy_topic: sForm.ntfy_topic.value.trim(),
        slack_webhook: sForm.slack_webhook.value.trim(), slack_signing_secret: sForm.slack_signing_secret.value.trim(),
        teams_webhook: sForm.teams_webhook.value.trim(), email_to: sForm.email_to.value.trim(),
        smtp_host: sForm.smtp_host.value.trim(), smtp_port: sForm.smtp_port.value ? +sForm.smtp_port.value : null,
        smtp_user: sForm.smtp_user.value.trim(), smtp_password: sForm.smtp_password.value,
        smtp_from: sForm.smtp_from.value.trim(),
        notify_as: sForm.notify_as.value || null, weekly_digest: sForm.weekly_digest.checked,
        second_person: sForm.second_person.checked });
      $("#settingsMsg").textContent = "Saved ✓"; setTimeout(() => ($("#settingsMsg").textContent = ""), 3000);
    } catch (err) { $("#settingsMsg").textContent = err.message; }
  });
  $("#testNotify").addEventListener("click", async () => {
    try {
      const r = await apiPost("/v1/settings/test");
      $("#settingsMsg").textContent = (r.sent.length ? `Sent to ${r.sent.join(" and ")} ✓ ` : "") + r.errors.join(" · ");
    } catch (err) { $("#settingsMsg").textContent = err.message; }
  });
  $("#ruleTest").addEventListener("submit", async (ev) => {
    ev.preventDefault();
    const f = ev.target;
    let input = f.input.value.trim();
    try { input = input ? JSON.parse(input) : null; } catch { /* plain text input is fine */ }
    try {
      const r = await apiPost("/v1/policy/check", { name: f.name.value.trim(), source: f.source.value.trim() || null, input });
      const what = { allow: "✓ Allowed", deny: "✕ Blocked", review: "? Held for a person" }[r.decision];
      $("#ruleResult").textContent = `${what} — ${r.reason}${r.rule_id ? " (rule " + r.rule_id + ")" : ""}`
        + (r.decision === "review" ? `; if nobody decides within ${Math.round(r.timeout_seconds / 60)} min it's ${r.on_timeout === "allow" ? "allowed" : "blocked"}${r.approvers ? "; who can approve: " + r.approvers.join(", ") : ""}` : "");
    } catch (err) { $("#ruleResult").textContent = err.message; }
  });

  // ---------- phone
  $("#phoneBtn").addEventListener("click", async () => {
    try {
      const r = await api("/v1/me/phone-link");
      $("#phoneAs").textContent = r.as;
      $("#phoneQr").src = r.qr;
      $("#phoneWarn").hidden = r.public;
      $("#phoneDlg").showModal();
    } catch (err) { toast(err instanceof AuthError ? "Sign in first" : err.message); }
  });

  renderFilters();
  showView(location.hash.slice(1));
  reload().then(() => { if (linkedEvent) openDrawer(linkedEvent); });
})();
</script>
</body>
</html>