Watch
0
0
Fork
You've already forked hyperhive
0

swarm: show subagent terminals in the swarm UI

An agent's subagent daemon publishes each subagent's output as terminal
rows on `$SWARM.term.<agent>.sub.<subagent>`, as the agent, into a
per-agent stream it creates itself; swarm-controller lists an agent's
subagents from that stream's subjects and relays one subagent's rows as
SSE; the swarm UI lists them under the agent's terminal preview and
reuses AgentTermPreview, full-screen tab included, with no input.

- swarm-nats.nix: the agent token may also publish
  `$SWARM.term.{agent}.sub.>` and `$JS.API.STREAM.CREATE|INFO` on
  `term-sub-{agent}`, and nothing else of JetStream. A module-eval arm
  pins the agent-token grant as an exact list.
- mcp.nix: hive-subagent-daemon loads the agent's store identity
  (`hive-agent-bao-cert/-key/-server-ca`, the ones hive-agent loads)
  whenever the agent has a store, not only on the opencode preset. The
  agent's own queue secret lives in the store, so this is the credential
  the harness connects with.
- hive-subagent-mcp: `swarm_term` reads the agent's queue secret under
  that identity, connects with the agent token, opens or creates
  `term-sub-<agent>` (max_age 24h), and publishes classified rows from
  the sink every subagent line already passes through. The sink only
  queues (bounded, drop-and-count); a missing store, refused credential,
  failed stream create or failed publish is a log line.
- The stream-json classifier (`stream_enrich`) and the `TermMsg` row
  types plus `fit` move from the hive-agent binary into hive-sh4re, so
  the subagent daemon publishes the rows AgentTermPreview already
  renders. hive-agent keeps its LiveEvent classifier on top.
- swarm-controller: `GET /api/agents/{name}/subagents` and
  `GET /api/agents/{name}/subagents/{subagent}/term/stream`.
- docs/swarm: what the UI shows and what the queue carries.

Closes #4827
This commit is contained in:
atlas 2026-10-02 22:09:43 +02:00 • committed by mara
commit d6f94e5247
35 changed files with 1529 additions and 340 deletions

View file

@ -1,5 +1,5 @@
//! Terminal-message wire shape: what the per-agent web UI's live/history
//! endpoints actually serve for the "terminal" event stream, as opposed to
//! The harness's events as terminal rows: what the per-agent web UI's
//! live/history endpoints serve for the "terminal" event stream, as opposed to
//! agent-state changes (`StatusChanged`/`ModelChanged`/`EffortChanged`/
//! `TokenUsageChanged`/`TurnStateChanged`), which have never rendered as
//! terminal rows (the header/badges poll `/api/state`, not this stream) and
@ -9,167 +9,17 @@
//! map to exactly one, but `LiveEvent::Stream` (one raw claude
//! `stream-json` line) can expand to several: an `assistant` message with
//! both a text block and a `tool_use` block produces two rows.
//!
//! Seven fields, and classification belongs **here**, not in the client: the
//! web UI renders what it is handed and owns no per-tool dispatch table, so
//! a new tool needs no frontend change. `level` carries styling, so a row
//! never names a CSS class. `kind`, `unread`, `from` and `expanded_default`
//! are deliberately absent — adding one back is a design change, not an
//! oversight.
//!
//! The seventh field is `ts`, and it is the row's own: [`classify`] stamps
//! every row it returns with the time of the event it classified, not the
//! time it ran. The swarm queue publishes the bare row with no envelope
//! around it, so a subscriber that reads a row has nowhere else to learn
//! when the thing happened; carrying the source event's time means a row
//! replayed out of sqlite months later still says when it happened rather
//! than when it was read.
use chrono::{DateTime, SecondsFormat};
use serde::Serialize;
use std::collections::HashMap;
pub use hive_sh4re::term_msg::{ClassifyCtx, Level, TermMsg, iso8601_utc};
use crate::events::LiveEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
/// Low-signal / ambient chatter — thinking, progress ticks, harness
/// housekeeping. The client's default rendering can dim/de-emphasize
/// these without hiding them outright.
Debug,
/// Routine substantive content — turn boundaries, assistant text, tool
/// calls/results, message bodies.
Info,
/// Heads-up, not necessarily broken — stderr lines, an unclassified
/// event shape landing (the old `.sys` catch-all), API retries.
Warn,
/// Something actually failed — a turn ending non-ok, a tool result with
/// `is_error: true`.
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BodyFormat {
Markdown,
Diff,
}
/// One terminal row. `body_format: None` with `body: Some(_)` means plain
/// text (the common case — no explicit tag on the wire for it, same logic
/// as `body` itself being absent meaning "nothing to expand").
#[derive(Debug, Clone, Serialize)]
pub struct TermMsg {
/// When the classified event happened, ISO 8601 / RFC 3339 UTC.
///
/// Empty on a freshly built row and filled by [`classify`], which is
/// the only path a row reaches either wire by — a builder that had to
/// be handed the time would repeat the same value across the several
/// rows one `stream-json` line expands into.
pub ts: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
pub level: Level,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_format: Option<BodyFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coalesce_key: Option<String>,
}
impl TermMsg {
pub fn new(level: Level, summary: impl Into<String>) -> Self {
Self {
ts: String::new(),
icon: None,
level,
summary: summary.into(),
body: None,
body_format: None,
coalesce_key: None,
}
}
#[must_use]
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
#[must_use]
pub fn body(mut self, body: impl Into<String>, format: Option<BodyFormat>) -> Self {
self.body = Some(body.into());
self.body_format = format;
self
}
#[must_use]
pub fn coalesce(mut self, key: impl Into<String>) -> Self {
self.coalesce_key = Some(key.into());
self
}
/// Stamp the row with the time of the event it came from.
#[must_use]
pub fn at(mut self, ts: impl Into<String>) -> Self {
self.ts = ts.into();
self
}
}
/// Render an event's unix-seconds stamp as ISO 8601 / RFC 3339 UTC.
///
/// Seconds rather than milliseconds because that is the resolution the
/// event bus and the sqlite `events.ts` column actually carry
/// (`crate::events`) — a `.000` on every row would be precision the source
/// does not have. UTC rather than local: the reader of a swarm-published
/// row is not on the machine that wrote it, and an offset-carrying stamp
/// would make two agents' rows sort by string differently than by time.
///
/// A stamp outside the representable range renders as the epoch: a row
/// whose only defect is an absurd clock is still worth reading.
#[must_use]
pub fn iso8601_utc(unix_seconds: i64) -> String {
DateTime::from_timestamp(unix_seconds, 0)
.unwrap_or_default()
.to_rfc3339_opts(SecondsFormat::Secs, true)
}
/// Per-connection/per-request classification state. A live SSE stream keeps
/// one of these alive for the connection's lifetime — `tool_use` id → name
/// correlation, so a `tool_result` can tell it's answering a `recv` call and
/// render its body as markdown instead of plain text. The history endpoint
/// uses a fresh one per page: correlation only works within the page
/// actually returned, not across the live/history boundary. Accepted
/// degradation — the only user-visible effect is a `recv` result whose
/// `tool_use` fell on the other side of a page/reconnect boundary rendering
/// its body as plain text instead of markdown.
#[derive(Default)]
pub struct ClassifyCtx {
tool_name_by_id: HashMap<String, String>,
}
impl ClassifyCtx {
pub fn record_tool_use(&mut self, id: &str, name: &str) {
self.tool_name_by_id.insert(id.to_owned(), name.to_owned());
}
#[must_use]
pub fn tool_name(&self, id: &str) -> Option<&str> {
self.tool_name_by_id.get(id).map(String::as_str)
}
}
/// Classify one [`LiveEvent`] into zero or more terminal rows, each
/// stamped with `ts` — the event's own unix-seconds time, from the bus on
/// the live path and from the `events` table on replay.
///
/// The stamp is applied here, over the rows the classifiers return, so that
/// every row reaching a wire carries one: a row built anywhere else has an
/// empty `ts` and cannot get out without passing through this function.
/// every row the harness puts on a wire carries one.
pub fn classify(ev: &LiveEvent, ts: i64, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
let ts = iso8601_utc(ts);
classify_rows(ev, ctx)
@ -202,7 +52,7 @@ fn classify_rows(ev: &LiveEvent, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
vec![msg]
}
LiveEvent::Note { text } => vec![classify_note(text)],
LiveEvent::Stream(v) => crate::stream_enrich::classify_stream_value(v, ctx),
LiveEvent::Stream(v) => hive_sh4re::stream_enrich::classify_stream_value(v, ctx),
// Agent-state transitions never render as terminal rows — the
// header/badges read `/api/state`, not this stream (see module doc).
LiveEvent::StatusChanged { .. }