feat: add set_status MCP tool and status field to whoami/dashboard (closes #325)

This commit is contained in:
damocles 2026-05-23 01:04:49 +02:00 committed by Mara
parent 6f3b56ad84
commit fe2933b213
8 changed files with 170 additions and 16 deletions

View file

@ -220,11 +220,39 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
},
}
}
AgentRequest::Whoami => AgentResponse::Whoami {
name: agent.to_owned(),
role: "agent".to_owned(),
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
},
AgentRequest::Whoami => {
let status_text = crate::container_view::read_agent_status_text(agent);
AgentResponse::Whoami {
name: agent.to_owned(),
role: "agent".to_owned(),
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
status_text,
}
}
AgentRequest::SetStatus { text } => {
let path = crate::coordinator::Coordinator::agent_notes_dir(agent)
.join("hyperhive-status");
let result = if text.trim().is_empty() {
// Empty = clear: remove the file (ignore missing).
std::fs::remove_file(&path)
.or_else(|e| if e.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(e)
})
} else {
std::fs::write(&path, format!("{}\n", text.trim()))
};
match result {
Ok(()) => {
// Kick a container rescan so the dashboard updates live.
let coord2 = Arc::clone(coord);
tokio::spawn(async move { coord2.rescan_containers_and_emit().await });
AgentResponse::Ok
}
Err(e) => AgentResponse::Err { message: format!("set_status write failed: {e}") },
}
}
AgentRequest::CancelLooseEnd { kind, id } => crate::questions::handle_cancel_loose_end(
coord, agent, *kind, *id,
)

View file

@ -77,6 +77,11 @@ pub struct ContainerView {
/// the file is absent or the agent declares no links.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_links: Vec<DashboardLink>,
/// Free-text status set by the agent via `mcp__hyperhive__set_status`.
/// Persisted to `{state_dir}/hyperhive-status`. `None` when the file
/// is absent or empty — the agent hasn't set one yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_text: Option<String>,
}
/// Build the full container list. Wraps `lifecycle::list()` and
@ -119,6 +124,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
.and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens));
let rate_limited = is_rate_limited(&logical);
let extra_links = read_dashboard_links(&logical);
let status_text = read_status_text(&logical);
out.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await,
@ -133,6 +139,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
context_window_tokens,
rate_limited,
extra_links,
status_text,
});
}
out
@ -172,6 +179,20 @@ fn is_rate_limited(name: &str) -> bool {
.exists()
}
/// Read the agent's free-text status set via `mcp__hyperhive__set_status`.
/// Returns `None` when the file is absent or empty — best-effort, never panics.
/// `pub` so `agent_server` and `manager_server` can include it in `Whoami` responses.
pub fn read_agent_status_text(name: &str) -> Option<String> {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
let s = std::fs::read_to_string(path).ok()?;
let trimmed = s.trim();
if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) }
}
fn read_status_text(name: &str) -> Option<String> {
read_agent_status_text(name)
}
/// Read the agent's most recent completed turn from its turn-stats
/// `SQLite`: the context-window size (prompt tokens) and the model name.
/// Returns `None` when the file is absent or has no rows. Best-effort

View file

@ -480,11 +480,36 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
},
}
}
ManagerRequest::Whoami => ManagerResponse::Whoami {
name: MANAGER_AGENT.to_owned(),
role: "manager".to_owned(),
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
},
ManagerRequest::Whoami => {
let status_text = crate::container_view::read_agent_status_text(MANAGER_AGENT);
ManagerResponse::Whoami {
name: MANAGER_AGENT.to_owned(),
role: "manager".to_owned(),
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
status_text,
}
}
ManagerRequest::SetStatus { text } => {
let path = Coordinator::agent_notes_dir(MANAGER_AGENT).join("hyperhive-status");
let result = if text.trim().is_empty() {
std::fs::remove_file(&path)
.or_else(|e| if e.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(e)
})
} else {
std::fs::write(&path, format!("{}\n", text.trim()))
};
match result {
Ok(()) => {
let coord2 = Arc::clone(coord);
tokio::spawn(async move { coord2.rescan_containers_and_emit().await });
ManagerResponse::Ok
}
Err(e) => ManagerResponse::Err { message: format!("set_status write failed: {e}") },
}
}
ManagerRequest::CancelLooseEnd { kind, id } => crate::questions::handle_cancel_loose_end(
coord,
MANAGER_AGENT,