diff --git a/TODO.md b/TODO.md
index 20bb3da..1896fed 100644
--- a/TODO.md
+++ b/TODO.md
@@ -27,12 +27,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
## UI / UX
-- **Per-agent inbox view.** Show the last N messages addressed to
- this agent on its page (the per-agent equivalent of the
- dashboard's operator inbox). Needs a new wire request from agent
- → host (host has the broker; agent doesn't); reuse the broker's
- `recent_for` query. Last-turn timing + dashboard back-link
- already shipped.
- **State badge: compacting + napping states.** Idle/thinking already
ship (driven from SSE turn_start/turn_end). Add `compacting 📦` and
`napping 😴` once the `/compact` trigger and `nap` tool exist —
diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css
index 3624f19..1303183 100644
--- a/hive-ag3nt/assets/agent.css
+++ b/hive-ag3nt/assets/agent.css
@@ -130,6 +130,42 @@ pre.diff {
align-items: center;
gap: 0.6em;
}
+/* Per-agent inbox section — collapsible, dim, lives between the
+ state row and the terminal so the operator can peek at what
+ landed without scrolling through the live tail. */
+.agent-inbox {
+ margin: 0.4em 0;
+ font-size: 0.85em;
+ color: var(--muted);
+}
+.agent-inbox > summary {
+ cursor: pointer;
+ letter-spacing: 0.05em;
+ list-style: none;
+}
+.agent-inbox > summary::marker { content: ''; }
+.agent-inbox[open] > summary > span::before { content: ''; }
+.agent-inbox ul {
+ list-style: none;
+ padding: 0.4em 0.8em;
+ margin: 0.3em 0 0;
+ background: rgba(255, 255, 255, 0.02);
+ border-left: 2px solid var(--purple-dim);
+ max-height: 16em;
+ overflow-y: auto;
+}
+.agent-inbox li {
+ padding: 0.15em 0;
+ display: grid;
+ grid-template-columns: auto auto auto 1fr;
+ gap: 0.5em;
+ align-items: baseline;
+}
+.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; }
+.agent-inbox .inbox-from { color: var(--amber); }
+.agent-inbox .inbox-sep { color: var(--muted); }
+.agent-inbox .inbox-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
+
.last-turn {
color: var(--muted);
font-size: 0.8em;
diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js
index f56bf7f..2702e7c 100644
--- a/hive-ag3nt/assets/app.js
+++ b/hive-ag3nt/assets/app.js
@@ -337,6 +337,30 @@
}
renderStateBadge();
}
+ function renderInbox(rows) {
+ const root = $('inbox-section');
+ const list = $('inbox-list');
+ const summary = $('inbox-summary');
+ if (!root || !list || !summary) return;
+ if (!rows.length) {
+ root.hidden = true;
+ return;
+ }
+ root.hidden = false;
+ summary.textContent = 'inbox · ' + rows.length;
+ list.innerHTML = '';
+ const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19);
+ for (const m of rows) {
+ const li = el('li');
+ li.append(
+ el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ',
+ el('span', { class: 'inbox-from' }, m.from), ' ',
+ el('span', { class: 'inbox-sep' }, '→'), ' ',
+ el('span', { class: 'inbox-body' }, m.body),
+ );
+ list.append(li);
+ }
+ }
function renderLastTurn(ms) {
const el_ = $('last-turn');
if (!el_) return;
@@ -386,6 +410,7 @@
const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; }
renderTermInput(s.label, s.status === 'online');
+ renderInbox(s.inbox || []);
// Drive the state badge from the harness status. Live SSE events
// override to 'thinking' / 'idle' as turns start/end; this only
// kicks in for the not-online (offline) case and the initial seed.
diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html
index 5d22c30..9906176 100644
--- a/hive-ag3nt/assets/index.html
+++ b/hive-ag3nt/assets/index.html
@@ -19,6 +19,11 @@
+
+ â–¸ inbox
+
+
+
diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs
index e1bff36..6f899e6 100644
--- a/hive-ag3nt/src/bin/hive-ag3nt.rs
+++ b/hive-ag3nt/src/bin/hive-ag3nt.rs
@@ -139,7 +139,7 @@ async fn serve(
turn::emit_turn_end(&bus, &outcome);
}
Ok(AgentResponse::Empty) => {}
- Ok(AgentResponse::Ok | AgentResponse::Status { .. }) => {
+ Ok(AgentResponse::Ok | AgentResponse::Status { .. } | AgentResponse::Recent { .. }) => {
tracing::warn!("recv produced unexpected response kind");
}
Ok(AgentResponse::Err { message }) => {
diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs
index d3cbd8d..06a6170 100644
--- a/hive-ag3nt/src/bin/hive-m1nd.rs
+++ b/hive-ag3nt/src/bin/hive-m1nd.rs
@@ -139,7 +139,8 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
Ok(
ManagerResponse::Ok
| ManagerResponse::Status { .. }
- | ManagerResponse::QuestionQueued { .. },
+ | ManagerResponse::QuestionQueued { .. }
+ | ManagerResponse::Recent { .. },
) => {
tracing::warn!("recv produced unexpected response kind");
}
diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs
index d48f4d8..cf1d76b 100644
--- a/hive-ag3nt/src/mcp.rs
+++ b/hive-ag3nt/src/mcp.rs
@@ -38,6 +38,7 @@ pub enum SocketReply {
Empty,
Status(u64),
QuestionQueued(i64),
+ Recent(Vec
),
}
impl From for SocketReply {
@@ -48,6 +49,7 @@ impl From for SocketReply {
hive_sh4re::AgentResponse::Message { from, body } => Self::Message { from, body },
hive_sh4re::AgentResponse::Empty => Self::Empty,
hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread),
+ hive_sh4re::AgentResponse::Recent { rows } => Self::Recent(rows),
}
}
}
@@ -61,6 +63,7 @@ impl From for SocketReply {
hive_sh4re::ManagerResponse::Empty => Self::Empty,
hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread),
hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id),
+ hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows),
}
}
}
diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs
index e0ea25f..9228857 100644
--- a/hive-ag3nt/src/web_ui.rs
+++ b/hive-ag3nt/src/web_ui.rs
@@ -124,6 +124,10 @@ struct StateSnapshot {
status: &'static str,
/// Present when `status == "needs_login_in_progress"`.
session: Option,
+ /// Last N messages addressed to this agent, newest-first. Pulled
+ /// from the broker via the per-agent socket on each render.
+ /// Empty on transport failure.
+ inbox: Vec,
}
#[derive(Serialize)]
@@ -157,14 +161,47 @@ async fn api_state(State(state): State) -> axum::Json {
.ok()
.and_then(|s| s.parse::().ok())
.unwrap_or(7000);
+ let inbox = recent_inbox(&state.socket, state.flavor).await;
axum::Json(StateSnapshot {
label: state.label.clone(),
dashboard_port,
status,
session: session_view,
+ inbox,
})
}
+/// Best-effort: pull the last 30 messages addressed to us via the
+/// per-agent / manager socket. Empty list on any transport / decode
+/// failure — the inbox section is decorative, not authoritative.
+async fn recent_inbox(socket: &std::path::Path, flavor: Flavor) -> Vec {
+ const LIMIT: u64 = 30;
+ match flavor {
+ Flavor::Agent => {
+ match client::request::<_, hive_sh4re::AgentResponse>(
+ socket,
+ &hive_sh4re::AgentRequest::Recent { limit: LIMIT },
+ )
+ .await
+ {
+ Ok(hive_sh4re::AgentResponse::Recent { rows }) => rows,
+ _ => Vec::new(),
+ }
+ }
+ Flavor::Manager => {
+ match client::request::<_, hive_sh4re::ManagerResponse>(
+ socket,
+ &hive_sh4re::ManagerRequest::Recent { limit: LIMIT },
+ )
+ .await
+ {
+ Ok(hive_sh4re::ManagerResponse::Recent { rows }) => rows,
+ _ => Vec::new(),
+ }
+ }
+ }
+}
+
// ---------------------------------------------------------------------------
// Action handlers
// ---------------------------------------------------------------------------
diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs
index 82329ca..1dacf46 100644
--- a/hive-c0re/src/agent_server.rs
+++ b/hive-c0re/src/agent_server.rs
@@ -121,5 +121,11 @@ async fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResp
message: format!("{e:#}"),
},
},
+ AgentRequest::Recent { limit } => match broker.recent_for(agent, *limit) {
+ Ok(rows) => AgentResponse::Recent { rows },
+ Err(e) => AgentResponse::Err {
+ message: format!("{e:#}"),
+ },
+ },
}
}
diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs
index bd6b45f..72486ba 100644
--- a/hive-c0re/src/broker.rs
+++ b/hive-c0re/src/broker.rs
@@ -6,7 +6,7 @@ use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
-use hive_sh4re::Message;
+use hive_sh4re::{InboxRow, Message};
use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize;
use tokio::sync::broadcast;
@@ -28,16 +28,6 @@ CREATE INDEX IF NOT EXISTS idx_messages_undelivered
/// may drop events past this; we send a `lagged` notice in their stream.
const EVENT_CHANNEL: usize = 256;
-/// One row in a `recent_for()` query — the broker's flat view of a
-/// message addressed to a given recipient.
-#[derive(Debug, Clone, Serialize)]
-pub struct InboxRow {
- pub id: i64,
- pub from: String,
- pub body: String,
- pub at: i64,
-}
-
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum MessageEvent {
diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs
index 0188fd7..eed8f9b 100644
--- a/hive-c0re/src/dashboard.rs
+++ b/hive-c0re/src/dashboard.rs
@@ -102,7 +102,7 @@ struct StateSnapshot {
/// Latest messages addressed to `operator` — surfaces agent replies
/// asynchronously so the operator can see them without watching the
/// live panel during a turn.
- operator_inbox: Vec,
+ operator_inbox: Vec,
/// Pending operator questions (currently only from the manager).
/// `ask_operator` returns immediately with the id; on `/answer-question`
/// we mark the row answered and fire `HelperEvent::OperatorAnswered`
diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs
index 9e4ae18..efefd64 100644
--- a/hive-c0re/src/manager_server.rs
+++ b/hive-c0re/src/manager_server.rs
@@ -100,6 +100,12 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
message: format!("{e:#}"),
},
},
+ ManagerRequest::Recent { limit } => match coord.broker.recent_for(MANAGER_AGENT, *limit) {
+ Ok(rows) => ManagerResponse::Recent { rows },
+ Err(e) => ManagerResponse::Err {
+ message: format!("{e:#}"),
+ },
+ },
ManagerRequest::Recv => match coord
.broker
.recv_blocking(MANAGER_AGENT, MANAGER_RECV_LONG_POLL)
diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs
index a1c3404..56eead0 100644
--- a/hive-sh4re/src/lib.rs
+++ b/hive-sh4re/src/lib.rs
@@ -146,6 +146,19 @@ pub struct Message {
pub body: String,
}
+/// One row of a broker inbox query — what the dashboard renders in
+/// its operator-inbox section and what a per-agent web UI returns
+/// from a `Recent` request. Lives in `hive_sh4re` so it can travel
+/// over both the dashboard's `/api/state` and the agent socket
+/// without an internal-to-wire conversion.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InboxRow {
+ pub id: i64,
+ pub from: String,
+ pub body: String,
+ pub at: i64,
+}
+
/// Requests on a per-agent socket. The agent's identity is the socket
/// it came in on; `Send.from` is filled in by the server, not the client.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -163,6 +176,10 @@ pub enum AgentRequest {
/// per-agent equivalent of the old dashboard T4LK form, but scoped to
/// the agent whose page the operator is on.
OperatorMsg { body: String },
+ /// Last `limit` messages addressed to this agent, newest-first.
+ /// Non-mutating — pulls from the broker without delivering. The
+ /// per-agent web UI uses this to render its own inbox section.
+ Recent { limit: u64 },
}
/// Responses on a per-agent socket.
@@ -179,6 +196,8 @@ pub enum AgentResponse {
Empty,
/// `Status` result: how many pending messages are in this agent's inbox.
Status { unread: u64 },
+ /// `Recent` result: newest-first inbox rows.
+ Recent { rows: Vec },
}
// -----------------------------------------------------------------------------
@@ -264,6 +283,11 @@ pub enum ManagerRequest {
OperatorMsg {
body: String,
},
+ /// Last `limit` messages addressed to the manager, newest-first.
+ /// Non-mutating; mirror of `AgentRequest::Recent`.
+ Recent {
+ limit: u64,
+ },
/// Submit a spawn request for the user to approve. On approval the host
/// creates and starts the container. Brand-new agent names only — if an
/// agent of the same name already exists, the approval will fail.
@@ -329,4 +353,8 @@ pub enum ManagerResponse {
QuestionQueued {
id: i64,
},
+ /// `Recent` result: mirror of `AgentResponse::Recent`.
+ Recent {
+ rows: Vec,
+ },
}