// Dashboard SPA. Renders containers + approvals from `/api/state`, wires // up async-form submission (URL-encoded POST + spinner + state refresh), // and tails the unified dashboard event channel over `/dashboard/stream`. (() => { // ─── helpers ──────────────────────────────────────────────────────────── const $ = (id) => document.getElementById(id); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&':'&', '<':'<', '>':'>', '"':'"' }[c]) ); const el = (tag, attrs = {}, ...children) => { const e = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'class') e.className = v; else if (k === 'html') e.innerHTML = v; else if (k.startsWith('data-')) e.setAttribute(k, v); else e.setAttribute(k, v); } for (const c of children) { if (c == null) continue; e.append(c.nodeType ? c : document.createTextNode(c)); } return e; }; const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => { const f = el('form', { method: 'POST', action, class: 'inline', 'data-async': '', ...(confirmMsg ? { 'data-confirm': confirmMsg } : {}), // Endpoints whose mutation fires a DashboardEvent (and whose // derived store applies it live) opt out of the post-submit // /api/state refetch. See the async-form handler. ...(opts.noRefresh ? { 'data-no-refresh': '' } : {}), }); for (const [name, value] of Object.entries(extra)) { f.append(el('input', { type: 'hidden', name, value })); } f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel)); return f; }; // ─── side panel ───────────────────────────────────────────────────────── // Singleton drawer that swipes in from the right. Long content // (file previews, approval diffs, journald logs, applied config) // opens here via `Panel.open(title, node)` instead of expanding // inline. Body is swapped on each open; closing just slides out so // the content stays visible through the transition. const Panel = (() => { const root = $('side-panel'); const titleEl = $('side-panel-title'); const bodyEl = $('side-panel-body'); function open(title, content) { titleEl.textContent = title; bodyEl.replaceChildren(...(content ? [content] : [])); root.classList.add('open'); root.setAttribute('aria-hidden', 'false'); } function close() { root.classList.remove('open'); root.setAttribute('aria-hidden', 'true'); } function bind() { $('side-panel-close').addEventListener('click', close); $('side-panel-backdrop').addEventListener('click', close); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && root.classList.contains('open')) close(); }); } return { open, close, bind }; })(); // ─── path linkification ───────────────────────────────────────────────── // Agents constantly drop pointer strings into messages + question // bodies (it's the 1 KiB-cap escape hatch). Anything matching the // PATH_RE patterns becomes a clickable anchor; clicking expands an // inline
with the file's contents, fetched lazily from // /api/state-file. The legacy in-container `/state/...` prefix is // deliberately not matched — it's ambiguous from the host's // perspective (we'd need to know which agent the message is about // to translate it). Prefer `/agents//state/...` in agent // outputs and the link will resolve. async function fetchStateFile(path) { const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path)); const text = await resp.text(); if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status)); return text; } // Lazy-load `path` from /api/state-file into the side panel. // Markdown files render through `marked` into a `.md` block; every // other file stays raw text in a
.
  async function openFilePanel(path) {
    const isMd = /\.(md|markdown)$/i.test(path);
    const view = isMd
      ? el('div', { class: 'md' })
      : el('pre', { class: 'path-preview-body' });
    view.textContent = '(fetching…)';
    Panel.open('↳ ' + path, view);
    try {
      const text = await fetchStateFile(path);
      if (isMd && window.marked && typeof window.marked.parse === 'function') {
        marked.setOptions({ breaks: true, gfm: true });
        view.innerHTML = marked.parse(text);
      } else {
        view.textContent = text;
      }
    } catch (e) {
      view.textContent = 'error: ' + (e.message || e);
    }
  }
  function makePathLink(path) {
    const anchor = el('a', {
      href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
    }, path);
    anchor.addEventListener('click', (e) => {
      e.preventDefault();
      openFilePanel(path);
    });
    return anchor;
  }
  // Append `text` to `parent` as a mix of text nodes + path anchors.
  // `refs` is the server-attached `file_refs` array (verified-file
  // tokens that appear in `text`); each occurrence of a ref becomes a
  // clickable anchor that opens the file in the side panel. Anything
  // not in `refs` stays plain text. No client-side regex, no probe
  // endpoint — the server saw the body first and made the call. When
  // `refs` is empty/missing we just emit plain text.
  function appendLinkified(parent, text, refs) {
    if (text == null) return;
    const str = String(text);
    const tokens = (refs || []).slice();
    if (!tokens.length) {
      if (str) parent.appendChild(document.createTextNode(str));
      return;
    }
    // Walk the string left-to-right, at each step looking for the
    // next occurrence of any token. Longest-first tie-break so a
    // ref like `/agents/foo/state/x.md` wins over a (hypothetical)
    // shorter token that prefixes it. O(text * refs) worst case;
    // refs is bounded server-side to whatever fits in a body, so
    // this stays cheap.
    tokens.sort((a, b) => b.length - a.length);
    let i = 0;
    while (i < str.length) {
      let bestStart = -1;
      let bestToken = null;
      for (const t of tokens) {
        const idx = str.indexOf(t, i);
        if (idx === -1) continue;
        if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) {
          bestStart = idx;
          bestToken = t;
        }
      }
      if (bestStart === -1) {
        parent.appendChild(document.createTextNode(str.slice(i)));
        break;
      }
      if (bestStart > i) {
        parent.appendChild(document.createTextNode(str.slice(i, bestStart)));
      }
      parent.appendChild(makePathLink(bestToken));
      i = bestStart + bestToken.length;
    }
  }

  // ─── browser notifications ──────────────────────────────────────────────
  // Fires OS notifications on three operator-bound signals:
  //   - new approval landed in the queue
  //   - new operator question queued (ask, target IS NULL)
  //   - broker message sent `to: "operator"`
  // permission grant is per-browser; a localStorage "muted" toggle lets
  // the operator silence without revoking. Secure-context only (HTTPS /
  // localhost) — on other origins the API is unavailable and we hide
  // the controls.
  const NOTIF = (() => {
    const supported = typeof Notification !== 'undefined';
    const MUTED_KEY = 'hyperhive.notify.muted';
    const isMuted  = () => localStorage.getItem(MUTED_KEY) === '1';
    const setMuted = (v) => v
      ? localStorage.setItem(MUTED_KEY, '1')
      : localStorage.removeItem(MUTED_KEY);
    function renderControls() {
      const enable = $('notif-enable');
      const mute   = $('notif-mute');
      const unmute = $('notif-unmute');
      const status = $('notif-status');
      if (!enable || !mute || !unmute || !status) return;
      if (!supported) {
        enable.hidden = mute.hidden = unmute.hidden = true;
        status.hidden = false;
        status.textContent = 'notifications unsupported in this browser';
        return;
      }
      const perm = Notification.permission;
      enable.hidden = perm === 'granted';
      mute.hidden   = perm !== 'granted' || isMuted();
      unmute.hidden = perm !== 'granted' || !isMuted();
      status.hidden = perm !== 'denied';
      if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
    }
    function bind() {
      const enable = $('notif-enable');
      const mute   = $('notif-mute');
      const unmute = $('notif-unmute');
      if (!supported || !enable || !mute || !unmute) return;
      enable.addEventListener('click', async () => {
        await Notification.requestPermission();
        renderControls();
      });
      mute.addEventListener('click', () => { setMuted(true); renderControls(); });
      unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
      renderControls();
    }
    function show(title, body, tag) {
      if (!supported) {
        console.debug('notify: Notification API not supported');
        return;
      }
      if (Notification.permission !== 'granted') {
        console.debug('notify: permission not granted', Notification.permission);
        return;
      }
      if (isMuted()) {
        console.debug('notify: muted');
        return;
      }
      try {
        // Per-event tag so distinct messages stack instead of
        // collapsing into one slot. Caller passes a unique tag per
        // notification kind/id; we don't fall back to 'hyperhive'
        // because that one tag would replace itself on every fire.
        const n = new Notification(title, {
          body,
          tag: tag || ('hyperhive:' + Date.now()),
        });
        n.onclick = () => { window.focus(); n.close(); };
        console.debug('notify: shown', title, 'tag=', tag);
      } catch (err) {
        console.warn('notification show failed', err);
      }
    }
    return { bind, show, renderControls };
  })();

  // Track which items we've already notified about so a re-render
  // doesn't re-fire for the same row. Keyed by stable ids; reset only
  // when the page reloads.
  const seenApprovals  = new Set();
  const seenQuestions  = new Set();
  let seededNotify = false;

  function notifyDeltas(s) {
    const approvals = s.approvals || [];
    const questions = s.questions || [];
    if (!seededNotify) {
      // First render after page load — fill the "seen" sets without
      // firing notifications. We only want to notify on NEW items
      // that arrived while the page is open. The inbox no longer
      // needs seeding here: it's derived from the broker stream which
      // does its own per-event notification on live arrival, and
      // history-replayed events are silent by virtue of `fromHistory`.
      for (const a of approvals) seenApprovals.add(a.id);
      for (const q of questions) seenQuestions.add(q.id);
      seededNotify = true;
      return;
    }
    for (const a of approvals) {
      if (seenApprovals.has(a.id)) continue;
      seenApprovals.add(a.id);
      const verb = a.kind === 'spawn' ? 'spawn approval' : 'config commit';
      NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
        'hyperhive:approval:' + a.id);
    }
    for (const q of questions) {
      if (seenQuestions.has(q.id)) continue;
      seenQuestions.add(q.id);
      const targetLabel = q.target || 'operator';
      NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`,
        q.question.slice(0, 120),
        'hyperhive:question:' + q.id);
    }
  }

  // ─── async forms ────────────────────────────────────────────────────────
  document.addEventListener('submit', async (e) => {
    const f = e.target;
    if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
    e.preventDefault();
    if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
    if (f.dataset.prompt) {
      const ans = prompt(f.dataset.prompt, '');
      if (ans === null) return;  // operator hit Cancel
      // Drop into a hidden input named after `data-prompt-field` (or
      // 'note' by default) so the value rides along on the POST.
      const field = f.dataset.promptField || 'note';
      let input = f.querySelector(`input[name="${field}"]`);
      if (!input) {
        input = document.createElement('input');
        input.type = 'hidden';
        input.name = field;
        f.append(input);
      }
      input.value = ans;
    }
    const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
    const original = btn ? btn.innerHTML : '';
    if (btn) { btn.disabled = true; btn.innerHTML = ''; }
    try {
      const resp = await fetch(f.action, {
        method: f.method || 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams(new FormData(f)),
        redirect: 'manual',
      });
      const ok = resp.ok || resp.type === 'opaqueredirect'
        || (resp.status >= 200 && resp.status < 400);
      if (!ok) {
        const text = await resp.text().catch(() => '');
        alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
        if (btn) { btn.disabled = false; btn.innerHTML = original; }
        return;
      }
      // Re-enable the button — refreshState() rebuilds most lists but
      // skips forms that didn't change (e.g. the spawn form), so without
      // this the spinner sticks and the button can't be clicked again.
      if (btn) { btn.disabled = false; btn.innerHTML = original; }
      // Clear text inputs whose value was just submitted.
      f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
      // Forms whose endpoint already emits a DashboardEvent that
      // updates the derived store can opt out of the post-submit
      // /api/state refetch (the event delivers the new row faster
      // than the snapshot poll anyway). Container-lifecycle forms
      // still rely on the refresh since `ContainerView` isn't yet
      // event-derivable.
      if (!f.hasAttribute('data-no-refresh')) {
        refreshState();
      }
    } catch (err) {
      alert('action failed: ' + err);
      if (btn) { btn.disabled = false; btn.innerHTML = original; }
    }
  });

  // Derived container state — cold-loaded from /api/state.containers,
  // then mutated live by `container_state_changed` (upsert by name)
  // and `container_removed` (drop by name). The coordinator's rescan
  // helper fires these after every mutation site + on a periodic poll
  // in crash_watch. Keyed by ContainerView.name so the lifecycle
  // forms' POST → 200 → matching event flips the row without a
  // snapshot refetch.
  const containersState = new Map();
  function syncContainersFromSnapshot(s) {
    containersState.clear();
    for (const c of s.containers || []) containersState.set(c.name, c);
  }
  function applyContainerStateChanged(ev) {
    if (!ev.container || !ev.container.name) return;
    containersState.set(ev.container.name, ev.container);
    renderContainersFromState();
  }
  function applyContainerRemoved(ev) {
    if (containersState.delete(ev.name)) renderContainersFromState();
  }

  // Derived tombstones + meta_inputs. Both are emitted as full
  // snapshots (not diffs) — the lists are tiny and recomputing
  // avoids ordering races between a same-tick destroy + purge.
  let tombstonesState = [];
  let metaInputsState = [];
  function syncTombstonesFromSnapshot(s) {
    tombstonesState = (s.tombstones || []).slice();
  }
  function syncMetaInputsFromSnapshot(s) {
    metaInputsState = (s.meta_inputs || []).slice();
  }
  function applyTombstonesChanged(ev) {
    tombstonesState = (ev.tombstones || []).slice();
    renderTombstonesFromState();
  }
  function applyMetaInputsChanged(ev) {
    metaInputsState = (ev.inputs || []).slice();
    renderMetaInputsFromState();
  }
  function renderTombstonesFromState() {
    renderTombstones({ tombstones: tombstonesState });
  }
  function renderMetaInputsFromState() {
    renderMetaInputs({ meta_inputs: metaInputsState });
  }

  // Derived transient state — cold-loaded from /api/state.transients,
  // then mutated live by `transient_set` / `transient_cleared`. Keyed
  // by agent name so add/remove are O(1). `since_unix` is wall-clock so
  // the elapsed-seconds badge ticks without polling.
  const transientsState = new Map();
  function syncTransientsFromSnapshot(s) {
    transientsState.clear();
    for (const t of s.transients || []) {
      // Snapshot ships `secs` (server-computed); reconstruct an
      // approximate since_unix so the live ticker keeps progressing
      // without surprising jumps when the next snapshot lands.
      const nowUnix = Math.floor(Date.now() / 1000);
      transientsState.set(t.name, {
        kind: t.kind,
        since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
      });
    }
  }
  function applyTransientSet(ev) {
    transientsState.set(ev.name, {
      kind: ev.transient_kind,
      since_unix: ev.since_unix,
    });
    renderContainersFromState();
  }
  function applyTransientCleared(ev) {
    if (transientsState.delete(ev.name)) renderContainersFromState();
  }
  // Re-render using the last cached snapshot (containers come from
  // /api/state, transients overlay from the derived map). The snapshot
  // is stashed on window.__hyperhive_state by refreshState; on cold
  // load before the first snapshot we just skip.
  function renderContainersFromState() {
    const s = window.__hyperhive_state;
    if (s) renderContainers(s);
  }

  // Re-derive port conflicts from the live containers map. Mirrors the
  // server-side `build_port_conflicts` so the banner reacts to event
  // updates instead of waiting for a /api/state refetch.
  function derivePortConflicts(containers) {
    const byPort = new Map();
    for (const c of containers) {
      if (!byPort.has(c.port)) byPort.set(c.port, []);
      byPort.get(c.port).push(c.name);
    }
    const out = [];
    for (const [port, agents] of byPort) {
      if (agents.length > 1) {
        agents.sort();
        out.push({ port, agents });
      }
    }
    out.sort((a, b) => a.port - b.port);
    return out;
  }

  // ─── state rendering ────────────────────────────────────────────────────
  function renderContainers(s) {
    const root = $('containers-section');
    root.innerHTML = '';

    // Containers come from the derived map (event-driven) rather than
    // `s.containers`; `s` still supplies hostname (for the web-ui
    // link) and tombstones/meta_inputs (not event-derived yet).
    const containers = Array.from(containersState.values())
      .sort((a, b) => a.name.localeCompare(b.name));
    const portConflicts = derivePortConflicts(containers);
    const anyStale = containers.some((c) => c.needs_update);

    // Port-hash collisions: rename one of the listed agents and
    // rebuild. The banner sits above the agent list so it's the
    // first thing the operator sees when something's wedged.
    if (portConflicts.length) {
      const banner = el('div', { class: 'port-conflict' },
        el('strong', {}, '⚠  port collision'), ' — ');
      const groups = portConflicts.map((c) =>
        `:${c.port} (${c.agents.join(' + ')})`).join('; ');
      banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
      root.append(banner);
    }

    if (anyStale) {
      root.append(form(
        '/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
        'rebuild every stale container?',
        {}, { noRefresh: true },
      ));
    }

    if (transientsState.size) {
      const ul = el('ul');
      const nowUnix = Math.floor(Date.now() / 1000);
      for (const [name, t] of transientsState) {
        const secs = Math.max(0, nowUnix - t.since_unix);
        ul.append(el('li', {},
          el('span', { class: 'glyph spinner' }, '◐'), ' ',
          el('span', { class: 'agent' }, name), ' ',
          el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
          el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`),
        ));
      }
      root.append(ul);
    }

    if (!containers.length && !transientsState.size) {
      root.append(el('p', { class: 'empty' }, 'no managed containers'));
      return;
    }

    const hostname = (s && s.hostname) || window.location.hostname;
    const ul = el('ul', { class: 'containers' });
    for (const c of containers) {
      const url = `http://${hostname}:${c.port}/`;
      // Pending state is overlaid from the transient store, not from
      // the container row — `ContainerStateChanged` doesn't carry it,
      // `TransientSet` / `TransientCleared` do.
      const pending = transientsState.get(c.name)?.kind || null;
      const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });

      // ── line 1: identity ─────────────────────────────────────────
      const head = el('div', { class: 'head' });
      head.append(
        el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
        el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
          c.is_manager ? 'm1nd' : 'ag3nt'),
        el('a', {
          class: 'meta',
          href: url + 'stats',
          target: '_blank',
          rel: 'noopener',
          title: 'per-agent stats page (turn rate, durations, tokens, tool mix)',
        }, '📊'),
      );
      if (pending) {
        head.append(el('span', { class: 'pending-state' },
          el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
      } else if (c.needs_login) {
        head.append(el('a',
          { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
          'needs login →'));
      }
      if (c.needs_update) {
        head.append(form(
          '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
          'rebuild ' + c.name + '? hot-reloads the container.',
          {}, { noRefresh: true },
        ));
      }
      head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`));
      if (c.deployed_sha) {
        head.append(el('span',
          { class: 'meta', title: 'sha currently locked in /meta/flake.lock' },
          `deployed:${c.deployed_sha}`));
      }
      if (c.pending_reminders && c.pending_reminders > 0) {
        head.append(el('span',
          {
            class: 'badge badge-reminder',
            title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
          },
          `⏰ ${c.pending_reminders}`));
      }
      li.append(head);

      // ── line 2: action buttons ───────────────────────────────────
      const actions = el('div', { class: 'actions' });
      if (c.running) {
        actions.append(
          form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT',
            'restart ' + c.name + '?', {}, { noRefresh: true }),
        );
        if (!c.is_manager) {
          actions.append(
            form('/kill/' + c.name, 'btn-stop', '■ ST0P',
              'stop ' + c.name + '?', {}, { noRefresh: true }),
          );
        }
      } else {
        actions.append(
          form('/start/' + c.name, 'btn-start', '▶ ST4RT',
            'start ' + c.name + '?', {}, { noRefresh: true }),
        );
      }
      actions.append(
        form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
          'rebuild ' + c.name + '? hot-reloads the container.',
          {}, { noRefresh: true }),
      );
      if (!c.is_manager) {
        // DESTR0Y is event-covered (ContainerRemoved); PURG3 also
        // wipes tombstone state which isn't event-derived yet, so it
        // Both event-covered now (ContainerRemoved +
        // TombstonesChanged); no /api/state refetch needed.
        actions.append(
          form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
            'destroy ' + c.name + '? container is removed; state + creds kept.',
            {}, { noRefresh: true }),
          form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
            'PURGE ' + c.name + '? container, config history, claude creds, '
            + 'and notes are all WIPED. no undo.',
            { purge: 'on' }, { noRefresh: true }),
        );
      }
      li.append(actions);

      // Per-container journald viewer. Opens the side panel and
      // fetches the last N lines; refresh re-fetches; unit selector
      // narrows to the harness service (or empty = full machine).
      const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
      li.append(buildJournalTrigger(c.container, journalUnit));
      // Per-container applied config viewer. Shows the agent.nix
      // the container is actually built against.
      li.append(buildConfigTrigger(c.name));

      ul.append(li);
    }
    root.append(ul);
  }

  // Per-container journald viewer. Returns an inline trigger; the
  // click opens the side panel and fetches the last N lines. Refresh
  // re-fetches; the unit toggle switches between the harness service
  // and the full machine journal.
  function buildJournalTrigger(containerName, defaultUnit) {
    const trigger = el('button', { type: 'button', class: 'panel-trigger' },
      '↳ logs · ' + containerName);
    trigger.addEventListener('click', () => {
      const body = el('div', { class: 'journal-body' });
      const controls = el('div', { class: 'journal-controls' });
      const unitSelect = el('select', { class: 'journal-unit' });
      unitSelect.append(
        el('option', { value: defaultUnit }, defaultUnit),
        el('option', { value: '' }, '(full machine journal)'),
      );
      const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
        '↻ refresh');
      const pre = el('pre', { class: 'journal-output' }, 'fetching…');
      let fetching = false;
      async function fetchLogs() {
        if (fetching) return;
        fetching = true;
        pre.textContent = 'fetching…';
        const unit = unitSelect.value;
        const params = new URLSearchParams({ lines: '500' });
        if (unit) params.set('unit', unit);
        try {
          const resp = await fetch('/api/journal/' + containerName + '?' + params);
          const text = await resp.text();
          if (!resp.ok) {
            pre.textContent = 'error: ' + resp.status + '\n' + text;
          } else {
            pre.textContent = text || '(empty)';
            // Auto-scroll the panel to the newest lines on fresh fetch.
            const sb = $('side-panel-body');
            if (sb) sb.scrollTop = sb.scrollHeight;
          }
        } catch (err) {
          pre.textContent = 'fetch failed: ' + err;
        } finally {
          fetching = false;
        }
      }
      refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
      unitSelect.addEventListener('change', fetchLogs);
      controls.append(unitSelect, refresh);
      body.append(controls, pre);
      Panel.open('logs · ' + containerName, body);
      fetchLogs();
    });
    return trigger;
  }

  // Per-container applied-config viewer. Returns an inline trigger;
  // the click opens the side panel and fetches agent.nix. Read-only —
  // the file is hive-c0re's applied repo, mutated only via approvals.
  function buildConfigTrigger(agentName) {
    const trigger = el('button', { type: 'button', class: 'panel-trigger' },
      '↳ agent.nix · ' + agentName);
    trigger.addEventListener('click', () => {
      const body = el('div', { class: 'journal-body' });
      const controls = el('div', { class: 'journal-controls' });
      const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
        '↻ refresh');
      const pre = el('pre', { class: 'journal-output' }, 'fetching…');
      let fetching = false;
      async function fetchConfig() {
        if (fetching) return;
        fetching = true;
        pre.textContent = 'fetching…';
        try {
          const resp = await fetch('/api/agent-config/' + agentName);
          const text = await resp.text();
          if (!resp.ok) {
            pre.textContent = 'error: ' + resp.status + '\n' + text;
          } else {
            pre.textContent = text || '(empty)';
          }
        } catch (err) {
          pre.textContent = 'fetch failed: ' + err;
        } finally {
          fetching = false;
        }
      }
      refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
      controls.append(refresh);
      body.append(controls, pre);
      Panel.open('agent.nix · ' + agentName, body);
      fetchConfig();
    });
    return trigger;
  }

  function renderTombstones(s) {
    const root = $('tombstones-section');
    root.innerHTML = '';
    if (!s.tombstones || !s.tombstones.length) {
      root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
      return;
    }
    const fmtBytes = (n) => {
      if (n < 1024) return n + ' B';
      if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
      if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
      return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
    };
    const fmtAge = (ts) => {
      if (!ts) return '?';
      const d = Math.floor((Date.now() / 1000 - ts) / 86400);
      if (d <= 0) return 'today';
      if (d === 1) return '1 day ago';
      return d + ' days ago';
    };
    const ul = el('ul', { class: 'containers' });
    for (const t of s.tombstones) {
      const li = el('li', { class: 'container-row tombstone' });
      const head = el('div', { class: 'head' });
      head.append(
        el('span', { class: 'name' }, t.name),
        el('span', { class: 'badge badge-muted' }, 'destroyed'),
      );
      if (t.has_creds) {
        head.append(el('span', { class: 'badge badge-muted' }, 'creds kept'));
      }
      head.append(el('span', { class: 'meta' },
        `${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`));
      li.append(head);

      const actions = el('div', { class: 'actions' });
      // Reuse the existing spawn form pattern via /request-spawn — operator
      // can queue an approval that recreates the agent with the same name
      // and reuses the kept state.
      const respawn = el('form', {
        method: 'POST', action: '/request-spawn',
        class: 'inline', 'data-async': '',
        'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
      });
      respawn.append(
        el('input', { type: 'hidden', name: 'name', value: t.name }),
        el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'),
      );
      actions.append(respawn);
      actions.append(form(
        '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
        'PURGE ' + t.name + '? config history, claude creds, '
        + 'and notes are all WIPED. no undo.',
        {}, { noRefresh: true },
      ));
      li.append(actions);
      ul.append(li);
    }
    root.append(ul);
  }

  // Derived question state — cold-loaded from /api/state, then mutated
  // live by `question_added` / `question_resolved` dashboard events.
  const QUESTION_HISTORY_LIMIT = 20;
  const questionsState = { pending: [], history: [] };
  function syncQuestionsFromSnapshot(s) {
    questionsState.pending = (s.questions || []).slice();
    questionsState.history = (s.question_history || []).slice();
  }
  function applyQuestionAdded(ev) {
    if (questionsState.pending.some((q) => q.id === ev.id)) return;
    questionsState.pending.push({
      id: ev.id,
      asker: ev.asker,
      question: ev.question,
      options: ev.options || [],
      multi: !!ev.multi,
      asked_at: ev.asked_at,
      deadline_at: ev.deadline_at ?? null,
      target: ev.target || null,
      question_refs: ev.question_refs || [],
    });
    renderQuestions();
  }
  function applyQuestionResolved(ev) {
    const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
    const existing = idx >= 0 ? questionsState.pending[idx] : null;
    if (idx >= 0) questionsState.pending.splice(idx, 1);
    questionsState.history.unshift({
      id: ev.id,
      asker: existing?.asker || '?',
      question: existing?.question || '',
      options: existing?.options || [],
      multi: existing?.multi || false,
      asked_at: existing?.asked_at || ev.answered_at,
      answered_at: ev.answered_at,
      answer: ev.answer,
      answerer: ev.answerer,
      target: existing?.target ?? ev.target ?? null,
      question_refs: existing?.question_refs || [],
      answer_refs: ev.answer_refs || [],
    });
    if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
      questionsState.history.length = QUESTION_HISTORY_LIMIT;
    }
    renderQuestions();
  }
  // Filter selection for the questions section. Persisted so the
  // operator's preferred view (all / operator-targeted / peer)
  // survives a reload.
  const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
  function getQuestionsFilter() {
    return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
  }
  function setQuestionsFilter(v) {
    localStorage.setItem(QUESTIONS_FILTER_KEY, v);
    renderQuestions();
  }
  function questionMatchesFilter(q, filter) {
    if (filter === 'all') return true;
    if (filter === 'operator') return !q.target;
    if (filter === 'peer') return !!q.target;
    // `agent:` matches when the agent appears as asker OR target.
    if (filter.startsWith('agent:')) {
      const name = filter.slice('agent:'.length);
      return q.asker === name || q.target === name;
    }
    return true;
  }
  function renderQuestions() {
    const root = $('questions-section');
    root.innerHTML = '';
    const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
    const allPending = questionsState.pending;
    const activeFilter = getQuestionsFilter();
    const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));

    // Filter chips. Always include `all` / `operator` / `peer`; add
    // per-agent chips for any agent that appears as asker or target
    // in the pending list so the operator can isolate a single
    // thread without typing.
    const participants = new Set();
    for (const q of allPending) {
      participants.add(q.asker);
      if (q.target) participants.add(q.target);
    }
    const filterRow = el('div', { class: 'questions-filters' });
    const mkChip = (value, label) => {
      const b = el('button', {
        type: 'button',
        class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''),
      }, label);
      b.addEventListener('click', () => setQuestionsFilter(value));
      return b;
    };
    filterRow.append(
      mkChip('all', `all · ${allPending.length}`),
      mkChip('operator', '@operator'),
      mkChip('peer', '@peer'),
    );
    for (const name of Array.from(participants).sort()) {
      filterRow.append(mkChip('agent:' + name, '@' + name));
    }
    root.append(filterRow);

    if (!pending.length) {
      root.append(el('p', { class: 'empty' },
        activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
    }
    const ul = el('ul', { class: 'questions' });
    for (const q of pending) {
      const targetLabel = q.target || 'operator';
      const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
      const head = el('div', { class: 'q-head' },
        el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
        el('span', { class: 'msg-from' }, q.asker), ' ',
        el('span', { class: 'msg-sep' }, '→'), ' ',
        el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
        el('span', { class: 'msg-sep' }, 'asks:'),
      );
      if (q.deadline_at) {
        const remaining = q.deadline_at - Math.floor(Date.now() / 1000);
        let txt;
        if (remaining <= 0) txt = 'expiring…';
        else if (remaining < 60) txt = '⏳ ' + remaining + 's';
        else if (remaining < 3600) txt = '⏳ ' + Math.floor(remaining / 60) + 'm '
          + (remaining % 60) + 's';
        else txt = '⏳ ' + Math.floor(remaining / 3600) + 'h '
          + Math.floor((remaining % 3600) / 60) + 'm';
        head.append(' ', el('span', { class: 'q-ttl' }, txt));
      }
      const qBody = el('div', { class: 'q-body' });
      appendLinkified(qBody, q.question, q.question_refs);
      li.append(head, qBody);
      const f = el('form', {
        method: 'POST', action: '/answer-question/' + q.id,
        class: 'qform', 'data-async': '', 'data-no-refresh': '',
      });
      const hasOptions = q.options && q.options.length;
      const isMulti = !!q.multi && hasOptions;
      const freeText = el('textarea', {
        name: 'answer-free', rows: '2', autocomplete: 'off',
        placeholder: (hasOptions ? 'or type your own…' : 'your answer')
          + '  (shift+enter for newline)',
      });
      // Enter submits; shift+enter inserts a newline (textarea default).
      freeText.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' && !e.shiftKey) {
          e.preventDefault();
          f.requestSubmit();
        }
      });
      const optionGroup = el('div', { class: 'q-options' });
      if (hasOptions) {
        for (const opt of q.options) {
          const inputType = isMulti ? 'checkbox' : 'radio';
          const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8);
          const input = el('input', { type: inputType, name: 'choice', value: opt, id });
          const label = el('label', { for: id }, ' ' + opt);
          optionGroup.append(el('div', { class: 'q-option' }, input, label));
        }
      }
      // On submit, build the final `answer` field from selected
      // options + free-text, joined by ', '. This lets the operator
      // pick options AND add free text in the same form.
      f.addEventListener('submit', (ev) => {
        const parts = [];
        for (const cb of f.querySelectorAll('input[name="choice"]:checked')) {
          parts.push(cb.value);
        }
        const ft = (freeText.value || '').trim();
        if (ft) parts.push(ft);
        const merged = parts.join(', ');
        // Replace the existing hidden `answer` (if any) with the merged value.
        const existing = f.querySelector('input[name="answer"]');
        if (existing) existing.remove();
        f.append(el('input', { type: 'hidden', name: 'answer', value: merged }));
        if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
      }, true);
      if (hasOptions) f.append(optionGroup);
      const buttons = el('div', { class: 'q-buttons' });
      // On peer threads the operator's answer is an override —
      // mark the button so it's clear what the click does (the
      // backend permits it via OperatorQuestions::answer's
      // answerer-auth rule).
      const answerLabel = q.target
        ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3')
        : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R');
      buttons.append(
        el('button', {
          type: 'submit',
          class: 'btn btn-approve' + (q.target ? ' btn-override' : ''),
          title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '',
        }, answerLabel),
      );
      f.append(
        el('div', { class: 'q-free' }, freeText),
        buttons,
      );
      li.append(f);
      // Separate form so the cancel button doesn't get the answer
      // merge-on-submit handler attached to the main form.
      const cancelTargetLabel = q.target ? q.target : 'asker';
      const cancelForm = el('form', {
        method: 'POST', action: '/cancel-question/' + q.id,
        class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
        'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
          + '"[cancelled]" as the answer.',
      });
      cancelForm.append(
        el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
      );
      li.append(cancelForm);
      ul.append(li);
    }
    if (pending.length) root.append(ul);

    // Answered question history
    const hist = questionsState.history;
    if (hist.length) {
      const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
      details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
      const hul = el('ul', { class: 'questions questions-answered' });
      for (const q of hist) {
        const targetLabel = q.target || 'operator';
        const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
        const head = el('div', { class: 'q-head' },
          el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ',
          el('span', { class: 'msg-from' }, q.asker), ' ',
          el('span', { class: 'msg-sep' }, '→'), ' ',
          el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
          el('span', { class: 'msg-sep' }, 'asked:'),
        );
        const histBody = el('div', { class: 'q-body' });
        appendLinkified(histBody, q.question, q.question_refs);
        const ansText = el('span', { class: 'q-answer-text' });
        appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
        const ansLine = el('div', { class: 'q-answer' },
          el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
          ansText,
        );
        li.append(head, histBody, ansLine);
        hul.append(li);
      }
      details.append(hul);
      root.append(details);
    }
  }

  // ─── operator inbox (derived from the broker message stream) ───────────
  // No longer shipped on `/api/state.operator_inbox`. The dashboard
  // terminal's HiveTerminal feeds this via `onAnyEvent` — backfill from
  // `/dashboard/history` populates on load, live SSE keeps it current.
  // Newest-first to match the previous behaviour.
  const INBOX_LIMIT = 50;
  const operatorInbox = [];
  function inboxAppendFromEvent(ev) {
    if (ev.kind !== 'sent' || ev.to !== 'operator') return false;
    operatorInbox.unshift({
      from: ev.from,
      body: ev.body,
      at: ev.at,
      file_refs: ev.file_refs || [],
    });
    if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT;
    return true;
  }
  function renderInbox() {
    const root = $('inbox-section');
    if (!root) return;
    root.innerHTML = '';
    if (!operatorInbox.length) {
      root.append(el('p', { class: 'empty' }, 'no messages'));
      return;
    }
    const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
    const ul = el('ul', { class: 'inbox' });
    for (const m of operatorInbox) {
      const li = el('li');
      const body = el('span', { class: 'msg-body' });
      appendLinkified(body, m.body, m.file_refs);
      li.append(
        el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
        el('span', { class: 'msg-from' }, m.from), ' ',
        el('span', { class: 'msg-sep' }, '→ '),
        body,
      );
      ul.append(li);
    }
    root.append(ul);
  }

  const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
  // Derived approval state — cold-loaded from /api/state, then mutated
  // live by `approval_added` / `approval_resolved` dashboard events.
  // `pending` is the open queue (newest-first); `history` is the last
  // 30 resolved rows.
  const APPROVAL_HISTORY_LIMIT = 30;
  const approvalsState = { pending: [], history: [] };
  function syncApprovalsFromSnapshot(s) {
    approvalsState.pending = (s.approvals || []).slice();
    approvalsState.history = (s.approval_history || []).slice();
  }
  function applyApprovalAdded(ev) {
    // Upsert by id so a snapshot that already included the row (cold
    // load + event lands at the same tick) doesn't double it.
    const existing = approvalsState.pending.findIndex((a) => a.id === ev.id);
    const row = {
      id: ev.id,
      agent: ev.agent,
      kind: ev.approval_kind,
      sha_short: ev.sha_short || null,
      diff: ev.diff || null,
      description: ev.description || null,
    };
    if (existing >= 0) approvalsState.pending[existing] = row;
    else approvalsState.pending.push(row);
    renderApprovals();
  }
  function applyApprovalResolved(ev) {
    // Drop from pending; prepend to history (newest-first), cap at 30.
    approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id);
    approvalsState.history.unshift({
      id: ev.id,
      agent: ev.agent,
      kind: ev.approval_kind,
      sha_short: ev.sha_short || null,
      status: ev.status,
      resolved_at: ev.resolved_at,
      note: ev.note || null,
      description: ev.description || null,
    });
    if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
      approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
    }
    renderApprovals();
  }
  function renderApprovals() {
    const root = $('approvals-section');
    root.innerHTML = '';

    // Spawn request form: submitting it queues a Spawn approval that
    // lands in this same list, so the form belongs here rather than on
    // the containers list (the agent doesn't exist yet).
    const spawn = el('form', {
      method: 'POST', action: '/request-spawn',
      class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
    });
    spawn.append(
      el('input', {
        name: 'name',
        placeholder: 'new agent name (≤9 chars)',
        maxlength: '9', required: '', autocomplete: 'off',
      }),
      el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
    );
    root.append(spawn);

    const pending = approvalsState.pending;
    const history = approvalsState.history;
    const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
    const tabs = el('div', { class: 'approval-tabs' });
    const pendingTab = el(
      'button',
      {
        type: 'button',
        class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
      },
      `pending · ${pending.length}`,
    );
    const historyTab = el(
      'button',
      {
        type: 'button',
        class: 'approval-tab' + (active === 'history' ? ' active' : ''),
      },
      `history · ${history.length}`,
    );
    pendingTab.addEventListener('click', () => {
      localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
      renderApprovals();
    });
    historyTab.addEventListener('click', () => {
      localStorage.setItem(APPROVAL_TAB_KEY, 'history');
      renderApprovals();
    });
    tabs.append(pendingTab, historyTab);
    root.append(tabs);

    if (active === 'history') {
      renderApprovalHistory(root, history);
      return;
    }

    if (!pending.length) {
      root.append(el('p', { class: 'empty' }, 'queue empty'));
      return;
    }
    const ul = el('ul', { class: 'approvals' });
    for (const a of pending) {
      const li = el('li');
      const row = el('div', { class: 'row' });
      if (a.kind === 'apply_commit') {
        row.append(
          el('span', { class: 'glyph' }, '→'), ' ',
          el('span', { class: 'id' }, '#' + a.id), ' ',
          el('span', { class: 'agent' }, a.agent), ' ',
          el('span', { class: 'kind' }, 'apply'), ' ',
          el('code', {}, a.sha_short || ''),
        );
      } else {
        row.append(
          el('span', { class: 'glyph' }, '⊕'), ' ',
          el('span', { class: 'id' }, '#' + a.id), ' ',
          el('span', { class: 'agent' }, a.agent), ' ',
          el('span', { class: 'kind kind-spawn' }, 'spawn'), ' ',
          el('span', { class: 'meta' },
            'new sub-agent — container will be created on approve'),
        );
      }
      if (a.description) {
        li.append(el('div', { class: 'approval-description' }, a.description));
      }
      // Deny prompts the operator for an optional reason; the
      // submit handler stashes it into a hidden `note` input that
      // rides along on the POST and is surfaced to the manager via
      // HelperEvent::ApprovalResolved { note }.
      const denyForm = el('form', {
        method: 'POST', action: '/deny/' + a.id,
        class: 'inline', 'data-async': '', 'data-no-refresh': '',
        'data-prompt': 'reason for denying (optional, sent to manager):',
      });
      denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
      row.append(
        ' ',
        form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
        ' ',
        denyForm,
      );
      li.append(row);
      if (a.diff) {
        const trigger = el('button', { type: 'button', class: 'panel-trigger' },
          'diff vs applied');
        trigger.addEventListener('click', () => {
          // Server ships the raw unified diff; classify each line by
          // its leading char so `.diff-add` / `.diff-del` /
          // `.diff-hunk` / `.diff-file` / `.diff-ctx` colour the
          // output. Building spans here (instead of innerHTML-ing
          // pre-rendered markup) keeps the snapshot wire format
          // text-only and one less HTML-escape surface server-side.
          const pre = el('pre', { class: 'diff' });
          for (const raw of a.diff.split('\n')) {
            let cls = 'diff-ctx';
            if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
            else if (raw.startsWith('@')) cls = 'diff-hunk';
            else if (raw.startsWith('+'))  cls = 'diff-add';
            else if (raw.startsWith('-'))  cls = 'diff-del';
            const span = document.createElement('span');
            span.className = cls;
            span.textContent = raw + '\n';
            pre.appendChild(span);
          }
          Panel.open('diff · ' + a.agent + ' #' + a.id, pre);
        });
        li.append(trigger);
      }
      ul.append(li);
    }
    root.append(ul);
  }

  function renderApprovalHistory(root, history) {
    if (!history.length) {
      root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
      return;
    }
    const ul = el('ul', { class: 'approvals approvals-history' });
    for (const a of history) {
      const li = el('li');
      const row = el('div', { class: 'row' });
      const glyph = a.status === 'approved' ? '✓'
        : a.status === 'denied' ? '✗'
        : '⚠';
      row.append(
        el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
        el('span', { class: 'id' }, '#' + a.id), ' ',
        el('span', { class: 'agent' }, a.agent), ' ',
        el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : 'spawn'), ' ',
      );
      if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
      row.append(
        el('span', { class: 'status status-' + a.status }, a.status), ' ',
        el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
      );
      li.append(row);
      if (a.note) {
        li.append(el('div', { class: 'history-note' }, a.note));
      }
      ul.append(li);
    }
    root.append(ul);
  }

  // Relative time, anchored to now. resolved_at is unix seconds (server-
  // authored), so we don't have to worry about client/server clock skew
  // for sub-minute precision.
  function fmtAgo(unixSecs) {
    const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
    if (ageSec < 60) return ageSec + 's ago';
    if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
    if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
    return Math.floor(ageSec / 86400) + 'd ago';
  }

  function renderMetaInputs(s) {
    const root = $('meta-inputs-section');
    if (!root) return;
    root.innerHTML = '';
    const inputs = s.meta_inputs || [];
    if (!inputs.length) {
      root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
      return;
    }
    const form = el('form', {
      method: 'POST',
      action: '/meta-update',
      class: 'meta-inputs-form',
      'data-async': '',
      // run_meta_update emits MetaInputsChanged once the lock
      // bump finishes; per-agent rebuilds fire their own
      // ContainerStateChanged. No /api/state refetch needed.
      'data-no-refresh': '',
      'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
    });
    const ul = el('ul', { class: 'meta-inputs' });
    for (const inp of inputs) {
      const li = el('li');
      const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
      const cb = el('input', {
        type: 'checkbox',
        name: 'meta_input_' + inp.name,
        id,
        value: inp.name,
        'data-meta-input': inp.name,
      });
      const label = el('label', { for: id });
      label.append(
        cb,
        el('span', { class: 'meta-input-name' }, inp.name), ' ',
        el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ',
        el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)),
      );
      if (inp.url) {
        label.append(' ', el('span', { class: 'meta-input-url', title: inp.url },
          '· ' + truncate(inp.url, 48)));
      }
      li.append(label);
      ul.append(li);
    }
    form.append(ul);
    // Hidden input the POST handler reads — populated at submit
    // time from the checkbox states. axum's Form extractor doesn't
    // natively decode repeated keys, so we join into one CSV.
    const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' });
    form.append(hidden);
    const btn = el('button', {
      type: 'submit',
      class: 'btn btn-meta-update',
      disabled: '',
    }, '◆ UPD4TE & R3BU1LD');
    form.append(btn);
    function refreshDisabled() {
      const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0;
      if (any) btn.removeAttribute('disabled');
      else btn.setAttribute('disabled', '');
    }
    form.addEventListener('change', refreshDisabled);
    form.addEventListener('submit', () => {
      const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked'))
        .map((b) => b.dataset.metaInput);
      hidden.value = selected.join(',');
    });
    root.append(form);
  }

  function truncate(s, n) {
    return s.length <= n ? s : s.slice(0, n - 1) + '…';
  }

  // ─── reminders ──────────────────────────────────────────────────────────
  // Reminders aren't part of /api/state (separate sqlite table, separate
  // mutation cadence). Refresh fires alongside refreshState() so a
  // cancel POST or a cold load both reflect within the same tick. A
  // periodic poll isn't necessary — new reminders are queued by the
  // agents themselves and the operator already sees them next time
  // they interact with the page.
  async function refreshReminders() {
    const root = $('reminders-section');
    if (!root) return;
    try {
      const resp = await fetch('/api/reminders');
      if (!resp.ok) {
        root.innerHTML = '';
        root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
        return;
      }
      const rows = await resp.json();
      renderReminders(rows);
    } catch (err) {
      root.innerHTML = '';
      root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
    }
  }
  function renderReminders(rows) {
    const root = $('reminders-section');
    if (!root) return;
    root.innerHTML = '';
    if (!rows.length) {
      root.append(el('p', { class: 'empty' }, 'no queued reminders'));
      return;
    }
    const ul = el('ul', { class: 'reminders' });
    for (const r of rows) {
      const failed = (r.attempt_count || 0) > 0;
      const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
      const dueIn = r.due_at - Math.floor(Date.now() / 1000);
      const dueLabel = dueIn <= 0
        ? `overdue ${fmtAgo(r.due_at)}`
        : `in ${fmtDuration(dueIn)}`;
      const head = el('div', { class: 'reminder-head' },
        el('span', { class: 'agent' }, r.agent), ' ',
        el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel),
        ' ',
        el('span', { class: 'meta' }, `· id ${r.id}`),
      );
      if (r.file_path) {
        head.append(' ', el('span', { class: 'meta' }, '· payload → '));
        appendLinkified(head, r.file_path);
      }
      if (failed) {
        head.append(' ', el('span',
          {
            class: 'badge badge-warn',
            title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
          },
          `⚠ ${r.attempt_count} failed`));
      }
      const body = el('div', { class: 'reminder-body' });
      appendLinkified(body, r.message);
      li.append(head, body);
      if (r.last_error) {
        li.append(el('div', { class: 'reminder-error' },
          el('span', { class: 'msg-sep' }, 'error: '),
          r.last_error,
        ));
      }
      const actions = el('div', { class: 'reminder-actions' });
      if (failed) {
        // Retry resets the failure counters so the scheduler picks
        // the row up again on its next 5s tick. No data-no-refresh
        // — the resulting refreshState re-fires refreshReminders.
        const retryForm = el('form', {
          method: 'POST', action: '/retry-reminder/' + r.id,
          class: 'inline', 'data-async': '',
        });
        retryForm.append(el('button',
          { type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
        actions.append(retryForm);
      }
      const cancelForm = el('form', {
        method: 'POST', action: '/cancel-reminder/' + r.id,
        class: 'inline', 'data-async': '',
        'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
      });
      cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
      actions.append(cancelForm);
      li.append(actions);
      ul.append(li);
    }
    root.append(ul);
  }
  function fmtDuration(secs) {
    if (secs < 60) return secs + 's';
    if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
    if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
    return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
  }

  // ─── state polling ──────────────────────────────────────────────────────
  let pollTimer = null;
  // Sections whose innerHTML gets blown away on each refresh. If the
  // operator is typing in one of them, skip the refresh — the next
  // tick (or a manual action) will pick it up after they blur.
  const MANAGED_SECTION_IDS = [
    'containers-section',
    'tombstones-section',
    'questions-section',
    'inbox-section',
    'approvals-section',
    'meta-inputs-section',
    'reminders-section',
  ];
  // 
sections that should survive a refresh need a stable // `data-restore-key` attribute. snapshotOpenDetails walks managed // sections and records which keys are currently open; restoreOpenDetails // re-applies after the render. (Long-content drill-ins — file // previews, diffs, logs, config — open in the side panel instead, // which lives outside the managed sections and survives re-render // on its own.) function snapshotOpenDetails() { const open = new Set(); for (const id of MANAGED_SECTION_IDS) { const sect = document.getElementById(id); if (!sect) continue; for (const d of sect.querySelectorAll('details[data-restore-key]')) { if (d.open) open.add(d.dataset.restoreKey); } } return open; } function restoreOpenDetails(open) { if (!open.size) return; for (const id of MANAGED_SECTION_IDS) { const sect = document.getElementById(id); if (!sect) continue; for (const d of sect.querySelectorAll('details[data-restore-key]')) { if (open.has(d.dataset.restoreKey)) d.open = true; } } } function operatorIsTyping() { const el_ = document.activeElement; if (!el_ || el_ === document.body) return false; const tag = el_.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false; return MANAGED_SECTION_IDS.some((id) => { const sect = document.getElementById(id); return sect && sect.contains(el_); }); } async function refreshState() { // Don't yank the form out from under the operator. Try again // shortly on the next tick; eventually they'll blur and the // refresh lands. if (operatorIsTyping()) { if (pollTimer) clearTimeout(pollTimer); pollTimer = setTimeout(refreshState, 2000); return; } try { const resp = await fetch('/api/state'); if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); // Stash the latest snapshot for any sub-widget that wants a // synchronous read (e.g. the compose autocomplete pulls agent // names from here instead of refetching on every keystroke). window.__hyperhive_state = s; const openDetails = snapshotOpenDetails(); // Sync transients + containers first so renderContainers below // sees the current derived maps (it reads from // `transientsState` + `containersState`, not from `s.*`). syncTransientsFromSnapshot(s); syncContainersFromSnapshot(s); syncTombstonesFromSnapshot(s); syncMetaInputsFromSnapshot(s); renderContainers(s); renderTombstones(s); // Sync the derived approvals + questions stores from the // snapshot, then render. Live `*_added` / `*_resolved` events // mutate the stores directly and re-render without a snapshot // refetch. syncQuestionsFromSnapshot(s); renderQuestions(); renderInbox(); syncApprovalsFromSnapshot(s); renderApprovals(); renderMetaInputs(s); refreshReminders(); restoreOpenDetails(openDetails); notifyDeltas(s); // No periodic refresh timer. Phase 6 covers every container // mutation with `ContainerStateChanged` / `ContainerRemoved` // (lifecycle ops, destroy, rebuild, crash_watch's 10s poll); // approvals + questions + transients have their own events; // broker traffic flows through the SSE channel. The only // /api/state fetches are the initial cold load and the // post-submit refetch on forms without `data-no-refresh` // (tombstones, meta-input updates). if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } } catch (err) { console.error('refreshState failed', err); // Schedule a single retry on transient errors so the page // recovers from a brief network blip without making the // operator reload. pollTimer = setTimeout(refreshState, 5000); } } refreshState(); NOTIF.bind(); Panel.bind(); // ─── message flow: shared terminal pane ──────────────────────────────── // Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS // (window.HiveTerminal). What stays here is the broker-message // renderer + the page-local side effects (banner pulse, inbox refresh // on operator-bound traffic, OS notifications). (() => { const flow = $('msgflow'); if (!flow || !window.HiveTerminal) return; flow.innerHTML = ''; const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19); // Pulse the page banner whenever a broker event lands. Each event // nudges the shimmer window; if traffic stops, the class falls off // after the grace timer. const banner = document.querySelector('.banner'); let bannerOffTimer = null; function pulseBanner() { if (!banner) return; banner.classList.add('active'); if (bannerOffTimer) clearTimeout(bannerOffTimer); bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000); } function renderMsg(ev, api, glyph) { const row = api.row('msgrow ' + ev.kind, ''); // Build via DOM so path anchors stay live + escape rules are // automatic (text nodes don't need esc()). const ts = document.createElement('span'); ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at); const arrow = document.createElement('span'); arrow.className = 'msg-arrow'; arrow.textContent = glyph; const from = document.createElement('span'); from.className = 'msg-from'; from.textContent = ev.from; const sep = document.createElement('span'); sep.className = 'msg-sep'; sep.textContent = '→'; const to = document.createElement('span'); to.className = 'msg-to'; to.textContent = ev.to; const body = document.createElement('span'); body.className = 'msg-body'; appendLinkified(body, ev.body, ev.file_refs); row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body); } HiveTerminal.create({ logEl: flow, historyUrl: '/dashboard/history', streamUrl: '/dashboard/stream', renderers: { sent: (ev, api) => renderMsg(ev, api, '→'), delivered: (ev, api) => renderMsg(ev, api, '✓'), // Mutation events update derived state and trigger a // section re-render — no terminal log row (the terminal is // for broker traffic, not state-change chatter). approval_added: (ev) => { applyApprovalAdded(ev); }, approval_resolved: (ev) => { applyApprovalResolved(ev); }, question_added: (ev) => { applyQuestionAdded(ev); }, question_resolved: (ev) => { applyQuestionResolved(ev); }, transient_set: (ev) => { applyTransientSet(ev); }, transient_cleared: (ev) => { applyTransientCleared(ev); }, container_state_changed: (ev) => { applyContainerStateChanged(ev); }, container_removed: (ev) => { applyContainerRemoved(ev); }, tombstones_changed: (ev) => { applyTombstonesChanged(ev); }, meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); }, }, // Both history backfill and live frames flow through here, so the // inbox section ends up populated correctly on first paint and // updated thereafter — no /api/state refetch needed for inbox // freshness (which used to be the workaround for the // double-render bug). onAnyEvent: (ev /* , { fromHistory } */) => { if (inboxAppendFromEvent(ev)) renderInbox(); }, onLiveEvent: (ev) => { pulseBanner(); if (ev.kind === 'sent' && ev.to === 'operator') { NOTIF.show( '◆ ' + ev.from + ' → operator', String(ev.body || '').slice(0, 200), // Unique-per-arrival tag so a burst stacks instead of // overwriting itself in the OS notification center. 'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6), ); } }, }); })(); // ─── compose: @-mention with sticky recipient ─────────────────────────── (() => { const input = $('op-compose-input'); const prompt = $('op-compose-prompt'); const suggest = $('op-compose-suggest'); if (!input || !prompt || !suggest) return; const STORAGE_KEY = 'hyperhive:op-compose:to'; let stickyTo = localStorage.getItem(STORAGE_KEY) || ''; let suggestActive = -1; function renderPrompt() { prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>'; } function knownAgents() { // Read live from the derived containers map so newly-spawned // agents become addressable without an /api/state refetch. // Broker uses the literal recipient `manager` for the manager's // inbox, not the container name `hm1nd`. const names = Array.from(containersState.values()) .map((c) => (c.is_manager ? 'manager' : c.name)); // `*` fans out to every registered agent (server-side // broadcast_send). names.unshift('*'); return names; } function autosize() { input.style.height = 'auto'; input.style.height = `${input.scrollHeight}px`; } /// Parse "@name body…" — return {to, body} when the input opens /// with a known @-mention, otherwise null. function parseAddressed(raw) { const m = raw.match(/^@([\w*-]+)\s+([\s\S]+)$/); if (!m) return null; return { to: m[1], body: m[2] }; } function hideSuggest() { suggest.hidden = true; suggest.innerHTML = ''; suggestActive = -1; } function renderSuggest(matches) { suggest.innerHTML = ''; if (!matches.length) { hideSuggest(); return; } for (let i = 0; i < matches.length; i += 1) { const item = document.createElement('div'); item.className = 'item' + (i === suggestActive ? ' active' : ''); item.textContent = '@' + matches[i]; item.addEventListener('mousedown', (e) => { e.preventDefault(); applySuggestion(matches[i]); }); suggest.append(item); } suggest.hidden = false; } function applySuggestion(name) { // Replace the partial @-token at the start with the full name. const v = input.value; const m = v.match(/^@(\S*)/); if (m) { input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, ''); } else { input.value = `@${name} ` + v; } hideSuggest(); input.focus(); input.setSelectionRange(input.value.length, input.value.length); autosize(); } function updateSuggest() { const v = input.value; // Only suggest when an @-token sits at the very start of the // input — switching recipient is always "redirect this whole // line." Mid-message @-mentions stay literal. const m = v.match(/^@(\S*)/); if (!m) { hideSuggest(); return; } const partial = m[1].toLowerCase(); const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial)); if (!matches.length) { hideSuggest(); return; } if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0; renderSuggest(matches); } async function submit() { const raw = input.value.trim(); if (!raw) return; let to; let body; const addressed = parseAddressed(raw); if (addressed) { to = addressed.to; body = addressed.body.trim(); } else if (stickyTo) { to = stickyTo; body = raw; } else { flashError('no recipient — start with @name to address a message'); return; } if (!body) return; const fd = new FormData(); fd.append('to', to); fd.append('body', body); input.disabled = true; try { // /op-send now returns 200 (no more 303-to-/). The SSE channel // carries the resulting MessageEvent → the terminal renders the // sent row + the inbox updates on its own; no /api/state // refetch needed. const resp = await fetch('/op-send', { method: 'POST', body: new URLSearchParams(fd), }); if (!resp.ok) { flashError(`send failed: http ${resp.status}`); return; } } catch (err) { flashError(`send failed: ${err}`); return; } finally { input.disabled = false; } stickyTo = to; localStorage.setItem(STORAGE_KEY, to); input.value = ''; autosize(); renderPrompt(); input.focus(); } function flashError(msg) { const flow = $('msgflow'); if (!flow) return; const row = document.createElement('div'); row.className = 'msgrow meta'; row.textContent = msg; flow.insertBefore(row, flow.firstChild); } input.addEventListener('input', () => { autosize(); updateSuggest(); }); input.addEventListener('keydown', (e) => { if (!suggest.hidden) { if (e.key === 'ArrowDown') { const items = suggest.querySelectorAll('.item'); suggestActive = (suggestActive + 1) % items.length; renderSuggest(Array.from(items).map((i) => i.textContent.slice(1))); e.preventDefault(); return; } if (e.key === 'ArrowUp') { const items = suggest.querySelectorAll('.item'); suggestActive = (suggestActive - 1 + items.length) % items.length; renderSuggest(Array.from(items).map((i) => i.textContent.slice(1))); e.preventDefault(); return; } if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) { const active = suggest.querySelector('.item.active'); if (active) { applySuggestion(active.textContent.slice(1)); e.preventDefault(); return; } } if (e.key === 'Escape') { hideSuggest(); e.preventDefault(); return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } }); input.addEventListener('blur', () => { // Defer so a click on a suggestion item (mousedown) lands first. setTimeout(hideSuggest, 100); }); renderPrompt(); autosize(); })(); })();