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

@ -25,6 +25,10 @@ export function App() {
<Route path="/" component={HivesPage} />
<Route path="/agents" component={AgentsPage} />
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
<Route
path="/agents/:name/subagents/:subagent/terminal"
component={AgentTerminalPage}
/>
<Route path="/jobs" component={JobsPage} />
<Route path="/issues" component={IssueReportPage} />
<Route path="/components" component={ComponentsPage} />

View file

@ -2,9 +2,10 @@
// page, a small read-only preview embedded below `AgentsPage`'s detail
// panel fields. No input — sending input back to the agent is explicitly
// follow-up scope, not this issue's. Consumes swarm-controller's
// `GET /api/agents/{name}/term/stream` via `useSwarmTermStream`,
// rendering through the same `@hive/shared` `Row` component the per-hive
// local terminal uses — "share components between hive and swarm level
// `GET /api/agents/{name}/term/stream`, or for a subagent
// `GET /api/agents/{name}/subagents/{subagent}/term/stream`, via
// `useSwarmTermStream`, rendering through the same `@hive/shared` `Row`
// component the per-hive local terminal uses — "share components between hive and swarm level
// term as much as possible" (mara).
//
// **Floating header badges** — turn state / model / ctx / cost parity
@ -100,21 +101,50 @@ function fmtTokens(n: number): string {
return String(n);
}
// The paths one terminal is reached at. A subagent's terminal sits under its
// agent's, and has no turn-state header: the swarm relays none for it.
interface TermPaths {
label: string;
page: string;
stream: string;
state: string | null;
}
export function termPaths(agentName: string, subagent?: string): TermPaths {
const agent = `/agents/${encodeURIComponent(agentName)}`;
if (subagent === undefined) {
return {
label: agentName,
page: `${agent}/terminal`,
stream: `/api${agent}/term/stream`,
state: `/api${agent}/state/stream`,
};
}
const sub = `${agent}/subagents/${encodeURIComponent(subagent)}`;
return {
label: `${agentName}/${subagent}`,
page: `${sub}/terminal`,
stream: `/api${sub}/term/stream`,
state: null,
};
}
export function AgentTermPreview({
agentName,
subagent,
showHeaderBadges = true,
fullHeight = false,
}: {
agentName: string;
// Set: this is one of `agentName`'s subagents, output only like the
// agent's own.
subagent?: string;
showHeaderBadges?: boolean;
fullHeight?: boolean;
}) {
const { rows, connection } = useSwarmTermStream(
`/api/agents/${encodeURIComponent(agentName)}/term/stream`,
);
const { header } = useSwarmAgentStateStream(
`/api/agents/${encodeURIComponent(agentName)}/state/stream`,
);
const paths = termPaths(agentName, subagent);
const { rows, connection } = useSwarmTermStream(paths.stream);
const { header } = useSwarmAgentStateStream(paths.state);
const { open: openTab } = useDynamicTabs();
const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true);
@ -156,13 +186,8 @@ export function AgentTermPreview({
variant="quiet"
icon={<ExpandIcon />}
value="expand"
title={`open ${agentName}'s terminal in its own tab`}
onClick={() =>
openTab({
href: `/agents/${encodeURIComponent(agentName)}/terminal`,
label: agentName,
})
}
title={`open ${paths.label}'s terminal in its own tab`}
onClick={() => openTab({ href: paths.page, label: paths.label })}
/>
)}
</div>

View file

@ -13,30 +13,42 @@
// idempotent (dedupes by href), so this is safe to call every mount.
import { useEffect } from "preact/hooks";
import type { RouteComponentProps } from "wouter-preact";
import { AgentTermPreview } from "./AgentTermPreview.js";
import { AgentTermPreview, termPaths } from "./AgentTermPreview.js";
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
import "./AgentTerminalPage.css";
// Also mounted at `/agents/:name/subagents/:subagent/terminal`, for one of
// the agent's subagents.
export function AgentTerminalPage({
params,
}: RouteComponentProps<{ name: string }>) {
}: RouteComponentProps<{ name: string; subagent?: string }>) {
const { open } = useDynamicTabs();
const name = decodeURIComponent(params.name);
const subagent =
params.subagent === undefined
? undefined
: decodeURIComponent(params.subagent);
const { page, label } = termPaths(name, subagent);
// Runs once per mounted agent name — re-registering on every render
// Runs once per mounted terminal — re-registering on every render
// would just be redundant work against an already-deduped list, but
// there's no reason to pay it more than once per identity.
useEffect(() => {
open({ href: `/agents/${encodeURIComponent(name)}/terminal`, label: name });
open({ href: page, label });
// eslint-disable-next-line react-hooks/exhaustive-deps -- `open` is a
// fresh function identity every render (`useDynamicTabs` doesn't
// memoize it); including it would re-fire this effect every render
// instead of once per agent `name`.
}, [name]);
// instead of once per terminal `page`.
}, [page]);
return (
<div class="ui-agent-terminal-page">
<AgentTermPreview agentName={name} fullHeight showHeaderBadges />
<AgentTermPreview
agentName={name}
subagent={subagent}
fullHeight
showHeaderBadges
/>
</div>
);
}

View file

@ -32,6 +32,7 @@ import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from "@hive/shared/icons.js";
import { AgentCard } from "./AgentCard.js";
import { AgentTermPreview } from "./AgentTermPreview.js";
import { SubagentTerms } from "./SubagentTerms.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { Button } from "../../ui/button/Button.js";
@ -654,6 +655,10 @@ export function AgentsPage() {
preview, no header/no input — the full terminal
(+ sending input back) is explicit follow-up scope. */}
<AgentTermPreview agentName={detailTarget.name} />
<SubagentTerms
key={detailTarget.name}
agentName={detailTarget.name}
/>
</div>
) : (
<p class="ui-agents-empty">select an agent to see its details</p>

View file

@ -0,0 +1,17 @@
/* <SubagentTerms> — one row of subagent names under the agent's own
terminal preview; the picked subagent's preview sits below it. */
.ui-subagent-terms {
margin-block-start: 1rem;
}
.ui-subagent-terms-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
}
.ui-subagent-terms-heading,
.ui-subagent-terms-empty {
color: var(--muted);
}

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>
);
}

View file

@ -47,7 +47,7 @@ export interface UseSwarmAgentStateStreamResult {
}
export function useSwarmAgentStateStream(
streamUrl: string,
streamUrl: string | null,
): UseSwarmAgentStateStreamResult {
const [header, setHeader] = useState<AgentStateHeader | null>(null);
const [connection, setConnection] = useState<ConnectionState>("connecting");
@ -55,6 +55,8 @@ export function useSwarmAgentStateStream(
useEffect(() => {
setHeader(null);
setConnection("connecting");
// `null`: a terminal with no header to follow (a subagent's).
if (streamUrl === null) return;
const es = new EventSource(streamUrl);
es.onopen = () => setConnection("open");
es.onmessage = (e) => {

View file

@ -62,13 +62,15 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
// its own table view (plus the list+detail split) hits the same cap.
const WIDE_BODY_ROUTES = new Set(["/issues", "/agents"]);
// `/agents/:name/terminal` needs the same widening but can't join
// `/agents/:name/terminal` (and a subagent's, under
// `/agents/:name/subagents/:subagent/`) needs the same widening but can't join
// the `Set` above — it's a parameterized path, one literal string can't
// match every agent name. A 60em-capped terminal pane reads as cramped
// (this is a full tab, not a sidebar preview), same reasoning as the
// table routes above just for a different reason (a wide pane, not a
// wide table).
const WIDE_BODY_ROUTE_PATTERN = /^\/agents\/[^/]+\/terminal$/;
const WIDE_BODY_ROUTE_PATTERN =
/^\/agents\/[^/]+(\/subagents\/[^/]+)?\/terminal$/;
function isWideBodyRoute(location: string): boolean {
return (