Watch
0
0
Fork
You've already forked hyperhive
0

agents: remove send(operator) and the operator inbox; alert on turn failures

Agents reach the operator over matrix now (#5115). A `send` to
`operator` is refused, by the broker and by the MCP allow-list check,
with an error that names the matrix MCP tools. The dashboard's Y3R C4LL
tab goes with it: the operator inbox, `GET /api/operator-inbox`,
`Broker::unread_for_recipient`, and the browser notifications that only
ever fired on operator-bound messages.

The harness no longer posts turn failures and plugin-install failures
to the operator inbox. Both are already logged at warn; a new Grafana
rule, `hh-agent-turn-failed`, fires per agent on the harness's
`turn failed` line.

Dashboard-to-agent messaging (op-send, the agent web UI's message box,
mark-all-read for agent inboxes) is unchanged.

Decisions per mara on #5115: q1 a, q2 a plus a grafana alert, q3 keep
dashboard-to-agent messaging and leave swarm-level code alone.
This commit is contained in:
atlas 2026-10-12 00:44:11 +02:00 • committed by mara
commit 7e6a4c7727
37 changed files with 246 additions and 848 deletions

View file

@ -78,7 +78,7 @@ Every agent is a full NixOS container with its own unix user (passwordless
sudo inside the cage) and a durable, self-compacting session. Out of the
box it gets:
- **messaging** — `send` / `recv` to other agents and the operator,
- **messaging** — `send` / `recv` to other agents (the operator is on matrix),
`remind` to wake itself, scheduled prompts the operator sets for it at
swarm level, and a todo list that forge notifications and other
producers feed. → [schedules](docs/swarm/schedules.md)

View file

@ -42,7 +42,7 @@ Five tables, all in one file — the message queue, a small key/value
table, the read-only schedule header/targets split, and the per-agent
power-intent registry:
- `messages` — every inter-agent / operator-bound message.
- `messages` — every inter-agent / operator-authored message.
`sender / recipient / body / sent_at / delivered_at / acked_at /
in_reply_to / priority`. `in_reply_to` links a reply to its parent
row id; the dashboard and per-agent inbox render these as threaded

View file

@ -25,7 +25,7 @@ Four env vars cover the identity surface, read by
module.
- `HYPERHIVE_HIVE_DOMAIN` — the hive's canonical DNS domain (e.g.
`darkest.space`), set by `nix/host-modules/hive-c0re/environment.nix`
from `services.hyperhive.domain`. When configured, `qualified_label()`
from `services.hyperhive.domain`. When configured, `qualify(label)`
returns `${label}@${domain}` (for example `iris@darkest.space`); when
unset (single-hive deployments, dev/test) it degrades to just
the short label so existing callers see no change. The
@ -46,11 +46,6 @@ can share a swarm name. Humans want both: the address
(`@darkest.space`) AND the prose name (`pr1ma`). Matrix MXIDs
use the domain-based convention.
`qualify(label)` is the same shape as `qualified_label()` but
applies to an arbitrary label the caller already has (for example a peer
name from the broker); it's the right surface when rendering a
peer's name when the caller knows it's hive-local.
## Identity = socket
No auth tokens exist on the per-agent unix sockets. The socket
@ -87,8 +82,10 @@ angle-bracket and asterisk shapes below are structurally safe.
- `*` — broadcast: deliver to every running agent except the sender
(`socket_server::handle_send` fans out via `Coordinator::broadcast_send`).
- `operator` — the human at the dashboard. Messages accumulate in the
inbox view; no agent ever `recv`'s them.
- `operator` — the human at the dashboard. Reserved so no agent can take
the name, and refused as a recipient: the broker answers a `send` to it
with `OPERATOR_SEND_REFUSAL` (`hive-sh4re/src/manager.rs`). It appears
only as the sender of operator-authored messages.
The broker stores the recipient exactly as the agent passed it.

View file

@ -434,7 +434,7 @@ system prompts — and federated hives at different domains can share one.
`hiveName` surfaces in the same places but is also the leftmost label of the
hive's domain. `swarm.name` names the group; `hiveName` names this hive.
The env-var chain and `qualify()` / `qualified_label()` semantics:
The env-var chain and `qualify()` semantics:
[`conventions.md`](../process/conventions.md) § Hive identity.
Trust inside a swarm comes from the secret store's `pki` root

View file

@ -126,9 +126,7 @@ user-configured MCP server can push its own todos the same way. See
what each built-in todo producer watches and how the store + `get_loose_ends`
merge work.
Plugin install failures aren't fatal: each entry comes back as a
human-readable failure string that gets routed via
`Surface::send_to_operator` to the operator.
Plugin install failures aren't fatal: the harness logs each at `warn` and skips it.
### Turn outcomes
@ -144,7 +142,7 @@ else a `TurnError`) drives the post-claude branch:
| `Err(SessionNotFound)` | resume + create self-heal both missed ("shouldn't happen"); requeue inflight so the next turn creates fresh — no status park, message not dropped |
| `Err(ApiStall)` | idle watchdog sent claude SIGINT after `HIVE_TURN_IDLE_SECS` (default 600) of output silence and claude exited; sleep `HIVE_STALL_SLEEP_SECS` (default 60), requeue inflight, status back to `online` |
| `Err(AgentStall(note))` | the same, for an ACP agent: after `HIVE_TURN_IDLE_SECS` with no `session/update` the harness sent the agent `session/cancel`, and SIGINT if it ignored that; `note` says which |
| `Err(Failed(err))` | route `[system] \`<qualified-label>\` claude turn failed:\n<err>` to `operator` via `send_to_operator` |
| `Err(Failed(err))` | the harness logs it at `warn` as `turn failed`, the line the Grafana alert rule `hh-agent-turn-failed` (`nix/host-modules/swarm-grafana.nix`) fires on |
`ApiStall` catches an Anthropic API stall — a multi-retry connection storm where
the stream goes silent for minutes. On `claude` the idle watchdog lives in

View file

@ -32,14 +32,13 @@ is opt-in via the P3RM1SS10NS tab.
**Messaging** (`messaging` group): `send(to, body, in_reply_to?)`,
`recv(max?)`, `ack_until(up_to)`.
- `send` — message a peer (logical name) or the operator
(`to: "operator"`). Optional `in_reply_to: i64` links the
message to a prior id for thread rendering. Per-agent
`services.hyperhive.agent.allowedRecipients` (default: empty = unrestricted) limits
which names `send` accepts — useful for sandboxing: set
`[ "operator" ]` to restrict a sub-agent to operator messages only.
The operator stays reachable regardless of this list, so an agent can
always report a block.
- `send` — message a peer on this hive (logical name). Optional
`in_reply_to: i64` links the message to a prior id for thread
rendering. Per-agent `services.hyperhive.agent.allowedRecipients`
(default: empty = unrestricted) limits which names `send` accepts —
useful for sandboxing. The broker refuses `to: "operator"` with an error
that points at the matrix MCP tools: agents reach the operator over
matrix ([matrix](../tools/matrix.md)).
- `recv` — drain inbox. Always an immediate peek, never blocks. `max`
(default 1, cap 5) drains up to N rows. `recv` prefixes each returned row
with `[msg #<id>]` (broker row id; note the highest id seen, then pass

View file

@ -16,12 +16,10 @@ Settings, Core). Every page links back to H0M3, so you're
never more than one step from the hub.
The **dashboard** itself (`/dashboard.html`) is where you'll spend most
of your time. It's a single page with exactly four tabs:
of your time. It's a single page with exactly three tabs:
- **SW4RM** — every agent, live. This is the default tab and the one
you'll check most.
- **Y3R C4LL** — anything waiting on _you_: messages agents sent to
the operator.
- **P3RM1SS10NS** — what tools and system-level access each agent has.
- **SCH3DUL3S** — a read-only list of the scheduled prompts left in the
hive, due for removal; operators set schedules at swarm level now.
@ -30,9 +28,7 @@ Everything else lives on its own page instead, all reachable from the
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
the whole swarm), **Stats** (`/stats.html`, swarm-wide usage stats),
**Builds** (`/builds.html`, the rebuild queue and build history), and
**Core** (`/core.html`, tombstones and container resource use). Your local
browser preferences (notifications) live in the dashboard's Y3R C4LL
tab.
**Core** (`/core.html`, tombstones and container resource use).
Each agent also has its own page — a full terminal view of that
agent's session, reachable by selecting its name anywhere in the
@ -47,10 +43,6 @@ whether it's running, what it's currently doing (a live status pill —
and quick links (stats, screen, forge profile). Select the name to open
its terminal and watch it work in real time.
**Read what agents sent you.** Y3R C4LL is the one tab worth checking
regularly — it's everything that needs _you_. The tab's count pill
tells you at a glance if anything's unread.
**Review a config change.** Agent config changes (new packages, env
vars, MCP servers) are pull requests on the agent's `agent-configs`
repo. Review and merge them on the forge; the merge deploys the change.

View file

@ -10,7 +10,7 @@ matrix accounts, the controller's job graph — are on the
hive-c0re serves the dashboard at `/dashboard.html` (with the home page at `/`).
It has a fixed chrome header at the top and a `<main>` that shows exactly
one tab pane at a time. The URL hash (`#swarm`, `#call`,
one tab pane at a time. The URL hash (`#swarm`,
`#permissions`, `#schedules`) drives which pane is
active; hash changes don't reload the page. FL0W and the other
standalone pages are reachable from the H0M3 hub at `/`, not
@ -19,13 +19,12 @@ from the dashboard tab strip.
**Chrome header** (fixed, overlays the active tab pane):
- **← home back-link**: top-left of the chrome, links to the H0M3 hub
at `/`. Every surface links back to the hub rather than to each other.
- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ P3RM1SS10NS ◆`,
- **Tab strip**: `◆ SW4RM ◆`, `◆ P3RM1SS10NS ◆`,
`◆ SCH3DUL3S ◆`. In-page tabs only — the SYST3M panels live on the
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
live on their own pages too, all reachable from the
H0M3 hub (not the tab strip). Count pills on SW4RM
(container count), Y3R C4LL (unread
operator messages), and SCH3DUL3S (active schedules left to copy).
(container count) and SCH3DUL3S (active schedules left to copy).
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip.
- **Server-warnings banner** — a generic, sticky top-of-page strip shown
@ -46,12 +45,7 @@ from the dashboard tab strip.
snapshot. Hidden when there are no warnings.
- **Browser tab title** — `hyperhive // h1ve-c0re` by default; updated
to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name`
arrive in the state snapshot. When there are unread operator messages,
the page prepends a `(N)` prefix — `(3) pr1ma // h1ve-c0re` — so the
operator can see the call count in an unfocused browser tab without
opening the dashboard. The page sets the prefix on the operator-inbox
cold-load and updates it live as broker `sent` events arrive; it's
preserved when `hive_name` / `swarm_name` later replace the raw title.
arrive in the state snapshot.
The FL0W page uses a slim header (a `← home` back-link + the page
title) rather than the dashboard tab strip — it's a standalone surface,
@ -71,41 +65,6 @@ conflicting agent.
`↻ UPD4TE 4LL` button appears above the containers list when any
agent is stale.
## Y3R C4LL tab
Things blocked on operator decision — the operator inbox.
**0PER4T0R 1NB0X** — messages agents have sent to `to="operator"` but
the operator hasn't read yet. Cold-loaded from `/api/operator-inbox` on
tab activation + page load; appended live from the broker `sent` stream
(deduped on row id). Each row shows sender · timestamp · body (with
file-path linkification). A `✓ mark all read` button on the right acks
all rows via `POST /api/agent/operator/mark-all-read` (reuses the existing
mark-read endpoint). Unread count folds into the Y3R C4LL tab pill so
messages are visible from any tab even while inactive. Backed by
`GET /api/operator-inbox` → `{ messages: [...] }` (id, from, body, at,
in_reply_to, file_refs).
**◆ PR3F3R3NC3S ◆** — operator-local preferences. State lives in the
browser's `localStorage` — preferences **don't** sync between devices and
**don't** survive a profile wipe. One section today (browser
notifications); future preferences land here as sibling `<h3>` blocks
in `dashboard.html` — a standalone page for one toggle wouldn't earn
its own tile.
**◇ browser notifications** — `🔔 enable notifications` button when
permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted
(mute silences the dispatch without revoking the OS-level
permission). On unsupported origins (non-secure context, or
browsers without the `Notification` API) the controls hide and a
single status line explains why. See `### Browser notifications`
below for the dispatch model + the three signals the dashboard
emits OS notifications on.
The "expand tool output panels" toggle lives on the per-agent page's
own settings popover (`SettingsMenu`, `@hive/shared/settings-menu.js`)
instead — it's agent-terminal-only, unrelated to this section.
## C0R3 page (`/core.html`)
Passive / rare-interaction state, reached from the **Core** tile on the
@ -437,14 +396,6 @@ the model id, longest match wins) mapping to
`{ input, output, cache_read, cache_write }` USD-per-million-token
prices. Models not covered fall back to hive-c0re's built-in estimate.
The FL0W page **doesn't** host the PR3F3R3NC3S pane — it lives only on the
dashboard's Y3R C4LL tab (reach it via the FL0W page's `← home`
back-link → Dashboard). Notifications still fire on the FL0W page when
they're enabled there, because `NOTIF.show()` in
`common.js` depends on `Notification.permission` + the
`hyperhive.notify.muted` localStorage key, not on the buttons
existing in the page DOM.
## FL0W page (`/flow.html`)
A dedicated full-page terminal (not a tab pane — a separate HTML
@ -452,10 +403,6 @@ page). Slim chrome: a `← home` back-link to the H0M3 hub, the FL0W
title, and the agent-filter select (see below). No dashboard tab
strip — FL0W is a standalone surface reached from the hub.
The operator inbox is **not** on this page — it lives on the
dashboard's Y3R C4LL tab (◆ 1NB0X ◆ section, with per-message and
mark-all read). FL0W stays the pure event firehose.
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
Cold load backfills the last ~200 messages from `/api/dashboard/history`;
live frames arrive on `/api/dashboard/stream`. Each row is one broker
@ -767,30 +714,6 @@ frosted-mauve bar slides up from the bottom of the viewport
Every render prunes stale selections (agents destroyed while
selected) before the bar appears.
## Browser notifications
Pure frontend (`Notification` API), triggered by a new broker message
sent `to: "operator"` (live via SSE).
The toggle controls live in the Y3R C4LL tab's ◆ PR3F3R3NC3S ◆
section; see that section above for the user-facing shape. Dispatch
logic lives in `common.js::NOTIF`.
<!-- vale write-good.Passive = NO -->
Only messages that arrive while the page is open count. Per-event tags
(`hyperhive:msg:<at>:<rand>`) so distinct events stack in the OS
notification center instead of overwriting each other.
`console.debug` logs at every block point (unsupported,
permission ungranted, muted) for in-browser debugging. Selecting it
focuses the dashboard tab. The localStorage key
`hyperhive.notify.muted` (`"1"` = muted, absent = unmuted) backs
the toggle and silences dispatch without revoking the OS
permission. Requires a secure context (HTTPS or localhost); on
other origins the controls hide themselves. Browsers typically
suppress notifications while the originating tab is focused —
that's a browser-level decision, not the dashboard's.
<!-- vale write-good.Passive = YES -->
## Dashboard endpoints
Also browsable interactively at `/api/docs` (a Swagger UI, linked from
@ -812,13 +735,6 @@ below — some endpoints aren't in it yet.
not yet delivered and sets `acked_at = now`. Returns
`{ "marked": N }`. Agent name validated against
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
- `GET /api/operator-inbox` — list unread messages addressed to
`to="operator"` (broker rows with `acked_at = NULL`). Cold-loaded
for the Y3R C4LL tab's ◆ 1NB0X ◆ section on page load + tab activation;
live updates fed from the broker `sent` stream. Returns
`{ messages: [{ id, from, body, at, in_reply_to, file_refs }, …] }`,
newest-first. Reuses `/api/agent/operator/mark-all-read` to ack (filters
are identical so every listed row is exactly what mark-read clears).
- `POST /api/op-send` (`to=<name>`, `body=<text>`) — drop an
operator-authored message into `<name>`'s inbox. `to=*` fans
out to every registered agent. Returns 200; the broker
@ -950,8 +866,7 @@ payload):
(the broker row id) and `in_reply_to: Option<i64>` for thread
rendering. The dashboard message-flow terminal renders reply
rows with a `↳ reply` tag that scroll-highlights the parent
row on selection. Used by the message-flow terminal renderer and
the operator-inbox derived state.
row on selection. Used by the message-flow terminal renderer.
- `transient_set` (name, transient_kind, since_unix) /
`transient_cleared` (name) — lifecycle action spinners. The
client ticks the elapsed-seconds badge off `since_unix`
@ -1007,7 +922,7 @@ payload):
forms that mutate non-event-derived state** (PURG3 +
meta-update, since tombstones + meta_inputs aren't event-
shaped yet). Every other section — transients,
containers, operator inbox, message flow —
containers, message flow —
derives from `/api/dashboard/stream` after the initial snapshot,
maintaining its own client-side store and applying events on
top.

View file

@ -208,7 +208,7 @@ re-render), call `refreshState()`. State shapes live in
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
`web_ui/state.rs::StateSnapshot` — when
adding state fields, plumb through the snapshot struct and the
relevant domain module (`swarm.js`, `call.js`, etc.) render function.
relevant domain module (`swarm.js`, `schedules.js`, etc.) render function.
**Focus preservation:** `refreshState` checks whether
`document.activeElement` sits inside one of the managed sections

View file

@ -5,7 +5,7 @@
// avoid this comment drifting out of sync with the real build steps.
//
// Each JS entry bundles `./common.js` (DOM helpers, Panel singleton,
// NOTIF, path linkification) independently — esbuild inlines the shared
// path linkification) independently — esbuild inlines the shared
// module into each bundle rather than emitting a shared chunk (no
// `splitting: true`). The Rust binary mounts `dist/` as a
// `tower_http::ServeDir` fallback (`append_index_html_on_directories`

View file

@ -1,114 +0,0 @@
// Y3R C4LL domain — the dashboard pane for things blocked on an operator
// decision: the operator inbox (messages agents sent to
// `to="operator"`). Lifted out of the dashboard entry (tabs.js) into its
// own module so the entry stays a thin coordinator; mirrors the
// permissions.js / schedules.js extractions.
//
// Tab-count wiring: like schedules.js this module does not call the
// coordinator's `refreshTabCounts` directly (that would be a circular
// import). Instead it exposes count getters the coordinator pulls, and the
// live-mutation paths call an injected `onCountsChanged` callback the entry
// registers once via `initCall`.
import { $, appendLinkified } from "./common.js";
import { el } from "@hive/shared/dom.js";
// Registered by the dashboard entry at boot; defaults to a no-op so the
// module is safe to call before wiring.
let onCountsChanged = () => {};
export function initCall(opts = {}) {
if (typeof opts.onCountsChanged === "function")
onCountsChanged = opts.onCountsChanged;
}
// ─── operator inbox — unread agent→operator messages ────────────
// The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so
// the operator stops missing them. Unread = broker rows to "operator"
// with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`,
// appended live from the broker `sent` stream, and cleared via the
// existing per-recipient ack (`POST /api/agent/operator/mark-all-read`).
// Count folds into the Y3R C4LL pill + browser-title prefix.
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
export function operatorInboxCount() {
return operatorInbox.length;
}
export async function refreshOperatorInbox() {
try {
const r = await fetch("/api/operator-inbox");
if (r.ok) {
const data = await r.json();
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
}
} catch {
/* keep prior list on transient failure */
}
renderOperatorInbox();
onCountsChanged();
}
function renderOperatorInbox() {
const root = $("operator-inbox-section");
if (!root) return;
root.replaceChildren();
if (!operatorInbox.length) {
root.append(el("p", { class: "meta" }, "no unread messages"));
return;
}
const mark = el(
"button",
{ type: "button", class: "btn", id: "op-inbox-mark-read" },
`✓ mark all read (${operatorInbox.length})`,
);
mark.addEventListener("click", markOperatorInboxRead);
root.append(el("div", { class: "inbox-toolbar" }, mark));
const fmt = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 19);
const ul = el("ul", { class: "inbox" });
for (const m of operatorInbox) {
const body = el("span", { class: "msg-body" });
appendLinkified(body, m.body, m.file_refs);
ul.append(
el(
"li",
{},
el("span", { class: "msg-ts" }, fmt(m.at)),
" ",
el("span", { class: "msg-from" }, m.from),
" ",
el("span", { class: "msg-sep" }, "→ "),
body,
),
);
}
root.append(ul);
}
async function markOperatorInboxRead() {
try {
await fetch("/api/agent/operator/mark-all-read", { method: "POST" });
} catch {
/* best-effort; the next refresh reconciles */
}
operatorInbox = [];
renderOperatorInbox();
onCountsChanged();
}
// Live append from the broker stream — a `sent` frame addressed to
// "operator". De-dupes on broker row id so a history/live overlap or
// a refresh racing the stream doesn't double-list.
export function operatorInboxAppendFromEvent(ev) {
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
operatorInbox.unshift({
id: ev.id,
from: ev.from,
body: ev.body,
at: ev.at,
file_refs: ev.file_refs || [],
});
if (operatorInbox.length > 100) operatorInbox.length = 100;
renderOperatorInbox();
onCountsChanged();
}

View file

@ -337,46 +337,8 @@ code {
pages' sticky back-link bar) moved to @hive/shared/chrome.css, imported
at the top of this file. */
/* ─── operator inbox + message rows ────────────────────────────────
.inbox and .msg-* are rendered by common.js (renderInbox) and
flow.js — both the dashboard side panel and the /flow.html page
display broker messages. */
.inbox {
background: var(--bg-elev);
border: 1px solid var(--border);
padding: 0.5em 0.8em;
/* No max-height cap — let the inbox grow to fill the
side-panel-body which already scrolls. */
}
.inbox li {
padding: 0.25em 0;
border-bottom: 1px solid var(--border);
display: grid;
grid-template-columns: auto auto auto 1fr;
gap: 0.5em;
align-items: baseline;
}
.inbox li:last-child {
border-bottom: 0;
}
.inbox .msg-ts {
color: var(--muted);
font-size: 0.85em;
}
.inbox .msg-from {
color: var(--amber);
}
.inbox .msg-sep {
color: var(--muted);
}
.inbox .msg-body {
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
}
/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap`. The
msgrow / msg-* rules below power both the dashboard CALL tab and
the /flow.html full-page terminal. */
msgrow / msg-* rules below power the /flow.html full-page terminal. */
.live .msgrow {
display: flex;
flex-wrap: wrap;

View file

@ -1,7 +1,6 @@
// Shared dashboard helpers used by both dashboard.html (./tabs.js) and
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
// the OS-notification module, and the path-link / file-preview
// infrastructure for the side panel.
// and the path-link / file-preview infrastructure for the side panel.
import { linkify as termLinkify } from "@hive/shared/terminal.js";
import { el } from "@hive/shared/dom.js";
@ -563,93 +562,6 @@ export function appendLinkified(parent, text, refs) {
}
}
// ─── browser notifications ──────────────────────────────────────────────
// Fires OS notifications on broker messages 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.
export 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, tag) {
if (!supported) {
console.debug("notify: Notification API not supported");
return;
}
if (Notification.permission !== "granted") {
console.debug("notify: permission not granted", Notification.permission);
return;
}
if (isMuted()) {
console.debug("notify: muted");
return;
}
try {
// Per-event tag so distinct messages stack instead of
// collapsing into one slot. Caller passes a unique tag per
// notification kind/id; we don't fall back to 'hyperhive'
// because that one tag would replace itself on every fire.
const n = new Notification(title, {
body,
tag: tag || "hyperhive:" + Date.now(),
});
n.onclick = () => {
window.focus();
n.close();
};
console.debug("notify: shown", title, "tag=", tag);
} catch (err) {
console.warn("notification show failed", err);
}
}
return { bind, show, renderControls };
})();
// ─── server warnings banner ──────────────────────────────────────────
// A generic top-of-page banner shown on every page (dashboard + the
// stand-alone FL0W / H0M3 pages). The backend decides what to

View file

@ -125,11 +125,6 @@ body.dashboard-shell {
font-variant-numeric: tabular-nums;
font-weight: bold;
}
.tab-count-attn {
/* Y3R C4LL — operator-action-required pill colour, harder to miss. */
background: color-mix(in srgb, var(--red) 22%, transparent);
color: var(--red);
}
/* Tab pane visibility — createTabStrip (@hive/shared/tabs.js) sets the
`hidden` attribute on every inactive pane (resolved from the URL hash;
@ -443,41 +438,12 @@ hive-agent-menu {
the standing caveat (a pulsing banner that's always present just
teaches you to stop seeing it), `severity="red" pulse` for the port
collision (an incident, not a caveat). See core.js/swarm.js and
hive-warn.css for the actual styling now.
Notification controls below sit between the banner and the containers. */
hive-warn.css for the actual styling now. */
/* Build-queue summary banner on the SW4RM tab moved to the shared
`JobqRollup` Preact component (`.jqr-*` classes, imported above) —
mounted by swarm.js::initJobqRollup into #jobq-rollup-section. */
/* Browser-notification toggle row, now living in the Y3R C4LL tab's
◆ PR3F3R3NC3S ◆ section (was its own S3TT1NGS page/tile; mara: a
standalone page for one toggle didn't earn its own tile). Buttons
wired by NOTIF.bind() in common.js. */
.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: color-mix(in srgb, var(--cyan) 10%, transparent);
box-shadow: 0 0 10px -2px currentColor;
}
.pending-state {
color: var(--amber);
font-size: 0.85em;

View file

@ -35,21 +35,6 @@
<span class="tab-label">◆ SW4RM ◆</span>
<span class="tab-count" id="tab-count-swarm" hidden></span>
</a>
<a
class="tab"
id="tab-call"
href="#call"
role="tab"
aria-controls="tab-pane-call"
data-tab="call"
>
<span class="tab-label">◆ Y3R C4LL ◆</span>
<span
class="tab-count tab-count-attn"
id="tab-count-call"
hidden
></span>
</a>
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
reached from the H0M3 hub — keeps the dashboard tab strip lean.
The rebuild-queue STATE still lives in tabs.js (it drives the
@ -132,74 +117,6 @@
</div>
</section>
<!-- Y3R C4LL: things blocked on operator decision — the operator
inbox; surfaces full bodies inline so the operator can decide
without leaving the pane. -->
<section
class="tab-pane"
id="tab-pane-call"
data-tab-pane="call"
hidden
role="tabpanel"
aria-labelledby="tab-call"
>
<!-- 1NB0X: unread agent→operator messages. Fetched on
tab activation + cold load, appended live from the broker
stream, cleared via "mark all read". Count folds into the
Y3R C4LL pill so messages aren't missed. -->
<h2>◆ 1NB0X ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<div id="operator-inbox-section">
<p class="meta">loading…</p>
</div>
<!-- Operator-local preferences (browser notifications). Was its own
S3TT1NGS page (`/settings.html`); mara moved it in here since a
standalone page for one toggle didn't earn its own tile. State
lives in the browser's localStorage — NOTIF.bind() (common.js)
wires the buttons, same dispatch NOTIF.show() already used for
inbox notifications above. -->
<h2>◆ PR3F3R3NC3S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
operator-local preferences. these live in the browser's localStorage
— they do not sync between devices and do not survive a profile
wipe.
</p>
<h3>◇ browser notifications</h3>
<p class="meta">
desktop notifications for broker messages addressed to you. requires
a secure context (https or localhost). mute silences the
notifications without revoking the OS-level permission.
</p>
<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>
</section>
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
meta inputs, rebuild queue, kept state, container load. Reached
from the H0M3 hub. -->

View file

@ -1,9 +1,7 @@
// /flow.html entry point. Owns the full-page broker terminal and the
// @-mention compose box. Pulls shared infrastructure (DOM helpers,
// side panel, OS notifications, path linkification) from `./common.js`.
//
// The operator inbox lives on the dashboard's Y3R C4LL tab now; FL0W
// stays the pure event firehose.
// side panel, path linkification) from `./common.js`. FL0W is the pure
// event firehose.
//
// Does NOT contain the dashboard's tab renderers, mutation-event
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
@ -14,7 +12,6 @@
import { create as termCreate } from "@hive/shared/terminal.js";
import {
$,
NOTIF,
appendLinkified,
openStream,
initServerWarnings,
@ -23,7 +20,6 @@ import { el } from "@hive/shared/dom.js";
import { epochSec } from "./util.js";
(() => {
NOTIF.bind();
initServerWarnings();
// ─── local containers cache (for compose autocomplete) ──────────────────
@ -94,8 +90,7 @@ import { epochSec } from "./util.js";
// ─── message flow: shared terminal pane ────────────────────────────────
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
// What stays here is the broker-message renderer + the page-local
// side effects (banner pulse, OS notifications on operator-bound
// traffic).
// side effect (banner pulse).
(() => {
const flow = $("msgflow");
if (!flow) return;
@ -283,21 +278,7 @@ import { epochSec } from "./util.js";
})
.catch(() => {});
},
onLiveEvent: (ev) => {
pulseBanner();
if (ev.kind === "sent" && ev.to === "operator") {
NOTIF.show(
"◆ " + ev.from + " → operator",
String(ev.body || "").slice(0, 200),
// Unique-per-arrival tag so a burst stacks instead of
// overwriting itself in the OS notification center.
"hyperhive:msg:" +
ev.at +
":" +
Math.random().toString(36).slice(2, 6),
);
}
},
onLiveEvent: () => pulseBanner(),
});
})();

View file

@ -3,7 +3,7 @@
//
// The roster is read by nearly every tab (the container tree, the
// capabilities + tool-group matrices, the schedule target-chips, the
// container-load poll, the selection bar, and the operator inbox), so
// container-load poll, and the selection bar), so
// it lives here as a single source of truth that each domain module
// imports rather than threading through call signatures. Everything
// else in the dashboard is domain-local and lives with its own module.

View file

@ -593,7 +593,7 @@ export function renderContainers(s) {
// Belt-and-suspenders for any future page that adds tabs.js
// without a #containers-section — matches the
// no-op-when-target-absent convention the other renderers
// (renderOperatorInbox, etc.) follow.
// follow.
if (!root) return;
// Containers come from the derived map (event-driven) rather than

View file

@ -4,14 +4,14 @@
// dashboard event stream (subscribed via `openStream` from common.js). See
// docs/web-ui/shape.md::Shape (shared by both) for the broader contract.
//
// Pure helpers (DOM, side panel, OS notifications, path linkification)
// live in `./common.js`; the flow-page surface (operator inbox, broker
// Pure helpers (DOM, side panel, path linkification)
// live in `./common.js`; the flow-page surface (broker
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
// `dashboard.html` each load their own bundle.
// SW4RM (containers) domain lives in `./swarm.js`.
import { marked } from "marked";
import { $, NOTIF, openStream, renderServerWarnings } from "./common.js";
import { $, openStream, renderServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
import { createTabStrip } from "@hive/shared/tabs.js";
@ -24,12 +24,6 @@ import {
initPermissions,
} from "./permissions.js";
import { refreshSchedules, activeScheduleCount } from "./schedules.js";
import {
initCall,
refreshOperatorInbox,
operatorInboxAppendFromEvent,
operatorInboxCount,
} from "./call.js";
import {
initJobqRollup,
syncTransientsFromSnapshot,
@ -138,10 +132,7 @@ window.marked = marked;
const label = swarm && hive ? `${swarm} / ${hive}` : hive || swarm;
hiveId.textContent = label;
hiveId.hidden = false;
// Preserve any (N) call-count prefix already applied by
// refreshTabCounts so the title doesn't flicker on reload.
const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || "";
document.title = existingPrefix + label + " // h1ve-c0re";
document.title = label + " // h1ve-c0re";
}
}
const openDetails = snapshotOpenDetails();
@ -181,22 +172,10 @@ window.marked = marked;
}
}
refreshState();
// Cold-load the operator inbox so the Y3R C4LL pill + browser
// title reflect unread agent->operator messages immediately, before the
// operator opens the tab. Live updates arrive via the broker stream.
refreshOperatorInbox();
// NOTIF.bind() wires the enable/mute/unmute toggle buttons that now
// live in the Y3R C4LL tab's ◆ PR3F3R3NC3S ◆ section. NOTIF.show()
// (inbox notifications) reads the same browser permission +
// localStorage mute flag regardless of which page fires it — bind()
// only needs to run once, here, where the buttons live. The side
// panel (<hive-side-panel>) wires its own internals in
// connectedCallback — no bind() step needed for it.
NOTIF.bind();
// ─── live updates: dashboard event stream ──────────────────────────────
// The dashboard subscribes to /dashboard/stream for live mutation
// events so the SW4RM / Y3R C4LL / SYST3M panes update without an
// events so the SW4RM / SYST3M panes update without an
// operator action triggering a refreshState.
//
// Bare `EventSource` (no terminal infrastructure needed — the
@ -209,7 +188,7 @@ window.marked = marked;
//
// Both /dashboard.html and /flow.html subscribe to `/api/dashboard/stream`
// and filter client-side — the dashboard ignores broker traffic
// and the inbox ignores mutation events.
// and FL0W ignores mutation events.
const MUTATION_HANDLERS = {
transient_set: applyTransientSet,
transient_cleared: applyTransientCleared,
@ -231,14 +210,10 @@ window.marked = marked;
// EventSource-shaped facade with a graceful direct-EventSource
// fallback when SharedWorker isn't supported.
//
// kinds= matches MUTATION_HANDLERS below verbatim, plus `sent`
// (checked separately, just above, for the operator inbox) —
// narrows this from all 12 wire kinds down to the 8 this page
// actually acts on. This page was one of 4 unfiltered
// `/api/dashboard/stream` subscribers before this (subscription
// discipline, part 1 of the dashboard-event-stream-split issue).
// kinds= matches MUTATION_HANDLERS verbatim, so the stream carries
// only the 7 wire kinds this page acts on.
const es = openStream(
"/api/dashboard/stream?kinds=sent," +
"/api/dashboard/stream?kinds=" +
"transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," +
"capabilities_changed,tool_groups_changed",
@ -250,12 +225,6 @@ window.marked = marked;
} catch {
return;
}
// Broker `sent` frames aren't mutation events, but the operator
// inbox cares about ones addressed to "operator".
if (ev.kind === "sent" && ev.to === "operator") {
operatorInboxAppendFromEvent(ev);
return;
}
const h = MUTATION_HANDLERS[ev.kind];
if (!h) return; // broker rows + future kinds — dashboard doesn't care
try {
@ -279,7 +248,7 @@ window.marked = marked;
})();
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#swarm` / `#call` / `#system` / `#permissions` /
// Hash-based: `#swarm` / `#system` / `#permissions` /
// `#schedules` activate the matching pane on the dashboard. Empty
// hash defaults to SW4RM. FL0W and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/stats.html`) reached from
@ -311,9 +280,6 @@ window.marked = marked;
fetchAndRenderCapabilities();
fetchAndRenderToolGroups();
}
if (target === "call") {
refreshOperatorInbox();
}
}
// Wire the shared tab strip now that activateTab + the lazy-load fns it
@ -322,11 +288,6 @@ window.marked = marked;
// fires activateTab for the per-tab side-effects on every change.
createTabStrip($("tabbar"), { defaultId: "swarm", onShow: activateTab });
// Register the Y3R C4LL domain's count callback (call.js) — its live
// mutations (inbox stream append, mark-read) trigger a tab-count refresh
// through this instead of reaching back into the coordinator directly.
initCall({ onCountsChanged: refreshTabCounts });
// Tab count pills — pure derived data from the existing state
// stores so SSE-driven updates flow through without extra plumbing.
// Set `hidden` when the count is zero so the pill doesn't draw
@ -349,15 +310,6 @@ window.marked = marked;
if (c.needs_update) swarm++;
}
setTabCount("swarm", swarm);
// Y3R C4LL — unread agent->operator messages.
const callCount = operatorInboxCount();
setTabCount("call", callCount);
// Browser tab title prefix — lets the operator see the pending
// call count without switching to the window. Strips any existing
// `(N) ` prefix before re-applying so identity-title updates
// (which run once on state load, not every tick) compose cleanly.
const rawTitle = document.title.replace(/^\(\d+\) /, "");
document.title = callCount > 0 ? `(${callCount}) ${rawTitle}` : rawTitle;
// SCH3DUL3S — count of schedules with at least one still-active
// target: the ones left to recreate with `swarmctl schedule`.
setTabCount("schedules", activeScheduleCount());

View file

@ -7,7 +7,7 @@ use rmcp::schemars;
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SendArgs {
/// Logical agent name to deliver the message to (e.g. `"manager"`,
/// `"alice"`, or the literal `"operator"` for the dashboard's T4LK box).
/// `"alice"`). `"operator"` is refused: reach the operator over matrix.
pub to: String,
/// Message body. Plain text; the broker doesn't parse it.
pub body: String,

Some files were not shown because too many files have changed in this diff Show more