diff --git a/TODO.md b/TODO.md index e38bcf82..a9ee2bee 100644 --- a/TODO.md +++ b/TODO.md @@ -47,6 +47,21 @@ 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 b3d4d208..66b02432 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -1,10 +1,9 @@ -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). +You are hyperhive agent `{label}` in a multi-agent system. 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 74e774ca..123b9596 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. The operator (recipient `operator`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person. +You are the hyperhive manager `{label}` in a multi-agent system. You coordinate sub-agents and relay between them and the operator. Tools (hyperhive surface): diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index d8831b4b..ab0e749d 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -580,24 +580,10 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { "ask_operator", ], }; - let mut out: Vec = names + names .iter() .map(|t| format!("mcp__{SERVER_NAME}__{t}")) - .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 + .collect() } /// Combined allow-list passed to `--allowedTools` (auto-approve) — covers @@ -619,78 +605,20 @@ 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 `). Merges in -/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the -/// agent's NixOS config. +/// the container (forwarded to the child as `--socket `). #[must_use] pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String { - 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; + let config = serde_json::json!({ + "mcpServers": { + SERVER_NAME: { + "command": agent_binary, + "args": ["--socket", socket.display().to_string(), "mcp"], + "env": {} + } } - 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 6b1ee0ce..d85ef8cf 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -107,11 +107,7 @@ pub async fn write_system_prompt( mcp::Flavor::Agent => include_str!("../prompts/agent.md"), mcp::Flavor::Manager => include_str!("../prompts/manager.md"), }; - 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 body = template.replace("{label}", label); 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 07e58236..58ed9899 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -744,10 +744,6 @@ 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); @@ -828,180 +824,4 @@ }), 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 66588910..b487d500 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -475,63 +475,6 @@ 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 df294684..4abf1dd6 100644 --- a/hive-c0re/assets/index.html +++ b/hive-c0re/assets/index.html @@ -49,15 +49,8 @@

◆ MESS4GE FL0W ◆

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

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.

+

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

connecting…
-
- @—> - - -