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

@ -10,7 +10,8 @@ Tools (hyperhive surface):
- `mcp__hyperhive__get_loose_ends()` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), plus reminders you've scheduled that haven't fired. No args, cheap server-side sweep. Useful at turn start to remember what's outstanding without scanning inbox archaeology. - `mcp__hyperhive__get_loose_ends()` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), plus reminders you've scheduled that haven't fired. No args, cheap server-side sweep. Useful at turn start to remember what's outstanding without scanning inbox archaeology.
- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by <you>]` answer so the waiter unblocks) or `"reminder"` (hard-deleted before it fires). `id` from the matching `get_loose_ends` row or the original submission reply. - `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by <you>]` answer so the waiter unblocks) or `"reminder"` (hard-deleted before it fires). `id` from the matching `get_loose_ends` row or the original submission reply.
- `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your *own* inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached. - `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your *own* inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached.
- `mcp__hyperhive__whoami()` — self-introspection: returns your canonical agent name (from socket identity, not the prompt-substituted label), role, and current hyperhive rev. No args. Use it when you want a trustworthy identity stamp for state files, commit messages, or cross-agent attribution that won't drift across renames. - `mcp__hyperhive__whoami()` — self-introspection: returns your canonical agent name (from socket identity, not the prompt-substituted label), role, and current hyperhive rev. No args. Use it when you want a trustworthy identity stamp for state files, commit messages, or cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale.
- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"reviewing PR #42"`, `"fixing #319 model priority"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts.
- `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args. - `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args.
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config. Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config.

View file

@ -53,6 +53,7 @@ pub enum SocketReply {
name: String, name: String,
role: String, role: String,
hyperhive_rev: Option<String>, hyperhive_rev: Option<String>,
status_text: Option<String>,
}, },
} }
@ -74,10 +75,12 @@ impl From<hive_sh4re::AgentResponse> for SocketReply {
name, name,
role, role,
hyperhive_rev, hyperhive_rev,
status_text,
} => Self::Whoami { } => Self::Whoami {
name, name,
role, role,
hyperhive_rev, hyperhive_rev,
status_text,
}, },
} }
} }
@ -102,10 +105,12 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
name, name,
role, role,
hyperhive_rev, hyperhive_rev,
status_text,
} => Self::Whoami { } => Self::Whoami {
name, name,
role, role,
hyperhive_rev, hyperhive_rev,
status_text,
}, },
} }
} }
@ -269,9 +274,14 @@ pub fn format_whoami(resp: Result<SocketReply, anyhow::Error>) -> String {
name, name,
role, role,
hyperhive_rev, hyperhive_rev,
status_text,
}) => { }) => {
let rev = hyperhive_rev.as_deref().unwrap_or("<unknown>"); let rev = hyperhive_rev.as_deref().unwrap_or("<unknown>");
format!("name: {name}\nrole: {role}\nhyperhive_rev: {rev}") let mut out = format!("name: {name}\nrole: {role}\nhyperhive_rev: {rev}");
if let Some(s) = status_text {
out.push_str(&format!("\nstatus: {s}"));
}
out
} }
Ok(SocketReply::Err(m)) => format!("whoami failed: {m}"), Ok(SocketReply::Err(m)) => format!("whoami failed: {m}"),
Ok(other) => format!("whoami unexpected response: {other:?}"), Ok(other) => format!("whoami unexpected response: {other:?}"),
@ -551,8 +561,9 @@ impl AgentServer {
#[tool( #[tool(
description = "Self-introspection: returns your own canonical agent name (the \ description = "Self-introspection: returns your own canonical agent name (the \
socket-identity name, NOT the prompt-substituted label), role (`agent`), and \ socket-identity name, NOT the prompt-substituted label), role (`agent`), and \
the current hyperhive rev hive-c0re is running against. No args. Useful when \ the current hyperhive rev hive-c0re is running against. Also returns the \
you want a trustworthy identity stamp for state files / commit messages / \ current `status` text if one has been set via `set_status`. No args. Useful \
when you want a trustworthy identity stamp for state files / commit messages / \
cross-agent attribution that won't drift across renames or session-continue \ cross-agent attribution that won't drift across renames or session-continue \
boundaries where the system-prompt label could be stale." boundaries where the system-prompt label could be stale."
)] )]
@ -564,6 +575,22 @@ impl AgentServer {
.await .await
} }
#[tool(
description = "Set a free-text status string visible on the operator dashboard. \
Call this at the START of every task to describe what you're working on (e.g. \
`\"reviewing PR #42\"`, `\"fixing bitburner crash\"`, `\"idle\"`). Pass an empty \
string to clear. The status is shown on your dashboard card and persists across \
harness restarts."
)]
async fn set_status(&self, Parameters(args): Parameters<SetStatusArgs>) -> String {
run_tool_envelope("set_status", args.text.clone(), async move {
let (resp, retries) =
self.dispatch(hive_sh4re::AgentRequest::SetStatus { text: args.text }).await;
annotate_retries(format_ack(resp, "set_status", "status updated".to_owned()), retries)
})
.await
}
#[tool( #[tool(
description = "Cancel an open thread you own — a `question` you asked (the \ description = "Cancel an open thread you own — a `question` you asked (the \
asker gets `[cancelled by <you>]` as the answer and unblocks) or a `reminder` \ asker gets `[cancelled by <you>]` as the answer and unblocks) or a `reminder` \
@ -724,6 +751,12 @@ pub struct KillArgs {
pub name: String, pub name: String,
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SetStatusArgs {
/// Status text to display on the dashboard card. Pass an empty string to clear.
pub text: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct StartArgs { pub struct StartArgs {
/// Sub-agent name (without the `h-` container prefix). /// Sub-agent name (without the `h-` container prefix).
@ -1246,7 +1279,8 @@ impl ManagerServer {
#[tool( #[tool(
description = "Self-introspection for the manager: returns canonical name \ description = "Self-introspection for the manager: returns canonical name \
(`manager`), role (`manager`), and the current hyperhive rev. Same shape as \ (`manager`), role (`manager`), and the current hyperhive rev. Also returns \
the current `status` text if one has been set via `set_status`. Same shape as \
the agent flavour; useful for cross-agent attribution / boot announcements / \ the agent flavour; useful for cross-agent attribution / boot announcements / \
state-file headers without trusting prompt substitution." state-file headers without trusting prompt substitution."
)] )]
@ -1258,6 +1292,20 @@ impl ManagerServer {
.await .await
} }
#[tool(
description = "Set a free-text status string visible on the operator dashboard. \
Call this at the START of every task to describe what you're working on. \
Pass an empty string to clear. Persists across harness restarts."
)]
async fn set_status(&self, Parameters(args): Parameters<SetStatusArgs>) -> String {
run_tool_envelope("set_status", args.text.clone(), async move {
let (resp, retries) =
self.dispatch(hive_sh4re::ManagerRequest::SetStatus { text: args.text }).await;
annotate_retries(format_ack(resp, "set_status", "status updated".to_owned()), retries)
})
.await
}
#[tool( #[tool(
description = "Cancel any open thread in the swarm — a `question` (cancels \ description = "Cancel any open thread in the swarm — a `question` (cancels \
with the operator-override sentinel so the asker unblocks) or a `reminder` \ with the operator-override sentinel so the asker unblocks) or a `reminder` \
@ -1377,6 +1425,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
"remind", "remind",
"get_loose_ends", "get_loose_ends",
"whoami", "whoami",
"set_status",
"cancel_loose_end", "cancel_loose_end",
], ],
Flavor::Manager => &[ Flavor::Manager => &[
@ -1395,6 +1444,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
"get_loose_ends", "get_loose_ends",
"remind", "remind",
"whoami", "whoami",
"set_status",
"cancel_loose_end", "cancel_loose_end",
], ],
}; };

View file

@ -727,6 +727,14 @@
} }
body.append(head); body.append(head);
// ── agent status text ─────────────────────────────────────────
if (c.status_text) {
body.append(el('div', { class: 'agent-status', title: 'agent self-reported status' },
el('span', { class: 'status-icon' }, '◈ '),
c.status_text,
));
}
// ── action buttons ─────────────────────────────────────────── // ── action buttons ───────────────────────────────────────────
const actions = el('div', { class: 'actions' }); const actions = el('div', { class: 'actions' });
if (c.running) { if (c.running) {

View file

@ -207,6 +207,16 @@ a:hover {
color: var(--red); border-color: var(--red); color: var(--red); border-color: var(--red);
text-shadow: 0 0 6px rgba(243, 139, 168, 0.5); text-shadow: 0 0 6px rgba(243, 139, 168, 0.5);
} }
.agent-status {
font-size: 0.82em;
color: var(--subtext0, #a6adc8);
padding: 0.1em 0.3em 0.25em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-status .status-icon { opacity: 0.65; }
.container-row.tombstone { .container-row.tombstone {
border-style: dashed; border-style: dashed;
background: rgba(24, 24, 37, 0.35); background: rgba(24, 24, 37, 0.35);

View file

@ -220,11 +220,39 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
}, },
} }
} }
AgentRequest::Whoami => AgentResponse::Whoami { AgentRequest::Whoami => {
name: agent.to_owned(), let status_text = crate::container_view::read_agent_status_text(agent);
role: "agent".to_owned(), AgentResponse::Whoami {
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), 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( AgentRequest::CancelLooseEnd { kind, id } => crate::questions::handle_cancel_loose_end(
coord, agent, *kind, *id, coord, agent, *kind, *id,
) )

View file

@ -77,6 +77,11 @@ pub struct ContainerView {
/// the file is absent or the agent declares no links. /// the file is absent or the agent declares no links.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_links: Vec<DashboardLink>, 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 /// 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)); .and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens));
let rate_limited = is_rate_limited(&logical); let rate_limited = is_rate_limited(&logical);
let extra_links = read_dashboard_links(&logical); let extra_links = read_dashboard_links(&logical);
let status_text = read_status_text(&logical);
out.push(ContainerView { out.push(ContainerView {
port: lifecycle::agent_web_port(&logical), port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await, running: lifecycle::is_running(&logical).await,
@ -133,6 +139,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
context_window_tokens, context_window_tokens,
rate_limited, rate_limited,
extra_links, extra_links,
status_text,
}); });
} }
out out
@ -172,6 +179,20 @@ fn is_rate_limited(name: &str) -> bool {
.exists() .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 /// Read the agent's most recent completed turn from its turn-stats
/// `SQLite`: the context-window size (prompt tokens) and the model name. /// `SQLite`: the context-window size (prompt tokens) and the model name.
/// Returns `None` when the file is absent or has no rows. Best-effort /// 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 { ManagerRequest::Whoami => {
name: MANAGER_AGENT.to_owned(), let status_text = crate::container_view::read_agent_status_text(MANAGER_AGENT);
role: "manager".to_owned(), ManagerResponse::Whoami {
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), 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( ManagerRequest::CancelLooseEnd { kind, id } => crate::questions::handle_cancel_loose_end(
coord, coord,
MANAGER_AGENT, MANAGER_AGENT,

View file

@ -443,6 +443,10 @@ pub enum AgentRequest {
/// identity after a rename or session-continue boundary where the /// identity after a rename or session-continue boundary where the
/// system-prompt-substituted label is no longer reliable. /// system-prompt-substituted label is no longer reliable.
Whoami, Whoami,
/// Set a free-text status string visible on the dashboard. Persisted
/// to `{state_dir}/hyperhive-status` so it survives harness restarts.
/// Pass an empty string to clear the status.
SetStatus { text: String },
/// Cancel an open thread the agent owns: a `Question` they asked /// Cancel an open thread the agent owns: a `Question` they asked
/// (returns `[cancelled by <self>]` as the answer to the asker) /// (returns `[cancelled by <self>]` as the answer to the asker)
/// or a `Reminder` they scheduled (hard-deletes the row). /// or a `Reminder` they scheduled (hard-deletes the row).
@ -507,12 +511,15 @@ pub enum AgentResponse {
/// hive-c0re is running against. `role` is `"agent"` for /// hive-c0re is running against. `role` is `"agent"` for
/// sub-agents (the only path that reaches this variant of the /// sub-agents (the only path that reaches this variant of the
/// response). `hyperhive_rev` is `None` only when the configured /// response). `hyperhive_rev` is `None` only when the configured
/// flake URL has no canonical path. /// flake URL has no canonical path. `status_text` is the last
/// value written via `SetStatus`, or `None` if none has been set.
Whoami { Whoami {
name: String, name: String,
role: String, role: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
hyperhive_rev: Option<String>, hyperhive_rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status_text: Option<String>,
}, },
} }
@ -843,6 +850,8 @@ pub enum ManagerRequest {
/// Manager-flavour self-introspection. Same wire shape as /// Manager-flavour self-introspection. Same wire shape as
/// `AgentRequest::Whoami`, but `role` is always `"manager"`. /// `AgentRequest::Whoami`, but `role` is always `"manager"`.
Whoami, Whoami,
/// Mirror of `AgentRequest::SetStatus` on the manager surface.
SetStatus { text: String },
/// Cancel an open thread (question or reminder). Manager surface /// Cancel an open thread (question or reminder). Manager surface
/// can cancel any row (no owner check) — same dispatch as /// can cancel any row (no owner check) — same dispatch as
/// `AgentRequest::CancelLooseEnd` but with privileged auth. /// `AgentRequest::CancelLooseEnd` but with privileged auth.
@ -925,5 +934,7 @@ pub enum ManagerResponse {
role: String, role: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
hyperhive_rev: Option<String>, hyperhive_rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status_text: Option<String>,
}, },
} }