diff --git a/TODO.md b/TODO.md index a9ee2bee..e38bcf82 100644 --- a/TODO.md +++ b/TODO.md @@ -47,21 +47,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in claude-code's `--allowedTools` extended grammar. Likely lives in `agent.nix` so each agent can scope its own shell surface. -## Per-agent extension - -- **Custom per-agent MCP tools.** Today every sub-agent gets the - same fixed MCP surface (`send`, `recv`). To move bitburner-agent - (and anything else with rich domain tooling) into hyperhive, an - agent needs a way to ship its own tools alongside hyperhive's. - Sketch: `agent.nix` declares a list of extra MCP servers - (command + args + env), each registered into the agent's - `--mcp-config` blob at flake-render time. The harness MCP server - remains the hyperhive surface; new servers slot in as additional - entries under `mcpServers.` so claude sees them as - `mcp____`. Per-agent tool whitelist (`allowedTools`) - derived from the same config so the operator stays in control of - what's exposed. - ## Operational hygiene (post-meta-flake) - **Tag retention.** Every approval mints up to 5 tags in diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 66b02432..b3d4d208 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -1,9 +1,10 @@ -You are hyperhive agent `{label}` in a multi-agent system. +You are hyperhive agent `{label}` in a multi-agent system. The operator (recipient `operator` in `send`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager). Tools (hyperhive surface): - `mcp__hyperhive__recv(wait_seconds?)` — drain one more message from your inbox (returns `(empty)` if nothing pending after the wait). Without `wait_seconds` it long-polls 30s. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — you'll be woken instantly when a message arrives, otherwise return after the timeout. That is strictly better than calling `recv` repeatedly with short waits: lower latency on new work, fewer turns, no busy-loop. Never use a fixed `sleep` shell command for the same purpose. - `mcp__hyperhive__send(to, body)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). +- (some agents only) **extra MCP tools** surfaced as `mcp____` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time. - `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question to the human operator on the dashboard. Returns immediately with a question id — do NOT wait inline. When the operator answers, a system message with event `operator_answered { id, question, answer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, or choice between options. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the operator pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` when the decision becomes moot. 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. diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index 123b9596..74e774ca 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -1,4 +1,4 @@ -You are the hyperhive manager `{label}` in a multi-agent system. You coordinate sub-agents and relay between them and the operator. +You are the hyperhive manager `{label}` in a multi-agent system. You coordinate sub-agents and relay between them and the operator. The operator (recipient `operator`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person. Tools (hyperhive surface): diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index ab0e749d..d8831b4b 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -580,10 +580,24 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { "ask_operator", ], }; - names + let mut out: Vec = names .iter() .map(|t| format!("mcp__{SERVER_NAME}__{t}")) - .collect() + .collect(); + // Extra MCP servers declared via `hyperhive.extraMcpServers` in + // the agent's NixOS config. Each entry maps its `allowedTools` + // pattern list to `mcp____` so claude can call + // them without per-tool operator approval. `["*"]` (the default) + // expands to `mcp____*` — every tool from that server. + for (server, spec) in load_extra_mcp() { + if server == SERVER_NAME { + continue; + } + for pat in spec.allowed_tools { + out.push(format!("mcp__{server}__{pat}")); + } + } + out } /// Combined allow-list passed to `--allowedTools` (auto-approve) — covers @@ -605,20 +619,78 @@ pub fn builtin_tools_arg() -> String { ALLOWED_BUILTIN_TOOLS.join(",") } +/// Where the NixOS module writes the per-agent extra-MCP spec (see +/// `nix/templates/harness-base.nix`). Each entry becomes an additional +/// `mcpServers.` block in the rendered claude config + a +/// `mcp____` pattern in `--allowedTools`. +const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; + +#[derive(Debug, serde::Deserialize)] +struct ExtraMcpServer { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: std::collections::BTreeMap, + #[serde(default = "default_allowed_tools")] + #[serde(rename = "allowedTools")] + allowed_tools: Vec, +} + +fn default_allowed_tools() -> Vec { + vec!["*".to_owned()] +} + +/// Read + parse the extra-MCP spec. Returns an empty map on missing / +/// unparsable file (the agent has none configured, or the file is +/// malformed — both cases degrade to "no extra servers"). +fn load_extra_mcp() -> std::collections::BTreeMap { + let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else { + return std::collections::BTreeMap::new(); + }; + serde_json::from_str(&raw).unwrap_or_else(|e| { + tracing::warn!( + path = EXTRA_MCP_PATH, + error = ?e, + "extra-mcp spec parse failed; ignoring", + ); + std::collections::BTreeMap::new() + }) +} + /// Render the MCP config blob claude reads from `--mcp-config `. /// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt` /// executable; `socket` is the hyperhive per-agent socket bind-mounted into -/// the container (forwarded to the child as `--socket `). +/// the container (forwarded to the child as `--socket `). Merges in +/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the +/// agent's NixOS config. #[must_use] pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String { - let config = serde_json::json!({ - "mcpServers": { - SERVER_NAME: { - "command": agent_binary, - "args": ["--socket", socket.display().to_string(), "mcp"], - "env": {} - } + let mut servers = serde_json::Map::new(); + servers.insert( + SERVER_NAME.to_owned(), + serde_json::json!({ + "command": agent_binary, + "args": ["--socket", socket.display().to_string(), "mcp"], + "env": {} + }), + ); + for (name, spec) in load_extra_mcp() { + if name == SERVER_NAME { + tracing::warn!( + "extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring", + ); + continue; } - }); + servers.insert( + name, + serde_json::json!({ + "command": spec.command, + "args": spec.args, + "env": spec.env, + }), + ); + } + let config = serde_json::json!({ "mcpServers": servers }); serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into()) } diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index d85ef8cf..6b1ee0ce 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -107,7 +107,11 @@ pub async fn write_system_prompt( mcp::Flavor::Agent => include_str!("../prompts/agent.md"), mcp::Flavor::Manager => include_str!("../prompts/manager.md"), }; - let body = template.replace("{label}", label); + let pronouns = + std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned()); + let body = template + .replace("{label}", label) + .replace("{operator_pronouns}", &pronouns); let path = parent.join("claude-system-prompt.md"); tokio::fs::write(&path, body).await?; tracing::info!(path = %path.display(), "wrote claude system prompt"); diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 58ed9899..07e58236 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -744,6 +744,10 @@ const resp = await fetch('/api/state'); if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); + // Stash the latest snapshot for any sub-widget that wants a + // synchronous read (e.g. the compose autocomplete pulls agent + // names from here instead of refetching on every keystroke). + window.__hyperhive_state = s; const openDetails = snapshotOpenDetails(); renderContainers(s); renderTombstones(s); @@ -824,4 +828,180 @@ }), flow.firstChild); }; })(); + + // ─── compose: @-mention with sticky recipient ─────────────────────────── + (() => { + const input = $('op-compose-input'); + const prompt = $('op-compose-prompt'); + const suggest = $('op-compose-suggest'); + if (!input || !prompt || !suggest) return; + const STORAGE_KEY = 'hyperhive:op-compose:to'; + let stickyTo = localStorage.getItem(STORAGE_KEY) || ''; + let suggestActive = -1; + function renderPrompt() { + prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>'; + } + function knownAgents() { + const s = window.__hyperhive_state; + if (!s || !Array.isArray(s.containers)) return []; + // The broker uses the literal recipient `manager` for the + // manager's inbox, not the container name `hm1nd`. Swap on + // suggestion so `@manager` Just Works. + return s.containers.map((c) => (c.is_manager ? 'manager' : c.name)); + } + function autosize() { + input.style.height = 'auto'; + input.style.height = `${input.scrollHeight}px`; + } + /// Parse "@name body…" — return {to, body} when the input opens + /// with a known @-mention, otherwise null. + function parseAddressed(raw) { + const m = raw.match(/^@([\w-]+)\s+([\s\S]+)$/); + if (!m) return null; + return { to: m[1], body: m[2] }; + } + function hideSuggest() { + suggest.hidden = true; + suggest.innerHTML = ''; + suggestActive = -1; + } + function renderSuggest(matches) { + suggest.innerHTML = ''; + if (!matches.length) { hideSuggest(); return; } + for (let i = 0; i < matches.length; i += 1) { + const item = document.createElement('div'); + item.className = 'item' + (i === suggestActive ? ' active' : ''); + item.textContent = '@' + matches[i]; + item.addEventListener('mousedown', (e) => { + e.preventDefault(); + applySuggestion(matches[i]); + }); + suggest.append(item); + } + suggest.hidden = false; + } + function applySuggestion(name) { + // Replace the partial @-token at the start with the full name. + const v = input.value; + const m = v.match(/^@(\S*)/); + if (m) { + input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, ''); + } else { + input.value = `@${name} ` + v; + } + hideSuggest(); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + autosize(); + } + function updateSuggest() { + const v = input.value; + // Only suggest when an @-token sits at the very start of the + // input — switching recipient is always "redirect this whole + // line." Mid-message @-mentions stay literal. + const m = v.match(/^@(\S*)/); + if (!m) { hideSuggest(); return; } + const partial = m[1].toLowerCase(); + const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial)); + if (!matches.length) { hideSuggest(); return; } + if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0; + renderSuggest(matches); + } + async function submit() { + const raw = input.value.trim(); + if (!raw) return; + let to; + let body; + const addressed = parseAddressed(raw); + if (addressed) { + to = addressed.to; + body = addressed.body.trim(); + } else if (stickyTo) { + to = stickyTo; + body = raw; + } else { + flashError('no recipient — start with @name to address a message'); + return; + } + if (!body) return; + const fd = new FormData(); + fd.append('to', to); + fd.append('body', body); + input.disabled = true; + try { + const resp = await fetch('/op-send', { + method: 'POST', + body: new URLSearchParams(fd), + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok) { + flashError(`send failed: http ${resp.status}`); + return; + } + } catch (err) { + flashError(`send failed: ${err}`); + return; + } finally { + input.disabled = false; + } + stickyTo = to; + localStorage.setItem(STORAGE_KEY, to); + input.value = ''; + autosize(); + renderPrompt(); + input.focus(); + } + function flashError(msg) { + const flow = $('msgflow'); + if (!flow) return; + const row = document.createElement('div'); + row.className = 'msgrow meta'; + row.textContent = msg; + flow.insertBefore(row, flow.firstChild); + } + input.addEventListener('input', () => { autosize(); updateSuggest(); }); + input.addEventListener('keydown', (e) => { + if (!suggest.hidden) { + if (e.key === 'ArrowDown') { + const items = suggest.querySelectorAll('.item'); + suggestActive = (suggestActive + 1) % items.length; + renderSuggest(Array.from(items).map((i) => i.textContent.slice(1))); + e.preventDefault(); + return; + } + if (e.key === 'ArrowUp') { + const items = suggest.querySelectorAll('.item'); + suggestActive = (suggestActive - 1 + items.length) % items.length; + renderSuggest(Array.from(items).map((i) => i.textContent.slice(1))); + e.preventDefault(); + return; + } + if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) { + const active = suggest.querySelector('.item.active'); + if (active) { + applySuggestion(active.textContent.slice(1)); + e.preventDefault(); + return; + } + } + if (e.key === 'Escape') { + hideSuggest(); + e.preventDefault(); + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }); + input.addEventListener('blur', () => { + // Defer so a click on a suggestion item (mousedown) lands first. + setTimeout(hideSuggest, 100); + }); + renderPrompt(); + autosize(); + })(); })(); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index b487d500..66588910 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -475,6 +475,63 @@ summary:hover { color: var(--purple); } .msg-sep { color: var(--muted); } .msg-to { color: var(--pink); } .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; } +.op-compose { + position: relative; + display: flex; + align-items: flex-start; + gap: 0.6em; + margin-top: 0.4em; + padding: 0.55em 0.8em; + background: rgba(24, 24, 37, 0.85); + border: 1px solid var(--border); + border-top: none; +} +.op-compose-prompt { + color: var(--purple); + text-shadow: 0 0 4px currentColor; + font-weight: bold; + white-space: nowrap; + user-select: none; + padding-top: 0.15em; +} +.op-compose-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--fg); + font: inherit; + font-size: 0.85em; + line-height: 1.5; + resize: none; + overflow: hidden; + min-height: 1.5em; + caret-color: var(--purple); +} +.op-compose-input::placeholder { color: var(--muted); } +.op-compose-suggest { + position: absolute; + bottom: 100%; + left: 0.8em; + margin-bottom: 0.2em; + background: rgba(24, 24, 37, 0.95); + border: 1px solid var(--border); + font-size: 0.85em; + min-width: 12em; + max-height: 12em; + overflow-y: auto; + z-index: 10; +} +.op-compose-suggest .item { + padding: 0.2em 0.8em; + cursor: pointer; + color: var(--fg); +} +.op-compose-suggest .item.active, +.op-compose-suggest .item:hover { + background: rgba(203, 166, 247, 0.18); + color: var(--purple); +} footer { margin-top: 4em; text-align: center; diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html index 4abf1dd6..df294684 100644 --- a/hive-c0re/assets/index.html +++ b/hive-c0re/assets/index.html @@ -49,8 +49,15 @@

◆ MESS4GE FL0W ◆

══════════════════════════════════════════════════════════════
-

live tail — newest at the top. tap on every send / recv through the broker.

+

live tail — newest at the top. tap on every send / recv through the broker. compose below: @name picks the recipient (sticky until you @ someone else); tab completes.

connecting…
+
+ @—> + + +