Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d6d8e96c1 | ||
|
|
50ef806266 | ||
|
|
5208b0112a |
19 changed files with 510 additions and 41 deletions
15
TODO.md
15
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.<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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">@—></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>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
|||
&claude_dir,
|
||||
¬es_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<()> {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
|
|||
&claude_dir,
|
||||
¬es_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,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
)
|
||||
.await?;
|
||||
if let Some(rev) = current_rev {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -135,6 +137,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
&coord.operator_pronouns,
|
||||
)
|
||||
.await?;
|
||||
HostResponse::success()
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue