diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index d7adc84..15b82f3 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?)` — 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. +- `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. 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 575cf99..d48f4d8 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -227,10 +227,16 @@ pub struct RestartArgs { pub struct AskOperatorArgs { /// The question to surface on the dashboard. pub question: String, - /// Optional fixed-choice answers. If empty, the dashboard renders a - /// free-text input. Otherwise renders a select list of these options. + /// Optional fixed-choice answers. The dashboard always renders a + /// free-text fallback ("Other…") so the operator is never trapped + /// by an incomplete list. #[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)] @@ -369,7 +375,9 @@ 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." + 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." )] async fn ask_operator(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -378,6 +386,7 @@ 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 6090909..33ed880 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -205,20 +205,46 @@ method: 'POST', action: '/answer-question/' + q.id, class: 'qform', 'data-async': '', }); - let input; - if (q.options && q.options.length) { - input = el('select', { name: 'answer', required: '' }); - input.append(el('option', { value: '', disabled: '', selected: '' }, 'choose…')); + 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) { for (const opt of q.options) { - input.append(el('option', { value: opt }, opt)); + 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)); } - } else { - input = el('input', { - name: 'answer', type: 'text', required: '', - placeholder: 'your answer', autocomplete: 'off', - }); } - f.append(input, el('button', { type: 'submit', class: 'btn btn-approve' }, '▸ ANSW3R')); + // 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'), + ); li.append(f); ul.append(li); } diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index e61c5ac..08479d8 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -292,18 +292,36 @@ summary:hover { color: var(--purple); } white-space: pre-wrap; word-break: break-word; } -.qform { display: flex; gap: 0.6em; align-items: stretch; margin-top: 0.3em; } -.qform input, .qform select { +.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; 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 input::placeholder { color: var(--muted); } -.qform input:focus, .qform select:focus { outline: 1px solid var(--amber); } +.qform .q-free input::placeholder { color: var(--muted); } +.qform .q-free input:focus { outline: 1px solid var(--amber); } +.qform button { align-self: flex-start; } .inbox { background: var(--bg-elev); border: 1px solid var(--border); diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 8eb1c4a..f17f297 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -182,9 +182,16 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse }, } } - ManagerRequest::AskOperator { question, options } => { - tracing::info!(%question, ?options, "manager: ask_operator"); - match coord.questions.submit(MANAGER_AGENT, question, options) { + ManagerRequest::AskOperator { + question, + options, + multi, + } => { + tracing::info!(%question, ?options, multi, "manager: ask_operator"); + match coord + .questions + .submit(MANAGER_AGENT, question, options, *multi) + { 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 aa985f4..cfb8b4f 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -25,12 +25,29 @@ 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, @@ -51,18 +68,25 @@ 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]) -> Result { + pub fn submit( + &self, + asker: &str, + question: &str, + options: &[String], + multi: bool, + ) -> 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, asked_at) - VALUES (?1, ?2, ?3, ?4)", - params![asker, question, options_json, now_unix()], + "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()], )?; Ok(conn.last_insert_rowid()) } @@ -95,7 +119,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, asked_at, answered_at, answer + "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer FROM operator_questions WHERE id = ?1", params![id], row_to_question, @@ -107,7 +131,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, asked_at, answered_at, answer + "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer FROM operator_questions WHERE answered_at IS NULL ORDER BY id ASC", @@ -121,14 +145,16 @@ 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, - asked_at: row.get(4)?, - answered_at: row.get(5)?, - answer: row.get(6)?, + multi: multi != 0, + asked_at: row.get(5)?, + answered_at: row.get(6)?, + answer: row.get(7)?, }) } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 067d79e..a1c3404 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -289,13 +289,22 @@ pub enum ManagerRequest { agent: String, commit_ref: String, }, - /// 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. + /// 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 ", ". AskOperator { question: String, #[serde(default)] options: Vec, + #[serde(default)] + multi: bool, }, }