diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index 15b82f36..d7adc846 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -9,7 +9,7 @@ Tools (hyperhive surface): - `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__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval. -- `mcp__hyperhive__ask_operator(question, options?, multi?)` — 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. Do not poll inside the same turn — finish the current work and react when the event lands. +- `mcp__hyperhive__ask_operator(question, options?)` — 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. 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 d48f4d8e..575cf99d 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -227,16 +227,10 @@ pub struct RestartArgs { pub struct AskOperatorArgs { /// The question to surface on the dashboard. pub question: String, - /// Optional fixed-choice answers. The dashboard always renders a - /// free-text fallback ("Other…") so the operator is never trapped - /// by an incomplete list. + /// Optional fixed-choice answers. If empty, the dashboard renders a + /// free-text input. Otherwise renders a select list of these options. #[serde(default)] pub options: Vec, - /// When true, options are rendered as checkboxes — operator can pick - /// any subset. The answer comes back as a single string with - /// selections joined by ", ". Ignored when `options` is empty. - #[serde(default)] - pub multi: bool, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -375,9 +369,7 @@ impl ManagerServer { with event `operator_answered { id, question, answer }` lands in your inbox; handle it \ on a future turn. Use this when a decision needs human signal (ambiguous sub-agent \ request, policy call, scope clarification). `options` is advisory: pass a short \ - fixed-choice list when applicable, otherwise leave empty for free text. Set \ - `multi: true` to let the operator pick multiple options (checkboxes); the answer \ - comes back as a comma-separated string." + fixed-choice list when applicable, otherwise leave empty for free text." )] async fn ask_operator(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -386,7 +378,6 @@ impl ManagerServer { .dispatch(hive_sh4re::ManagerRequest::AskOperator { question: args.question, options: args.options, - multi: args.multi, }) .await; match resp { diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 33ed8804..4fdc6923 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -118,65 +118,61 @@ return; } - const ul = el('ul', { class: 'containers' }); + const ul = el('ul'); for (const c of s.containers) { const url = `http://${s.hostname}:${c.port}/`; - const li = el('li', { class: 'container-row' + (c.pending ? ' pending' : '') }); - - // ── line 1: identity ───────────────────────────────────────── - const head = el('div', { class: 'head' }); - head.append( - el('a', { class: 'name', href: url }, c.name), + const li = el('li'); + li.append( + el('a', { href: url }, c.name), + ' ', el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), ); - if (c.pending) { - head.append(el('span', { class: 'pending-state' }, - el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…')); - } else if (c.needs_login) { - head.append(el('a', - { class: 'badge badge-warn', href: url }, 'needs login →')); + if (c.needs_login) { + li.append(' ', el('a', + { class: 'role role-pending', href: url }, 'needs login →')); } if (c.needs_update) { - head.append(form( - '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', + li.append(' ', form( + '/rebuild/' + c.name, 'role role-pending btn-inline', 'needs update ↻', 'rebuild ' + c.name + '? hot-reloads the container.', )); } - head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`)); - li.append(head); + li.append(' ', el('span', { class: 'meta' }, `${c.container} :${c.port}`)); - // ── line 2: action buttons ─────────────────────────────────── - const actions = el('div', { class: 'actions' }); if (c.running) { - actions.append( + li.append( + ' ', form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?'), ); if (!c.is_manager) { - actions.append( + li.append( + ' ', form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'), ); } } else { - actions.append( + li.append( + ' ', form('/start/' + c.name, 'btn-start', '▶ ST4RT', 'start ' + c.name + '?'), ); } - actions.append( + li.append( + ' ', form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD', 'rebuild ' + c.name + '? hot-reloads the container.'), ); if (!c.is_manager) { - actions.append( + li.append( + ' ', form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y', 'destroy ' + c.name + '? container is removed; state + creds kept.'), + ' ', form('/destroy/' + c.name, 'btn-destroy', 'PURG3', 'PURGE ' + c.name + '? container, config history, claude creds, ' + 'and /state/ notes are all WIPED. no undo.', { purge: 'on' }), ); } - li.append(actions); - ul.append(li); } root.append(ul); @@ -205,46 +201,20 @@ method: 'POST', action: '/answer-question/' + q.id, class: 'qform', 'data-async': '', }); - const hasOptions = q.options && q.options.length; - const isMulti = !!q.multi && hasOptions; - const freeText = el('input', { - type: 'text', name: 'answer-free', - placeholder: hasOptions ? 'or type your own…' : 'your answer', - autocomplete: 'off', - }); - const optionGroup = el('div', { class: 'q-options' }); - if (hasOptions) { + let input; + if (q.options && q.options.length) { + input = el('select', { name: 'answer', required: '' }); + input.append(el('option', { value: '', disabled: '', selected: '' }, 'choose…')); for (const opt of q.options) { - const inputType = isMulti ? 'checkbox' : 'radio'; - const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); - const input = el('input', { type: inputType, name: 'choice', value: opt, id }); - const label = el('label', { for: id }, ' ' + opt); - optionGroup.append(el('div', { class: 'q-option' }, input, label)); + input.append(el('option', { value: opt }, opt)); } + } else { + input = el('input', { + name: 'answer', type: 'text', required: '', + placeholder: 'your answer', autocomplete: 'off', + }); } - // On submit, build the final `answer` field from selected - // options + free-text, joined by ', '. This lets the operator - // pick options AND add free text in the same form. - f.addEventListener('submit', (ev) => { - const parts = []; - for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { - parts.push(cb.value); - } - const ft = (freeText.value || '').trim(); - if (ft) parts.push(ft); - const merged = parts.join(', '); - // Replace the existing hidden `answer` (if any) with the merged value. - const existing = f.querySelector('input[name="answer"]'); - if (existing) existing.remove(); - f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); - if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } - }, true); - if (hasOptions) f.append(optionGroup); - f.append( - el('div', { class: 'q-free' }, freeText), - el('button', { type: 'submit', class: 'btn btn-approve' }, - isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'), - ); + f.append(input, el('button', { type: 'submit', class: 'btn btn-approve' }, '▸ ANSW3R')); li.append(f); ul.append(li); } @@ -334,10 +304,8 @@ renderQuestions(s); renderInbox(s); renderApprovals(s); - // Auto-refresh while a spawn is in flight OR while any container - // has a pending lifecycle action; otherwise back off. - const anyPending = s.containers.some((c) => c.pending); - const next = (s.transients.length || anyPending) ? 2000 : 0; + // Auto-refresh while a spawn is in flight; otherwise back off. + const next = s.transients.length ? 2000 : 0; if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } if (next) pollTimer = setTimeout(refreshState, next); } catch (err) { diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 08479d83..10b86ac1 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -86,65 +86,6 @@ a:hover { } .role-m1nd { color: var(--pink); border-color: var(--pink); background: rgba(245, 194, 231, 0.08); } .role-ag3nt { color: var(--amber); border-color: var(--amber); background: rgba(250, 179, 135, 0.08); } -/* Container rows: identity + meta on a flowing first line, action - buttons grouped on a second. Pending rows dim everything except - the pending-state indicator. */ -.containers { display: flex; flex-direction: column; gap: 0.4em; } -.container-row { - padding: 0.6em 0.8em; - border: 1px solid var(--border); - border-radius: 4px; - background: rgba(24, 24, 37, 0.55); - transition: opacity 200ms ease, border-color 200ms ease; -} -.container-row.pending { - border-color: var(--amber); - background: rgba(250, 179, 135, 0.05); -} -.container-row.pending .actions { opacity: 0.4; pointer-events: none; } -.container-row .head { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.5em; - margin-bottom: 0.4em; -} -.container-row .head .name { - font-size: 1.05em; - font-weight: bold; -} -.container-row .head .meta { margin-left: auto; } -.container-row .actions { - display: flex; - flex-wrap: wrap; - gap: 0.4em; -} -.container-row .actions form.inline { display: inline-block; margin: 0; } -.badge { - display: inline-block; - padding: 0.05em 0.5em; - border: 1px solid; - border-radius: 2px; - font-size: 0.75em; - letter-spacing: 0.08em; - text-transform: uppercase; -} -.badge-warn { - color: var(--amber); border-color: var(--amber); - text-shadow: 0 0 6px rgba(250, 179, 135, 0.5); -} -.pending-state { - color: var(--amber); - font-size: 0.85em; - letter-spacing: 0.08em; - text-transform: uppercase; - text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); - animation: badge-pulse 1.6s ease-in-out infinite; -} -@keyframes badge-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } -} .meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; } .id { color: var(--pink); font-weight: bold; margin-right: 0.4em; } .agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; } @@ -292,36 +233,18 @@ summary:hover { color: var(--purple); } white-space: pre-wrap; word-break: break-word; } -.qform { - display: flex; - flex-direction: column; - gap: 0.5em; - margin-top: 0.4em; -} -.qform .q-options { - display: flex; - flex-direction: column; - gap: 0.25em; - background: var(--bg); - border: 1px solid var(--border); - border-radius: 4px; - padding: 0.4em 0.6em; -} -.qform .q-option label { cursor: pointer; user-select: none; } -.qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); } -.qform .q-free { display: flex; } -.qform .q-free input { - flex: 1; +.qform { display: flex; gap: 0.6em; align-items: stretch; margin-top: 0.3em; } +.qform input, .qform select { font-family: inherit; font-size: 1em; background: var(--bg); color: var(--fg); border: 1px solid var(--border); padding: 0.4em 0.6em; + flex: 1; } -.qform .q-free input::placeholder { color: var(--muted); } -.qform .q-free input:focus { outline: 1px solid var(--amber); } -.qform button { align-self: flex-start; } +.qform input::placeholder { color: var(--muted); } +.qform input:focus, .qform select:focus { outline: 1px solid var(--amber); } .inbox { background: var(--bg-elev); border: 1px solid var(--border); diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 1b54efc0..069b640c 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -140,12 +140,7 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> bail!("refusing to destroy the manager ({name})"); } tracing::info!(%name, purge, "destroy"); - coord.set_transient(name, TransientKind::Destroying); - let result = lifecycle::destroy(name).await; - if result.is_err() { - coord.clear_transient(name); - } - result?; + lifecycle::destroy(name).await?; coord.unregister_agent(name); let runtime = Coordinator::agent_dir(name); if runtime.exists() { @@ -171,7 +166,6 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> "agent destroyed" }, ); - coord.clear_transient(name); coord.notify_manager(&HelperEvent::Destroyed { agent: name.to_owned(), }); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ed44beaf..389397d2 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -54,16 +54,6 @@ pub struct TransientState { pub enum TransientKind { /// `lifecycle::spawn` is running (nixos-container create + update + start). Spawning, - /// `lifecycle::start` is running. - Starting, - /// `lifecycle::kill` is running. - Stopping, - /// `lifecycle::restart` is running. - Restarting, - /// `lifecycle::rebuild` is running (nixos-container update). - Rebuilding, - /// `actions::destroy` is running. - Destroying, } impl Coordinator { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index c5f9334a..02b5bc9c 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -120,11 +120,6 @@ struct ContainerView { running: bool, needs_update: bool, needs_login: bool, - /// When a lifecycle action is in flight on this container, the kind - /// (`starting`, `stopping`, etc.) so the JS can render a spinner + - /// disable other buttons. - #[serde(skip_serializing_if = "Option::is_none")] - pending: Option<&'static str>, } #[derive(Serialize)] @@ -181,9 +176,6 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J } else { !claude_has_session(&Coordinator::agent_claude_dir(&logical)) }; - let pending = transient_snapshot - .get(&logical) - .map(|st| transient_label(st.kind)); containers.push(ContainerView { port: lifecycle::agent_web_port(&logical), running: lifecycle::is_running(&logical).await, @@ -192,7 +184,6 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J is_manager, needs_update, needs_login, - pending, }); } @@ -205,7 +196,9 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J }) .map(|(name, st)| TransientView { name, - kind: transient_label(st.kind), + kind: match st.kind { + crate::coordinator::TransientKind::Spawning => "spawning", + }, secs: st.since.elapsed().as_secs(), }) .collect(); @@ -344,15 +337,10 @@ async fn post_rebuild(State(state): State, AxumPath(name): AxumPath Redirect::to("/").into_response(), - Err(e) => error_response(&format!("rebuild {logical} failed: {e:#}")), + Err(e) => error_response(&format!("rebuild {name} failed: {e:#}")), } } @@ -361,12 +349,7 @@ async fn post_kill(State(state): State, AxumPath(name): AxumPath { state.coord.unregister_agent(&logical); state @@ -380,27 +363,20 @@ async fn post_kill(State(state): State, AxumPath(name): AxumPath, AxumPath(name): AxumPath) -> Response { +async fn post_restart( + State(_state): State, + AxumPath(name): AxumPath, +) -> Response { let logical = strip_container_prefix(&name); - state - .coord - .set_transient(&logical, crate::coordinator::TransientKind::Restarting); - let result = lifecycle::restart(&logical).await; - state.coord.clear_transient(&logical); - match result { + match lifecycle::restart(&logical).await { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("restart {logical} failed: {e:#}")), } } -async fn post_start(State(state): State, AxumPath(name): AxumPath) -> Response { +async fn post_start(State(_state): State, AxumPath(name): AxumPath) -> Response { let logical = strip_container_prefix(&name); - state - .coord - .set_transient(&logical, crate::coordinator::TransientKind::Starting); - let result = lifecycle::start(&logical).await; - state.coord.clear_transient(&logical); - match result { + match lifecycle::start(&logical).await { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("start {logical} failed: {e:#}")), } @@ -440,20 +416,6 @@ async fn post_update_all(State(state): State) -> Response { } } -fn transient_label(k: crate::coordinator::TransientKind) -> &'static str { - use crate::coordinator::TransientKind::{ - Destroying, Rebuilding, Restarting, Spawning, Starting, Stopping, - }; - match k { - Spawning => "spawning", - Starting => "starting", - Stopping => "stopping", - Restarting => "restarting", - Rebuilding => "rebuilding", - Destroying => "destroying", - } -} - /// Convert either a logical name or a container name back to the logical /// name. Sub-agents are `h-foo` → `foo`; manager stays `hm1nd`. fn strip_container_prefix(name: &str) -> String { diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index f17f2972..8eb1c4ab 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -182,16 +182,9 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse }, } } - ManagerRequest::AskOperator { - question, - options, - multi, - } => { - tracing::info!(%question, ?options, multi, "manager: ask_operator"); - match coord - .questions - .submit(MANAGER_AGENT, question, options, *multi) - { + ManagerRequest::AskOperator { question, options } => { + tracing::info!(%question, ?options, "manager: ask_operator"); + match coord.questions.submit(MANAGER_AGENT, question, options) { Ok(id) => { tracing::info!(%id, "operator question queued"); ManagerResponse::QuestionQueued { id } diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index cfb8b4fd..aa985f4e 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -25,29 +25,12 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending ON operator_questions (id) WHERE answered_at IS NULL; "; -/// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN` -/// has no `IF NOT EXISTS` form in sqlite, so we check pragma_table_info first. -fn ensure_multi_column(conn: &Connection) -> Result<()> { - let has: bool = conn - .prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")? - .exists([])?; - if !has { - conn.execute_batch( - "ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0;", - ) - .context("add operator_questions.multi column")?; - } - Ok(()) -} - #[derive(Debug, Clone, Serialize)] -#[allow(clippy::doc_markdown)] pub struct OpQuestion { pub id: i64, pub asker: String, pub question: String, pub options: Vec, - pub multi: bool, pub asked_at: i64, pub answered_at: Option, pub answer: Option, @@ -68,25 +51,18 @@ impl OperatorQuestions { .with_context(|| format!("open operator_questions db {}", path.display()))?; conn.execute_batch(SCHEMA) .context("apply operator_questions schema")?; - ensure_multi_column(&conn).context("migrate operator_questions.multi")?; Ok(Self { conn: Mutex::new(conn), }) } - pub fn submit( - &self, - asker: &str, - question: &str, - options: &[String], - multi: bool, - ) -> Result { + pub fn submit(&self, asker: &str, question: &str, options: &[String]) -> Result { let conn = self.conn.lock().unwrap(); let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into()); conn.execute( - "INSERT INTO operator_questions (asker, question, options_json, multi, asked_at) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![asker, question, options_json, i64::from(multi), now_unix()], + "INSERT INTO operator_questions (asker, question, options_json, asked_at) + VALUES (?1, ?2, ?3, ?4)", + params![asker, question, options_json, now_unix()], )?; Ok(conn.last_insert_rowid()) } @@ -119,7 +95,7 @@ impl OperatorQuestions { pub fn get(&self, id: i64) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer + "SELECT id, asker, question, options_json, asked_at, answered_at, answer FROM operator_questions WHERE id = ?1", params![id], row_to_question, @@ -131,7 +107,7 @@ impl OperatorQuestions { pub fn pending(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer + "SELECT id, asker, question, options_json, asked_at, answered_at, answer FROM operator_questions WHERE answered_at IS NULL ORDER BY id ASC", @@ -145,16 +121,14 @@ impl OperatorQuestions { fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result { let options_json: String = row.get(3)?; let options: Vec = serde_json::from_str(&options_json).unwrap_or_default(); - let multi: i64 = row.get(4)?; Ok(OpQuestion { id: row.get(0)?, asker: row.get(1)?, question: row.get(2)?, options, - multi: multi != 0, - asked_at: row.get(5)?, - answered_at: row.get(6)?, - answer: row.get(7)?, + asked_at: row.get(4)?, + answered_at: row.get(5)?, + answer: row.get(6)?, }) } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index a1c34044..067d79e7 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -289,22 +289,13 @@ pub enum ManagerRequest { agent: String, commit_ref: String, }, - /// Ask the operator a question. Returns immediately with the queued - /// question id; the operator's answer arrives later as a - /// `HelperEvent::OperatorAnswered` in the manager inbox. - /// - /// - `options` is advisory: empty = free-text only; non-empty = the - /// dashboard renders the choices alongside a free-text fallback - /// ("Other…") so the operator is never trapped. - /// - `multi=true` lets the operator pick multiple options (rendered - /// as checkboxes). The answer is returned as a single string with - /// selections joined by ", ". + /// Ask the operator a question. The host-side handler blocks until the + /// operator answers via the dashboard; the answer is then returned as the + /// response. `options` is advisory: an empty list means free-text. AskOperator { question: String, #[serde(default)] options: Vec, - #[serde(default)] - multi: bool, }, }