generated from coulomb/repo-seed
feat(CUST-WP-0015): implement agent inbox for inter-agent coordination
Adds a message-passing layer to state-hub so Claude instances can coordinate across sessions without polling shared progress events. - Migration f3a4b5c6d7e8: agent_messages table with thread support - FastAPI router: POST/GET /messages/, thread view, mark-read, archive, reply - 4 MCP tools: send_message, get_messages, mark_message_read, reply_to_message - Observable dashboard: /inbox page with unread/read/archived sections + KPI - CLAUDE.md updates: global, custodian, marki-docx, activity-core, template - TOOLS.md: Agent Inbox tools section documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
15
dashboard/src/data/messages.json.py
Normal file
15
dashboard/src/data/messages.json.py
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches /messages/ from the API."""
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/messages/?limit=100", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e), "messages": []}))
|
||||
187
dashboard/src/inbox.md
Normal file
187
dashboard/src/inbox.md
Normal file
@@ -0,0 +1,187 @@
|
||||
---
|
||||
title: Agent Inbox
|
||||
---
|
||||
|
||||
```js
|
||||
import {API, POLL} from "./components/config.js";
|
||||
```
|
||||
|
||||
```js
|
||||
// Live poll: messages list
|
||||
const inboxState = (async function*() {
|
||||
while (true) {
|
||||
let messages = [], ok = false;
|
||||
try {
|
||||
const resp = await fetch(`${API}/messages/?limit=100`);
|
||||
ok = resp.ok;
|
||||
if (ok) messages = await resp.json();
|
||||
} catch {}
|
||||
yield {messages, ok, ts: new Date()};
|
||||
await new Promise(res => setTimeout(res, POLL));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const messages = inboxState.messages ?? [];
|
||||
const _ok = inboxState.ok ?? false;
|
||||
const _ts = inboxState.ts;
|
||||
|
||||
const unread = messages.filter(m => !m.read_at && !m.archived_at);
|
||||
const read = messages.filter(m => m.read_at && !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
|
||||
|
||||
```js
|
||||
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">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()`.
|
||||
|
||||
---
|
||||
|
||||
## Unread
|
||||
|
||||
```js
|
||||
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 = !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>
|
||||
<span class="msg-time">${fmtDate(m.created_at)}</span>
|
||||
<div class="msg-actions">
|
||||
${showMarkRead ? 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>`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Read
|
||||
|
||||
```js
|
||||
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
|
||||
|
||||
```js
|
||||
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>`);
|
||||
}
|
||||
```
|
||||
|
||||
<style>
|
||||
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
|
||||
|
||||
.kpi-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.3rem 0; }
|
||||
.kpi-row + .kpi-row { border-top: 1px solid var(--theme-foreground-faint, #eee); }
|
||||
.kpi-row-label { font-size: 0.8rem; color: var(--theme-foreground-muted, #666); white-space: nowrap; }
|
||||
.kpi-row-right { text-align: right; }
|
||||
.kpi-row-value { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
|
||||
.msg-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.msg-card { border-left: 4px solid #6b7280; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.6rem 0.9rem; }
|
||||
.msg-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.8rem; }
|
||||
.msg-from { font-weight: 700; color: var(--theme-foreground, #222); }
|
||||
.msg-arrow { color: var(--theme-foreground-muted, #999); }
|
||||
.msg-to { font-weight: 600; color: #1e40af; }
|
||||
.msg-broadcast { color: #7c3aed; }
|
||||
.msg-time { color: var(--theme-foreground-muted, #666); margin-left: auto; }
|
||||
.msg-actions { display: flex; gap: 0.3rem; }
|
||||
.msg-btn { padding: 0.12rem 0.5rem; border-radius: 5px; font-size: 0.7rem; font-weight: 600; cursor: pointer; border: 1px solid; }
|
||||
.msg-btn-read { border-color: #d97706; background: #fffbeb; color: #92400e; }
|
||||
.msg-btn-read:hover { background: #fef3c7; }
|
||||
.msg-btn-archive { border-color: #9ca3af; background: #f9fafb; color: #374151; }
|
||||
.msg-btn-archive:hover { background: #f3f4f6; }
|
||||
.msg-subject { font-weight: 600; font-size: 0.95rem; color: var(--theme-foreground, #222); margin-bottom: 0.2rem; }
|
||||
.msg-body-wrap { font-size: 0.8rem; color: var(--theme-foreground-muted, #555); margin-top: 0.2rem; }
|
||||
.msg-body-wrap summary { cursor: pointer; font-style: italic; }
|
||||
.msg-body { white-space: pre-wrap; margin: 0.4rem 0 0; font-family: var(--mono); font-size: 0.8rem; background: var(--theme-background); padding: 0.5rem; border-radius: 4px; }
|
||||
.msg-thread { font-size: 0.7rem; color: var(--theme-foreground-muted, #999); margin-top: 0.2rem; }
|
||||
.dim { color: gray; font-style: italic; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user