diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index fed3154..c08f5b8 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -4,12 +4,12 @@ Tools (hyperhive surface): - `mcp__hyperhive__recv(wait_seconds?)` — drain one more message from your inbox. Without `wait_seconds` (or with `0`) it returns immediately — a cheap inbox peek you can drop between actions. To **wait** when you have nothing else to do, call with a long wait (e.g. `wait_seconds: 180`, the max) — you'll wake instantly on new work, otherwise return after the timeout. Use that instead of ending the turn or sleeping in a Bash command. - `mcp__hyperhive__send(to, body)` — message an agent (by name), another peer, or the operator (`operator` surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). -- `mcp__hyperhive__request_spawn(name)` — queue a brand-new sub-agent for operator approval (≤9 char name). +- `mcp__hyperhive__request_spawn(name, description?)` — queue a brand-new sub-agent for operator approval (≤9 char name). Pass an optional `description` and it appears on the dashboard approval card — no need to send a separate message explaining the request. - `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent. No approval required. - `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required. - `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required. - `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event. -- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval. At submit time hive-c0re fetches your commit into the agent's applied repo and pins it as `proposal/`; from that moment your proposed-side commit can be amended or force-pushed freely without changing what the operator will build. +- `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)` — submit a config change for any agent (`hm1nd` for self) for operator approval. Pass an optional `description` and it appears on the dashboard approval card so the operator knows what changed without opening the diff. At submit time hive-c0re fetches your commit into the agent's applied repo and pins it as `proposal/`; from that moment your proposed-side commit can be amended or force-pushed freely without changing what the operator will build. - `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline — useful when the decision becomes moot if the operator hasn't responded in time; on expiry the answer is `[expired]`. Do not poll inside the same turn — finish the current work and react when the event lands. Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index a31a791..90a8e73 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -265,6 +265,10 @@ pub struct RequestSpawnArgs { /// New sub-agent name (≤9 chars). Queues a Spawn approval; the /// operator approves on the dashboard before the container is created. pub name: String, + /// Optional description shown on the dashboard approval card so the + /// operator knows what the new agent is for without a separate message. + #[serde(default)] + pub description: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -320,6 +324,10 @@ pub struct RequestApplyCommitArgs { pub agent: String, /// Git sha (full or short) pointing at the proposed `agent.nix`. pub commit_ref: String, + /// Optional description shown on the dashboard approval card so the + /// operator knows what the change does without opening the diff. + #[serde(default)] + pub description: Option, } #[derive(Debug, Clone)] @@ -395,7 +403,10 @@ impl ManagerServer { let name = args.name.clone(); run_tool_envelope("request_spawn", log, async move { let resp = self - .dispatch(hive_sh4re::ManagerRequest::RequestSpawn { name: args.name }) + .dispatch(hive_sh4re::ManagerRequest::RequestSpawn { + name: args.name, + description: args.description, + }) .await; format_ack( resp, @@ -521,6 +532,7 @@ impl ManagerServer { .dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit { agent: args.agent, commit_ref: args.commit_ref, + description: args.description, }) .await; format_ack( diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 7b075c0..ecaae20 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -684,6 +684,9 @@ 'new sub-agent — container will be created on approve'), ); } + if (a.description) { + li.append(el('div', { class: 'approval-description' }, a.description)); + } // Deny prompts the operator for an optional reason; the // submit handler stashes it into a hidden `note` input that // rides along on the POST and is surfaced to the manager via diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 27269eb..5f92e40 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -258,6 +258,7 @@ code { } .approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; } .approvals form.inline { display: inline; margin-left: 0.4em; } +.approval-description { font-size: 0.85em; color: var(--fg-dim, #888); margin: 0.2em 0 0.4em 1.2em; } .approval-tabs { display: flex; gap: 0.4em; diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index 995c355..b5cbca9 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -24,6 +24,20 @@ CREATE INDEX IF NOT EXISTS idx_approvals_pending ON approvals (id) WHERE status = 'pending'; "; +/// Add the `description` column to pre-description databases. Manager-supplied +/// note shown on the dashboard approval card at submission time (distinct from +/// `note` which is set on denial/failure). +fn ensure_description_column(conn: &Connection) -> Result<()> { + let has: bool = conn + .prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'description'")? + .exists([])?; + if !has { + conn.execute_batch("ALTER TABLE approvals ADD COLUMN description TEXT;") + .context("add approvals.description column")?; + } + Ok(()) +} + /// Add the `kind` column to pre-Phase-8 databases. ALTER TABLE ADD COLUMN is /// idempotent here only via a column-existence check (sqlite doesn't support /// IF NOT EXISTS on ADD COLUMN). Defaults legacy rows to `apply_commit`, @@ -72,21 +86,24 @@ impl Approvals { .context("apply approvals schema")?; ensure_kind_column(&conn).context("migrate approvals.kind")?; ensure_fetched_sha_column(&conn).context("migrate approvals.fetched_sha")?; + ensure_description_column(&conn).context("migrate approvals.description")?; Ok(Self { conn: Mutex::new(conn), }) } - pub fn submit(&self, agent: &str, commit_ref: &str) -> Result { - self.submit_kind(agent, ApprovalKind::ApplyCommit, commit_ref) - } - - pub fn submit_kind(&self, agent: &str, kind: ApprovalKind, commit_ref: &str) -> Result { + pub fn submit_kind( + &self, + agent: &str, + kind: ApprovalKind, + commit_ref: &str, + description: Option<&str>, + ) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( - "INSERT INTO approvals (agent, kind, commit_ref, requested_at, status) - VALUES (?1, ?2, ?3, ?4, 'pending')", - params![agent, kind_to_str(kind), commit_ref, now_unix()], + "INSERT INTO approvals (agent, kind, commit_ref, requested_at, status, description) + VALUES (?1, ?2, ?3, ?4, 'pending', ?5)", + params![agent, kind_to_str(kind), commit_ref, now_unix(), description], )?; Ok(conn.last_insert_rowid()) } @@ -107,7 +124,7 @@ impl Approvals { pub fn recent_resolved(&self, limit: u64) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description FROM approvals WHERE status IN ('approved', 'denied', 'failed') ORDER BY resolved_at DESC, id DESC @@ -121,7 +138,7 @@ impl Approvals { pub fn pending(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description FROM approvals WHERE status = 'pending' ORDER BY id ASC", @@ -134,7 +151,7 @@ impl Approvals { pub fn get(&self, id: i64) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description FROM approvals WHERE id = ?1", params![id], row_to_approval, @@ -147,9 +164,9 @@ impl Approvals { /// approval so the caller can run the action and pass the agent name. pub fn mark_approved(&self, id: i64) -> Result { let conn = self.conn.lock().unwrap(); - let current: Option<(String, String, String, i64, String, Option)> = conn - .query_row( - "SELECT agent, kind, commit_ref, requested_at, status, fetched_sha + let current: Option<(String, String, String, i64, String, Option, Option)> = + conn.query_row( + "SELECT agent, kind, commit_ref, requested_at, status, fetched_sha, description FROM approvals WHERE id = ?1", params![id], |row| { @@ -160,11 +177,14 @@ impl Approvals { row.get(3)?, row.get(4)?, row.get(5)?, + row.get(6)?, )) }, ) .optional()?; - let Some((agent, kind, commit_ref, requested_at, status, fetched_sha)) = current else { + let Some((agent, kind, commit_ref, requested_at, status, fetched_sha, description)) = + current + else { bail!("approval {id} not found"); }; if status != "pending" { @@ -185,6 +205,7 @@ impl Approvals { resolved_at: Some(resolved_at), note: None, fetched_sha, + description, }) } @@ -224,7 +245,7 @@ impl Approvals { } fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { - // Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha. + // Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description. let kind: String = row.get(2)?; let kind = match kind.as_str() { "apply_commit" => ApprovalKind::ApplyCommit, @@ -261,6 +282,7 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { resolved_at: row.get(6)?, note: row.get(7)?, fetched_sha: row.get(8)?, + description: row.get(9)?, }) } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 4fdb417..3e31d57 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -237,6 +237,9 @@ struct ApprovalView { sha_short: Option, /// Pre-rendered syntax-coloured diff HTML, for `ApplyCommit` only. diff_html: Option, + /// Manager-supplied description shown on the approval card. + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, } /// Replace silent `.unwrap_or_default()` on the data sources behind @@ -605,10 +608,11 @@ async fn build_approval_views(approvals: Vec) -> Vec { let diff = approval_diff(&a.agent, a.id).await; ApprovalView { id: a.id, - agent: a.agent, + agent: a.agent.clone(), kind: "apply_commit", sha_short: Some(sha), diff_html: Some(render_diff_lines(&diff)), + description: a.description, } } hive_sh4re::ApprovalKind::Spawn => ApprovalView { @@ -617,6 +621,7 @@ async fn build_approval_views(approvals: Vec) -> Vec { kind: "spawn", sha_short: None, diff_html: None, + description: a.description, }, }); } @@ -1065,7 +1070,7 @@ async fn post_request_spawn( match state .coord .approvals - .submit_kind(&name, hive_sh4re::ApprovalKind::Spawn, "") + .submit_kind(&name, hive_sh4re::ApprovalKind::Spawn, "", None) { Ok(id) => { tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 47c1854..aa74a53 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -131,12 +131,14 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp message: format!("{e:#}"), }, }, - ManagerRequest::RequestSpawn { name } => { + ManagerRequest::RequestSpawn { name, description } => { tracing::info!(%name, "manager: request_spawn"); - match coord - .approvals - .submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "") - { + match coord.approvals.submit_kind( + name, + hive_sh4re::ApprovalKind::Spawn, + "", + description.as_deref(), + ) { Ok(id) => { tracing::info!(%id, %name, "spawn approval queued"); ManagerResponse::Ok @@ -257,9 +259,13 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp }, } } - ManagerRequest::RequestApplyCommit { agent, commit_ref } => { + ManagerRequest::RequestApplyCommit { + agent, + commit_ref, + description, + } => { tracing::info!(%agent, %commit_ref, "manager: request_apply_commit"); - match submit_apply_commit(coord, agent, commit_ref).await { + match submit_apply_commit(coord, agent, commit_ref, description.as_deref()).await { Ok((id, sha)) => { tracing::info!(%id, %agent, manager_ref = %commit_ref, %sha, "approval queued + proposal tag planted"); ManagerResponse::Ok @@ -287,6 +293,7 @@ async fn submit_apply_commit( coord: &Arc, agent: &str, commit_ref: &str, + description: Option<&str>, ) -> anyhow::Result<(i64, String)> { let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(agent); let applied_dir = crate::coordinator::Coordinator::agent_applied_dir(agent); @@ -304,7 +311,12 @@ async fn submit_apply_commit( } let id = coord .approvals - .submit(agent, commit_ref) + .submit_kind( + agent, + hive_sh4re::ApprovalKind::ApplyCommit, + commit_ref, + description, + ) .map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?; let tag = format!("proposal/{id}"); let sha = match crate::lifecycle::git_fetch_to_tag( diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 1a16291..c11a894 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -106,7 +106,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { tracing::info!(%name, "request_spawn"); let id = coord .approvals - .submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "")?; + .submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "", None)?; tracing::info!(%id, %name, "spawn approval queued"); HostResponse::success() } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index ed81051..5a6fd0c 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -83,6 +83,11 @@ pub struct Approval { pub resolved_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub note: Option, + /// Optional free-text description the manager attached at submission + /// time — shown on the dashboard approval card so the operator can + /// understand the change without opening the diff. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } /// What action the approval, when granted, will trigger. @@ -414,6 +419,9 @@ pub enum ManagerRequest { /// agent of the same name already exists, the approval will fail. RequestSpawn { name: String, + /// Optional description shown on the dashboard approval card. + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, }, /// Stop a sub-agent (graceful). Kill { @@ -439,6 +447,10 @@ pub enum ManagerRequest { RequestApplyCommit { agent: String, commit_ref: String, + /// Optional description shown on the dashboard approval card so the + /// operator knows what the change does without opening the diff. + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, }, /// Ask the operator a question. Returns immediately with the queued /// question id; the operator's answer arrives later as a