// Live SSE consumer for the swarm-relayed agent turn-state header // (`GET /api/agents/{name}/state/stream`, swarm-controller's // `agent_state_stream.rs`). Same transport shape as `useSwarmTermStream` // — one core-NATS subject relayed as bare SSE `data`, no envelope, no // replay — but the payload here is a full header that *replaces* the // last one rather than an appendable row, so there is nothing to // coalesce: each event is just the latest value. // // **No seed on attach.** `agent_state_stream.rs`'s own module doc names // this limitation: the subject is published on transition and re-sent // once a minute, so an agent that has sat idle since before this hook // mounted announces nothing for up to a minute. `header` stays `null` // until the first event lands — callers should render no badges in that // gap rather than a stale or guessed one (there is no swarm-level // endpoint today that can seed turn_state/model/ctx/cost synchronously — // `GET /api/agents/status`'s snapshot carries only // `status_text`/`running`). import { useEffect, useState } from "preact/hooks"; import { connectionAfterError, type ConnectionState, } from "../../ui/stream/connection.js"; export type { ConnectionState }; export interface TokenUsage { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; } // `swarm_wire::agent_inference::Source`. export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default"; // Mirrors `hive_agent::swarm_agent_state::AgentStateMsg` field-for-field // — see that struct's own doc comment for why `turn_state_since` (an // ISO 8601 string here, unlike the per-agent page's unix-seconds // `/api/state`) and `agent_state` (the swarm `AgentState` enum, not a // bare `paused: bool`) diverge from the per-agent page's own shape. export interface AgentStateHeader { turn_state: "idle" | "thinking" | "compacting"; turn_state_since: string; agent_state: "up" | "offline" | "paused" | "destroyed"; model: string; effort: string; model_source: InferenceSource; effort_source: InferenceSource; resolved_model: string | null; context_window_tokens: number; ctx_usage: TokenUsage | null; cost_usage: TokenUsage | null; // Absent from an agent that predates the field. needs_login?: boolean; } export interface UseSwarmAgentStateStreamResult { header: AgentStateHeader | null; connection: ConnectionState; /** Opens the stream again, for one the browser gave up on. */ reconnect: () => void; } export function useSwarmAgentStateStream( streamUrl: string | null, ): UseSwarmAgentStateStreamResult { const [header, setHeader] = useState(null); const [connection, setConnection] = useState("connecting"); // Bumped by `reconnect`, which reruns the effect below. const [attempt, setAttempt] = useState(0); 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) => { try { setHeader(JSON.parse(e.data) as AgentStateHeader); } catch { // Malformed payload: drop it silently. Unlike the terminal // preview there is no row list to append a "[parse err]" line // to here, and one bad header isn't worth degrading the whole // badge cluster over — the next valid header replaces it. } }; es.onerror = () => setConnection(connectionAfterError(es.readyState)); return () => es.close(); }, [streamUrl, attempt]); return { header, connection, reconnect: () => setAttempt((n) => n + 1) }; }