90 lines
3.5 KiB
TypeScript
90 lines
3.5 KiB
TypeScript
// 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";
|
|
|
|
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
|
|
|
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;
|
|
}
|
|
|
|
export function useSwarmAgentStateStream(
|
|
streamUrl: string | null,
|
|
): UseSwarmAgentStateStreamResult {
|
|
const [header, setHeader] = useState<AgentStateHeader | null>(null);
|
|
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
|
|
|
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(
|
|
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
|
|
);
|
|
};
|
|
return () => es.close();
|
|
}, [streamUrl]);
|
|
|
|
return { header, connection };
|
|
}
|