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

@ -0,0 +1,76 @@
// <SubagentTerms> — an agent's subagents, under its own terminal preview in
// `AgentsPage`'s detail panel. The list is `GET /api/agents/{name}/subagents`:
// the subagents the swarm queue holds terminal rows for. Subagents are
// spawned on demand, so it is refetched every `REFRESH_MS`. Picking one
// shows its terminal in `AgentTermPreview`, expand-to-tab included. Read-only
// like the agent's own: subagents take no input.
//
// Mount it keyed by agent name: the refresh timer is armed once per mount.
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { AgentTermPreview } from "./AgentTermPreview.js";
import { useRefreshInterval } from "../../ui/refresh-interval/RefreshInterval.js";
import "./SubagentTerms.css";
const REFRESH_MS = 10_000;
export function SubagentTerms({ agentName }: { agentName: string }) {
const [names, setNames] = useState<string[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [selected, setSelected] = useState<string | null>(null);
async function refresh() {
const res = await fetch(
`/api/agents/${encodeURIComponent(agentName)}/subagents`,
);
if (!res.ok) {
setError(await readApiError(res));
return;
}
setNames((await res.json()) as string[]);
setError(null);
}
useRefreshInterval(REFRESH_MS, () => {
refresh().catch((e: unknown) => setError({ detail: String(e) }));
});
return (
<section class="ui-subagent-terms" aria-label={`${agentName}'s subagents`}>
<div class="ui-subagent-terms-list">
<span class="ui-subagent-terms-heading">subagents</span>
{names === null && !error && <Badge value="loading…" />}
{names?.length === 0 && (
<span class="ui-subagent-terms-empty">
none with output on record
</span>
)}
{names?.map((name) => (
<Badge
key={name}
variant={name === selected ? "default" : "quiet"}
value={name}
expanded={name === selected}
title={
name === selected
? `hide ${name}'s terminal`
: `show ${name}'s terminal`
}
onClick={() => setSelected(name === selected ? null : name)}
/>
))}
</div>
{error && (
<ApiErrorPanel
problem={error}
context={`failed to list ${agentName}'s subagents`}
/>
)}
{selected && (
<AgentTermPreview agentName={agentName} subagent={selected} />
)}
</section>
);
}