Compare commits

...
Author SHA1 Message Date
müde
ace13cd785 agent ui: terminal-themed live panel; pretty tool calls; collapsed results
- tool_use renders per-tool (Read /path, Bash $ cmd, send → operator: ...)
- tool_result with >120 chars collapses into <details>; short ones inline
- session_init / result / rate_limit dropped from the panel
- thinking content shown inline if present, fallback indicator otherwise
- TurnStart carries unread count → header badge "· 3 unread"
- per-tool [status] line dropped from envelope; lives in wake prompt + UI
- send form moved below the live panel
- live panel themed as a terminal (crust bg, inset shadow, monospace)
2026-05-15 18:20:58 +02:00
müde
d8807b8e8c claude: pass --settings as a file path (avoid argv length limit) 2026-05-15 18:12:07 +02:00
müde
cf8f1e64b1 claude settings: extract to prompts/claude-settings.json (formatted) 2026-05-15 18:09:05 +02:00
müde
71bf8bf47e claude: pin effortLevel=medium in inline settings 2026-05-15 18:07:53 +02:00
müde
6e75d8e6db manager: don't trust agents on config asks; sketch ask_operator tool in TODO 2026-05-15 18:06:01 +02:00
müde
ac4a978846 prompts: nudge agents to keep messages short, drop big payloads in /state 2026-05-15 18:01:36 +02:00
müde
ff8f8c7c56 per-agent /state dir for durable notes; manager sees them via /agents 2026-05-15 18:00:08 +02:00
müde
7be64c5e66 theme: bring back the vibec0re glow on catppuccin mocha 2026-05-15 17:51:36 +02:00
20 changed files with 392 additions and 117 deletions

View file

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

View file

@ -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
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
{
"autoCompactEnabled": false,
"autoMemoryEnabled": false,
"effortLevel": "medium"
}

View file

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

View file

@ -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<()> {

View file

@ -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,
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
&notes_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,
&notes_dir,
coord_bg.dashboard_port,
)
.await;

View file

@ -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,
&notes_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,
&notes_dir,
coord.dashboard_port,
)
.await?;

View file

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

View file

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

View file

@ -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,
&notes_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,
&notes_dir,
coord.dashboard_port,
)
.await?;