Compare commits
19 changed files with 41 additions and 510 deletions
15
TODO.md
15
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
|
claude-code's `--allowedTools` extended grammar. Likely lives in
|
||||||
`agent.nix` so each agent can scope its own shell surface.
|
`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)
|
## Operational hygiene (post-meta-flake)
|
||||||
|
|
||||||
- **Tag retention.** Every approval mints up to 5 tags in
|
- **Tag retention.** Every approval mints up to 5 tags in
|
||||||
|
|
|
||||||
|
|
@ -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):
|
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__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).
|
- `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.
|
- `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.
|
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. 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):
|
Tools (hyperhive surface):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -580,24 +580,10 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
|
||||||
"ask_operator",
|
"ask_operator",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let mut out: Vec<String> = names
|
names
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| format!("mcp__{SERVER_NAME}__{t}"))
|
.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
|
/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers
|
||||||
|
|
@ -619,78 +605,20 @@ pub fn builtin_tools_arg() -> String {
|
||||||
ALLOWED_BUILTIN_TOOLS.join(",")
|
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>`.
|
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
|
||||||
/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt`
|
/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt`
|
||||||
/// executable; `socket` is the hyperhive per-agent socket bind-mounted into
|
/// executable; `socket` is the hyperhive per-agent socket bind-mounted into
|
||||||
/// the container (forwarded to the child as `--socket <path>`). Merges in
|
/// the container (forwarded to the child as `--socket <path>`).
|
||||||
/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the
|
|
||||||
/// agent's NixOS config.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String {
|
pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String {
|
||||||
let mut servers = serde_json::Map::new();
|
let config = serde_json::json!({
|
||||||
servers.insert(
|
"mcpServers": {
|
||||||
SERVER_NAME.to_owned(),
|
SERVER_NAME: {
|
||||||
serde_json::json!({
|
"command": agent_binary,
|
||||||
"command": agent_binary,
|
"args": ["--socket", socket.display().to_string(), "mcp"],
|
||||||
"args": ["--socket", socket.display().to_string(), "mcp"],
|
"env": {}
|
||||||
"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())
|
serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,11 +107,7 @@ pub async fn write_system_prompt(
|
||||||
mcp::Flavor::Agent => include_str!("../prompts/agent.md"),
|
mcp::Flavor::Agent => include_str!("../prompts/agent.md"),
|
||||||
mcp::Flavor::Manager => include_str!("../prompts/manager.md"),
|
mcp::Flavor::Manager => include_str!("../prompts/manager.md"),
|
||||||
};
|
};
|
||||||
let pronouns =
|
let body = template.replace("{label}", label);
|
||||||
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");
|
let path = parent.join("claude-system-prompt.md");
|
||||||
tokio::fs::write(&path, body).await?;
|
tokio::fs::write(&path, body).await?;
|
||||||
tracing::info!(path = %path.display(), "wrote claude system prompt");
|
tracing::info!(path = %path.display(), "wrote claude system prompt");
|
||||||
|
|
|
||||||
|
|
@ -744,10 +744,6 @@
|
||||||
const resp = await fetch('/api/state');
|
const resp = await fetch('/api/state');
|
||||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||||
const s = await resp.json();
|
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();
|
const openDetails = snapshotOpenDetails();
|
||||||
renderContainers(s);
|
renderContainers(s);
|
||||||
renderTombstones(s);
|
renderTombstones(s);
|
||||||
|
|
@ -828,180 +824,4 @@
|
||||||
}), flow.firstChild);
|
}), 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,63 +475,6 @@ summary:hover { color: var(--purple); }
|
||||||
.msg-sep { color: var(--muted); }
|
.msg-sep { color: var(--muted); }
|
||||||
.msg-to { color: var(--pink); }
|
.msg-to { color: var(--pink); }
|
||||||
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
.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 {
|
footer {
|
||||||
margin-top: 4em;
|
margin-top: 4em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
|
|
@ -49,15 +49,8 @@
|
||||||
|
|
||||||
<h2>◆ MESS4GE FL0W ◆</h2>
|
<h2>◆ MESS4GE FL0W ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<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. compose below: <code>@name</code> picks the recipient (sticky until you @ someone else); <code>tab</code> completes.</p>
|
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker.</p>
|
||||||
<div id="msgflow" class="msgflow"><span class="meta">connecting…</span></div>
|
<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>
|
<footer>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,6 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
||||||
&claude_dir,
|
&claude_dir,
|
||||||
¬es_dir,
|
¬es_dir,
|
||||||
coord_bg.dashboard_port,
|
coord_bg.dashboard_port,
|
||||||
&coord_bg.operator_pronouns,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
coord_bg.clear_transient(&agent_bg);
|
coord_bg.clear_transient(&agent_bg);
|
||||||
|
|
@ -338,13 +337,7 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()>
|
||||||
/// destroy). Idempotent — a no-op when nothing changed.
|
/// destroy). Idempotent — a no-op when nothing changed.
|
||||||
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
|
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
|
||||||
let agents = lifecycle::agents_for_meta_listing().await?;
|
let agents = lifecycle::agents_for_meta_listing().await?;
|
||||||
crate::meta::sync_agents(
|
crate::meta::sync_agents(&coord.hyperhive_flake, coord.dashboard_port, &agents).await
|
||||||
&coord.hyperhive_flake,
|
|
||||||
coord.dashboard_port,
|
|
||||||
&coord.operator_pronouns,
|
|
||||||
&agents,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
|
pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
|
||||||
&claude_dir,
|
&claude_dir,
|
||||||
¬es_dir,
|
¬es_dir,
|
||||||
coord.dashboard_port,
|
coord.dashboard_port,
|
||||||
&coord.operator_pronouns,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
coord.clear_transient(name);
|
coord.clear_transient(name);
|
||||||
|
|
@ -145,7 +144,6 @@ pub async fn ensure_manager(coord: &Arc<Coordinator>) -> Result<()> {
|
||||||
&claude_dir,
|
&claude_dir,
|
||||||
¬es_dir,
|
¬es_dir,
|
||||||
coord.dashboard_port,
|
coord.dashboard_port,
|
||||||
&coord.operator_pronouns,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(rev) = current_rev {
|
if let Some(rev) = current_rev {
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,6 @@ pub struct Coordinator {
|
||||||
/// each per-agent flake so the agent's web UI can build the right
|
/// each per-agent flake so the agent's web UI can build the right
|
||||||
/// rebuild-button URL pointing back at the dashboard.
|
/// rebuild-button URL pointing back at the dashboard.
|
||||||
pub dashboard_port: u16,
|
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: Mutex<HashMap<String, AgentSocket>>,
|
||||||
/// Agents whose lifecycle action (currently just spawn) is in flight.
|
/// Agents whose lifecycle action (currently just spawn) is in flight.
|
||||||
/// Read by the dashboard to render a spinner; cleared when the action
|
/// Read by the dashboard to render a spinner; cleared when the action
|
||||||
|
|
@ -74,12 +67,7 @@ pub enum TransientKind {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Coordinator {
|
impl Coordinator {
|
||||||
pub fn open(
|
pub fn open(db_path: &Path, hyperhive_flake: String, dashboard_port: u16) -> Result<Self> {
|
||||||
db_path: &Path,
|
|
||||||
hyperhive_flake: String,
|
|
||||||
dashboard_port: u16,
|
|
||||||
operator_pronouns: String,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let broker = Broker::open(db_path).context("open broker")?;
|
let broker = Broker::open(db_path).context("open broker")?;
|
||||||
let approvals = Approvals::open(db_path).context("open approvals")?;
|
let approvals = Approvals::open(db_path).context("open approvals")?;
|
||||||
let questions = OperatorQuestions::open(db_path).context("open operator_questions")?;
|
let questions = OperatorQuestions::open(db_path).context("open operator_questions")?;
|
||||||
|
|
@ -89,7 +77,6 @@ impl Coordinator {
|
||||||
questions: Arc::new(questions),
|
questions: Arc::new(questions),
|
||||||
hyperhive_flake,
|
hyperhive_flake,
|
||||||
dashboard_port,
|
dashboard_port,
|
||||||
operator_pronouns,
|
|
||||||
agents: Mutex::new(HashMap::new()),
|
agents: Mutex::new(HashMap::new()),
|
||||||
transient: Mutex::new(HashMap::new()),
|
transient: Mutex::new(HashMap::new()),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/api/journal/{name}", get(get_journal))
|
.route("/api/journal/{name}", get(get_journal))
|
||||||
.route("/api/agent-config/{name}", get(get_agent_config))
|
.route("/api/agent-config/{name}", get(get_agent_config))
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
.route("/op-send", post(post_op_send))
|
|
||||||
.route("/messages/stream", get(messages_stream))
|
.route("/messages/stream", get(messages_stream))
|
||||||
.with_state(AppState { coord });
|
.with_state(AppState { coord });
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
|
|
@ -709,38 +708,6 @@ 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(
|
async fn post_request_spawn(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Form(form): Form<RequestSpawnForm>,
|
Form(form): Form<RequestSpawnForm>,
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,6 @@ pub async fn spawn(
|
||||||
claude_dir: &Path,
|
claude_dir: &Path,
|
||||||
notes_dir: &Path,
|
notes_dir: &Path,
|
||||||
dashboard_port: u16,
|
dashboard_port: u16,
|
||||||
operator_pronouns: &str,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
validate(name)?;
|
validate(name)?;
|
||||||
if let Some(other) = port_collision(name).await {
|
if let Some(other) = port_collision(name).await {
|
||||||
|
|
@ -149,7 +148,7 @@ pub async fn spawn(
|
||||||
// before `nixos-container create` so the `--flake meta#<name>`
|
// before `nixos-container create` so the `--flake meta#<name>`
|
||||||
// ref resolves.
|
// ref resolves.
|
||||||
let agents = agents_after_spawn(name).await?;
|
let agents = agents_after_spawn(name).await?;
|
||||||
crate::meta::sync_agents(hyperhive_flake, dashboard_port, operator_pronouns, &agents).await?;
|
crate::meta::sync_agents(hyperhive_flake, dashboard_port, &agents).await?;
|
||||||
let container = container_name(name);
|
let container = container_name(name);
|
||||||
let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display());
|
let flake_ref = format!("{}#{name}", crate::meta::meta_dir().display());
|
||||||
run(&["create", &container, "--flake", &flake_ref]).await?;
|
run(&["create", &container, "--flake", &flake_ref]).await?;
|
||||||
|
|
@ -258,7 +257,6 @@ pub async fn destroy(name: &str) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn rebuild(
|
pub async fn rebuild(
|
||||||
name: &str,
|
name: &str,
|
||||||
hyperhive_flake: &str,
|
hyperhive_flake: &str,
|
||||||
|
|
@ -267,7 +265,6 @@ pub async fn rebuild(
|
||||||
claude_dir: &Path,
|
claude_dir: &Path,
|
||||||
notes_dir: &Path,
|
notes_dir: &Path,
|
||||||
dashboard_port: u16,
|
dashboard_port: u16,
|
||||||
operator_pronouns: &str,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Sync the meta flake (idempotent — no-op when the rendered
|
// Sync the meta flake (idempotent — no-op when the rendered
|
||||||
// flake matches disk) so a manual rebuild from the dashboard
|
// flake matches disk) so a manual rebuild from the dashboard
|
||||||
|
|
@ -275,7 +272,7 @@ pub async fn rebuild(
|
||||||
// got added directly via `nixos-container create` outside
|
// got added directly via `nixos-container create` outside
|
||||||
// hive-c0re).
|
// hive-c0re).
|
||||||
let agents = agents_for_meta(None).await?;
|
let agents = agents_for_meta(None).await?;
|
||||||
crate::meta::sync_agents(hyperhive_flake, dashboard_port, operator_pronouns, &agents).await?;
|
crate::meta::sync_agents(hyperhive_flake, dashboard_port, &agents).await?;
|
||||||
// Then bump just this agent's input — picks up whatever
|
// Then bump just this agent's input — picks up whatever
|
||||||
// `applied/<n>/main` currently points at (deployed/<latest>).
|
// `applied/<n>/main` currently points at (deployed/<latest>).
|
||||||
// Commits the lock if it changed.
|
// Commits the lock if it changed.
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,6 @@ enum Cmd {
|
||||||
/// Dashboard HTTP port.
|
/// Dashboard HTTP port.
|
||||||
#[arg(long, default_value_t = 7000)]
|
#[arg(long, default_value_t = 7000)]
|
||||||
dashboard_port: u16,
|
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
|
/// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses
|
||||||
/// the approval queue — use only as an operator on the host. For
|
/// the approval queue — use only as an operator on the host. For
|
||||||
|
|
@ -100,14 +95,8 @@ async fn main() -> Result<()> {
|
||||||
hyperhive_flake,
|
hyperhive_flake,
|
||||||
db,
|
db,
|
||||||
dashboard_port,
|
dashboard_port,
|
||||||
operator_pronouns,
|
|
||||||
} => {
|
} => {
|
||||||
let coord = Arc::new(Coordinator::open(
|
let coord = Arc::new(Coordinator::open(&db, hyperhive_flake, dashboard_port)?);
|
||||||
&db,
|
|
||||||
hyperhive_flake,
|
|
||||||
dashboard_port,
|
|
||||||
operator_pronouns,
|
|
||||||
)?);
|
|
||||||
manager_server::start(coord.clone())?;
|
manager_server::start(coord.clone())?;
|
||||||
// Idempotent pre-flight: rewrite pre-meta-layout applied
|
// Idempotent pre-flight: rewrite pre-meta-layout applied
|
||||||
// repos, ensure proposed repos carry the `applied`
|
// repos, ensure proposed repos carry the `applied`
|
||||||
|
|
|
||||||
|
|
@ -53,13 +53,12 @@ pub fn meta_dir() -> PathBuf {
|
||||||
pub async fn sync_agents(
|
pub async fn sync_agents(
|
||||||
hyperhive_flake: &str,
|
hyperhive_flake: &str,
|
||||||
dashboard_port: u16,
|
dashboard_port: u16,
|
||||||
operator_pronouns: &str,
|
|
||||||
agents: &[AgentSpec],
|
agents: &[AgentSpec],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let dir = meta_dir();
|
let dir = meta_dir();
|
||||||
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
||||||
|
|
||||||
let new_flake = render_flake(hyperhive_flake, dashboard_port, operator_pronouns, agents);
|
let new_flake = render_flake(hyperhive_flake, dashboard_port, agents);
|
||||||
let flake_path = dir.join("flake.nix");
|
let flake_path = dir.join("flake.nix");
|
||||||
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
|
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
|
||||||
let initial = !dir.join(".git").exists();
|
let initial = !dir.join(".git").exists();
|
||||||
|
|
@ -181,12 +180,7 @@ pub async fn lock_update_hyperhive() -> Result<()> {
|
||||||
git_commit(&dir, "bump hyperhive").await
|
git_commit(&dir, "bump hyperhive").await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_flake(
|
fn render_flake(hyperhive_flake: &str, dashboard_port: u16, agents: &[AgentSpec]) -> String {
|
||||||
hyperhive_flake: &str,
|
|
||||||
dashboard_port: u16,
|
|
||||||
operator_pronouns: &str,
|
|
||||||
agents: &[AgentSpec],
|
|
||||||
) -> String {
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
|
out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n");
|
||||||
|
|
@ -199,15 +193,9 @@ fn render_flake(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
out.push_str(" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n");
|
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!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port }}:"
|
" dashboardPort = {dashboard_port};\n mkAgent = {{ name, isManager, port }}:"
|
||||||
);
|
);
|
||||||
out.push_str(
|
out.push_str(
|
||||||
r#" let
|
r#" let
|
||||||
|
|
@ -229,7 +217,6 @@ fn render_flake(
|
||||||
HIVE_PORT = toString port;
|
HIVE_PORT = toString port;
|
||||||
HIVE_LABEL = name;
|
HIVE_LABEL = name;
|
||||||
HIVE_DASHBOARD_PORT = toString dashboardPort;
|
HIVE_DASHBOARD_PORT = toString dashboardPort;
|
||||||
HIVE_OPERATOR_PRONOUNS = operatorPronouns;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -62,13 +62,7 @@ pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
|
||||||
// Phase 3: meta repo.
|
// Phase 3: meta repo.
|
||||||
let agents = lifecycle::agents_for_meta_listing().await.unwrap_or_default();
|
let agents = lifecycle::agents_for_meta_listing().await.unwrap_or_default();
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
meta::sync_agents(
|
meta::sync_agents(&coord.hyperhive_flake, coord.dashboard_port, &agents).await
|
||||||
&coord.hyperhive_flake,
|
|
||||||
coord.dashboard_port,
|
|
||||||
&coord.operator_pronouns,
|
|
||||||
&agents,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
tracing::warn!(error = ?e, "migration: meta sync_agents failed");
|
tracing::warn!(error = ?e, "migration: meta sync_agents failed");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ async fn handle(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
let result: anyhow::Result<HostResponse> = async {
|
let result: anyhow::Result<HostResponse> = async {
|
||||||
Ok(match req {
|
Ok(match req {
|
||||||
|
|
@ -76,7 +75,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
&claude_dir,
|
&claude_dir,
|
||||||
¬es_dir,
|
¬es_dir,
|
||||||
coord.dashboard_port,
|
coord.dashboard_port,
|
||||||
&coord.operator_pronouns,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -137,7 +135,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
&claude_dir,
|
&claude_dir,
|
||||||
¬es_dir,
|
¬es_dir,
|
||||||
coord.dashboard_port,
|
coord.dashboard_port,
|
||||||
&coord.operator_pronouns,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
|
|
|
||||||
|
|
@ -37,21 +37,6 @@ in
|
||||||
default = 7000;
|
default = 7000;
|
||||||
description = "TCP port the hive-c0re dashboard listens on.";
|
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 {
|
config = lib.mkIf cfg.enable {
|
||||||
|
|
@ -84,7 +69,7 @@ in
|
||||||
];
|
];
|
||||||
environment.HYPERHIVE_GIT = "${pkgs.git}/bin/git";
|
environment.HYPERHIVE_GIT = "${pkgs.git}/bin/git";
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
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}";
|
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --dashboard-port ${toString cfg.dashboardPort}";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 2;
|
RestartSec = 2;
|
||||||
RuntimeDirectory = "hyperhive";
|
RuntimeDirectory = "hyperhive";
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,10 @@
|
||||||
{ pkgs, lib, config, ... }:
|
{ pkgs, lib, ... }:
|
||||||
{
|
{
|
||||||
# Shared scaffolding for any hyperhive harness container — both
|
# Shared scaffolding for any hyperhive harness container — both
|
||||||
# sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend
|
# sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend
|
||||||
# this. The systemd service that actually runs the harness binary
|
# this. The systemd service that actually runs the harness binary
|
||||||
# differs per role and lives in the child module.
|
# 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;
|
boot.isNspawnContainer = true;
|
||||||
|
|
||||||
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue