Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ace13cd785 | ||
|
|
d8807b8e8c | ||
|
|
cf8f1e64b1 | ||
|
|
71bf8bf47e | ||
|
|
6e75d8e6db | ||
|
|
ac4a978846 | ||
|
|
ff8f8c7c56 | ||
|
|
7be64c5e66 |
20 changed files with 392 additions and 117 deletions
|
|
@ -115,6 +115,10 @@ nix/
|
|||
viable — OAuth refresh tokens rotate, so any sibling refresh invalidates
|
||||
all the others. Login flow runs from the per-agent web UI; creds persist
|
||||
across `destroy`/recreate.
|
||||
- **Persistent notes dir per agent.** `/var/lib/hyperhive/agents/<name>/state/`
|
||||
bind-mounts to `/state` (RW). System prompts tell agents to keep durable
|
||||
knowledge here (`/state/notes.md`, anything else under `/state/`).
|
||||
Survives destroy/recreate alongside the claude dir.
|
||||
- **Orphan approvals.** If state dirs are wiped out from under a pending
|
||||
approval (test scripts, manual `rm -rf`), the dashboard's next render
|
||||
marks them `failed` with note `"agent state dir missing"` so they fall out
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ host (NixOS, runs hive-c0re.service)
|
|||
│ └── sockets /run/hyperhive/{host,manager,agents/<n>}/mcp.sock
|
||||
│
|
||||
└── nixos-containers (each bind-mounts its socket dir → /run/hive,
|
||||
│ its credentials dir → /root/.claude;
|
||||
│ credentials dir → /root/.claude,
|
||||
│ durable notes dir → /state;
|
||||
│ manager additionally gets /agents RW)
|
||||
│
|
||||
├── hm1nd hive-m1nd serve : claude turn loop +
|
||||
|
|
|
|||
51
TODO.md
51
TODO.md
|
|
@ -34,13 +34,54 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
|||
the harness. Pairs well with the unprivileged-container work — would let
|
||||
the operator drop into the container without `nixos-container root-login`.
|
||||
|
||||
## Manager → operator question channel
|
||||
|
||||
- **`mcp__hyperhive__ask_operator(question, options?)` tool** on the manager
|
||||
MCP surface. The manager turn pauses; the question gets surfaced as a
|
||||
prominent prompt on the dashboard (its own section, or interleaved with
|
||||
the operator inbox); the operator's typed answer comes back as the tool
|
||||
result. Modelled after Claude Code's `AskUserQuestion` tool.
|
||||
|
||||
Design open questions:
|
||||
|
||||
- **Storage.** New sqlite table `operator_questions(id, asker, question,
|
||||
options_json, asked_at, answered_at, answer)` — or piggyback on the
|
||||
existing message broker with a new envelope kind. Probably a new
|
||||
table because the lifecycle (pending → answered) is different from
|
||||
fire-and-forget messages.
|
||||
|
||||
- **Waiting semantics.** The MCP tool call needs to block until
|
||||
answered. Two options:
|
||||
1. Long-poll from inside the tool handler (broker-style — broadcast
|
||||
on insert, await via `tokio::sync::broadcast`). Simple but the
|
||||
claude turn stays alive for the whole wait, eating context-window
|
||||
budget.
|
||||
2. Tool returns a `question_id` immediately; manager re-enters its
|
||||
inbox loop and a `HelperEvent::OperatorAnswered { id, answer }`
|
||||
wakes it. Cheaper context-wise but two-step.
|
||||
|
||||
- **Dashboard UX.** New "◆ M1ND H4S QU3STI0NS ◆" section at the top
|
||||
when any question is pending. Inline `<form>` with a textarea (or
|
||||
select if `options` were provided), POST `/api/answer-question`.
|
||||
State refresh + the live SSE stream notify the manager harness.
|
||||
|
||||
- **Sub-agent path.** Sub-agents don't get the tool — they message the
|
||||
manager and the manager decides whether to relay the question to the
|
||||
operator. The manager's system prompt already covers this.
|
||||
|
||||
- **Timeout / cancel.** Questions that sit pending too long: do they
|
||||
expire? Manager probably wants to know if the operator hasn't
|
||||
answered after some interval so it can fall back. Maybe a per-
|
||||
question `ttl_seconds`.
|
||||
|
||||
## Loop substance
|
||||
|
||||
- **Notes / state persistence.** Per-agent `notes.md` for durable scratch
|
||||
memory across turns. Compaction-on-overflow runs a separate short-lived
|
||||
claude session (à la bitburner-agent). The `--continue` session already
|
||||
gives short-term memory, but notes give cross-session durable knowledge
|
||||
that isn't lost on a `/compact` boundary.
|
||||
- **Notes compaction.** `/state/` is bind-mounted persistently and agents
|
||||
are told (in the system prompt) to keep `/state/notes.md` for durable
|
||||
knowledge — but we don't currently nudge them to compact when notes
|
||||
grow. Bitburner-agent's pattern: a short-lived secondary claude session
|
||||
that takes the existing notes + a "compact this" prompt and rewrites
|
||||
them in place. Add when the notes start bloating.
|
||||
|
||||
## Lifecycle / reliability
|
||||
|
||||
|
|
|
|||
|
|
@ -25,12 +25,14 @@ body {
|
|||
text-align: center;
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 0.95em;
|
||||
text-shadow: 0 0 6px rgba(203, 166, 247, 0.55), 0 0 14px rgba(203, 166, 247, 0.25);
|
||||
overflow-x: auto;
|
||||
}
|
||||
h2, h3 {
|
||||
color: var(--purple);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
||||
}
|
||||
.divider {
|
||||
color: var(--purple-dim);
|
||||
|
|
@ -39,11 +41,14 @@ h2, h3 {
|
|||
margin-bottom: 0.5em;
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 0.85em; }
|
||||
.status-online { color: var(--green); }
|
||||
.status-needs-login { color: var(--amber); }
|
||||
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
|
||||
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
|
||||
code { background: rgba(203, 166, 247, 0.12); padding: 0.05em 0.3em; border-radius: 2px; }
|
||||
a { color: var(--cyan); }
|
||||
a:hover { color: var(--fg); }
|
||||
a {
|
||||
color: var(--cyan);
|
||||
text-shadow: 0 0 4px rgba(137, 220, 235, 0.5);
|
||||
}
|
||||
a:hover { color: var(--fg); text-shadow: 0 0 12px rgba(137, 220, 235, 0.9); }
|
||||
.btn {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
|
|
@ -54,7 +59,15 @@ a:hover { color: var(--fg); }
|
|||
cursor: pointer;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.btn:hover { background: rgba(205, 214, 244, 0.06); }
|
||||
.btn {
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
transition: box-shadow 0.15s ease, text-shadow 0.15s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(205, 214, 244, 0.06);
|
||||
text-shadow: 0 0 10px currentColor;
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
.btn-login { color: var(--amber); border-color: var(--amber); }
|
||||
.btn-cancel { color: var(--red); border-color: var(--red); font-size: 0.85em; padding: 0.15em 0.6em; }
|
||||
.btn-rebuild {
|
||||
|
|
@ -100,6 +113,20 @@ pre.diff {
|
|||
word-break: break-all;
|
||||
max-height: 30em;
|
||||
}
|
||||
/* Terminal-ish look for the live panel. Crust as bg (almost-black),
|
||||
slightly inset, mauve phosphor glow. */
|
||||
.live.terminal {
|
||||
background: #11111b;
|
||||
border: 1px solid var(--purple-dim);
|
||||
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
padding: 0.8em 1em;
|
||||
overflow-y: auto;
|
||||
max-height: 32em;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
font-size: 0.92em;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
.live {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--purple-dim);
|
||||
|
|
@ -108,6 +135,43 @@ pre.diff {
|
|||
max-height: 32em;
|
||||
font-family: inherit;
|
||||
}
|
||||
.live .unread-badge {
|
||||
color: var(--amber);
|
||||
font-weight: normal;
|
||||
margin-left: 0.6em;
|
||||
font-size: 0.85em;
|
||||
text-shadow: 0 0 6px rgba(250, 179, 135, 0.55);
|
||||
}
|
||||
details.row {
|
||||
white-space: normal;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
details.row > summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
list-style: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
details.row > summary::before {
|
||||
content: '▸ ';
|
||||
color: var(--muted);
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
}
|
||||
details.row[open] > summary::before { content: '▾ '; }
|
||||
details.row.tool-result-block > summary { color: var(--muted); }
|
||||
details.row > pre.tool-body {
|
||||
margin: 0.3em 0 0.4em 1.2em;
|
||||
padding: 0.4em 0.6em;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-left: 2px solid var(--purple-dim);
|
||||
color: var(--fg);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 22em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.live .row {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
|
|
|||
|
|
@ -225,46 +225,101 @@
|
|||
log.scrollTop = log.scrollHeight;
|
||||
return e;
|
||||
}
|
||||
function details(cls, summary, body) {
|
||||
clearPlaceholder();
|
||||
const d = document.createElement('details');
|
||||
d.className = 'row ' + (cls || '');
|
||||
const s = document.createElement('summary');
|
||||
s.textContent = summary;
|
||||
d.appendChild(s);
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'tool-body';
|
||||
pre.textContent = body;
|
||||
d.appendChild(pre);
|
||||
log.appendChild(d);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
return d;
|
||||
}
|
||||
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }
|
||||
// Pretty-print a tool call: per-known-tool format, fallback to JSON
|
||||
// for unknown tools.
|
||||
function fmtToolUse(c) {
|
||||
const name = c.name || '';
|
||||
const input = c.input || {};
|
||||
const short = name.startsWith('mcp__hyperhive__')
|
||||
? name.slice('mcp__hyperhive__'.length) + '*' : name;
|
||||
switch (name) {
|
||||
case 'Read': return short + ' ' + (input.file_path || '');
|
||||
case 'Write': return short + ' ' + (input.file_path || '');
|
||||
case 'Edit': return short + ' ' + (input.file_path || '');
|
||||
case 'Glob': return short + ' ' + (input.pattern || '');
|
||||
case 'Grep': return short + ' ' + (input.pattern || '');
|
||||
case 'Bash': return short + ' $ ' + (input.command || '');
|
||||
case 'TodoWrite': return short + ' (' + ((input.todos || []).length) + ' items)';
|
||||
case 'mcp__hyperhive__send': return short + ' → ' + (input.to || '?') + ': '
|
||||
+ JSON.stringify(input.body || '').slice(0, 80);
|
||||
case 'mcp__hyperhive__recv': return short + '()';
|
||||
case 'mcp__hyperhive__request_spawn': return short + ' ' + (input.name || '');
|
||||
case 'mcp__hyperhive__kill': return short + ' ' + (input.name || '');
|
||||
case 'mcp__hyperhive__request_apply_commit':
|
||||
return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12);
|
||||
default: return name + ' ' + trim(JSON.stringify(input), 200);
|
||||
}
|
||||
}
|
||||
function renderToolResult(c) {
|
||||
const txt = Array.isArray(c.content)
|
||||
? c.content.map(p => p.text || '').join('')
|
||||
: (c.content || '');
|
||||
const summary = '← ' + (() => {
|
||||
const trimmed = txt.replace(/\s+/g, ' ').trim();
|
||||
if (!trimmed) return '(empty)';
|
||||
if (trimmed.length <= 120) return trimmed;
|
||||
const lines = txt.split('\n').filter(l => l.length).length;
|
||||
const headline = trimmed.slice(0, 90) + '…';
|
||||
return `${lines}L · ${headline}`;
|
||||
})();
|
||||
// For empty / short results, render as a flat row (no expand).
|
||||
if (!txt.trim() || txt.length <= 120) {
|
||||
row('tool-result', summary);
|
||||
} else {
|
||||
details('tool-result-block', summary, txt);
|
||||
}
|
||||
}
|
||||
function renderStream(v) {
|
||||
if (v.type === 'system' && v.subtype === 'init') {
|
||||
row('sys', '· session init · tools=' + (v.tools||[]).length + ' model=' + (v.model || '?'));
|
||||
return;
|
||||
}
|
||||
if (v.type === 'rate_limit_event') {
|
||||
const u = Math.round((v.rate_limit_info?.utilization || 0) * 100);
|
||||
const s = v.rate_limit_info?.status || '';
|
||||
row('sys', '· rate-limit util=' + u + '% (' + s + ')');
|
||||
return;
|
||||
}
|
||||
// Drop session init, claude's result line, rate-limit — they're
|
||||
// noise. TurnEnd communicates pass/fail; session init data isn't
|
||||
// actionable.
|
||||
if (v.type === 'system' && v.subtype === 'init') return;
|
||||
if (v.type === 'rate_limit_event') return;
|
||||
if (v.type === 'result') return;
|
||||
if (v.type === 'assistant' && v.message && v.message.content) {
|
||||
for (const c of v.message.content) {
|
||||
if (c.type === 'text' && c.text && c.text.trim()) row('text', c.text);
|
||||
else if (c.type === 'thinking') row('thinking', '· thinking …');
|
||||
else if (c.type === 'tool_use') row('tool-use', '→ ' + c.name + ' ' + trim(JSON.stringify(c.input || {}), 240));
|
||||
else if (c.type === 'thinking') {
|
||||
const txt = (c.thinking || c.text || '').trim();
|
||||
row('thinking', txt ? '· ' + txt : '· thinking …');
|
||||
}
|
||||
else if (c.type === 'tool_use') row('tool-use', '→ ' + fmtToolUse(c));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (v.type === 'user' && v.message && v.message.content) {
|
||||
for (const c of v.message.content) {
|
||||
if (c.type === 'tool_result') {
|
||||
const txt = Array.isArray(c.content)
|
||||
? c.content.map(p => p.text || '').join(' ')
|
||||
: (c.content || '');
|
||||
row('tool-result', '← ' + trim(txt, 300));
|
||||
}
|
||||
if (c.type === 'tool_result') renderToolResult(c);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (v.type === 'result') {
|
||||
row('result', '✓ done · ' + (v.subtype || '') + (v.is_error ? ' [error]' : ''));
|
||||
return;
|
||||
}
|
||||
row('sys', '· ' + trim(JSON.stringify(v), 200));
|
||||
}
|
||||
function handle(ev) {
|
||||
if (ev.kind === 'turn_start') {
|
||||
const block = row('turn-start', '◆ TURN ← ' + ev.from);
|
||||
if (ev.unread > 0) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'unread-badge';
|
||||
badge.textContent = '· ' + ev.unread + ' unread';
|
||||
block.appendChild(badge);
|
||||
}
|
||||
const body = document.createElement('div');
|
||||
body.className = 'turn-body';
|
||||
body.textContent = ev.body;
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@
|
|||
<h2 id="title">◆ … ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
|
||||
<h3>live</h3>
|
||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||
|
||||
<div id="status">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<h3>live</h3>
|
||||
<div id="live" class="live"><div class="meta">connecting…</div></div>
|
||||
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ Tools (hyperhive surface):
|
|||
- `mcp__hyperhive__recv()` — drain one more message from your inbox (returns `(empty)` if nothing pending).
|
||||
- `mcp__hyperhive__send(to, body)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard).
|
||||
|
||||
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. The manager 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.
|
||||
|
||||
Need to ask the human operator a question (clarification, permission, choice)? You don't have direct operator access — ask the manager to surface the question on your behalf ("please ask the operator: …"). The manager has a channel for this.
|
||||
|
||||
Durable knowledge: write to `/state/notes.md` (free-form) or any other path under `/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/state/` is forever. Read it back at the start of relevant turns to remember things across resets.
|
||||
|
||||
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your `/state/` from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's `/state/` directly — go through the manager if a payload needs to reach another sub-agent.
|
||||
|
||||
When your inbox has a message, handle it and stop. Don't narrate intent — act.
|
||||
|
|
|
|||
5
hive-ag3nt/prompts/claude-settings.json
Normal file
5
hive-ag3nt/prompts/claude-settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"autoCompactEnabled": false,
|
||||
"autoMemoryEnabled": false,
|
||||
"effortLevel": "medium"
|
||||
}
|
||||
|
|
@ -10,6 +10,29 @@ Tools (hyperhive surface):
|
|||
|
||||
Your own editable config lives at `/agents/hm1nd/config/agent.nix`; every sub-agent's lives at `/agents/<name>/config/agent.nix`. Use file/git tools to edit + commit, then `request_apply_commit`.
|
||||
|
||||
Sub-agents are NOT trusted by default. When one asks for a config change (new packages, env vars, etc.), verify the request before staging:
|
||||
|
||||
- Does it match what the agent actually needs to do its declared role?
|
||||
- Is the package legitimate (no obviously-malicious names, no overly broad permissions)?
|
||||
- Are there cheaper / safer alternatives that don't need a config edit?
|
||||
- If the change has any ambiguity or could affect other agents / the host, surface the question to the operator (see below) instead of staging it yourself.
|
||||
|
||||
You're the policy gate between sub-agents and the operator's approval queue — the operator clicks ◆ APPR0VE on your commits, so don't submit changes you wouldn't defend.
|
||||
|
||||
You can surface questions to the operator. (NOT YET IMPLEMENTED: a dedicated `mcp__hyperhive__ask_operator` tool will land soon — it pauses the turn, drops a prompt on the dashboard, and resumes with the answer.) For now, send to `operator` with a clear question and wait for the next turn to see their reply; the cadence is slower but the shape is the same.
|
||||
|
||||
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`. Use these to react to lifecycle changes — e.g. greet a freshly-spawned agent, retry a failed rebuild, or note the change to the operator.
|
||||
|
||||
Durable knowledge:
|
||||
|
||||
- Your own: `/state/notes.md` (free-form) or anything else under `/state/`. Bind-mounted from the host — survives destroy/recreate. Claude's `--continue` session only carries short-term context; `/state/` is forever. Good place for a roster of active sub-agents, ongoing initiatives, decisions you've made.
|
||||
- Sub-agents': every sub-agent has its own `/state/` too. From your container that's `/agents/<name>/state/` (your `/agents` mount is RW), so you can read what they've recorded and write notes for them when you need to leave a heads-up or task list.
|
||||
|
||||
Keep messages short — a few sentences each. For anything big (digests, agent rosters, plans, transcripts) write the payload to a file and `send` a short pointer:
|
||||
|
||||
- To a sub-agent X: write to `/agents/X/state/<descriptive-name>` and tell them "see /state/<descriptive-name>".
|
||||
- To the operator: write to your own `/state/<descriptive-name>` (host path `/var/lib/hyperhive/agents/hm1nd/state/`) and tell them where to look.
|
||||
|
||||
A one-line headline + the file path beats a wall-of-text every time — it survives context compaction and the operator can read it in their own time.
|
||||
|
||||
When your inbox has a message, handle it and stop. Don't narrate intent — act.
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ async fn serve(
|
|||
tracing::info!(socket = %socket.display(), "hive-ag3nt serve");
|
||||
let _ = state; // reserved for future state transitions (turn-loop -> needs-login)
|
||||
let mcp_config = turn::write_mcp_config(socket).await?;
|
||||
let settings = turn::write_settings(socket).await?;
|
||||
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into());
|
||||
let system_prompt = turn::write_system_prompt(socket, &label, mcp::Flavor::Agent).await?;
|
||||
loop {
|
||||
|
|
@ -134,15 +135,18 @@ async fn serve(
|
|||
match recv {
|
||||
Ok(AgentResponse::Message { from, body }) => {
|
||||
tracing::info!(%from, %body, "inbox");
|
||||
let unread = inbox_unread(socket).await;
|
||||
bus.emit(LiveEvent::TurnStart {
|
||||
from: from.clone(),
|
||||
body: body.clone(),
|
||||
unread,
|
||||
});
|
||||
let prompt = format_wake_prompt(&from, &body);
|
||||
let prompt = format_wake_prompt(&from, &body, unread);
|
||||
let outcome = turn::drive_turn(
|
||||
&prompt,
|
||||
&mcp_config,
|
||||
&system_prompt,
|
||||
&settings,
|
||||
&bus,
|
||||
mcp::Flavor::Agent,
|
||||
)
|
||||
|
|
@ -166,9 +170,24 @@ async fn serve(
|
|||
|
||||
/// Per-turn user prompt. The role/tools/etc. is in the system prompt
|
||||
/// (`prompts/agent.md` → `claude --system-prompt-file`); this is just the
|
||||
/// wake signal claude reacts to.
|
||||
fn format_wake_prompt(from: &str, body: &str) -> String {
|
||||
format!("Incoming message from `{from}`:\n---\n{body}\n---")
|
||||
/// wake signal claude reacts to. `unread` is the count of *other*
|
||||
/// messages in the inbox right after this one was popped.
|
||||
fn format_wake_prompt(from: &str, body: &str, unread: u64) -> String {
|
||||
let pending = if unread == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)")
|
||||
};
|
||||
format!("Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||
}
|
||||
|
||||
/// Best-effort: ask our own per-agent socket how many messages are still
|
||||
/// pending after the wake-up Recv. Returns 0 if anything goes wrong.
|
||||
async fn inbox_unread(socket: &Path) -> u64 {
|
||||
match client::request::<_, AgentResponse>(socket, &AgentRequest::Status).await {
|
||||
Ok(AgentResponse::Status { unread }) => unread,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(resp: &AgentResponse) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ async fn one_shot(socket: &Path, req: ManagerRequest) -> Result<()> {
|
|||
async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
|
||||
let mcp_config = turn::write_mcp_config(socket).await?;
|
||||
let settings = turn::write_settings(socket).await?;
|
||||
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into());
|
||||
let system_prompt = turn::write_system_prompt(socket, &label, mcp::Flavor::Manager).await?;
|
||||
loop {
|
||||
|
|
@ -150,15 +151,18 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
|||
// so the wake prompt can label it as such.
|
||||
}
|
||||
tracing::info!(%from, %body, "manager inbox");
|
||||
let unread = inbox_unread(socket).await;
|
||||
bus.emit(LiveEvent::TurnStart {
|
||||
from: from.clone(),
|
||||
body: body.clone(),
|
||||
unread,
|
||||
});
|
||||
let prompt = format_wake_prompt(&from, &body);
|
||||
let prompt = format_wake_prompt(&from, &body, unread);
|
||||
let outcome = turn::drive_turn(
|
||||
&prompt,
|
||||
&mcp_config,
|
||||
&system_prompt,
|
||||
&settings,
|
||||
&bus,
|
||||
mcp::Flavor::Manager,
|
||||
)
|
||||
|
|
@ -183,7 +187,20 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
|||
|
||||
/// Per-turn user prompt. The role/tools/etc. is in the system prompt
|
||||
/// (`prompts/manager.md` → `claude --system-prompt-file`); this is just
|
||||
/// the wake signal.
|
||||
fn format_wake_prompt(from: &str, body: &str) -> String {
|
||||
format!("Incoming message from `{from}`:\n---\n{body}\n---")
|
||||
/// the wake signal. `unread` is the inbox depth after this message was
|
||||
/// popped.
|
||||
fn format_wake_prompt(from: &str, body: &str, unread: u64) -> String {
|
||||
let pending = if unread == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)")
|
||||
};
|
||||
format!("Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||
}
|
||||
|
||||
async fn inbox_unread(socket: &Path) -> u64 {
|
||||
match client::request::<_, ManagerResponse>(socket, &ManagerRequest::Status).await {
|
||||
Ok(ManagerResponse::Status { unread }) => unread,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@ const CHANNEL_CAPACITY: usize = 256;
|
|||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LiveEvent {
|
||||
/// Harness popped a wake-up message and is about to invoke claude.
|
||||
TurnStart { from: String, body: String },
|
||||
/// `unread` is the count of *other* messages still in the inbox at
|
||||
/// that moment — surfaced as a badge in the live panel header.
|
||||
TurnStart {
|
||||
from: String,
|
||||
body: String,
|
||||
unread: u64,
|
||||
},
|
||||
/// One line of claude's `--output-format stream-json` stdout, parsed as
|
||||
/// a generic JSON value (so we don't have to track every claude-code
|
||||
/// event variant). The frontend pretty-prints by `type` field.
|
||||
|
|
|
|||
|
|
@ -87,34 +87,18 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Format helper for the status peek used in the status line.
|
||||
pub fn format_status(resp: Result<SocketReply, anyhow::Error>) -> String {
|
||||
match resp {
|
||||
Ok(SocketReply::Status(unread)) => format!("{unread} unread message(s) in inbox"),
|
||||
Ok(other) => format!("status: unexpected response {other:?}"),
|
||||
Err(e) => format!("status: transport error: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Common envelope around every MCP tool handler: pre-log → run → append
|
||||
/// a status line → post-log. Free function so both `AgentServer` and
|
||||
/// `ManagerServer` use the same shape; the per-server `status_line`
|
||||
/// closure is what differs (different `Status` wire types).
|
||||
pub async fn run_tool_envelope<F, S>(tool: &'static str, args: String, status: S, body: F) -> String
|
||||
/// Common envelope around every MCP tool handler: pre-log → run →
|
||||
/// post-log. The inbox-status hint used to be appended to every tool
|
||||
/// result; that lives in the wake prompt + UI header now, so tool
|
||||
/// results stay clean.
|
||||
pub async fn run_tool_envelope<F>(tool: &'static str, args: String, body: F) -> String
|
||||
where
|
||||
F: Future<Output = String>,
|
||||
S: Future<Output = String>,
|
||||
{
|
||||
tracing::info!(tool, %args, "tool: request");
|
||||
let result = body.await;
|
||||
let status_text = status.await;
|
||||
let full = if status_text.is_empty() {
|
||||
result
|
||||
} else {
|
||||
format!("{result}\n\n[status] {status_text}")
|
||||
};
|
||||
tracing::info!(tool, result = %full, "tool: result");
|
||||
full
|
||||
tracing::info!(tool, result = %result, "tool: result");
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
|
|
@ -141,19 +125,6 @@ impl AgentServer {
|
|||
pub fn new(socket: PathBuf) -> Self {
|
||||
Self { socket }
|
||||
}
|
||||
|
||||
/// Non-mutating peek used in the status line. Falls back to a vague
|
||||
/// note rather than failing the whole tool call when the socket
|
||||
/// hiccups.
|
||||
async fn status_line(&self) -> String {
|
||||
let resp = client::request::<_, hive_sh4re::AgentResponse>(
|
||||
&self.socket,
|
||||
&hive_sh4re::AgentRequest::Status,
|
||||
)
|
||||
.await
|
||||
.map(SocketReply::from);
|
||||
format_status(resp)
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
|
|
@ -165,7 +136,7 @@ impl AgentServer {
|
|||
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
let to = args.to.clone();
|
||||
run_tool_envelope("send", log, self.status_line(), async move {
|
||||
run_tool_envelope("send", log, async move {
|
||||
let resp = client::request::<_, hive_sh4re::AgentResponse>(
|
||||
&self.socket,
|
||||
&hive_sh4re::AgentRequest::Send {
|
||||
|
|
@ -185,7 +156,7 @@ impl AgentServer {
|
|||
or an empty marker if nothing is waiting."
|
||||
)]
|
||||
async fn recv(&self, Parameters(_args): Parameters<RecvArgs>) -> String {
|
||||
run_tool_envelope("recv", String::new(), self.status_line(), async move {
|
||||
run_tool_envelope("recv", String::new(), async move {
|
||||
let resp = client::request::<_, hive_sh4re::AgentResponse>(
|
||||
&self.socket,
|
||||
&hive_sh4re::AgentRequest::Recv,
|
||||
|
|
@ -258,16 +229,6 @@ impl ManagerServer {
|
|||
Self { socket }
|
||||
}
|
||||
|
||||
async fn status_line(&self) -> String {
|
||||
let resp = client::request::<_, hive_sh4re::ManagerResponse>(
|
||||
&self.socket,
|
||||
&hive_sh4re::ManagerRequest::Status,
|
||||
)
|
||||
.await
|
||||
.map(SocketReply::from);
|
||||
format_status(resp)
|
||||
}
|
||||
|
||||
/// Helper: issue any `ManagerRequest`, convert the reply through
|
||||
/// `SocketReply`. Manager tools that just need an `Ok` ack share this.
|
||||
async fn dispatch(
|
||||
|
|
@ -289,7 +250,7 @@ impl ManagerServer {
|
|||
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
let to = args.to.clone();
|
||||
run_tool_envelope("send", log, self.status_line(), async move {
|
||||
run_tool_envelope("send", log, async move {
|
||||
let resp = self
|
||||
.dispatch(hive_sh4re::ManagerRequest::Send {
|
||||
to: args.to,
|
||||
|
|
@ -306,7 +267,7 @@ impl ManagerServer {
|
|||
empty."
|
||||
)]
|
||||
async fn recv(&self, Parameters(_args): Parameters<RecvArgs>) -> String {
|
||||
run_tool_envelope("recv", String::new(), self.status_line(), async move {
|
||||
run_tool_envelope("recv", String::new(), async move {
|
||||
let resp = self.dispatch(hive_sh4re::ManagerRequest::Recv).await;
|
||||
format_recv(resp)
|
||||
})
|
||||
|
|
@ -320,7 +281,7 @@ impl ManagerServer {
|
|||
async fn request_spawn(&self, Parameters(args): Parameters<RequestSpawnArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
let name = args.name.clone();
|
||||
run_tool_envelope("request_spawn", log, self.status_line(), async move {
|
||||
run_tool_envelope("request_spawn", log, async move {
|
||||
let resp = self
|
||||
.dispatch(hive_sh4re::ManagerRequest::RequestSpawn { name: args.name })
|
||||
.await;
|
||||
|
|
@ -340,7 +301,7 @@ impl ManagerServer {
|
|||
async fn kill(&self, Parameters(args): Parameters<KillArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
let name = args.name.clone();
|
||||
run_tool_envelope("kill", log, self.status_line(), async move {
|
||||
run_tool_envelope("kill", log, async move {
|
||||
let resp = self
|
||||
.dispatch(hive_sh4re::ManagerRequest::Kill { name: args.name })
|
||||
.await;
|
||||
|
|
@ -364,7 +325,6 @@ impl ManagerServer {
|
|||
run_tool_envelope(
|
||||
"request_apply_commit",
|
||||
log,
|
||||
self.status_line(),
|
||||
async move {
|
||||
let resp = self
|
||||
.dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit {
|
||||
|
|
|
|||
|
|
@ -17,14 +17,15 @@ use crate::events::{Bus, LiveEvent};
|
|||
use crate::login::{self, LoginState};
|
||||
use crate::mcp;
|
||||
|
||||
/// Inline `--settings` JSON applied to every claude invocation. We turn off
|
||||
/// claude's in-session auto-compaction and its cross-session auto-memory
|
||||
/// because hyperhive owns those concerns: compaction is operator/harness-
|
||||
/// driven (`/compact` on overflow), notes persistence is a hyperhive
|
||||
/// concern (planned, not yet wired). Unknown keys are silently ignored by
|
||||
/// claude-code; if the key names ever rename, we'll spot it because
|
||||
/// auto-compact will start firing mid-turn again.
|
||||
const CLAUDE_SETTINGS: &str = r#"{"autoCompactEnabled":false,"autoMemoryEnabled":false}"#;
|
||||
/// `--settings` JSON applied to every claude invocation. Lives as a
|
||||
/// properly-formatted file in `prompts/claude-settings.json` so it's easy
|
||||
/// to read and edit; we ship it via `include_str!`. We turn off claude's
|
||||
/// in-session auto-compaction and its cross-session auto-memory because
|
||||
/// hyperhive owns those concerns (`/compact` on overflow, notes
|
||||
/// persistence under `/state`). Unknown keys are silently ignored by
|
||||
/// claude-code; if a key gets renamed we'll spot it because the
|
||||
/// corresponding behavior will start firing mid-turn again.
|
||||
const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json");
|
||||
|
||||
/// Regex-ish marker claude-code emits when context overflows. Same string
|
||||
/// bitburner-agent watches for. Empirically reliable across claude-code
|
||||
|
|
@ -50,6 +51,18 @@ pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> {
|
|||
Ok(path)
|
||||
}
|
||||
|
||||
/// Drop the static `--settings` JSON next to the MCP config so we can
|
||||
/// pass a path (`--settings <file>`) instead of an ever-growing inline
|
||||
/// blob — the CLI argv has a finite length budget.
|
||||
pub async fn write_settings(socket: &Path) -> Result<PathBuf> {
|
||||
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
|
||||
tokio::fs::create_dir_all(parent).await.ok();
|
||||
let path = parent.join("claude-settings.json");
|
||||
tokio::fs::write(&path, CLAUDE_SETTINGS).await?;
|
||||
tracing::info!(path = %path.display(), "wrote claude settings");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Write the agent's / manager's static system prompt to a file next to
|
||||
/// the MCP config and return the path. Passed to claude via
|
||||
/// `--system-prompt-file`, replacing claude's default system prompt with
|
||||
|
|
@ -90,16 +103,17 @@ pub async fn drive_turn(
|
|||
prompt: &str,
|
||||
mcp_config: &Path,
|
||||
system_prompt: &Path,
|
||||
settings: &Path,
|
||||
bus: &Bus,
|
||||
flavor: mcp::Flavor,
|
||||
) -> TurnOutcome {
|
||||
match run_turn(prompt, mcp_config, system_prompt, bus, flavor).await {
|
||||
match run_turn(prompt, mcp_config, system_prompt, settings, bus, flavor).await {
|
||||
TurnOutcome::PromptTooLong => {
|
||||
if let Err(e) = compact_session(bus).await {
|
||||
if let Err(e) = compact_session(settings, bus).await {
|
||||
tracing::warn!(error = %format!("{e:#}"), "compact failed");
|
||||
return TurnOutcome::Failed(e);
|
||||
}
|
||||
run_turn(prompt, mcp_config, system_prompt, bus, flavor).await
|
||||
run_turn(prompt, mcp_config, system_prompt, settings, bus, flavor).await
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
|
|
@ -156,6 +170,7 @@ pub async fn run_turn(
|
|||
prompt: &str,
|
||||
mcp_config: &Path,
|
||||
system_prompt: &Path,
|
||||
settings: &Path,
|
||||
bus: &Bus,
|
||||
flavor: mcp::Flavor,
|
||||
) -> TurnOutcome {
|
||||
|
|
@ -163,6 +178,7 @@ pub async fn run_turn(
|
|||
prompt,
|
||||
mcp_config,
|
||||
Some(system_prompt),
|
||||
settings,
|
||||
bus,
|
||||
flavor,
|
||||
ClaudeMode::Turn,
|
||||
|
|
@ -178,7 +194,7 @@ pub async fn run_turn(
|
|||
/// Run claude's built-in `/compact` slash command on the persistent
|
||||
/// session so the next turn can fit. No MCP tools needed; we just feed
|
||||
/// `/compact` over stdin and let claude rewrite its own history.
|
||||
pub async fn compact_session(bus: &Bus) -> Result<()> {
|
||||
pub async fn compact_session(settings: &Path, bus: &Bus) -> Result<()> {
|
||||
bus.emit(LiveEvent::Note(
|
||||
"context overflow — running /compact on the persistent session".into(),
|
||||
));
|
||||
|
|
@ -186,6 +202,7 @@ pub async fn compact_session(bus: &Bus) -> Result<()> {
|
|||
"/compact",
|
||||
Path::new("/dev/null"),
|
||||
None,
|
||||
settings,
|
||||
bus,
|
||||
mcp::Flavor::Agent, // tool surface unused for /compact
|
||||
ClaudeMode::Compact,
|
||||
|
|
@ -205,6 +222,7 @@ async fn run_claude(
|
|||
prompt: &str,
|
||||
mcp_config: &Path,
|
||||
system_prompt: Option<&Path>,
|
||||
settings: &Path,
|
||||
bus: &Bus,
|
||||
flavor: mcp::Flavor,
|
||||
mode: ClaudeMode,
|
||||
|
|
@ -218,7 +236,7 @@ async fn run_claude(
|
|||
.arg("haiku")
|
||||
.arg("--continue")
|
||||
.arg("--settings")
|
||||
.arg(CLAUDE_SETTINGS);
|
||||
.arg(settings);
|
||||
if let Some(p) = system_prompt {
|
||||
cmd.arg("--system-prompt-file").arg(p);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ body {
|
|||
text-align: center;
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 0.95em;
|
||||
text-shadow: 0 0 6px rgba(203, 166, 247, 0.55), 0 0 14px rgba(203, 166, 247, 0.25);
|
||||
overflow-x: auto;
|
||||
}
|
||||
h1, h2 {
|
||||
|
|
@ -35,6 +36,7 @@ h1, h2 {
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
margin-top: 2em;
|
||||
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
||||
}
|
||||
.divider {
|
||||
color: var(--purple-dim);
|
||||
|
|
@ -49,8 +51,12 @@ a {
|
|||
color: var(--cyan);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 4px rgba(137, 220, 235, 0.5);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--fg);
|
||||
text-shadow: 0 0 12px rgba(137, 220, 235, 0.9);
|
||||
}
|
||||
a:hover { color: var(--fg); }
|
||||
.role {
|
||||
display: inline-block;
|
||||
margin-left: 0.4em;
|
||||
|
|
@ -87,8 +93,15 @@ ul form.inline { display: inline-block; }
|
|||
border: 1px solid;
|
||||
padding: 0.25em 0.8em;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(205, 214, 244, 0.06);
|
||||
text-shadow: 0 0 10px currentColor;
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
.btn:hover { background: rgba(205, 214, 244, 0.06); }
|
||||
.btn-approve { color: var(--green); border-color: var(--green); }
|
||||
.btn-deny { color: var(--red); border-color: var(--red); }
|
||||
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
|||
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
|
||||
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
|
||||
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
|
||||
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
|
||||
|
||||
match approval.kind {
|
||||
ApprovalKind::ApplyCommit => {
|
||||
|
|
@ -48,6 +49,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
|||
&agent_dir,
|
||||
&applied_dir,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
)
|
||||
.await
|
||||
|
|
@ -70,6 +72,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
|||
&proposed_dir,
|
||||
&applied_dir,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord_bg.dashboard_port,
|
||||
)
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -58,12 +58,14 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
|
|||
.with_context(|| format!("ensure_runtime {name}"))?;
|
||||
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||
let claude_dir = Coordinator::agent_claude_dir(name);
|
||||
let notes_dir = Coordinator::agent_notes_dir(name);
|
||||
let result = lifecycle::rebuild(
|
||||
name,
|
||||
&coord.hyperhive_flake,
|
||||
&agent_dir,
|
||||
&applied_dir,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
)
|
||||
.await;
|
||||
|
|
@ -122,6 +124,7 @@ pub async fn ensure_manager(coord: &Arc<Coordinator>) -> Result<()> {
|
|||
let proposed = Coordinator::agent_proposed_dir(MANAGER_NAME);
|
||||
let applied = Coordinator::agent_applied_dir(MANAGER_NAME);
|
||||
let claude_dir = Coordinator::agent_claude_dir(MANAGER_NAME);
|
||||
let notes_dir = Coordinator::agent_notes_dir(MANAGER_NAME);
|
||||
lifecycle::spawn(
|
||||
MANAGER_NAME,
|
||||
&coord.hyperhive_flake,
|
||||
|
|
@ -129,6 +132,7 @@ pub async fn ensure_manager(coord: &Arc<Coordinator>) -> Result<()> {
|
|||
&proposed,
|
||||
&applied,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -178,6 +178,14 @@ impl Coordinator {
|
|||
Self::agent_state_root(name).join("claude")
|
||||
}
|
||||
|
||||
/// Per-agent durable knowledge dir. Bind-mounted RW into the agent
|
||||
/// container at `/state`. Survives destroy/recreate alongside the
|
||||
/// claude dir. Agents are told (via the system prompt) to write
|
||||
/// long-lived notes / scratch state here.
|
||||
pub fn agent_notes_dir(name: &str) -> PathBuf {
|
||||
Self::agent_state_root(name).join("state")
|
||||
}
|
||||
|
||||
/// Authoritative applied config repo. Hive-c0re-only.
|
||||
pub fn agent_applied_dir(name: &str) -> PathBuf {
|
||||
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive";
|
|||
/// Persistent across destroy/recreate so OAuth login survives.
|
||||
pub const CONTAINER_CLAUDE_MOUNT: &str = "/root/.claude";
|
||||
|
||||
/// Mount point of the per-agent durable knowledge dir inside the container.
|
||||
/// Agents are told (system prompt) to keep `notes.md` and any other scratch
|
||||
/// state here; persists across destroy/recreate.
|
||||
pub const CONTAINER_NOTES_MOUNT: &str = "/state";
|
||||
|
||||
const GIT_NAME: &str = "hive-c0re";
|
||||
const GIT_EMAIL: &str = "hive-c0re@hyperhive";
|
||||
|
||||
|
|
@ -98,6 +103,7 @@ fn validate(name: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn spawn(
|
||||
name: &str,
|
||||
hyperhive_flake: &str,
|
||||
|
|
@ -105,16 +111,18 @@ pub async fn spawn(
|
|||
proposed_dir: &Path,
|
||||
applied_dir: &Path,
|
||||
claude_dir: &Path,
|
||||
notes_dir: &Path,
|
||||
dashboard_port: u16,
|
||||
) -> Result<()> {
|
||||
validate(name)?;
|
||||
setup_proposed(proposed_dir, name).await?;
|
||||
setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?;
|
||||
ensure_claude_dir(claude_dir)?;
|
||||
ensure_state_dir(notes_dir)?;
|
||||
let container = container_name(name);
|
||||
let flake_ref = format!("{}#default", applied_dir.display());
|
||||
run(&["create", &container, "--flake", &flake_ref]).await?;
|
||||
set_nspawn_flags(&container, agent_dir, claude_dir)?;
|
||||
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?;
|
||||
set_resource_limits(&container)?;
|
||||
systemd_daemon_reload().await?;
|
||||
run(&["start", &container]).await
|
||||
|
|
@ -176,14 +184,16 @@ pub async fn rebuild(
|
|||
agent_dir: &Path,
|
||||
applied_dir: &Path,
|
||||
claude_dir: &Path,
|
||||
notes_dir: &Path,
|
||||
dashboard_port: u16,
|
||||
) -> Result<()> {
|
||||
validate(name)?;
|
||||
setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?;
|
||||
ensure_claude_dir(claude_dir)?;
|
||||
ensure_state_dir(notes_dir)?;
|
||||
let container = container_name(name);
|
||||
let flake_ref = format!("{}#default", applied_dir.display());
|
||||
set_nspawn_flags(&container, agent_dir, claude_dir)?;
|
||||
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?;
|
||||
set_resource_limits(&container)?;
|
||||
systemd_daemon_reload().await?;
|
||||
run(&["update", &container, "--flake", &flake_ref]).await?;
|
||||
|
|
@ -349,6 +359,14 @@ fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
|
||||
if !notes_dir.exists() {
|
||||
std::fs::create_dir_all(notes_dir)
|
||||
.with_context(|| format!("create {}", notes_dir.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initial_agent_nix(name: &str) -> String {
|
||||
format!(
|
||||
"{{ ... }}:\n{{\n # Per-agent overrides for {name}. The manager edits this\n # file (and commits) to customise the agent's NixOS config.\n}}\n",
|
||||
|
|
@ -458,13 +476,19 @@ pub const CONTAINER_MANAGER_AGENTS_MOUNT: &str = "/agents";
|
|||
/// here so lifecycle stays usable as a leaf module).
|
||||
const HOST_AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
|
||||
|
||||
fn set_nspawn_flags(container: &str, runtime_dir: &Path, claude_dir: &Path) -> Result<()> {
|
||||
fn set_nspawn_flags(
|
||||
container: &str,
|
||||
runtime_dir: &Path,
|
||||
claude_dir: &Path,
|
||||
notes_dir: &Path,
|
||||
) -> Result<()> {
|
||||
let path = format!("/etc/nixos-containers/{container}.conf");
|
||||
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
|
||||
let mut binds = format!(
|
||||
"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{CONTAINER_CLAUDE_MOUNT}",
|
||||
"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{CONTAINER_CLAUDE_MOUNT} --bind={notes}:{CONTAINER_NOTES_MOUNT}",
|
||||
runtime = runtime_dir.display(),
|
||||
claude = claude_dir.display(),
|
||||
notes = notes_dir.display(),
|
||||
);
|
||||
if container == MANAGER_NAME {
|
||||
// Manager edits sub-agent proposed/ repos and its own. RW so it can
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
||||
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||
let claude_dir = Coordinator::agent_claude_dir(name);
|
||||
let notes_dir = Coordinator::agent_notes_dir(name);
|
||||
match lifecycle::spawn(
|
||||
name,
|
||||
&coord.hyperhive_flake,
|
||||
|
|
@ -72,6 +73,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
&proposed_dir,
|
||||
&applied_dir,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
)
|
||||
.await
|
||||
|
|
@ -122,12 +124,14 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
let agent_dir = coord.ensure_runtime(name)?;
|
||||
let applied_dir = Coordinator::agent_applied_dir(name);
|
||||
let claude_dir = Coordinator::agent_claude_dir(name);
|
||||
let notes_dir = Coordinator::agent_notes_dir(name);
|
||||
lifecycle::rebuild(
|
||||
name,
|
||||
&coord.hyperhive_flake,
|
||||
&agent_dir,
|
||||
&applied_dir,
|
||||
&claude_dir,
|
||||
¬es_dir,
|
||||
coord.dashboard_port,
|
||||
)
|
||||
.await?;
|
||||
|
|
|
|||
Loading…
Reference in a new issue