From 58c3cd853b9af10c355905b652e20426bc9e721c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 21:02:05 +0200 Subject: [PATCH 1/4] =?UTF-8?q?container=20crash=20watcher=20=E2=86=92=20H?= =?UTF-8?q?elperEvent::ContainerCrash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- TODO.md | 6 --- docs/approvals.md | 4 ++ hive-ag3nt/prompts/manager.md | 2 +- hive-c0re/src/crash_watch.rs | 72 +++++++++++++++++++++++++++++++++++ hive-c0re/src/main.rs | 5 +++ hive-sh4re/src/lib.rs | 10 +++++ 6 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 hive-c0re/src/crash_watch.rs diff --git a/TODO.md b/TODO.md index 684c0d68..6f0ab2ab 100644 --- a/TODO.md +++ b/TODO.md @@ -99,9 +99,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.). - diff --git a/docs/approvals.md b/docs/approvals.md index af80169f..88027d63 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -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. diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index 3cf86dc0..9a239f41 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -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: diff --git a/hive-c0re/src/crash_watch.rs b/hive-c0re/src/crash_watch.rs new file mode 100644 index 00000000..a0b7dc0f --- /dev/null +++ b/hive-c0re/src/crash_watch.rs @@ -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) { + 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 = 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(¤t_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; + } + }); +} diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index fde97e01..c16410d1 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -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 { diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 90949d82..e8558d99 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -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, + }, /// 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 From 8b9f7d21b7c2cf908fec9955386470b693b000e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 21:05:40 +0200 Subject: [PATCH 2/4] model persisted to /state; stop auto-allowing claude-code unfree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit model persistence: /model 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). --- README.md | 11 +++++++++ TODO.md | 20 +++++++++------- docs/gotchas.md | 14 +++++++---- flake.nix | 7 +++++- hive-ag3nt/src/events.rs | 44 +++++++++++++++++++++++++++++++--- nix/templates/harness-base.nix | 7 +++++- 6 files changed, 84 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index de64ccea..cb8191d3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md index 6f0ab2ab..4b495e5d 100644 --- a/TODO.md +++ b/TODO.md @@ -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,15 +42,6 @@ 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 ` 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 diff --git a/docs/gotchas.md b/docs/gotchas.md index e34863d5..26a178c4 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -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 diff --git a/flake.nix b/flake.nix index 9551efba..32d9a427 100644 --- a/flake.nix +++ b/flake.nix @@ -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 { diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index 23b138f5..97712170 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -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 { + 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) { - *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 diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index a612a327..fc574bed 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -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 From a67aada7c90d16150b2c5020d348af670661962f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 21:07:21 +0200 Subject: [PATCH 3/4] todo: browser notifications for approvals / questions / operator msgs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- TODO.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/TODO.md b/TODO.md index 4b495e5d..fde20212 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,30 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## UI / UX +- **Browser notifications for operator-bound events.** Dashboard + pings the OS notification center when (a) a new approval lands + in the queue, (b) a new `ask_operator` question is queued, (c) a + broker message is sent `to: "operator"`. All three data sources + are already in `/api/state` + `/messages/stream` so this is + pure frontend. Sketch: + 1. Small "🔔 enable notifications" button somewhere (header + or near the inbox section). Clicks call + `Notification.requestPermission()`. Hide once granted. + 2. Track last-seen counts in the JS app + (`approvals.length`, `questions.length`). On + `refreshState`, if the count went up, fire + `new Notification(...)` per new item. + 3. SSE handler for `messages/stream` fires a notification on + `kind === 'sent' && to === 'operator'` (already triggers + `refreshState`; just adds a notify call alongside). + 4. Notification body links back to the dashboard (`onclick → + window.focus()` + section anchor). + Caveats: Notification API requires a secure context (HTTPS or + localhost). Most operators access via LAN / Tailscale — works + fine for localhost forwards, otherwise needs a TLS cert in the + module. Persist a per-browser "muted" toggle in localStorage so + the operator can silence without revoking permission. + - **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 ` From 237b215c554b88fe8b3117d401e4c31d663f5dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 21:10:20 +0200 Subject: [PATCH 4/4] dashboard: browser notifications for operator-bound events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- TODO.md | 23 ------- hive-c0re/assets/app.js | 117 ++++++++++++++++++++++++++++++++- hive-c0re/assets/dashboard.css | 26 ++++++++ hive-c0re/assets/index.html | 7 ++ 4 files changed, 148 insertions(+), 25 deletions(-) diff --git a/TODO.md b/TODO.md index fde20212..1965d77a 100644 --- a/TODO.md +++ b/TODO.md @@ -44,29 +44,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## UI / UX -- **Browser notifications for operator-bound events.** Dashboard - pings the OS notification center when (a) a new approval lands - in the queue, (b) a new `ask_operator` question is queued, (c) a - broker message is sent `to: "operator"`. All three data sources - are already in `/api/state` + `/messages/stream` so this is - pure frontend. Sketch: - 1. Small "🔔 enable notifications" button somewhere (header - or near the inbox section). Clicks call - `Notification.requestPermission()`. Hide once granted. - 2. Track last-seen counts in the JS app - (`approvals.length`, `questions.length`). On - `refreshState`, if the count went up, fire - `new Notification(...)` per new item. - 3. SSE handler for `messages/stream` fires a notification on - `kind === 'sent' && to === 'operator'` (already triggers - `refreshState`; just adds a notify call alongside). - 4. Notification body links back to the dashboard (`onclick → - window.focus()` + section anchor). - Caveats: Notification API requires a secure context (HTTPS or - localhost). Most operators access via LAN / Tailscale — works - fine for localhost forwards, otherwise needs a TLS cert in the - module. Persist a per-browser "muted" toggle in localStorage so - the operator can silence without revoking permission. - **Terminal: `/model` slash command.** Operator-typeable model override from the terminal. Depends on the model-override work diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 3f9aff88..6bce65d2 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -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' ? '→' : '✓'; diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index d1a1841b..0245ac61 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -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; diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html index 26e75134..4abf1dd6 100644 --- a/hive-c0re/assets/index.html +++ b/hive-c0re/assets/index.html @@ -10,6 +10,13 @@ ░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░ +
+ + + + +
+

◆ C0NTAINERS ◆

══════════════════════════════════════════════════════════════