diff --git a/CLAUDE.md b/CLAUDE.md index b131fb4c..0933e938 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,10 +115,6 @@ 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//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 diff --git a/README.md b/README.md index 4878ebee..e6255ec8 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,7 @@ host (NixOS, runs hive-c0re.service) │ └── sockets /run/hyperhive/{host,manager,agents/}/mcp.sock │ └── nixos-containers (each bind-mounts its socket dir → /run/hive, - │ credentials dir → /root/.claude, - │ durable notes dir → /state; + │ its credentials dir → /root/.claude; │ manager additionally gets /agents RW) │ ├── hm1nd hive-m1nd serve : claude turn loop + diff --git a/TODO.md b/TODO.md index 8c1d1aed..a7f72c7d 100644 --- a/TODO.md +++ b/TODO.md @@ -34,54 +34,13 @@ 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 `
` 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 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. +- **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. ## Lifecycle / reliability diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 47882d3c..9a78466e 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -25,14 +25,12 @@ 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); @@ -41,14 +39,11 @@ h2, h3 { margin-bottom: 0.5em; } .meta { color: var(--muted); font-size: 0.85em; } -.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); } +.status-online { color: var(--green); } +.status-needs-login { color: var(--amber); } code { background: rgba(203, 166, 247, 0.12); padding: 0.05em 0.3em; border-radius: 2px; } -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); } +a { color: var(--cyan); } +a:hover { color: var(--fg); } .btn { font-family: inherit; font-size: 1em; @@ -59,15 +54,7 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px rgba(137, 220, 235, 0.9); } cursor: pointer; letter-spacing: 0.1em; } -.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:hover { background: rgba(205, 214, 244, 0.06); } .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 { @@ -113,20 +100,6 @@ 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); @@ -135,43 +108,6 @@ 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; diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index cc31b13d..54b1e838 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -225,101 +225,46 @@ 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) { - // 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 === '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; + } 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') { - const txt = (c.thinking || c.text || '').trim(); - row('thinking', txt ? '· ' + txt : '· thinking …'); - } - else if (c.type === 'tool_use') row('tool-use', '→ ' + fmtToolUse(c)); + 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)); } return; } if (v.type === 'user' && v.message && v.message.content) { for (const c of v.message.content) { - if (c.type === 'tool_result') renderToolResult(c); + 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)); + } } 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; diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index bfcdd465..b65ccd47 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -10,13 +10,13 @@

◆ … ◆

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

live

-
connecting…
-

loading…

+

live

+
connecting…
+ diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index a722f086..80994207 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -5,12 +5,6 @@ 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 + 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/` 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. +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. When your inbox has a message, handle it and stop. Don't narrate intent — act. diff --git a/hive-ag3nt/prompts/claude-settings.json b/hive-ag3nt/prompts/claude-settings.json deleted file mode 100644 index 612237de..00000000 --- a/hive-ag3nt/prompts/claude-settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "autoCompactEnabled": false, - "autoMemoryEnabled": false, - "effortLevel": "medium" -} diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index 76e54d15..2ffcac73 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -10,29 +10,6 @@ Tools (hyperhive surface): Your own editable config lives at `/agents/hm1nd/config/agent.nix`; every sub-agent's lives at `/agents//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//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/` and tell them "see /state/". -- To the operator: write to your own `/state/` (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. diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index ecb9bcc7..7fa08b9b 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -127,7 +127,6 @@ 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 { @@ -135,18 +134,15 @@ 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, unread); + let prompt = format_wake_prompt(&from, &body); let outcome = turn::drive_turn( &prompt, &mcp_config, &system_prompt, - &settings, &bus, mcp::Flavor::Agent, ) @@ -170,24 +166,9 @@ 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. `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, - } +/// wake signal claude reacts to. +fn format_wake_prompt(from: &str, body: &str) -> String { + format!("Incoming message from `{from}`:\n---\n{body}\n---") } fn render(resp: &AgentResponse) -> Result<()> { diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index 295dd1f1..9c8eead5 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -126,7 +126,6 @@ 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 { @@ -151,18 +150,15 @@ 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, unread); + let prompt = format_wake_prompt(&from, &body); let outcome = turn::drive_turn( &prompt, &mcp_config, &system_prompt, - &settings, &bus, mcp::Flavor::Manager, ) @@ -187,20 +183,7 @@ 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. `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, - } +/// the wake signal. +fn format_wake_prompt(from: &str, body: &str) -> String { + format!("Incoming message from `{from}`:\n---\n{body}\n---") } diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index 54e5888b..1449af2e 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -20,13 +20,7 @@ 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. - /// `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, - }, + TurnStart { from: String, body: String }, /// 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. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index c2c74855..bd168146 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -87,18 +87,34 @@ pub fn format_recv(resp: Result) -> 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(tool: &'static str, args: String, body: F) -> String +/// Format helper for the status peek used in the status line. +pub fn format_status(resp: Result) -> 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(tool: &'static str, args: String, status: S, body: F) -> String where F: Future, + S: Future, { tracing::info!(tool, %args, "tool: request"); let result = body.await; - tracing::info!(tool, result = %result, "tool: result"); - result + 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 } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -125,6 +141,19 @@ 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] @@ -136,7 +165,7 @@ impl AgentServer { async fn send(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let to = args.to.clone(); - run_tool_envelope("send", log, async move { + run_tool_envelope("send", log, self.status_line(), async move { let resp = client::request::<_, hive_sh4re::AgentResponse>( &self.socket, &hive_sh4re::AgentRequest::Send { @@ -156,7 +185,7 @@ impl AgentServer { or an empty marker if nothing is waiting." )] async fn recv(&self, Parameters(_args): Parameters) -> String { - run_tool_envelope("recv", String::new(), async move { + run_tool_envelope("recv", String::new(), self.status_line(), async move { let resp = client::request::<_, hive_sh4re::AgentResponse>( &self.socket, &hive_sh4re::AgentRequest::Recv, @@ -229,6 +258,16 @@ 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( @@ -250,7 +289,7 @@ impl ManagerServer { async fn send(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let to = args.to.clone(); - run_tool_envelope("send", log, async move { + run_tool_envelope("send", log, self.status_line(), async move { let resp = self .dispatch(hive_sh4re::ManagerRequest::Send { to: args.to, @@ -267,7 +306,7 @@ impl ManagerServer { empty." )] async fn recv(&self, Parameters(_args): Parameters) -> String { - run_tool_envelope("recv", String::new(), async move { + run_tool_envelope("recv", String::new(), self.status_line(), async move { let resp = self.dispatch(hive_sh4re::ManagerRequest::Recv).await; format_recv(resp) }) @@ -281,7 +320,7 @@ impl ManagerServer { async fn request_spawn(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); - run_tool_envelope("request_spawn", log, async move { + run_tool_envelope("request_spawn", log, self.status_line(), async move { let resp = self .dispatch(hive_sh4re::ManagerRequest::RequestSpawn { name: args.name }) .await; @@ -301,7 +340,7 @@ impl ManagerServer { async fn kill(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); - run_tool_envelope("kill", log, async move { + run_tool_envelope("kill", log, self.status_line(), async move { let resp = self .dispatch(hive_sh4re::ManagerRequest::Kill { name: args.name }) .await; @@ -325,6 +364,7 @@ impl ManagerServer { run_tool_envelope( "request_apply_commit", log, + self.status_line(), async move { let resp = self .dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit { diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index b5ca8cc4..4cb77213 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -17,15 +17,14 @@ use crate::events::{Bus, LiveEvent}; use crate::login::{self, LoginState}; use crate::mcp; -/// `--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"); +/// 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}"#; /// Regex-ish marker claude-code emits when context overflows. Same string /// bitburner-agent watches for. Empirically reliable across claude-code @@ -51,18 +50,6 @@ pub async fn write_mcp_config(socket: &Path) -> Result { Ok(path) } -/// Drop the static `--settings` JSON next to the MCP config so we can -/// pass a path (`--settings `) instead of an ever-growing inline -/// blob — the CLI argv has a finite length budget. -pub async fn write_settings(socket: &Path) -> Result { - 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 @@ -103,17 +90,16 @@ 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, settings, bus, flavor).await { + match run_turn(prompt, mcp_config, system_prompt, bus, flavor).await { TurnOutcome::PromptTooLong => { - if let Err(e) = compact_session(settings, bus).await { + if let Err(e) = compact_session(bus).await { tracing::warn!(error = %format!("{e:#}"), "compact failed"); return TurnOutcome::Failed(e); } - run_turn(prompt, mcp_config, system_prompt, settings, bus, flavor).await + run_turn(prompt, mcp_config, system_prompt, bus, flavor).await } other => other, } @@ -170,7 +156,6 @@ pub async fn run_turn( prompt: &str, mcp_config: &Path, system_prompt: &Path, - settings: &Path, bus: &Bus, flavor: mcp::Flavor, ) -> TurnOutcome { @@ -178,7 +163,6 @@ pub async fn run_turn( prompt, mcp_config, Some(system_prompt), - settings, bus, flavor, ClaudeMode::Turn, @@ -194,7 +178,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(settings: &Path, bus: &Bus) -> Result<()> { +pub async fn compact_session(bus: &Bus) -> Result<()> { bus.emit(LiveEvent::Note( "context overflow — running /compact on the persistent session".into(), )); @@ -202,7 +186,6 @@ pub async fn compact_session(settings: &Path, bus: &Bus) -> Result<()> { "/compact", Path::new("/dev/null"), None, - settings, bus, mcp::Flavor::Agent, // tool surface unused for /compact ClaudeMode::Compact, @@ -222,7 +205,6 @@ async fn run_claude( prompt: &str, mcp_config: &Path, system_prompt: Option<&Path>, - settings: &Path, bus: &Bus, flavor: mcp::Flavor, mode: ClaudeMode, @@ -236,7 +218,7 @@ async fn run_claude( .arg("haiku") .arg("--continue") .arg("--settings") - .arg(settings); + .arg(CLAUDE_SETTINGS); if let Some(p) = system_prompt { cmd.arg("--system-prompt-file").arg(p); } diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 8dee7fa6..79dfdd45 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -28,7 +28,6 @@ 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 { @@ -36,7 +35,6 @@ 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); @@ -51,12 +49,8 @@ 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; @@ -93,15 +87,8 @@ 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; } diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 8e7dee90..61a43d9a 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -37,7 +37,6 @@ pub async fn approve(coord: Arc, 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 => { @@ -49,7 +48,6 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { &agent_dir, &applied_dir, &claude_dir, - ¬es_dir, coord.dashboard_port, ) .await @@ -72,7 +70,6 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { &proposed_dir, &applied_dir, &claude_dir, - ¬es_dir, coord_bg.dashboard_port, ) .await; diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index 16a562a6..95006ca0 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -58,14 +58,12 @@ pub async fn rebuild_agent(coord: &Arc, 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; @@ -124,7 +122,6 @@ pub async fn ensure_manager(coord: &Arc) -> 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, @@ -132,7 +129,6 @@ pub async fn ensure_manager(coord: &Arc) -> Result<()> { &proposed, &applied, &claude_dir, - ¬es_dir, coord.dashboard_port, ) .await?; diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index fa19f58a..bc4f9389 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -178,14 +178,6 @@ 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}")) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 985b82d0..6cbef1be 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -26,11 +26,6 @@ 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"; @@ -103,7 +98,6 @@ fn validate(name: &str) -> Result<()> { Ok(()) } -#[allow(clippy::too_many_arguments)] pub async fn spawn( name: &str, hyperhive_flake: &str, @@ -111,18 +105,16 @@ 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, notes_dir)?; + set_nspawn_flags(&container, agent_dir, claude_dir)?; set_resource_limits(&container)?; systemd_daemon_reload().await?; run(&["start", &container]).await @@ -184,16 +176,14 @@ 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, notes_dir)?; + set_nspawn_flags(&container, agent_dir, claude_dir)?; set_resource_limits(&container)?; systemd_daemon_reload().await?; run(&["update", &container, "--flake", &flake_ref]).await?; @@ -359,14 +349,6 @@ 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", @@ -476,19 +458,13 @@ 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, - notes_dir: &Path, -) -> Result<()> { +fn set_nspawn_flags(container: &str, runtime_dir: &Path, claude_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={notes}:{CONTAINER_NOTES_MOUNT}", + "--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{CONTAINER_CLAUDE_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 diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index afe1985b..fb3b488f 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -65,7 +65,6 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> 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, @@ -73,7 +72,6 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { &proposed_dir, &applied_dir, &claude_dir, - ¬es_dir, coord.dashboard_port, ) .await @@ -124,14 +122,12 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> 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?;