feat(#2017): add hivectl agents list verb showing agent status + technical state

This commit is contained in:
damocles 2026-06-26 22:12:50 +02:00 committed by mara
commit 70d1cdc859
5 changed files with 186 additions and 0 deletions

View file

@ -18,6 +18,7 @@ This document contains the help content for the `hivectl` command-line program.
* [`hivectl gateway delete-user`↴](#hivectl-gateway-delete-user)
* [`hivectl gateway list-users`↴](#hivectl-gateway-list-users)
* [`hivectl agents`↴](#hivectl-agents)
* [`hivectl agents list`↴](#hivectl-agents-list)
* [`hivectl agents restart`↴](#hivectl-agents-restart)
* [`hivectl agents restart-all`↴](#hivectl-agents-restart-all)
* [`hivectl wg`↴](#hivectl-wg)
@ -267,11 +268,24 @@ Agent container management. Requires the hive-c0re daemon to be running (connect
###### **Subcommands:**
* `list` — Show all managed agents with their status (running / needs-login / needs-update) and technical state (deployed sha, parent, pending reminders). The host roster overview; reuses the dashboard's per-agent aggregation. Requires the daemon running
* `restart` — Stop and start a single agent container without rebuilding config. Useful for "kick the container" when the process is stuck or the container needs a clean restart without changing the NixOS config
* `restart-all` — Stop and restart ALL managed agent containers in sequence. Iterates the live container list and restarts each one. Any per-agent failure is reported at the end rather than stopping mid-run, so all containers get a restart attempt
## `hivectl agents list`
Show all managed agents with their status (running / needs-login / needs-update) and technical state (deployed sha, parent, pending reminders). The host roster overview; reuses the dashboard's per-agent aggregation. Requires the daemon running
**Usage:** `hivectl agents list [OPTIONS]`
###### **Options:**
* `--json` — Emit the raw JSON rows instead of the padded table (for scripting). The table is the default human-readable shape
## `hivectl agents restart`
Stop and start a single agent container without rebuilding config. Useful for "kick the container" when the process is stuck or the container needs a clean restart without changing the NixOS config

View file

@ -99,10 +99,21 @@ Container lifecycle shortcuts that go through the host admin socket.
Requires the `hive-c0re` daemon to be running.
```bash
hivectl agents list # roster: every agent's status + technical state
hivectl agents list --json # same data as raw JSON rows (for scripting)
hivectl agents restart iris # stop + start the `iris` container (no rebuild)
hivectl agents restart-all # stop + start every managed agent container in sequence
```
`list` prints a padded table with one row per managed agent —
`NAME STATUS REV PARENT REMIND`. STATUS collapses the health flags
(`running` / `stopped`, plus ` needs-login` / ` needs-update` when set);
REV is the first 12 chars of the agent's locked config sha; PARENT is its
place in the topology tree (`-` for a root agent); REMIND is the count of
pending reminders. It reuses the same per-agent aggregation the dashboard
renders, so the CLI roster and the web UI never drift. `--json` emits the
raw rows instead of the table.
`restart` is the manual equivalent of the MCP `restart` tool — useful
when you need to kick a container from the host without going through
the agent hierarchy. Failures on `restart-all` are collected and

View file

@ -504,6 +504,16 @@ const DEFAULT_HOST_SOCKET: &str = "/run/hyperhive/host.sock";
#[derive(Subcommand)]
enum AgentsCmd {
/// Show all managed agents with their status (running / needs-login /
/// needs-update) and technical state (deployed sha, parent, pending
/// reminders). The host roster overview; reuses the dashboard's
/// per-agent aggregation. Requires the daemon running.
List {
/// Emit the raw JSON rows instead of the padded table (for
/// scripting). The table is the default human-readable shape.
#[arg(long)]
json: bool,
},
/// Stop and start a single agent container without rebuilding config.
/// Useful for "kick the container" when the process is stuck or the
/// container needs a clean restart without changing the NixOS config.
@ -577,6 +587,7 @@ async fn main() -> Result<()> {
GatewayCmd::ListUsers { file } => gateway_list_users(&file),
},
Cmd::Agents { cmd } => match cmd {
AgentsCmd::List { json } => agents_list(&socket, json).await,
AgentsCmd::Restart { name } => agents_restart(&socket, &name).await,
AgentsCmd::RestartAll => agents_restart_all(&socket).await,
},
@ -1374,6 +1385,82 @@ async fn agents_restart(socket: &Path, name: &str) -> Result<()> {
}
}
/// `hivectl agents list` — fetch the per-agent status roster from the
/// daemon (`HostRequest::AgentStatus`) and render it as a padded table,
/// or the raw JSON rows with `--json`. Reuses the dashboard's
/// `ContainerView` aggregation, so the CLI and the web UI never drift.
async fn agents_list(socket: &Path, json: bool) -> Result<()> {
let resp = hive_c0re::client::request(socket, hive_sh4re::HostRequest::AgentStatus)
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
if !resp.ok {
bail!(
"agents list: {}",
resp.error.as_deref().unwrap_or("unknown error")
);
}
let rows = resp.agent_statuses.unwrap_or_default();
if json {
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
if rows.is_empty() {
println!("no managed agents found");
return Ok(());
}
// STATUS collapses the health flags into one space-separated token so
// the common case (`running`) stays short and anomalies stand out.
let status_of = |r: &hive_sh4re::AgentStatusRow| -> String {
let mut s = if r.running { "running" } else { "stopped" }.to_owned();
if r.needs_login {
s.push_str(" needs-login");
}
if r.needs_update {
s.push_str(" needs-update");
}
s
};
let headers = ["NAME", "STATUS", "REV", "PARENT", "REMIND"];
let table: Vec<[String; 5]> = rows
.iter()
.map(|r| {
[
r.name.clone(),
status_of(r),
r.deployed_sha.clone().unwrap_or_else(|| "-".to_owned()),
r.parent.clone().unwrap_or_else(|| "-".to_owned()),
if r.pending_reminders > 0 {
r.pending_reminders.to_string()
} else {
"-".to_owned()
},
]
})
.collect();
let mut widths = headers.map(str::len);
for row in &table {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(cell.len());
}
}
let fmt_row = |cells: &[String]| -> String {
cells
.iter()
.enumerate()
.map(|(i, c)| format!("{c:<w$}", w = widths[i]))
.collect::<Vec<_>>()
.join(" ")
.trim_end()
.to_owned()
};
let header_cells: Vec<String> = headers.iter().map(|h| (*h).to_owned()).collect();
println!("{}", fmt_row(&header_cells));
for row in &table {
println!("{}", fmt_row(row));
}
Ok(())
}
async fn agents_restart_all(socket: &Path) -> Result<()> {
let resp = hive_c0re::client::request(socket, hive_sh4re::HostRequest::RestartAll)
.await

View file

@ -139,6 +139,22 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
}
HostRequest::Rebuild { name } => handle_rebuild(&coord, name).await?,
HostRequest::List => HostResponse::list(lifecycle::list().await?),
HostRequest::AgentStatus => {
let rows = crate::container_view::build_all(&coord)
.await
.into_iter()
.map(|v| hive_sh4re::AgentStatusRow {
name: v.name,
running: v.running,
needs_update: v.needs_update,
needs_login: v.needs_login,
deployed_sha: v.deployed_sha,
pending_reminders: v.pending_reminders,
parent: v.parent,
})
.collect();
HostResponse::agent_statuses(rows)
}
// The hive domain is injected into c0re's service env by
// hive-c0re.nix (`HYPERHIVE_HIVE_DOMAIN`); surface it so the
// operator CLI can fill in this hive's own identity.
@ -246,6 +262,7 @@ async fn handle_restart_all() -> Result<HostResponse> {
agents: Some(ok_agents),
approvals: None,
domain: None,
agent_statuses: None,
})
}
}
@ -414,6 +431,7 @@ fn finish_lifecycle(ok_items: Vec<String>, errors: &[String]) -> HostResponse {
agents: Some(ok_items),
approvals: None,
domain: None,
agent_statuses: None,
}
}
}

View file

@ -44,6 +44,11 @@ pub enum HostRequest {
Rebuild { name: String },
/// List managed containers.
List,
/// List managed agents with their full status + technical state
/// (running / needs-login / needs-update / deployed sha / parent /
/// pending reminders) — the `hivectl agents list` roster view.
/// Reuses the dashboard's per-agent `ContainerView` aggregation.
AgentStatus,
/// Report this hive's canonical DNS domain
/// (`services.hyperhive.domain`), or `None` when unset. Lets the
/// operator CLI fill in the hive's own identity (e.g. the federation
@ -140,6 +145,11 @@ pub struct HostResponse {
/// when the domain is unset (no `services.hyperhive.domain`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
/// `AgentStatus` result — one row per managed agent with its
/// running/health flags + technical state. `None` for every other
/// request kind.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_statuses: Option<Vec<AgentStatusRow>>,
}
/// One row in the approval queue. `commit_ref` is overloaded per
@ -236,6 +246,7 @@ impl HostResponse {
agents: None,
approvals: None,
domain: None,
agent_statuses: None,
}
}
@ -247,6 +258,7 @@ impl HostResponse {
agents: None,
approvals: None,
domain: None,
agent_statuses: None,
}
}
@ -258,6 +270,7 @@ impl HostResponse {
agents: Some(agents),
approvals: None,
domain: None,
agent_statuses: None,
}
}
@ -269,6 +282,7 @@ impl HostResponse {
agents: None,
approvals: Some(approvals),
domain: None,
agent_statuses: None,
}
}
@ -281,6 +295,20 @@ impl HostResponse {
agents: None,
approvals: None,
domain,
agent_statuses: None,
}
}
/// `AgentStatus` result — one row per managed agent.
#[must_use]
pub fn agent_statuses(rows: Vec<AgentStatusRow>) -> Self {
Self {
ok: true,
error: None,
agents: None,
approvals: None,
domain: None,
agent_statuses: Some(rows),
}
}
}
@ -836,6 +864,34 @@ pub struct ContainerInfo {
pub running: bool,
}
/// One row in a `HostRequest::AgentStatus` result — the operator-CLI
/// projection of the dashboard's per-agent `ContainerView`. Carries the
/// agent's running/health flags plus the technical state an operator
/// wants in a roster overview (`hivectl agents list`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStatusRow {
/// Logical agent name (no `h-` prefix).
pub name: String,
/// Whether the container is currently running.
pub running: bool,
/// Config commit is pending — the locked rev differs from the
/// agent's proposed/applied config (a rebuild would change it).
pub needs_update: bool,
/// The agent has no live claude session and is parked waiting for
/// the operator's re-auth flow.
pub needs_login: bool,
/// First 12 chars of the sha the meta flake currently has locked for
/// this agent's input. `None` when the agent has no locked rev yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployed_sha: Option<String>,
/// Count of this agent's pending reminders.
#[serde(default)]
pub pending_reminders: u64,
/// Parent in the topology tree. `None` marks a root-level agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}
/// Serde default for the `running` field; keeps wire backwards-compat
/// with pre-running-field payloads. See
/// `docs/conventions.md::Agent metadata`.