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

@ -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;