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:
parent
b90be9e65e
commit
d6f94e5247
35 changed files with 1529 additions and 340 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in a new issue