state-hub/dashboard/src/inbox.md
tegwick ef541f58cf
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 53s
STATE-WP-0093: per-recipient broadcast receipts and standing notices (T01-T06).
Founder approved T01 on 2026-09-22 (D2, D3, D6 as recommended).
- T02: message_receipts table; kind/expires_at/supersedes_id on
  agent_messages; migration d7e8f9a0b1c2 archives existing broadcasts,
  leaves direct messages untouched, reversible.
- T03: reader-aware mark-read (unattributed broadcast mark-read is a
  metered, deprecated no-op), delivery receipts on the scoped unread inbox,
  POST /messages/{id}/ack, news/standing kinds, expiry, supersede, broadcast
  archive no longer stamps read_at, reply writes the replier's receipt.
- T04 (state-hub part): Codex MCP reader param and acknowledge_notice;
  hub-core part handed off (message 69fc387c).
- T05: GET /messages/notices, standing_notices in /state/summary,
  dashboard standing-notices panel.
- T06: 17 new tests; full suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 63291@bnt-lap001
Assistant-Session: 8bd77868-ca68-4f49-bb1e-d539ecc0d703
2026-09-22 00:26:33 +02:00

9.5 KiB

title
Agent Inbox
import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
// Live poll: messages list
const inboxState = (async function*() {
  let failures = 0;
  while (true) {
    let messages = [], notices = [], ok = false;
    try {
      const resp = await apiFetch("/messages/?limit=100");
      ok = resp.ok;
      if (ok) messages = await resp.json();
      const nresp = await apiFetch("/messages/notices");
      if (nresp.ok) notices = await nresp.json();
    } catch {}
    failures = ok ? 0 : failures + 1;
    yield {messages, notices, ok, ts: new Date()};
    await waitForVisible(pollDelay({ok, failures}));
  }
})();
const messages = inboxState.messages ?? [];
const notices = inboxState.notices ?? [];
const _ok = inboxState.ok ?? false;
const _ts = inboxState.ts;

// Broadcast read state is per reader (STATE-WP-0093): the dashboard has no
// reader identity, so live broadcasts get their own section.
const isBc = m => m.to_agent === "broadcast";
const unread     = messages.filter(m => !isBc(m) && !m.read_at && !m.archived_at);
const read       = messages.filter(m => !isBc(m) && m.read_at && !m.archived_at);
const broadcasts = messages.filter(m => isBc(m) && !m.archived_at);
const archived   = messages.filter(m => m.archived_at);

// Group unread by agent for KPI
const agentCounts = {};
for (const m of unread) {
  agentCounts[m.to_agent] = (agentCounts[m.to_agent] ?? 0) + 1;
}
const topAgents = Object.entries(agentCounts).sort((a, b) => b[1] - a[1]).slice(0, 4);

Agent Inbox

import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp}  from "./components/doc-overlay.js";

const _kpiBox = html`<div class="kpi-infobox">
  <div class="kpi-infobox-title">Inbox</div>
  <div class="kpi-row">
    <span class="kpi-row-label">unread</span>
    <div class="kpi-row-right">
      <div class="kpi-row-value" style="color:${unread.length > 0 ? '#d97706' : 'inherit'}">${unread.length}</div>
    </div>
  </div>
  <div class="kpi-row">
    <span class="kpi-row-label">standing notices</span>
    <div class="kpi-row-right">
      <div class="kpi-row-value" style="font-size:1rem">${notices.length}</div>
    </div>
  </div>
  <div class="kpi-row">
    <span class="kpi-row-label">total</span>
    <div class="kpi-row-right">
      <div class="kpi-row-value" style="font-size:1rem">${messages.length}</div>
    </div>
  </div>
  ${topAgents.map(([agent, count]) => html`
  <div class="kpi-row">
    <span class="kpi-row-label">${agent}</span>
    <div class="kpi-row-right">
      <div class="kpi-row-value" style="font-size:0.9rem">${count}</div>
    </div>
  </div>`)}
</div>`;

const _liveEl = html`<div class="live-indicator">
  <span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
  ${_ok
    ? `Live · updated ${_ts?.toLocaleTimeString()}`
    : html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`;

injectTocTop("inbox-kpi-box", _kpiBox);
injectTocTop("live-indicator", _liveEl);

Inter-agent coordination messages. Agents send messages via send_message() MCP tool and read them via get_messages().


Standing notices

Live standing broadcasts stay in every agent's inbox until that agent acknowledges them (POST /messages/{id}/ack). Repositories listed as not yet acknowledged are the ones still out of date.

function repoList(label, slugs, color) {
  if (!slugs.length) return "";
  return html`<details class="notice-list"><summary style="color:${color}">${label} (${slugs.length})</summary>
    <div class="notice-slugs">${slugs.join(", ")}</div></details>`;
}

if (notices.length === 0) {
  display(html`<p class="dim">No live standing notices.</p>`);
} else {
  display(html`<div class="msg-list">${notices.map(n => html`<div class="msg-card" style="border-left-color:#7c3aed">
    <div class="msg-header">
      <span class="msg-from">${n.from_agent}</span>
      <span class="msg-kind">standing</span>
      <span class="msg-time">expires ${n.expires_at ? new Date(n.expires_at).toLocaleDateString() : "never"}</span>
    </div>
    <div class="msg-subject">${n.subject}</div>
    <div class="notice-counts">${n.acked_count} acknowledged · ${n.delivered_only_count} delivered, not acknowledged · ${n.unreached_count} not reached</div>
    ${repoList("Not yet acknowledged — delivered", n.delivered_only, "#d97706")}
    ${repoList("Not yet acknowledged — never reached", n.unreached, "#dc2626")}
    ${repoList("Acknowledged", n.acknowledged, "#059669")}
  </div>`)}</div>`);
}

Unread

function fmtDate(s) {
  if (!s) return "—";
  const d = new Date(s);
  return d.toLocaleDateString() + " " + d.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"});
}

function renderMessage(m, showMarkRead = false) {
  const isBroadcast = m.to_agent === "broadcast";
  const borderColor = isBroadcast ? "#7c3aed" : (!m.read_at ? "#d97706" : "#6b7280");

  async function onMarkRead() {
    await fetch(`${API}/messages/${m.id}/read`, {method: "PATCH"});
  }

  async function onArchive() {
    await fetch(`${API}/messages/${m.id}/archive`, {method: "PATCH"});
  }

  return html`<div class="msg-card" style="border-left-color:${borderColor}">
    <div class="msg-header">
      <span class="msg-from">${m.from_agent}</span>
      <span class="msg-arrow">→</span>
      <span class="msg-to ${isBroadcast ? 'msg-broadcast' : ''}">${m.to_agent}</span>
      ${isBroadcast ? html`<span class="msg-kind">${m.kind}</span>` : ""}
      <span class="msg-time">${fmtDate(m.created_at)}</span>
      <div class="msg-actions">
        ${showMarkRead && !isBroadcast ? html`<button class="msg-btn msg-btn-read" onclick=${onMarkRead}>Mark read</button>` : ""}
        <button class="msg-btn msg-btn-archive" onclick=${onArchive}>Archive</button>
      </div>
    </div>
    <div class="msg-subject">${m.subject}</div>
    <details class="msg-body-wrap">
      <summary>body</summary>
      <pre class="msg-body">${m.body}</pre>
    </details>
    ${m.thread_id ? html`<div class="msg-thread">thread: ${m.thread_id}</div>` : ""}
  </div>`;
}

if (unread.length === 0) {
  display(html`<p class="dim">No unread messages.</p>`);
} else {
  display(html`<div class="msg-list">${unread.map(m => renderMessage(m, true))}</div>`);
}

Broadcasts

if (broadcasts.length === 0) {
  display(html`<p class="dim">No live broadcasts.</p>`);
} else {
  display(html`<div class="msg-list">${broadcasts.map(m => renderMessage(m, false))}</div>`);
}

Read

if (read.length === 0) {
  display(html`<p class="dim">No read messages.</p>`);
} else {
  display(html`<div class="msg-list">${read.map(m => renderMessage(m, false))}</div>`);
}

Archived

if (archived.length === 0) {
  display(html`<p class="dim">No archived messages.</p>`);
} else {
  display(html`<div class="msg-list">${archived.map(m => renderMessage(m, false))}</div>`);
}