Watch
0
0
Fork
You've already forked hyperhive
0

swarm UI: an agent that needs a Claude login shows it on its card

The agent's turn-state header ($SWARM.agent-state.<agent>) gains
`needs_login`, set by hive-agent from its own LoginState, so the flag
reaches the swarm without hive-c0re (mara on #5268: "id prefer the agent
container sending the info it needs login without hive").

- swarm-queue-client: `agent_login::Login`, the shared wire type, beside
  `agent_inference::Effective`. `needs_login: Option<bool>` with
  `#[serde(default)]`: a header from an older agent decodes as `None`
  (unknown), never as "needs login" or "logged in".
- hive-agent: the header publisher reads the shared LoginState cell.
  Every writer of that cell emits a status on the bus afterwards, so a
  login change goes out on the next rebuild, not the 60 s refresh.
- swarm-controller: `agent_login::Reported` follows every agent's header
  and keeps the latest flag; `GET /api/agents/status` merges it into each
  row as `needs_login` (null when unknown).
- swarm-ui: AgentCard shows a warning-tone "login needed" badge when the
  row's `needs_login` is true.

Refs #5268
This commit is contained in:
atlas 2026-10-14 19:32:05 +02:00
commit afeb0f9794
19 changed files with 365 additions and 15 deletions

View file

@ -225,8 +225,10 @@ is doing (`turn_state`, plus `turn_state_since` as an ISO 8601 UTC stamp),
which model and effort (`model`, `effort`, where each comes from as
`model_source` and `effort_source`, and the resolved id the last turn
actually ran on), the
context budget and the last turn's context and cost token blocks, and
`agent_state`.
context budget and the last turn's context and cost token blocks,
`agent_state`, and `needs_login`: `true` while the agent's claude has no
session and its turns wait for a login. The harness sets it from its own login
state, so it reaches the swarm without going through the hive.
`agent_state` reuses the swarm's own wanted-state vocabulary
(`up`/`offline`/`paused`/`destroyed`) so a reader can compare what an agent _is_
@ -251,7 +253,11 @@ Swarm-side, `GET /api/agents/<name>/state/stream` relays that subject as SSE,
resolving the agent's hive at request time exactly as the terminal stream does.
The payload passes through opaquely. Separately, the controller keeps each
agent's latest `model`, `effort` and their sources for
`GET /api/agents/<name>/inference` ([`inference.md`](inference.md)).
`GET /api/agents/<name>/inference` ([`inference.md`](inference.md)), and each
agent's latest `needs_login` for the `needs_login` field of
`GET /api/agents/status`. That field is `null` until a header carrying the flag
arrives, so for up to a minute after the controller starts, and for an agent
whose harness doesn't send it.
The third thing an agent publishes is its **icon**, the same SVG its own
`GET /icon` serves. It goes into the `agent-icons` KV bucket under the key

View file

@ -22,7 +22,9 @@ domain, covers host-level detail for one hive.
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
An agent created here or with `swarmctl agent create` starts `paused`; set it
`up` from its card.
`up` from its card. A card shows **login needed** when the agent's own
turn-state header says its claude has no session. Log it in from the agent's
own web UI.
### Agent and subagent terminals

View file

@ -39,6 +39,15 @@ export interface AgentRow {
snapshot: AgentStatusSnapshot | null;
config_pr: ConfigPrStatus | null;
wanted: Wanted;
// What the agent itself last published about its claude login. `null`:
// no header since swarm-controller started, or an agent that does not
// send the flag — not known, so not "needs login".
needs_login: boolean | null;
}
// Only an agent that said so gets the badge; `null` shows nothing.
export function showsLoginNeeded(row: Pick<AgentRow, "needs_login">): boolean {
return row.needs_login === true;
}
// Same tone pairing as HivesPage — one freshness enum shared by both

View file

@ -17,7 +17,7 @@ import type { ProblemDetails } from "@hive/shared/api-error.js";
import { Card } from "../../ui/card/Card.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { AgentIcon } from "../AgentIcon.js";
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
import { FRESHNESS, showsLoginNeeded, type AgentRow } from "../AgentTypes.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentCard.css";
@ -71,6 +71,13 @@ export function AgentCard({
</>
}
/>
{showsLoginNeeded(row) ? (
<Badge
tone="warning"
value="login needed"
title="the agent's claude has no session; its turns wait until it is logged in"
/>
) : null}
<span
class="ui-agent-card-wanted"
onClick={(e) => e.stopPropagation()}

View file

@ -46,6 +46,8 @@ export interface AgentStateHeader {
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 {

View file

@ -0,0 +1,19 @@
// The roster's "login needed" badge rule. Run by `npm test`, which Node runs
// with its built-in test runner and type stripping, so the `.ts` module is
// imported as it is.
import assert from "node:assert/strict";
import { test } from "node:test";
import { showsLoginNeeded } from "../src/agent/AgentTypes.ts";
test("an agent that says it needs a login gets the badge", () => {
assert.equal(showsLoginNeeded({ needs_login: true }), true);
});
test("an agent that is logged in gets none", () => {
assert.equal(showsLoginNeeded({ needs_login: false }), false);
});
test("an agent that has not said gets none", () => {
assert.equal(showsLoginNeeded({ needs_login: null }), false);
assert.equal(showsLoginNeeded({}), false);
});

View file

@ -490,7 +490,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
// Same reason, one subject over: the swarm's header bar wants the turn
// state from this agent's first transition, not from whenever a reader
// first asked.
swarm_agent_state::spawn(&bus);
swarm_agent_state::spawn(&bus, login_state.clone());
// Once per start: a new icon only arrives with a new harness process.
swarm_agent_icon::spawn();
// The model and effort the swarm sets for this agent, over its local ones.

View file

@ -26,6 +26,7 @@
//! the queue, so the bus receiver is the only thing this task blocks on; a
//! failed publish is a log line and the next transition is still attempted.
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use serde::Serialize;
@ -33,9 +34,11 @@ use tokio::sync::broadcast;
use tokio::time::{Interval, MissedTickBehavior};
use swarm_queue_client::agent_inference::Effective;
use swarm_queue_client::agent_login::Login;
use swarm_queue_client::wanted::AgentState;
use crate::events::{Bus, BusEvent, LiveEvent, TurnState};
use crate::login::LoginState;
use crate::term_msg::iso8601_utc;
/// How much room to leave under the announced limit for everything the
@ -94,6 +97,9 @@ struct AgentStateMsg {
/// Cumulative token usage across the most recent turn's inferences, the
/// cost signal. `None` until the first turn ends.
cost_usage: Option<hive_claude::TokenUsage>,
/// `needs_login`, from this process's own [`LoginState`], so always set.
#[serde(flatten)]
login: Login,
}
/// What this process can honestly report about the agent-state axis.
@ -125,8 +131,16 @@ fn observed_state() -> AgentState {
}
}
/// Build the current header from the bus plus the pause marker.
fn snapshot(bus: &Bus) -> AgentStateMsg {
/// The header's login fields for `state`.
fn login(state: LoginState) -> Login {
Login {
needs_login: Some(state == LoginState::NeedsLogin),
}
}
/// Build the current header from the bus, the login state and the pause
/// marker.
fn snapshot(bus: &Bus, login_state: &Mutex<LoginState>) -> AgentStateMsg {
let (turn_state, since_unix) = bus.state_snapshot();
let model = bus.model();
AgentStateMsg {
@ -143,17 +157,21 @@ fn snapshot(bus: &Bus) -> AgentStateMsg {
resolved_model: bus.last_resolved_model(),
ctx_usage: bus.last_ctx_usage(),
cost_usage: bus.last_cost_usage(),
login: login(*login_state.lock().unwrap_or_else(PoisonError::into_inner)),
}
}
/// Start the publish task, if this agent has a label to name its subject with.
pub fn spawn(bus: &Bus) {
///
/// Every writer of `login_state` emits a status on `bus` after the write, so
/// a login change goes out as an ordinary rebuild.
pub fn spawn(bus: &Bus, login_state: Arc<Mutex<LoginState>>) {
let agent = crate::identity::label();
if agent.is_empty() {
tracing::warn!("this agent has no label; not publishing turn state upward");
return;
}
tokio::spawn(run(bus.subscribe(), bus.clone(), agent));
tokio::spawn(run(bus.subscribe(), bus.clone(), login_state, agent));
}
/// Watch the bus and publish whenever the header actually changed.
@ -176,7 +194,12 @@ pub fn spawn(bus: &Bus) {
/// changes nothing.
///
/// A [`Trigger::Refresh`] re-sends the header even when it is unchanged.
async fn run(mut rx: broadcast::Receiver<BusEvent>, bus: Bus, agent: String) {
async fn run(
mut rx: broadcast::Receiver<BusEvent>,
bus: Bus,
login_state: Arc<Mutex<LoginState>>,
agent: String,
) {
let client = crate::swarm_queue::client().await;
let subject = swarm_queue_client::agent_state_subject(&agent);
tracing::info!(subject, "publishing agent turn state to the swarm queue");
@ -186,7 +209,8 @@ async fn run(mut rx: broadcast::Receiver<BusEvent>, bus: Bus, agent: String) {
let mut last: Option<Vec<u8>> = None;
let mut refresh = refresh_timer();
while let Some(trigger) = next_trigger(&mut rx, &mut refresh).await {
publish(&client, &subject, &snapshot(&bus), &trigger, &mut last).await;
let msg = snapshot(&bus, &login_state);
publish(&client, &subject, &msg, &trigger, &mut last).await;
}
}
@ -307,8 +331,12 @@ async fn publish(
#[cfg(test)]
mod tests {
use super::{AgentStateMsg, REFRESH_INTERVAL, Trigger, due, next_trigger, refresh_timer};
use super::{
AgentStateMsg, REFRESH_INTERVAL, Trigger, due, login, next_trigger, refresh_timer, snapshot,
};
use crate::events::{BusEvent, LiveEvent, TurnState};
use crate::login::LoginState;
use std::sync::Mutex;
use swarm_queue_client::agent_inference::{Effective, Source};
use swarm_queue_client::wanted::AgentState;
use tokio::sync::broadcast;
@ -358,6 +386,7 @@ mod tests {
"cache_read_input_tokens": 77,
"cache_creation_input_tokens": 88,
}))),
login: login(LoginState::NeedsLogin),
}
}
@ -384,6 +413,7 @@ mod tests {
"effort_source",
"model",
"model_source",
"needs_login",
"resolved_model",
"turn_state",
"turn_state_since",
@ -456,6 +486,44 @@ mod tests {
assert_eq!(value["agent_state"], serde_json::json!("up"));
}
/// swarm-controller reads the login flag back out of a header.
#[test]
fn the_header_decodes_as_its_login_flag() {
for state in [LoginState::NeedsLogin, LoginState::Online] {
let bytes = serde_json::to_vec(&AgentStateMsg {
login: login(state),
..msg()
})
.expect("serialises");
let decoded: swarm_queue_client::agent_login::Login =
serde_json::from_slice(&bytes).expect("decodes");
assert_eq!(
decoded.needs_login,
Some(state == LoginState::NeedsLogin),
"{state:?}"
);
}
}
/// A login landing, or a logout, changes the header the next bus event
/// rebuilds, so it goes out without waiting for a refresh.
#[test]
fn a_login_state_change_is_published_on_the_next_event() {
let (before, after) = crate::paths::with_harness_dir(|| {
let bus = crate::events::Bus::new();
let login_state = Mutex::new(LoginState::NeedsLogin);
let before = serde_json::to_vec(&snapshot(&bus, &login_state)).expect("serialises");
*login_state.lock().unwrap() = LoginState::Online;
let after = serde_json::to_vec(&snapshot(&bus, &login_state)).expect("serialises");
(before, after)
});
let mut last = Some(before.clone());
assert!(!due(&Trigger::Event, &mut last, &before));
assert!(due(&Trigger::Event, &mut last, &after));
let after: serde_json::Value = serde_json::from_slice(&after).expect("valid json");
assert_eq!(after["needs_login"], serde_json::json!(false));
}
fn event(event: LiveEvent) -> BusEvent {
BusEvent {
seq: 0,

View file

@ -446,6 +446,7 @@ pub(crate) mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),

View file

@ -0,0 +1,148 @@
//! Whether each agent last said its claude needs a login, for
//! `GET /api/agents/status`'s `needs_login`.
//!
//! The flag comes from the agent's own turn-state header
//! (`$SWARM.agent-state.<agent>`), not from its hive. Agents publish a header
//! when it changes and re-send it once a minute, and the queue keeps none, so
//! an agent has no entry for up to a minute after this daemon starts — the
//! same gap [`crate::inference::Reported`] has on the same subject.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use futures_util::StreamExt as _;
use swarm_queue_client::agent_login::Login;
/// The latest login flag each agent reported, by agent.
#[derive(Debug, Default)]
pub(crate) struct Reported {
latest: Mutex<HashMap<String, bool>>,
}
impl Reported {
/// Start recording every agent's login flag from `client`.
pub(crate) fn spawn(client: async_nats::Client) -> Arc<Self> {
let reported = Arc::new(Self::default());
let recording = Arc::clone(&reported);
tokio::spawn(async move { recording.follow(&client).await });
reported
}
/// Subscribe to every agent's header, and subscribe again if the
/// subscription ends.
async fn follow(&self, client: &async_nats::Client) {
const RETRY: Duration = Duration::from_secs(5);
let subject = swarm_queue_client::agent_state_subject_wildcard();
loop {
match client.subscribe(subject.clone()).await {
Ok(mut headers) => {
tracing::info!(%subject, "agent login: following agent turn-state headers");
while let Some(msg) = headers.next().await {
self.record(&msg.subject, &msg.payload);
}
tracing::warn!(%subject, "agent login: header subscription ended");
}
Err(e) => tracing::warn!(%subject, error = %e, "agent login: subscribe failed"),
}
tokio::time::sleep(RETRY).await;
}
}
/// Keep one header's login flag. A header without it, from an agent that
/// does not report it, removes what was kept.
fn record(&self, subject: &str, payload: &[u8]) {
let Some(agent) = swarm_queue_client::agent_from_state_subject(subject) else {
return;
};
let mut latest = self.latest.lock().unwrap_or_else(PoisonError::into_inner);
match serde_json::from_slice::<Login>(payload) {
Ok(Login {
needs_login: Some(needs_login),
}) => {
latest.insert(agent.to_owned(), needs_login);
}
Ok(Login { needs_login: None }) => {
latest.remove(agent);
}
Err(e) => {
tracing::debug!(%agent, error = %e, "agent login: header did not decode");
latest.remove(agent);
}
}
}
/// `agent`'s latest flag; `None` when no header since this daemon
/// started has carried one.
pub(crate) fn get(&self, agent: &str) -> Option<bool> {
self.latest
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(agent)
.copied()
}
}
#[cfg(test)]
mod tests {
use super::Reported;
fn header(needs_login: Option<bool>) -> Vec<u8> {
let mut header = serde_json::json!({
"turn_state": "idle",
"turn_state_since": "2026-09-13T12:35:03Z",
"agent_state": "up",
"model": "sonnet",
"effort": "high",
"model_source": "default",
"effort_source": "default",
"resolved_model": null,
"context_window_tokens": 200_000,
"ctx_usage": null,
"cost_usage": null,
});
if let Some(needs_login) = needs_login {
header["needs_login"] = needs_login.into();
}
serde_json::to_vec(&header).unwrap()
}
#[test]
fn a_header_without_the_flag_reports_nothing() {
let reported = Reported::default();
let subject = swarm_queue_client::agent_state_subject("iris");
reported.record(&subject, &header(None));
assert_eq!(reported.get("iris"), None);
}
#[test]
fn the_latest_header_wins_and_one_without_the_flag_clears_it() {
let reported = Reported::default();
let subject = swarm_queue_client::agent_state_subject("iris");
reported.record(&subject, &header(Some(true)));
assert_eq!(reported.get("iris"), Some(true));
reported.record(&subject, &header(Some(false)));
assert_eq!(reported.get("iris"), Some(false));
reported.record(&subject, &header(Some(true)));
reported.record(&subject, &header(None));
assert_eq!(reported.get("iris"), None);
reported.record(&subject, &header(Some(true)));
reported.record(&subject, b"not json");
assert_eq!(reported.get("iris"), None);
}
#[test]
fn a_header_is_kept_under_the_agent_its_subject_names() {
let reported = Reported::default();
reported.record(
&swarm_queue_client::agent_state_subject("iris"),
&header(Some(true)),
);
reported.record(
&swarm_queue_client::term_subject("atlas"),
&header(Some(true)),
);
assert_eq!(reported.get("iris"), Some(true));
assert_eq!(reported.get("atlas"), None);
}
}

View file

@ -40,8 +40,8 @@ use crate::AppState;
turn-state header, JSON as hive-agent published it (opaque to this relay) — \
`turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, `effort`, \
`model_source`, `effort_source`, `resolved_model`, `context_window_tokens`, \
`ctx_usage`, `cost_usage`. Sent on transition and re-sent once a minute, live, \
no replay",
`ctx_usage`, `cost_usage`, `needs_login`. Sent on transition and re-sent once a \
minute, live, no replay",
body = String, content_type = "text/event-stream"),
(status = 400, description = "the agent name is not shaped like an identifier \
(problem+json)", body = String),

View file

@ -65,6 +65,13 @@ pub struct AgentStatusRow {
/// here would blur that boundary for the same reason `config_pr`
/// doesn't merge forge state in directly.
pub wanted: Option<String>,
/// Whether the agent's latest turn-state header said its claude needs a
/// login. `None` when no header since swarm-controller started carried
/// the flag. Same rule as `config_pr`: always `None` out of this reader,
/// filled in by the `GET /api/agents/status` handler from
/// `AppState::agent_login` — this reader sees the hive's bucket, and the
/// flag comes from the agent itself.
pub needs_login: Option<bool>,
}
/// A snapshot as retained by the bucket, with the hive it was published
@ -186,6 +193,7 @@ fn render(
snapshot: None,
config_pr: None,
wanted: None,
needs_login: None,
},
})
.collect();
@ -230,6 +238,7 @@ fn row(
snapshot: offered.payload.clone(),
config_pr: None,
wanted: None,
needs_login: None,
}
}

View file

@ -153,6 +153,7 @@ mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),

View file

@ -123,6 +123,7 @@ mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),

View file

@ -958,6 +958,7 @@ pub(crate) mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),

View file

@ -45,6 +45,7 @@ mod agent_icon;
mod agent_identity;
mod agent_input;
mod agent_limits;
mod agent_login;
mod agent_renewal;
mod agent_state_stream;
mod agent_status;
@ -894,6 +895,10 @@ struct AppState {
/// headers on `status`'s connection. `None` in exactly the state
/// `status` is.
reported_inference: Option<Arc<inference::Reported>>,
/// Whether each agent last said it needs a login, from its turn-state
/// headers on `status`'s connection. `None` in exactly the state
/// `status` is.
agent_login: Option<Arc<agent_login::Reported>>,
/// The swarm-level job graph, wrapped in its
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it
/// (`spawn_jobq_worker`) — the graph alone was enough for the
@ -1260,6 +1265,12 @@ fn reported_inference(
Some(inference::Reported::spawn(status?.queue_client()))
}
/// Whether each agent last said it needs a login, read on the status
/// reader's connection — same rationale as [`wanted_writer`].
fn agent_login(status: Option<&Arc<status::StatusReader>>) -> Option<Arc<agent_login::Reported>> {
Some(agent_login::Reported::spawn(status?.queue_client()))
}
/// Same "log and carry on" shape as the queue/bridge/forge wiring in `main`.
/// A controller that cannot hold a webhook secret still serves every other
/// route; the webhook endpoint answers 503, which is the honest answer
@ -1671,6 +1682,11 @@ async fn get_agents_status(
});
}
}
if let Some(login) = state.agent_login.as_ref() {
for row in &mut rows {
row.needs_login = login.get(&row.name);
}
}
Ok(Json(rows))
}
Err(e) => {
@ -3334,6 +3350,7 @@ async fn main() -> Result<()> {
agent_status: agent_status_reader(status.as_ref()),
agent_icons: agent_icon_reader(status.as_ref()),
reported_inference: reported_inference(status.as_ref()),
agent_login: agent_login(status.as_ref()),
status,
jobq,
webhook_secret,
@ -3534,6 +3551,7 @@ mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::clone(&sched),
webhook_secret: None,
config_prs: std::sync::Arc::default(),

View file

@ -570,6 +570,7 @@ mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),

View file

@ -0,0 +1,53 @@
//! Whether an agent's claude is waiting for a login, as the agent itself
//! reports it.
//!
//! Part of the turn-state header the agent publishes on
//! [`crate::agent_state_subject`], beside [`crate::agent_inference::Effective`];
//! swarm-controller decodes it out of that header with this same type.
/// The login fields of an agent's turn-state header.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Login {
/// `true` while the agent has no claude session and its turn loop waits
/// for one, including while a login is in progress. `None` from an agent
/// whose header has no such field, which says nothing about its login.
#[serde(default)]
pub needs_login: Option<bool>,
}
#[cfg(test)]
mod tests {
use super::Login;
#[test]
fn a_header_without_the_field_decodes_as_unknown() {
let header = serde_json::json!({
"turn_state": "idle",
"turn_state_since": "2026-09-13T12:35:03Z",
"agent_state": "up",
"model": "sonnet",
"effort": "high",
"model_source": "default",
"effort_source": "default",
"resolved_model": null,
"context_window_tokens": 200_000,
"ctx_usage": null,
"cost_usage": null,
});
let login: Login = serde_json::from_value(header).expect("an old header decodes");
assert_eq!(login.needs_login, None);
}
#[test]
fn the_flag_round_trips_both_ways() {
for needs_login in [true, false] {
let sent = Login {
needs_login: Some(needs_login),
};
let value = serde_json::to_value(sent).expect("serialises");
assert_eq!(value["needs_login"], serde_json::json!(needs_login));
let decoded: Login = serde_json::from_value(value).expect("decodes");
assert_eq!(decoded, sent);
}
}
}

View file

@ -221,6 +221,10 @@ pub mod agent_inference;
/// writing needs `kv` and reading needs `agent-limits`.
pub mod agent_limits;
/// The login flag in an agent's turn-state header. No bucket: it travels
/// on [`agent_state_subject`] only.
pub mod agent_login;
#[cfg(feature = "kv")]
mod kv;