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

@ -20,7 +20,8 @@
//! **A running turn is not necessarily a working turn.** A wedged child
//! satisfies "running" as fully as a busy one, so every line of the child's
//! streams bumps `last_event_at` (`LivenessSink`) and `status` reports its
//! age — the timestamp says the child is alive, never what it said.
//! age — the timestamp says the child is alive, never what it said. What it
//! said goes to the swarm as the subagent's terminal (`crate::swarm_term`).
//!
//! **`continue` doesn't pre-check the session's existence — it waits for
//! the answer instead.** claude's own `--resume` is the authority, so
@ -98,6 +99,8 @@ use hive_runtime::{
use hive_sh4re::permissions::ToolGroup;
use tokio::sync::oneshot;
use crate::swarm_term::Output;
/// How long a `continue` holds its tool call open waiting to find out
/// whether the resume landed. A cap, not a delay: both real outcomes settle
/// it well inside this, and it is only ever reached by a child that neither
@ -380,6 +383,9 @@ pub struct State {
/// What a subagent's turns run on: claude unless [`State::on_runtime`]
/// says otherwise.
runtime: SubagentRuntime,
/// Where every subagent's output lines go up to the swarm, when this
/// agent can publish there ([`State::publishing_to`]).
term: Option<crate::swarm_term::Publisher>,
}
/// Which opaque URL segment belongs to which session — the whole of a
@ -432,9 +438,17 @@ impl State {
signal_base,
signal_tokens: Mutex::new(SignalTokens::default()),
runtime: SubagentRuntime::Claude,
term: None,
}
}
/// Hand every subagent's output lines to `term` as they arrive.
#[must_use]
pub fn publishing_to(mut self, term: Option<crate::swarm_term::Publisher>) -> Self {
self.term = term;
self
}
/// Run every subagent on `runtime` — the daemon passes the parent
/// agent's, read from its environment at startup. On ACP this resolves
/// the harness dir, which only exists inside a container.
@ -1488,23 +1502,23 @@ fn continue_on_runtime(
}
/// Bumps `name`'s liveness clock on every line of the turn's output, and
/// does nothing else with it. Replaces the `NoopSink` this daemon used to
/// run turns against, which discarded the stream wholesale and left `status`
/// unable to tell a working child from a wedged one.
/// offers the line to the swarm terminal publisher when this agent has one
/// ([`crate::swarm_term`]).
///
/// **All three callbacks, deliberately.** A stderr line or a stdout line
/// that didn't parse as JSON is proof the child is alive every bit as much
/// as a stream-json event is, and the failure that matters here is reporting
/// a live subagent as wedged — so anything the child says counts, and what
/// it said is never read: classifying *what* the subagent is doing is a
/// separate question from whether it's doing anything.
/// a live subagent as wedged — so anything the child says counts. Liveness
/// never reads what it said: classifying *what* the subagent is doing is the
/// publisher's job and a separate question from whether it's doing anything.
///
/// Sink methods are called synchronously from the driver's stream readers as
/// lines arrive, so the body has to stay cheap — one uncontended map write
/// is, and forwarding to a channel to do the same write elsewhere would cost
/// more than it saved. `settle` adds a second uncontended lock on a
/// `resume`d turn only, and finds an already-emptied slot after the first
/// event — strictly less work than the map write next to it.
/// event — strictly less work than the map write next to it. The publisher's
/// `offer` copies the line into a bounded channel and never waits.
///
/// **Liveness counts every callback; "the turn is underway" does not.** A
/// resume that matched nothing is not silent: claude writes the reason to
@ -1513,8 +1527,7 @@ fn continue_on_runtime(
/// every missed resume as a successful start. The narrowest fact that
/// separates the two is the event's own kind — a `result` is stream-json's
/// end-of-turn marker, so an event that isn't one is a turn still in
/// progress. That's the envelope, not the content: nothing here reads what
/// the subagent said.
/// progress. That's the envelope, not the content.
struct LivenessSink {
state: Arc<State>,
name: String,
@ -1530,14 +1543,27 @@ impl hive_claude::Sink for LivenessSink {
if event.get("type").and_then(serde_json::Value::as_str) != Some("result") {
settle(self.verdict.as_ref(), ResumeVerdict::Underway);
}
self.publish(|| Output::Event(event.clone()));
}
fn on_stdout_line(&self, _line: &str) {
fn on_stdout_line(&self, line: &str) {
self.state.note_event(&self.name);
self.publish(|| Output::Stdout(line.to_owned()));
}
fn on_stderr_line(&self, _line: &str) {
fn on_stderr_line(&self, line: &str) {
self.state.note_event(&self.name);
self.publish(|| Output::Stderr(line.to_owned()));
}
}
impl LivenessSink {
/// Offer a line to the publisher, building the copy only when there is
/// one to take it.
fn publish(&self, line: impl FnOnce() -> Output) {
if let Some(term) = &self.state.term {
term.offer(&self.name, line());
}
}
}