Compare commits

...
Author SHA1 Message Date
müde
7d6d8e96c1 per-agent extra MCP servers via hyperhive.extraMcpServers
new NixOS option in harness-base.nix:
  hyperhive.extraMcpServers.<key> = {
    command = "/path/to/server";
    args = [ ... ];
    env = { KEY = "value"; };
    allowedTools = [ "send_message" "join_room" ];  # or ["*"]
  };

declared as attrsOf submodule so agents stack arbitrarily many.
the module writes the whole map as JSON to
/etc/hyperhive/extra-mcp.json at activation; the harness reads
that file in mcp::render_claude_config and merges each entry
into the rendered --mcp-config under its own mcpServers.<key>
block. allowed_mcp_tools(flavor) extends the --allowedTools
arg with mcp__<key>__<pattern> for every entry — "*" (the
default) becomes mcp__<key>__* so every tool from that server
is auto-approved, or pass a concrete list to tighten.

collision guard: an extra server keyed "hyperhive" is dropped
with a warn-log so user config can't shadow the built-in
surface. malformed JSON / missing file fall back to "no
extras" silently.

prompt note added: agents see "(some agents only) extra MCP
tools surfaced as mcp__<server>__<tool>" and learn they're
declared via agent.nix. retires the matching TODO under
Per-agent extension. matrix-chat agents + bitburner-agent
migration unblocked.
2026-05-16 02:10:11 +02:00
müde
50ef806266 operator pronouns: configurable free-text, threaded into prompts
new NixOS module option services.hive-c0re.operatorPronouns
(free text, default 'she/her', example 'they/them'). hive-c0re
takes it as a CLI flag (--operator-pronouns, lib.escapeShellArg'd
in the systemd unit), stores it on Coordinator, threads it into
the meta flake's mkAgent so each agent's systemd service gets
HIVE_OPERATOR_PRONOUNS set. the harness reads the env at boot
and substitutes {operator_pronouns} into the agent / manager
system prompt alongside {label}. nix string is escaped against
backslash + double-quote so non-ascii / quoted values
round-trip safely. prompt addendum: both agent.md and
manager.md mention the operator's pronouns up front so claude
uses them naturally in third-person reference. propagates on
next ↻ R3BU1LD (meta lock bump, no per-agent approval).
2026-05-16 02:05:22 +02:00
müde
5208b0112a dashboard: terminal compose with @-mention sticky recipient
new section under MESS4GE FL0W. msgflow already tails only
broker traffic (sent + delivered), which is exactly the
'messages through core' view the operator wants; no
per-agent thinking leaks through. compose box below:

- a prompt span renders the sticky recipient ('@coder>'),
  rendered outside the textarea so it can't be edited
  inadvertently. on submit the recipient gets persisted to
  localStorage so it survives reload.
- start the input with '@name body' to redirect — the parser
  splits at the first whitespace and the new recipient
  becomes sticky.
- typing '@' at the start opens a completion dropdown over
  the textarea pulled from window.__hyperhive_state.containers;
  arrow keys cycle, tab/enter selects, escape closes. clicking
  works too.
- manager swap: agents flagged is_manager are surfaced as
  '@manager' (the broker's recipient string) instead of
  '@hm1nd' (the container name), so the message actually
  routes to the manager's inbox.

backend: new POST /op-send accepts {to, body} and drops a
broker.send({from:'operator', to, body}) — same shape as the
per-agent web UI's OperatorMsg, but lets the operator choose
the recipient explicitly from the main dashboard.
2026-05-16 01:55:00 +02:00
19 changed files with 510 additions and 41 deletions

15
TODO.md
View file

@ -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.<name>` so claude sees them as
`mcp__<name>__<tool>`. 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

View file

@ -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__<server>__<tool>` — 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.

View file

@ -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):

View file

@ -580,10 +580,24 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
"ask_operator",
],
};
names
let mut out: Vec<String> = 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__<server>__<pattern>` so claude can call
// them without per-tool operator approval. `["*"]` (the default)
// expands to `mcp__<server>__*` — 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.<key>` block in the rendered claude config + a
/// `mcp__<key>__<tool>` 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<String>,
#[serde(default)]
env: std::collections::BTreeMap<String, String>,
#[serde(default = "default_allowed_tools")]
#[serde(rename = "allowedTools")]
allowed_tools: Vec<String>,
}
fn default_allowed_tools() -> Vec<String> {
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<String, ExtraMcpServer> {
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 <path>`.
/// `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 <path>`).
/// the container (forwarded to the child as `--socket <path>`). 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())
}

View file

@ -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");

View file

@ -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();
})();
})();

View file

@ -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;

View file

@ -49,8 +49,15 @@
<h2>◆ MESS4GE FL0W ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker.</p>
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker. compose below: <code>@name</code> picks the recipient (sticky until you @ someone else); <code>tab</code> completes.</p>
<div id="msgflow" class="msgflow"><span class="meta">connecting…</span></div>
<div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea id="op-compose-input" class="op-compose-input"
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
rows="1" autocomplete="off"></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
<footer>
<div class="divider">══════════════════════════════════════════════════════════════</div>

View file

@ -69,6 +69,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
&claude_dir,
&notes_dir,
coord_bg.dashboard_port,
&coord_bg.operator_pronouns,
)
.await;
coord_bg.clear_transient(&agent_bg);
@ -337,7 +338,13 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()>
/// destroy). Idempotent — a no-op when nothing changed.
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
let agents = lifecycle::agents_for_meta_listing().await?;
crate::meta::sync_agents(&coord.hyperhive_flake, coord.dashboard_port, &agents).await
crate::meta::sync_agents(
&coord.hyperhive_flake,
coord.dashboard_port,
&coord.operator_pronouns,
&agents,
)
.await
}
pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {

View file

@ -72,6 +72,7 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
&claude_dir,
&notes_dir,
coord.dashboard_port,
&coord.operator_pronouns,
)
.await;
coord.clear_transient(name);
@ -144,6 +145,7 @@ pub async fn ensure_manager(coord: &Arc<Coordinator>) -> Result<()> {
&claude_dir,
&notes_dir,
coord.dashboard_port,
&coord.operator_pronouns,
)
.await?;
if let Some(rev) = current_rev {

View file

@ -35,6 +35,13 @@ pub struct Coordinator {
/// each per-agent flake so the agent's web UI can build the right
/// rebuild-button URL pointing back at the dashboard.
pub dashboard_port: u16,
/// Operator pronouns (free text) — `she/her` by default, set via
/// the NixOS module option `services.hive-c0re.operatorPronouns`.
/// Reaches each container as the `HIVE_OPERATOR_PRONOUNS` env var
/// (injected into systemd.services.<harness>.environment by the
/// meta flake); the harness substitutes it into the agent /
/// manager system prompt at boot.
pub operator_pronouns: String,
agents: Mutex<HashMap<String, AgentSocket>>,
/// Agents whose lifecycle action (currently just spawn) is in flight.
/// Read by the dashboard to render a spinner; cleared when the action
@ -67,7 +74,12 @@ pub enum TransientKind {
}
impl Coordinator {
pub fn open(db_path: &Path, hyperhive_flake: String, dashboard_port: u16) -> Result<Self> {
pub fn open(
db_path: &Path,
hyperhive_flake: String,
dashboard_port: u16,
operator_pronouns: String,
) -> Result<Self> {
let broker = Broker::open(db_path).context("open broker")?;
let approvals = Approvals::open(db_path).context("open approvals")?;
let questions = OperatorQuestions::open(db_path).context("open operator_questions")?;
@ -77,6 +89,7 @@ impl Coordinator {
questions: Arc::new(questions),
hyperhive_flake,
dashboard_port,
operator_pronouns,
agents: Mutex::new(HashMap::new()),
transient: Mutex::new(HashMap::new()),
})

View file

@ -56,6 +56,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/api/journal/{name}", get(get_journal))
.route("/api/agent-config/{name}", get(get_agent_config))
.route("/request-spawn", post(post_request_spawn))
.route("/op-send", post(post_op_send))
.route("/messages/stream", get(messages_stream))
.with_state(AppState { coord });
let addr = SocketAddr::from(([0, 0, 0, 0], port));
@ -708,6 +709,38 @@ async fn post_purge_tombstone(
}
}
/// Operator-side compose form on the dashboard terminal. Drops a
/// message into the broker as `{from: "operator", to, body}`. Same
/// shape that per-agent web UIs use via `OperatorMsg`, but here the
/// operator picks the recipient explicitly with `@name`. No
/// validation that `to` resolves to a known agent — broker accepts
/// arbitrary recipients (and the agent's inbox grows whether or not
/// they exist, which is fine for spawn-then-greet flows).
#[derive(Deserialize)]
struct OpSendForm {
to: String,
body: String,
}
async fn post_op_send(State(state): State<AppState>, Form(form): Form<OpSendForm>) -> Response {
let to = form.to.trim().to_owned();
let body = form.body.trim().to_owned();
if to.is_empty() {
return error_response("op-send: `to` required");
}
if body.is_empty() {
return error_response("op-send: `body` required");
}
if let Err(e) = state.coord.broker.send(&hive_sh4re::Message {
from: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
to: to.clone(),
body,
}) {
return error_response(&format!("op-send to {to} failed: {e:#}"));
}
Redirect::to("/").into_response()
}
async fn post_request_spawn(
State(state): State<AppState>,
Form(form): Form<RequestSpawnForm>,

View file

@ -132,6 +132,7 @@ pub async fn spawn(
claude_dir: &Path,
notes_dir: &Path,
dashboard_port: u16,
operator_pronouns: &str,
) -> Result<()> {
validate(name)?;
if let Some(other) = port_collision(name).await {
@ -148,7 +149,7 @@ pub async fn spawn(
// before `nixos-container create` so the `--flake meta#<name>`
// ref resolves.
let agents = agents_after_spawn(name).await?;
crate::meta::sync_agents(hyperhive_flake, dashboard_port, &agents).await?;
crate::meta::sync_agents(hyperhive_flake, dashboard_port, operator_pronouns, &agents).await?;
let container = container_name(name);
let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display());
run(&["create", &container, "--flake", &flake_ref]).await?;
@ -257,6 +258,7 @@ pub async fn destroy(name: &str) -> Result<()> {
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn rebuild(
name: &str,
hyperhive_flake: &str,
@ -265,6 +267,7 @@ pub async fn rebuild(
claude_dir: &Path,
notes_dir: &Path,
dashboard_port: u16,
operator_pronouns: &str,
) -> Result<()> {
// Sync the meta flake (idempotent — no-op when the rendered
// flake matches disk) so a manual rebuild from the dashboard
@ -272,7 +275,7 @@ pub async fn rebuild(
// got added directly via `nixos-container create` outside
// hive-c0re).
let agents = agents_for_meta(None).await?;
crate::meta::sync_agents(hyperhive_flake, dashboard_port, &agents).await?;
crate::meta::sync_agents(hyperhive_flake, dashboard_port, operator_pronouns, &agents).await?;
// Then bump just this agent's input — picks up whatever
// `applied/<n>/main` currently points at (deployed/<latest>).
// Commits the lock if it changed.

View file

@ -49,6 +49,11 @@ enum Cmd {
/// Dashboard HTTP port.
#[arg(long, default_value_t = 7000)]
dashboard_port: u16,
/// Operator pronouns (free text). Threaded into each
/// container's harness via `HIVE_OPERATOR_PRONOUNS` so the
/// system prompt can mention them. Default: `she/her`.
#[arg(long, default_value = "she/her")]
operator_pronouns: String,
},
/// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses
/// the approval queue — use only as an operator on the host. For
@ -95,8 +100,14 @@ async fn main() -> Result<()> {
hyperhive_flake,
db,
dashboard_port,
operator_pronouns,
} => {
let coord = Arc::new(Coordinator::open(&db, hyperhive_flake, dashboard_port)?);
let coord = Arc::new(Coordinator::open(
&db,
hyperhive_flake,
dashboard_port,
operator_pronouns,
)?);
manager_server::start(coord.clone())?;
// Idempotent pre-flight: rewrite pre-meta-layout applied
// repos, ensure proposed repos carry the `applied`

View file

@ -53,12 +53,13 @@ pub fn meta_dir() -> PathBuf {
pub async fn sync_agents(
hyperhive_flake: &str,
dashboard_port: u16,
operator_pronouns: &str,
agents: &[AgentSpec],
) -> Result<()> {
let dir = meta_dir();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let new_flake = render_flake(hyperhive_flake, dashboard_port, agents);
let new_flake = render_flake(hyperhive_flake, dashboard_port, operator_pronouns, agents);
let flake_path = dir.join("flake.nix");
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
let initial = !dir.join(".git").exists();
@ -180,7 +181,12 @@ pub async fn lock_update_hyperhive() -> Result<()> {
git_commit(&dir, "bump hyperhive").await
}
fn render_flake(hyperhive_flake: &str, dashboard_port: u16, agents: &[AgentSpec]) -> String {
fn render_flake(
hyperhive_flake: &str,
dashboard_port: u16,
operator_pronouns: &str,
agents: &[AgentSpec],
) -> String {
use std::fmt::Write as _;
let mut out = String::new();
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
@ -193,9 +199,15 @@ fn render_flake(hyperhive_flake: &str, dashboard_port: u16, agents: &[AgentSpec]
);
}
out.push_str(" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n");
// Free-text operator string — escape backslash + double-quote so a
// pronouns value like `he/him \ "rare"` round-trips into a valid
// nix string literal without breaking the flake.
let pronouns_escaped = operator_pronouns
.replace('\\', "\\\\")
.replace('"', "\\\"");
let _ = writeln!(
out,
" dashboardPort = {dashboard_port};\n mkAgent = {{ name, isManager, port }}:"
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port }}:"
);
out.push_str(
r#" let
@ -217,6 +229,7 @@ fn render_flake(hyperhive_flake: &str, dashboard_port: u16, agents: &[AgentSpec]
HIVE_PORT = toString port;
HIVE_LABEL = name;
HIVE_DASHBOARD_PORT = toString dashboardPort;
HIVE_OPERATOR_PRONOUNS = operatorPronouns;
};
}
];

View file

@ -62,7 +62,13 @@ pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
// Phase 3: meta repo.
let agents = lifecycle::agents_for_meta_listing().await.unwrap_or_default();
if let Err(e) =
meta::sync_agents(&coord.hyperhive_flake, coord.dashboard_port, &agents).await
meta::sync_agents(
&coord.hyperhive_flake,
coord.dashboard_port,
&coord.operator_pronouns,
&agents,
)
.await
{
tracing::warn!(error = ?e, "migration: meta sync_agents failed");
}

View file

@ -56,6 +56,7 @@ async fn handle(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
}
}
#[allow(clippy::too_many_lines)]
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
let result: anyhow::Result<HostResponse> = async {
Ok(match req {
@ -75,6 +76,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
&claude_dir,
&notes_dir,
coord.dashboard_port,
&coord.operator_pronouns,
)
.await
{
@ -135,6 +137,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
&claude_dir,
&notes_dir,
coord.dashboard_port,
&coord.operator_pronouns,
)
.await?;
HostResponse::success()

View file

@ -37,6 +37,21 @@ in
default = 7000;
description = "TCP port the hive-c0re dashboard listens on.";
};
operatorPronouns = lib.mkOption {
type = lib.types.str;
default = "she/her";
example = "they/them";
description = ''
Operator pronouns, free text. Threaded into every agent
container as the `HIVE_OPERATOR_PRONOUNS` env var; the
harness substitutes it into the agent / manager system
prompt at boot so claude refers to the operator naturally
in third person ("ask her", "tell them", etc.). Changes
propagate to running agents on the next ` R3BU1LD`
forwards as a meta flake env-var bump, no per-agent
approval needed.
'';
};
};
config = lib.mkIf cfg.enable {
@ -69,7 +84,7 @@ in
];
environment.HYPERHIVE_GIT = "${pkgs.git}/bin/git";
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --dashboard-port ${toString cfg.dashboardPort}";
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns}";
Restart = "on-failure";
RestartSec = 2;
RuntimeDirectory = "hyperhive";

View file

@ -1,10 +1,67 @@
{ pkgs, lib, ... }:
{ pkgs, lib, config, ... }:
{
# Shared scaffolding for any hyperhive harness container — both
# sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend
# this. The systemd service that actually runs the harness binary
# differs per role and lives in the child module.
options.hyperhive.extraMcpServers = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
command = lib.mkOption {
type = lib.types.str;
description = "Absolute path to the MCP server binary. Use `\${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`.";
};
args = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Args passed to the MCP server binary.";
};
env = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = "Environment variables for the MCP server child process.";
};
allowedTools = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "*" ];
example = [ "send_message" "join_room" ];
description = ''
Tool names this MCP server is auto-approved to call via
`--allowedTools`. Single entry `"*"` (the default) means
"every tool from this server" convenient but trusting.
Tighten to a specific list when you only want a subset.
Names are bare (e.g. `send_message`); the harness prepends
`mcp__<server-key>__` at build time.
'';
};
};
});
default = { };
example = lib.literalExpression ''
{
matrix = {
command = "/run/current-system/sw/bin/mcp-matrix";
args = [ "--config" "/state/matrix.toml" ];
env.MATRIX_HOMESERVER = "https://matrix.example.org";
allowedTools = [ "send_message" "join_room" ];
};
}
'';
description = ''
Extra MCP servers claude sees alongside the hyperhive tool surface.
Keys are the server names (claude addresses tools as
`mcp__<key>__<tool>`). Rendered to `/etc/hyperhive/extra-mcp.json`
at activation time; the harness reads that file at boot and merges
it into `--mcp-config` + `--allowedTools`. Take effect on the
agent's next harness restart (no operator approval needed beyond
whatever brought the new agent.nix into deployed/*).
'';
};
environment.etc."hyperhive/extra-mcp.json".text =
builtins.toJSON config.hyperhive.extraMcpServers;
boot.isNspawnContainer = true;
# `claude-code` is unfree. Each per-agent container's nixosConfiguration