Compare commits

...
Author SHA1 Message Date
müde
237b215c55 dashboard: browser notifications for operator-bound events
three signals fire OS notifications:
- new approval lands in the queue (per id, via /api/state delta)
- new ask_operator question queued (per id)
- broker message sent to operator (live via SSE)

first /api/state render after page load seeds the 'seen' sets
without firing — only items that arrive while the page is open
count. controls in a row under the banner: 🔔 enable
notifications (calls requestPermission, hides on grant), 🔕 mute /
🔔 unmute toggle (localStorage-backed so operator can silence
without revoking the permission), inline status text when blocked
or unsupported.

notification tag='hyperhive' collapses rapid bursts; onclick
focuses the dashboard tab. requires secure context (HTTPS or
localhost) — on other origins the API is unavailable and the
controls hide themselves.

todo: entry dropped.
2026-05-15 21:10:20 +02:00
müde
a67aada7c9 todo: browser notifications for approvals / questions / operator msgs
pure frontend — Notification API + existing /api/state and
/messages/stream signals. Caveats: secure-context requirement
(HTTPS or localhost), per-browser permission grant. Includes a
sketch of the implementation: request-permission button, count
deltas on refreshState, SSE hook on operator-bound sends,
localStorage 'muted' toggle.
2026-05-15 21:07:21 +02:00
müde
8b9f7d21b7 model persisted to /state; stop auto-allowing claude-code unfree
model persistence: /model <name> now writes to /state/hyperhive-model
(in-container), Bus::new reads it on init. operator override survives
harness restart and container rebuild; gone on --purge like every
other piece of agent state. path overridable via HYPERHIVE_MODEL_FILE
for tests. failure to persist is a warn, not fatal — runtime override
still applies, just won't survive a restart.

unfree opt-in: drop the auto-allowUnfreePredicate from
harness-base.nix and the claude-unstable overlay. operator now has to
set nixpkgs.config.allowUnfree (or a predicate listing claude-code)
in their own host config. silent unfree bypass was sketchy; this is
honest. readme + gotchas updated to spell out the snippet.

todo: drops model-persistence + container-crash + journald (all
shipped); adds per-agent send allow-list (constrain who an agent can
message).
2026-05-15 21:05:40 +02:00
müde
58c3cd853b container crash watcher → HelperEvent::ContainerCrash
new hive_c0re::crash_watch task polls every 10s, builds the set of
currently-running containers, and on running→stopped transitions
checks the transient snapshot: if no Stopping / Restarting /
Destroying / Rebuilding flag is set, the container exited
unexpectedly and we fire HelperEvent::ContainerCrash into the
manager's inbox so it can react (typically: start it again).

first poll is a seeding pass — no events on harness startup. dbus
subscription would be lower-latency but polling is honest and
debuggable, and a 10s delay on crash detection is fine for our
scale.

manager prompt + approvals doc updated to advertise the new
event variant. todo drops the entry (and the journald-viewer
entry that already shipped).
2026-05-15 21:02:05 +02:00
14 changed files with 325 additions and 28 deletions

View file

@ -91,6 +91,17 @@ hive-c0re will then:
- auto-create the manager container (`hm1nd`) if missing,
- auto-rebuild any managed container whose hyperhive rev is stale.
`claude-code` is unfree; hyperhive does not auto-allow it for you.
Add to your host config:
```nix
nixpkgs.config.allowUnfreePredicate =
pkg: builtins.elem (nixpkgs.lib.getName pkg) [ "claude-code" ];
```
(or `nixpkgs.config.allowUnfree = true`, your call). Each per-agent
container inherits this through the same nixpkgs evaluation.
## Build / deploy
```sh

27
TODO.md
View file

@ -3,6 +3,17 @@
Pick anything from here when relevant. Cross-cutting design notes live in
[CLAUDE.md](CLAUDE.md); high-level project intro in [README.md](README.md).
## Permissions / policy
- **Per-agent send allow-list.** Today any agent can `send` to any
other recipient (peer, manager, operator). Add a per-agent
policy that constrains the `to` field — declared in `agent.nix`,
e.g. `hyperhive.allowedRecipients = [ "manager" "alice" ]`.
Broker rejects with an `Err { message }` when the policy denies.
Default: unrestricted (back-compat). The manager can still
always send anywhere. Useful for sandboxing untrusted sub-agents
so they can only talk to the manager, not other sub-agents.
## Security
- **Unprivileged containers (userns mapping).** Today the nspawn container
@ -31,17 +42,9 @@ Pick anything from here when relevant. Cross-cutting design notes live in
derived from the same config so the operator stays in control of
what's exposed.
## Per-agent settings
- **Model override persistence.** `/model <name>` already switches
the model at runtime via `Bus::set_model`; the chip on the agent
page reflects the current value. Override is in-memory only and
resets on harness restart — by design for now, but consider
optional persistence (`/state/model` file?) so an operator-set
model survives a rebuild.
## UI / UX
- **Terminal: `/model` slash command.** Operator-typeable model
override from the terminal. Depends on the model-override work
above; once an override mechanism exists, wire a `/model <name>`
@ -99,9 +102,3 @@ Pick anything from here when relevant. Cross-cutting design notes live in
that takes the existing notes + a "compact this" prompt and rewrites
them in place. Add when the notes start bloating.
## Lifecycle / reliability
- **Container crash events.** Watch `container@*.service` via D-Bus, push
`HelperEvent::ContainerCrash` to the manager's inbox so the manager can
react (restart, escalate, etc.).

View file

@ -115,6 +115,10 @@ regular claude turn so the manager can react. Variants
- `Killed { agent }` — admin `HostRequest::Kill` + dashboard
`/kill` + manager `Kill` MCP tool.
- `Destroyed { agent }``actions::destroy`.
- `ContainerCrash { agent, note }``crash_watch`: a previously-
running container went away with no operator-initiated transient
state (Stopping / Restarting / Destroying / Rebuilding). Manager
can `start` it again or escalate.
- `OperatorAnswered { id, question, answer }` — dashboard
`/answer-question/{id}` after the operator submits the answer
form.

View file

@ -53,11 +53,15 @@ socket without needing a clean reinstall.
## `claude-code` is unfree
`harness-base.nix` allow-list's it specifically. The flake pins it to
**nixpkgs-unstable** via `overlays.claude-unstable` (stable lags too
far). The overlay imports unstable with its own
`allowUnfreePredicate` so the access inside the overlay doesn't
itself trip.
The flake pins it to **nixpkgs-unstable** via
`overlays.claude-unstable` (stable lags too far). The overlay
imports unstable inheriting the user's `nixpkgs.config`, so the
operator must opt in by setting `allowUnfree = true` (or an
`allowUnfreePredicate` that whitelists `claude-code`) on their host
config. hyperhive deliberately does NOT auto-allow — silent unfree
bypass would be sketchy, and the error message is clear enough that
the operator can fix it once and forget about it. Same on the
per-agent containers (they inherit through the same nixpkgs).
## Claude credentials are per-agent

View file

@ -67,9 +67,14 @@
claude-unstable =
final: prev:
let
# Inherit the *user's* nixpkgs config so allowUnfree (or an
# `allowUnfreePredicate` they set on their flake) propagates
# into the unstable import. hyperhive does not silently
# bypass the unfree gate — if the operator hasn't opted in,
# this overlay's `claude-code` access fails honestly.
unstable = import nixpkgs-unstable {
inherit (prev.stdenv.hostPlatform) system;
config.allowUnfreePredicate = pkg: builtins.elem (prev.lib.getName pkg) [ "claude-code" ];
config = prev.config;
};
in
{

View file

@ -26,7 +26,7 @@ You're the policy gate between sub-agents and the operator's approval queue —
Two ways to talk to the operator: `send(to: "operator", ...)` for fire-and-forget status / pointers (surfaces in the operator inbox), or `ask_operator(question, options?)` when you need a decision. `ask_operator` is non-blocking — it queues the question and returns an id immediately; the answer arrives on a future turn as an `operator_answered` system event. Prefer `ask_operator` over an open-ended `send` for anything you actually need to wait on.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`, `operator_answered`. Use these to react to lifecycle changes — e.g. greet a freshly-spawned agent, retry a failed rebuild, or pick up the operator's answer to a question you previously asked.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`, `container_crash`, `operator_answered`. Use these to react to lifecycle changes — e.g. greet a freshly-spawned agent, retry a failed rebuild, restart an agent whose container crashed, or pick up the operator's answer to a question you previously asked.
Durable knowledge:

View file

@ -24,6 +24,36 @@ const HISTORY_CAPACITY: usize = 2000;
/// `HYPERHIVE_EVENTS_DB` env var (used in tests and one-shot tools).
const DEFAULT_EVENTS_DB: &str = "/state/hyperhive-events.sqlite";
/// Persisted model name file. Same lifecycle as the events db —
/// survives destroy/recreate, gone on purge. Empty / missing file
/// falls back to `DEFAULT_MODEL`.
const DEFAULT_MODEL_FILE: &str = "/state/hyperhive-model";
/// Path to the persisted model file. Overridable via
/// `HYPERHIVE_MODEL_FILE` for dev / tests.
fn model_file_path() -> PathBuf {
std::env::var_os("HYPERHIVE_MODEL_FILE")
.map_or_else(|| PathBuf::from(DEFAULT_MODEL_FILE), PathBuf::from)
}
fn load_model() -> Option<String> {
let s = std::fs::read_to_string(model_file_path()).ok()?;
let name = s.trim();
if name.is_empty() {
None
} else {
Some(name.to_owned())
}
}
fn persist_model(name: &str) -> std::io::Result<()> {
let path = model_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(path, format!("{name}\n"))
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -177,11 +207,12 @@ impl Bus {
}
};
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
let initial_model = load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned());
Self {
tx: Arc::new(tx),
store,
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
model: Arc::new(Mutex::new(DEFAULT_MODEL.to_owned())),
model: Arc::new(Mutex::new(initial_model)),
}
}
@ -193,9 +224,16 @@ impl Bus {
}
/// Switch the model for future turns. The current turn (if any)
/// keeps the model it was already running.
/// keeps the model it was already running. Persisted to
/// `/state/hyperhive-model` so the override survives harness
/// restart and container rebuild (gone on `--purge`, matching
/// every other piece of agent state).
pub fn set_model(&self, name: impl Into<String>) {
*self.model.lock().unwrap() = name.into();
let value: String = name.into();
self.model.lock().unwrap().clone_from(&value);
if let Err(e) = persist_model(&value) {
tracing::warn!(error = ?e, "model: persist failed");
}
}
/// Update the harness's authoritative turn-loop state. Records

View file

@ -34,6 +34,113 @@
return f;
};
// ─── browser notifications ──────────────────────────────────────────────
// Fires OS notifications on three operator-bound signals:
// - new approval landed in the queue
// - new operator question queued (ask_operator)
// - broker message sent `to: "operator"`
// permission grant is per-browser; a localStorage "muted" toggle lets
// the operator silence without revoking. Secure-context only (HTTPS /
// localhost) — on other origins the API is unavailable and we hide
// the controls.
const NOTIF = (() => {
const supported = typeof Notification !== 'undefined';
const MUTED_KEY = 'hyperhive.notify.muted';
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
const setMuted = (v) => v
? localStorage.setItem(MUTED_KEY, '1')
: localStorage.removeItem(MUTED_KEY);
function renderControls() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
const status = $('notif-status');
if (!enable || !mute || !unmute || !status) return;
if (!supported) {
enable.hidden = mute.hidden = unmute.hidden = true;
status.hidden = false;
status.textContent = 'notifications unsupported in this browser';
return;
}
const perm = Notification.permission;
enable.hidden = perm === 'granted';
mute.hidden = perm !== 'granted' || isMuted();
unmute.hidden = perm !== 'granted' || !isMuted();
status.hidden = perm !== 'denied';
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
}
function bind() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
if (!supported || !enable || !mute || !unmute) return;
enable.addEventListener('click', async () => {
await Notification.requestPermission();
renderControls();
});
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
renderControls();
}
function show(title, body) {
if (!supported || Notification.permission !== 'granted' || isMuted()) return;
try {
const n = new Notification(title, {
body,
tag: 'hyperhive', // collapse rapid bursts
icon: '/static/dashboard.css', // any same-origin asset works as a favicon stand-in
});
n.onclick = () => { window.focus(); n.close(); };
} catch (err) {
console.warn('notification show failed', err);
}
}
return { bind, show, renderControls };
})();
// Track which items we've already notified about so a re-render
// doesn't re-fire for the same row. Keyed by stable ids; reset only
// when the page reloads.
const seenApprovals = new Set();
const seenQuestions = new Set();
const seenInboxIds = new Set();
let seededNotify = false;
function notifyDeltas(s) {
const approvals = s.approvals || [];
const questions = s.questions || [];
const inbox = s.operator_inbox || [];
if (!seededNotify) {
// First render after page load — fill the "seen" sets without
// firing notifications. We only want to notify on NEW items
// that arrived while the page is open.
for (const a of approvals) seenApprovals.add(a.id);
for (const q of questions) seenQuestions.add(q.id);
for (const m of inbox) seenInboxIds.add(m.id);
seededNotify = true;
return;
}
for (const a of approvals) {
if (seenApprovals.has(a.id)) continue;
seenApprovals.add(a.id);
const verb = a.kind === 'spawn' ? 'spawn approval' : 'config commit';
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`);
}
for (const q of questions) {
if (seenQuestions.has(q.id)) continue;
seenQuestions.add(q.id);
NOTIF.show('◆ manager asks', q.question.slice(0, 120));
}
// operator_inbox: only notify on truly new ids — sse already
// handles single-message notifications, but if the operator
// missed an SSE event (page reloaded), this catches up.
for (const m of inbox) {
if (seenInboxIds.has(m.id)) continue;
seenInboxIds.add(m.id);
// suppress here; SSE path handles the live notification.
}
}
// ─── async forms ────────────────────────────────────────────────────────
document.addEventListener('submit', async (e) => {
const f = e.target;
@ -477,6 +584,7 @@
renderQuestions(s);
renderInbox(s);
renderApprovals(s);
notifyDeltas(s);
// Auto-refresh: fast (2s) while a spawn or a per-container
// action is in flight, otherwise heartbeat (5s) so newly-queued
// approvals from the manager show up without the operator
@ -493,6 +601,7 @@
}
}
refreshState();
NOTIF.bind();
// ─── message flow SSE ───────────────────────────────────────────────────
(() => {
@ -517,8 +626,12 @@
let m;
try { m = JSON.parse(e.data); } catch { return; }
pulseBanner();
// Live-update the inbox when claude sends to operator.
if (m.kind === 'sent' && m.to === 'operator') refreshState();
// Live-update the inbox when claude sends to operator + ping
// the OS notification center.
if (m.kind === 'sent' && m.to === 'operator') {
refreshState();
NOTIF.show('◆ ' + m.from + ' → operator', String(m.body || '').slice(0, 200));
}
const row = document.createElement('div');
row.className = 'msgrow ' + m.kind;
const kind = m.kind === 'sent' ? '→' : '✓';

View file

@ -190,6 +190,32 @@ a:hover {
word-break: normal;
}
/* Notification controls sit between the banner and the
containers section. Hidden by JS when notifications are
unsupported, denied, or already in the right state. */
.notif-row {
display: flex;
gap: 0.5em;
align-items: center;
margin: 0.5em 0;
font-size: 0.85em;
}
.btn-notif {
font-family: inherit;
font-size: 0.85em;
background: transparent;
color: var(--cyan);
border: 1px solid var(--cyan);
padding: 0.2em 0.7em;
border-radius: 999px;
cursor: pointer;
text-shadow: 0 0 4px currentColor;
}
.btn-notif:hover {
background: rgba(137, 220, 235, 0.1);
box-shadow: 0 0 10px -2px currentColor;
}
.pending-state {
color: var(--amber);
font-size: 0.85em;

View file

@ -10,6 +10,13 @@
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
</pre>
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
<h2>◆ C0NTAINERS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="containers-section">

View file

@ -0,0 +1,72 @@
//! Container crash watcher. Polls every managed container's running
//! state on a fixed interval; when a previously-running container is
//! suddenly stopped AND no operator-initiated transient (`Stopping`,
//! `Restarting`, `Destroying`) was set, fire `HelperEvent::ContainerCrash`
//! into the manager's inbox. The manager can then react — usually
//! a `start` or a config rebuild.
//!
//! D-Bus subscription would be lower-latency, but polling is far
//! simpler and the failure modes are honest (a crash discovered 10s
//! late is fine for our scale).
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use crate::coordinator::{Coordinator, TransientKind};
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
const POLL_INTERVAL: Duration = Duration::from_secs(10);
pub fn spawn(coord: Arc<Coordinator>) {
tokio::spawn(async move {
// Seed the running-set from the first poll so we don't emit a
// crash for every agent on startup. First tick fills it; only
// running→stopped transitions across subsequent ticks count.
let mut prev_running: HashSet<String> = HashSet::new();
let mut seeded = false;
loop {
let raw = lifecycle::list().await.unwrap_or_default();
let mut current_running = HashSet::new();
for c in &raw {
let logical = if c == MANAGER_NAME {
MANAGER_NAME.to_owned()
} else if let Some(n) = c.strip_prefix(AGENT_PREFIX) {
n.to_owned()
} else {
continue;
};
if lifecycle::is_running(&logical).await {
current_running.insert(logical);
}
}
if seeded {
let transients = coord.transient_snapshot();
for stopped in prev_running.difference(&current_running) {
let deliberate = transients.get(stopped).is_some_and(|st| {
matches!(
st.kind,
TransientKind::Stopping
| TransientKind::Restarting
| TransientKind::Destroying
| TransientKind::Rebuilding
)
});
if deliberate {
continue;
}
tracing::warn!(agent = %stopped, "container crash detected");
coord.notify_manager(&hive_sh4re::HelperEvent::ContainerCrash {
agent: stopped.clone(),
note: Some("container stopped without an operator action".into()),
});
}
}
prev_running = current_running;
seeded = true;
tokio::time::sleep(POLL_INTERVAL).await;
}
});
}

View file

@ -12,6 +12,7 @@ mod auto_update;
mod broker;
mod client;
mod coordinator;
mod crash_watch;
mod dashboard;
mod events_vacuum;
mod lifecycle;
@ -130,6 +131,10 @@ async fn main() -> Result<()> {
// Per-agent events.sqlite vacuum: host-side so the harness
// doesn't need any retention wiring of its own.
events_vacuum::spawn(coord.clone());
// Container crash watcher: emits HelperEvent::ContainerCrash
// when a previously-running container goes away without an
// operator-initiated transient state.
crash_watch::spawn(coord.clone());
let dash_coord = coord.clone();
tokio::spawn(async move {
if let Err(e) = dashboard::serve(dashboard_port, dash_coord).await {

View file

@ -259,6 +259,16 @@ pub enum HelperEvent {
/// A sub-agent's container was torn down (container removed; state
/// dirs preserved per `destroy` semantics).
Destroyed { agent: String },
/// Container exited without an operator-initiated stop. Fired by
/// the crash watcher when an agent's container transitions from
/// running → stopped and no `Stopping` / `Restarting` /
/// `Destroying` transient was set, so the operator (or the
/// manager) knows it crashed rather than was killed on purpose.
ContainerCrash {
agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
/// The operator answered a question that was queued via
/// `AskOperator`. `id` matches the `QuestionQueued.id` returned to the
/// asker; `question` echoes the original prompt so the manager can

View file

@ -7,7 +7,12 @@
boot.isNspawnContainer = true;
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ];
# `claude-code` is unfree. hyperhive intentionally does NOT auto-allow
# it — the operator opts in by setting
# `nixpkgs.config.allowUnfreePredicate` (or `allowUnfree = true`) in
# their own host config / agent.nix. Without that, the per-agent
# build fails on this package and the operator sees an honest "this
# is unfree, are you sure?" error.
environment.systemPackages = with pkgs; [
hyperhive