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,

View file

@ -141,8 +141,8 @@ impl AgentServer {
#[tool_router]
impl AgentServer {
#[tool(
description = "Send a message to another hyperhive agent (or to the operator). \
Use this to talk to peers or to surface output for the human at the dashboard."
description = "Send a message to another hyperhive agent on this hive. \
Not for the operator: reach them over matrix with the matrix MCP tools."
)]
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
let log = format!("{args:?}");
@ -500,7 +500,7 @@ impl AgentServer {
#[tool_handler(
instructions = "You are a hyperhive agent. Use `send` to talk to peers (by their logical \
name) or to the operator (recipient `operator`). Use `recv` to drain your inbox one \
name); reach the operator over matrix. Use `recv` to drain your inbox one \
message at a time. Use `remind` to schedule a future wake-up message for yourself."
)]
impl ServerHandler for AgentServer {}

View file

@ -12,24 +12,14 @@
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
/// Enforce the per-agent send allow-list. Returns `Ok` when the
/// recipient is permitted (no list configured, the operator, or `to` is
/// in the list); returns `Err(refusal)` with a claude-readable string
/// when blocked — the harness surfaces the refusal as the tool result so
/// claude knows the message didn't land and can react (e.g. route to the
/// operator instead).
/// recipient is permitted (no list configured, or `to` is in the list);
/// returns `Err(refusal)` with a claude-readable string when blocked —
/// the harness surfaces the refusal as the tool result so claude knows
/// the message didn't land and can react. `operator` is refused whatever
/// the list says, with the same matrix-pointing text the broker returns.
pub fn check_send_allowed(to: &str) -> Result<(), String> {
if to == hive_sh4re::manager::OPERATOR_RECIPIENT {
// Always allow the operator — the allow-list constrains peer
// chatter, not the reporting line out, and an agent with no way
// to say "I am stuck" is an agent that fails silently.
//
// This bypass used to be spelled `<parent>`, which the broker
// resolved per `topology.json` and which fell back to `operator`
// for a root agent. The parent field is gone now, so every
// agent is what that fallback called a root — the exemption is
// now written as the name it always resolved to. Same reachable
// set, one fewer indirection.
return Ok(());
return Err(hive_sh4re::manager::OPERATOR_SEND_REFUSAL.to_owned());
}
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
return Ok(()); // file missing → no policy configured → unrestricted
@ -53,9 +43,19 @@ pub fn check_send_allowed(to: &str) -> Result<(), String> {
}
Err(format!(
"send refused: recipient '{to}' not in services.hyperhive.agent.allowedRecipients \
(configured in agent.nix). Allowed: {allow:?}. The operator is always \
reachable — route through `send(to: \"{}\", …)` if you need to reach \
someone outside the allow-list.",
hive_sh4re::manager::OPERATOR_RECIPIENT
(configured in agent.nix). Allowed: {allow:?}. To reach the operator, use \
the matrix MCP tools."
))
}
#[cfg(test)]
mod tests {
use super::check_send_allowed;
#[test]
fn operator_is_refused_with_the_matrix_pointer() {
let refusal = check_send_allowed(hive_sh4re::manager::OPERATOR_RECIPIENT)
.expect_err("operator must be refused");
assert!(refusal.contains("mcp__matrix__"), "{refusal}");
}
}

View file

@ -1,8 +1,8 @@
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator (recipient `operator` in `send`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + peers can disambiguate; within your own hive the short form is fine.
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator (the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + peers can disambiguate; within your own hive the short form is fine.
Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look:
- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at.
- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. `send` reaches peers on this hive only; **reach the operator over matrix** (the matrix MCP tools, `mcp__matrix__send_dm` or `mcp__matrix__send_message`) — `send(to: "operator")` is refused. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at.
- **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time.
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — open a PR on your `agent-configs/<name>` repo, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits.
@ -13,8 +13,8 @@ Operator boundary: starting, stopping and rebuilding containers is the operator'
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `container_crash`, `needs_update`. Use these to react to lifecycle changes:
- `needs_update` — agent's flake rev is stale. Ask the operator to rebuild it.
- `container_crash` — ask the operator to start it again; if it keeps crashing, say so with what you saw.
- `needs_update` — agent's flake rev is stale. Ask the operator over matrix to rebuild it.
- `container_crash` — ask the operator over matrix to start it again; if it keeps crashing, say so with what you saw.
Lifecycle notices that don't need an immediate turn — a new agent spawned, a container rebuilt/killed/destroyed, or its login state changing — surface as todos instead of messages now. Call `get_loose_ends` to see them.

View file

@ -48,20 +48,9 @@ pub fn swarm_name() -> Option<String> {
non_empty_env("HYPERHIVE_SWARM_NAME")
}
/// Hive-qualified agent identity. When the hive domain is configured, returns
/// `${label}@${domain}` (e.g. `iris@darkest.space`); when not, returns just
/// the short label so callers can render a single string regardless of
/// deployment shape. Callers that want to know whether the result is
/// qualified should check [`hive_domain`] directly.
#[must_use]
pub fn qualified_label() -> String {
qualify(&label())
}
/// Apply hive qualification to an arbitrary agent label (e.g. a peer name
/// from the broker). Mirrors [`qualified_label`] but lets a caller qualify
/// names it didn't read from the env. Returns `${label}@${domain}` when the
/// hive domain is set, else just `label`.
/// Apply hive qualification to an agent label (e.g. a peer name from the
/// broker). Returns `${label}@${domain}` when the hive domain is set, else
/// just `label`.
#[must_use]
pub fn qualify(label: &str) -> String {
match hive_domain() {
@ -151,27 +140,27 @@ mod tests {
}
#[test]
fn qualified_label_with_domain_set() {
fn qualify_with_domain_set() {
with_env(Some("iris"), Some("darkest.space"), || {
assert_eq!(qualified_label(), "iris@darkest.space");
assert_eq!(qualify(&label()), "iris@darkest.space");
assert_eq!(hive_domain().as_deref(), Some("darkest.space"));
});
}
#[test]
fn qualified_label_falls_back_to_short_when_domain_unset() {
fn qualify_falls_back_to_short_when_domain_unset() {
with_env(Some("iris"), None, || {
assert_eq!(qualified_label(), "iris");
assert_eq!(qualify(&label()), "iris");
assert!(hive_domain().is_none());
});
}
#[test]
fn qualified_label_falls_back_to_short_when_domain_empty() {
fn qualify_falls_back_to_short_when_domain_empty() {
// Empty string is treated the same as unset — a misconfigured
// module shouldn't surface `iris@` (no domain) to the operator.
with_env(Some("iris"), Some(""), || {
assert_eq!(qualified_label(), "iris");
assert_eq!(qualify(&label()), "iris");
assert!(hive_domain().is_none());
});
}
@ -247,8 +236,8 @@ mod tests {
assert!(hive_domain().is_none());
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
// qualified_label still degrades to short label without domain.
assert_eq!(qualified_label(), "iris");
// qualify still degrades to the short label without a domain.
assert_eq!(qualify(&label()), "iris");
},
);
}

View file

@ -137,22 +137,6 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) {
});
}
/// Body string for the turn-failure notification we route to the
/// operator on `TurnError::Failed`. Reads the hive-qualified
/// identity so the receiver sees `agent@hive` rather than relying on
/// the caller threading a `label` through every turn-handling layer.
/// Falls back to `<unknown>` when `HIVE_LABEL` is missing so a
/// misconfigured harness still produces a parseable line.
fn format_turn_failure(err: &anyhow::Error) -> String {
let who = crate::identity::qualified_label();
let who = if who.is_empty() {
"<unknown>".to_owned()
} else {
who
};
format!("[system] `{who}` claude turn failed:\n{err:#}")
}
/// What a finished turn tells the serve loop to do next.
struct TurnControl {
/// The turn ended in `AuthFailed` — caller parks on login.
@ -293,16 +277,6 @@ trait Surface {
/// fallback. Same shape as `graceful_stop_complete`.
fn pause_acknowledged(socket: &Path) -> impl Future<Output = ()>;
/// Send a message addressed to the operator. The reporting line out
/// of a container: this is where a turn failure or a plugin-install
/// failure surfaces when nothing inside the harness can act on it.
///
/// Was `<parent>` while the hierarchy existed, a sentinel the broker resolved per
/// `topology.json` and which already fell through to `operator` for a
/// root agent. With the parent field gone every agent takes that
/// branch, so the recipient is written out rather than resolved.
fn send_to_operator(socket: &Path, body: String) -> impl Future<Output = ()>;
/// Long-poll the broker for the next message. Wraps the
/// `Messages`/empty/error trichotomy in `RecvOutcome` so the
/// generic `serve_loop` doesn't need the per-role Response enum
@ -387,22 +361,6 @@ impl Surface for AgentSurface {
(threads, reminders)
}
async fn send_to_operator(socket: &Path, body: String) {
let res = hive_sock_client::request::<_, Response>(
socket,
&Request::Send {
to: hive_sh4re::manager::OPERATOR_RECIPIENT.into(),
body,
in_reply_to: None,
},
CONTROL_SOCKET_RETRY,
)
.await;
if let Err(e) = res {
tracing::warn!(error = ?e, "failed to notify the operator of turn failure");
}
}
async fn recv_next(socket: &Path) -> RecvOutcome {
let recv: Result<Response> = hive_sock_client::request_within(
socket,
@ -548,11 +506,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
}
}
let files = turn::TurnFiles::prepare(socket, &label).await?;
// Plugin install failures come back as a Vec<String> — route each
// through the `send_to_operator` failure-notify path.
for failure in plugins::install_configured().await {
S::send_to_operator(socket, failure).await;
}
plugins::install_configured().await;
// The forge notification poller used to be spawned here. It is its own
// process now (`hive-forge-notify`, its own systemd unit) so a harness
// restart doesn't take forge notifications down with it; it reaches the
@ -944,7 +898,8 @@ fn apply_todo_wake_checked(checked: Option<bool>, todo_miss_streak: &mut u32, bu
}
/// Drive a single turn: emit boot-of-turn events, run claude, ack on
/// success / requeue on rate-limit-or-401 / notify parent on failure,
/// success / requeue on rate-limit-or-401 / log `turn failed` on a hard
/// failure (the line the `hh-agent-turn-failed` Grafana rule alerts on),
/// record stats. Returns a `TurnControl` carrying the auth-failed flag and
/// the todo-wake-checked signal — the serve loop decides what to do next.
async fn handle_turn<S: Surface>(
@ -1061,9 +1016,11 @@ async fn handle_turn<S: Surface>(
/// The non-happy-path half of `handle_turn`: react to each `TurnError`
/// variant the turn could have failed with (park-and-retry on rate-limit/
/// stall/auth, requeue-for-a-fresh-turn on prompt-too-long/session-not-
/// found, notify the operator on a hard failure). Split out purely to keep
/// `handle_turn` itself under clippy's line-count lint — no behavior
/// change from when this lived inline.
/// found). A hard failure needs nothing here: `turn::emit_turn_end` has
/// already logged `turn failed`, which the `hh-agent-turn-failed` Grafana
/// rule alerts on. Split out purely to keep `handle_turn` itself under
/// clippy's line-count lint — no behavior change from when this lived
/// inline.
async fn handle_turn_error_recovery<S: Surface>(
outcome: &turn::TurnOutcome,
bus: &Bus,
@ -1123,7 +1080,4 @@ async fn handle_turn_error_recovery<S: Surface>(
tracing::warn!("session-not-found; requeueing message for a fresh turn");
S::requeue_inflight(socket).await;
}
if let Err(turn::TurnError::Failed(e)) = outcome {
S::send_to_operator(socket, format_turn_failure(e)).await;
}
}

View file

@ -96,23 +96,20 @@ async fn update_marketplaces() {
}
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
/// Returns a list of human-readable failure messages so the caller can
/// route them through their own per-role surface (turn-failure-style
/// notification, see `Surface::send_to_operator`). Wire-agnostic: the
/// caller picks the recipient, the same way failure-notify does.
pub async fn install_configured() -> Vec<String> {
/// A failed install is logged and skipped; the harness starts without it.
pub async fn install_configured() {
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
return Vec::new();
return;
};
let specs: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
return Vec::new();
return;
}
};
if specs.is_empty() {
return Vec::new();
return;
}
add_marketplaces().await;
if auto_update_enabled().await {
@ -120,7 +117,6 @@ pub async fn install_configured() -> Vec<String> {
} else {
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
}
let mut failures = Vec::new();
for spec in specs {
match Command::new("claude")
.args(["plugin", "install", &spec])
@ -138,18 +134,10 @@ pub async fn install_configured() -> Vec<String> {
stderr = %stderr,
"claude plugin install failed",
);
failures.push(format!(
"claude plugin install failed for `{spec}`:\n{}",
stderr.trim()
));
}
Err(e) => {
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
failures.push(format!(
"claude plugin install spawn failed for `{spec}`: {e}"
));
}
}
}
failures
}

View file

@ -201,8 +201,8 @@ pub enum TurnError {
/// provider (e.g. HTTP 429) without reporting it ends up here. Handled
/// like [`Self::ApiStall`].
AgentStall(String),
/// A hard failure with no recovery — the serve loop escalates it to the
/// operator (`send_to_operator`).
/// A hard failure with no recovery. Logged as `turn failed`, the exact
/// message the `hh-agent-turn-failed` Grafana alert matches.
Failed(anyhow::Error),
}
@ -604,6 +604,8 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
ok: false,
note: Some(note.clone()),
});
// The `hh-agent-turn-failed` alert in swarm-grafana.nix matches
// this message exactly.
tracing::warn!(error = %note, "turn failed");
}
}

View file

@ -1,6 +1,6 @@
//! Remaining single-endpoint dashboard handlers: the operator inbox
//! (`Y3R C4LL`) + mark-all-read, operator compose (`op-send`),
//! spawn-request, hive-wide turn stats, and container resources.
//! Remaining single-endpoint dashboard handlers: mark-all-read, operator
//! compose (`op-send`), spawn-request, hive-wide turn stats, and container
//! resources.
use axum::{
extract::{Form, Path as AxumPath, State},
@ -10,80 +10,10 @@ use axum::{
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use super::{AppState, Ident, error_response, scan_validated_paths};
use super::{AppState, Ident, error_response};
use crate::container_stats::ContainerResource;
use crate::hive_stats::HiveStats;
#[derive(Serialize, ToSchema)]
pub(super) struct OperatorInboxItem {
id: i64,
from: String,
body: String,
at: chrono::DateTime<chrono::Utc>,
in_reply_to: Option<i64>,
file_refs: Vec<String>,
}
#[derive(Serialize, ToSchema)]
pub(super) struct OperatorInboxBody {
messages: Vec<OperatorInboxItem>,
}
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox.
///
/// Returns messages addressed to `"operator"` that haven't been
/// acked yet (the operator clears them via the existing
/// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped
/// tokens are validated so the client renders file links like the
/// terminal does.
#[utoipa::path(
get,
path = "/api/operator-inbox",
responses(
(status = 200, description = "unread operator-directed messages", body = OperatorInboxBody),
(status = 500, description = "broker read failed"),
),
tag = "misc_api"
)]
pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Response {
const INBOX_LIMIT: u64 = 100;
match state
.coord
.broker
.unread_for_recipient("operator", INBOX_LIMIT)
{
Ok(messages) => {
let messages: Vec<OperatorInboxItem> = messages
.into_iter()
.filter_map(|m| {
let crate::broker::MessageEvent::Sent {
id,
from,
body,
at,
in_reply_to,
..
} = m
else {
return None;
};
let file_refs = scan_validated_paths(&body);
Some(OperatorInboxItem {
id,
from,
at: hive_sh4re::wire_time::from_secs(at),
body,
in_reply_to,
file_refs,
})
})
.collect();
axum::Json(OperatorInboxBody { messages }).into_response()
}
Err(e) => error_response(&format!("operator-inbox failed: {e:#}")),
}
}
#[derive(Deserialize, IntoParams)]
pub(super) struct StatsHiveQuery {
/// Stats window; defaults to `24h`.
@ -227,7 +157,7 @@ pub(super) async fn post_op_send(
// 200 instead of 303 → the client doesn't refetch /api/state. The
// broker `send` already emitted a `MessageEvent` which the
// dashboard channel forwarder mirrors as `DashboardEvent::Sent`,
// and the page's terminal + inbox derive from that stream — so the
// and the page's terminal derives from that stream — so the
// operator's send shows up the same way an agent's send does, with
// no full-state refresh in between.
(axum::http::StatusCode::OK, "ok").into_response()

View file

@ -36,7 +36,7 @@ use crate::lifecycle;
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"),
(name = "schedules", description = "read-only scheduled-prompt list + rebuild-queue cancel"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
@ -89,7 +89,7 @@ struct AppState {
handler; splitting that exhaustive list across helpers would \
obscure the route map for no readability gain"
)]
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
fn app(coord: Arc<Coordinator>) -> axum::Router {
// API-only: the gateway static-serves the dashboard dist and proxies
// non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`).
// Unmatched paths 404.
@ -115,7 +115,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.routes(routes!(health::get_health_ready))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::post_mark_all_read))
@ -156,18 +155,21 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
// the nix store (see the module doc comment above `ApiDoc`). `api`
// is `Clone`; each request gets its own owned copy for `Json` to
// serialize.
let app = router
router
.route(
"/api/openapi.json",
get(move || async move { Json(api.clone()) }),
)
.with_state(AppState { coord });
.with_state(AppState { coord })
}
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
// Binds loopback-only; external access via gateway.
// Rationale: docs/networking/gateway.md::Firewall posture.
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = bind_with_retry(addr).await?;
tracing::info!(%addr, "dashboard listening");
axum::serve(listener, app).await?;
axum::serve(listener, app(coord)).await?;
Ok(())
}
@ -287,6 +289,47 @@ fn error_response(message: &str) -> Response {
mod tests {
use super::*;
/// Status code of a bare HTTP/1.1 GET against the dashboard's own route
/// table, served on an ephemeral loopback port.
async fn status_of(path: &str) -> u16 {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let dir = tempfile::tempdir().expect("tempdir");
let coord = Coordinator::open(
&dir.path().join("broker.sqlite"),
crate::coordinator::HiveEnv::default(),
crate::hive_stats::PriceTable::new(),
1,
)
.expect("open coordinator");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("local addr");
let server = tokio::spawn(axum::serve(listener, app(Arc::new(coord))).into_future());
let mut stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
stream
.write_all(
format!("GET {path} HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n").as_bytes(),
)
.await
.expect("write");
let mut reply = String::new();
stream.read_to_string(&mut reply).await.expect("read");
server.abort();
reply
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.unwrap_or_else(|| panic!("no status line in {reply:?}"))
}
#[tokio::test]
async fn operator_inbox_route_is_gone() {
assert_eq!(status_of("/health/live").await, 200);
assert_eq!(status_of("/api/operator-inbox").await, 404);
}
#[test]
fn problem_details_carry_rfc9457_status_and_detail() {
// Contract the frontend depends on: the problem_details crate
@ -329,7 +372,6 @@ mod router_build_probe {
.routes(routes!(health::get_health_ready))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::post_mark_all_read))

View file

@ -491,7 +491,7 @@ fn handle_status(coord: &Arc<Coordinator>, agent: &str) -> hive_core_agent_sock:
}
/// `OperatorMsg` — deliver an operator-authored message into `agent`'s
/// inbox (from the `operator` recipient).
/// inbox (sender `operator`).
fn handle_operator_msg(
coord: &Arc<Coordinator>,
agent: &str,
@ -603,12 +603,11 @@ pub(crate) fn handle_send(
}
};
}
// Validate that the recipient is a known local agent or the
// special "operator" recipient. Without this check a typo in `to`
// silently queues a message nobody will ever read.
// Validate that the recipient is a known local agent. Without this
// check a typo in `to` silently queues a message nobody will ever read.
//
// Cross-hive messaging (`name@hive` qualified names) is not routed
// through the broker — use the Matrix MCP tools for that instead.
// Cross-hive messaging (`name@hive` qualified names) and the operator
// are not reached through the broker — the Matrix MCP tools are.
if to.contains('@') {
return Response::Err {
message: format!(
@ -617,20 +616,23 @@ pub(crate) fn handle_send(
),
};
}
if to != hive_sh4re::manager::OPERATOR_RECIPIENT {
// A name that doesn't parse as an Ident can't be a local agent, so
// it collapses into the same "unknown recipient" error as a valid
// name with no state dir.
let exists = hive_types::Ident::parse(to)
.is_ok_and(|id| crate::paths::agent_state_dir(&id).exists());
if !exists {
return Response::Err {
message: format!(
"send failed: unknown recipient `{to}` \
(no agent with that name exists on this hive)"
),
};
}
if to == hive_sh4re::manager::OPERATOR_RECIPIENT {
return Response::Err {
message: hive_sh4re::manager::OPERATOR_SEND_REFUSAL.to_owned(),
};
}
// A name that doesn't parse as an Ident can't be a local agent, so
// it collapses into the same "unknown recipient" error as a valid
// name with no state dir.
let exists =
hive_types::Ident::parse(to).is_ok_and(|id| crate::paths::agent_state_dir(&id).exists());
if !exists {
return Response::Err {
message: format!(
"send failed: unknown recipient `{to}` \
(no agent with that name exists on this hive)"
),
};
}
match coord.broker.send(&Message {
from: hive_sh4re::manager::trusted_sender(agent),
@ -664,6 +666,22 @@ mod tests {
(dir, Arc::new(coord))
}
#[test]
fn send_to_operator_is_refused_with_the_matrix_pointer() {
let (_dir, coord) = coordinator();
let resp = handle_send(
&coord,
"iris",
hive_sh4re::manager::OPERATOR_RECIPIENT,
"hi",
None,
);
assert!(
matches!(&resp, Response::Err { message } if message.contains("mcp__matrix__")),
"{resp:?}"
);
}
#[tokio::test]
async fn over_long_line_is_rejected_after_reading_only_the_bound() {
let total = 2 * REQUEST_LINE_MAX_BYTES as u64;

View file

@ -301,44 +301,6 @@ impl Broker {
.map_err(Into::into)
}
/// Unacknowledged messages addressed to `recipient`, newest-first.
/// Backs the dashboard's operator inbox: the operator never
/// `recv`s over an agent socket, so messages to `"operator"` sit in
/// the broker with `acked_at IS NULL` until the operator hits "mark
/// all read" (which calls [`Broker::mark_all_read`]). This read
/// mirrors that filter EXACTLY — `recipient = ?1 AND acked_at IS
/// NULL`, with no `delivered_at` condition — so everything listed
/// here is precisely what `mark_all_read` will clear (operator rows
/// may never get `delivered_at` set). Returned as
/// [`MessageEvent::Sent`] so the dashboard reuses its live renderer.
///
/// # Errors
///
/// Returns `Err` if the `SQLite` prepare or query fails.
pub fn unread_for_recipient(&self, recipient: &str, limit: u64) -> Result<Vec<MessageEvent>> {
let conn = self.conn.lock().unwrap();
let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX);
let mut stmt = conn.prepare(
"SELECT id, sender, recipient, body, sent_at, in_reply_to
FROM messages
WHERE recipient = ?1 AND acked_at IS NULL
ORDER BY id DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![recipient, limit_i], |row| {
Ok(MessageEvent::Sent {
id: row.get(0)?,
from: row.get(1)?,
to: row.get(2)?,
body: row.get(3)?,
at: row.get(4)?,
in_reply_to: row.get(5)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
/// Number of undelivered messages addressed to `recipient`. Non-mutating
/// — used by the harness to surface "N unread" in tool-result status
/// lines without popping the queue.

View file

@ -69,11 +69,9 @@ pub struct Message {
pub in_reply_to: Option<i64>,
}
/// One row of a broker inbox query — what the dashboard renders in
/// its operator-inbox section and what a per-agent web UI returns
/// from a `Recent` request. Lives in `hive_sh4re` so it can travel
/// over both the dashboard's `/api/state` and the agent socket
/// without an internal-to-wire conversion.
/// One row of a broker inbox query — what a per-agent web UI returns
/// from a `Recent` request. Lives in `hive_sh4re` so it can travel over
/// the agent socket without an internal-to-wire conversion.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboxRow {
pub id: i64,

View file

@ -9,11 +9,17 @@ use serde::{Deserialize, Serialize};
/// Logical name the broker uses for the manager.
pub const MANAGER_AGENT: &str = "ruth";
/// Logical name the broker uses for the human operator. Messages with
/// `to = OPERATOR_RECIPIENT` accumulate in sqlite and surface on the
/// dashboard's inbox view — they are never `recv`'d by an agent harness.
/// Logical name of the human operator: the sender of operator-authored
/// messages. Not a recipient — `send` to it fails with
/// [`OPERATOR_SEND_REFUSAL`].
pub const OPERATOR_RECIPIENT: &str = "operator";
/// The error a `send` to [`OPERATOR_RECIPIENT`] returns. Agents reach the
/// operator over matrix, so the text names the matrix tools to use.
pub const OPERATOR_SEND_REFUSAL: &str = "send failed: `operator` is not a send recipient. \
Reach the operator over matrix with the matrix MCP tools \
(`mcp__matrix__send_dm`, or `mcp__matrix__send_message` to a room the operator is in).";
/// Sender hive-c0re uses for events it pushes into the manager's inbox.
/// Manager harness recognises this and parses the body as a `HelperEvent`.
pub const SYSTEM_SENDER: &str = "system";

View file

@ -51,14 +51,13 @@ in
description = ''
Names this agent is allowed to `send` to via
`mcp__hyperhive__send`. Empty list (the default) means
unrestricted — the agent can message any peer, the
operator, or the manager. Non-empty list constrains the
unrestricted — the agent can message any peer or the
manager. Non-empty list constrains the
surface: only the listed names + the manager (always
allowed) get through; anything else returns an error
string to claude without touching the broker. The
operator (`operator`) needs to be in the list if the
agent should be able to surface output on the
dashboard.
string to claude without touching the broker.
`operator` is refused either way: agents reach the operator
over matrix.
Useful for sandboxing untrusted sub-agents — set
`[ "manager" ]` to scope them to manager-only chatter.

View file

@ -996,6 +996,20 @@ in
for = "5m";
summary = "{{ $labels.hive }}/{{ $labels.agent }} finds nothing at its queue credential path and cannot connect to the queue";
})
# Logged once per failed turn, by `emit_turn_end` in
# hive-agent's turn.rs; the window is how long the rule
# stays firing after it. The exact `_msg` match and the
# unit keep out the subagent daemon's `subagent: turn
# failed` and the ACP backend's `ACP checkpoint turn
# failed`, which a phrase match would also count.
(logCountRule {
uid = "hh-agent-turn-failed";
title = "agent turn failed";
windowSeconds = 900;
expr = ''_time:15m _SYSTEMD_UNIT:="hive-agent.service" _msg:="turn failed" | stats by (hive, agent) count() as failures'';
for = "0s";
summary = "{{ $labels.hive }}/{{ $labels.agent }} had a claude turn fail; the reason is the `F_ERROR` field of the harness's `turn failed` log line";
})
# Same cadence as the forge rule: one pass every 5m
# (`RECONCILE_INTERVAL` in swarm-controller's
# agent_renewal.rs), one line per failed pass, so one

View file

@ -288,6 +288,25 @@ let
&& lib.all (r: r.field == "severity_text" && r.enabled == true && lib.elem r.level canonical) rules
&& lib.all (text: lib.any (r: r.value == text) rules) emitted;
}
{
# Harness turn failures reach the operator only through this rule, so
# its absence is silent. Asserted on the query, not the title: the
# exact `_msg` is what keeps subagent turn failures out, and a phrase
# match would still provision and fire.
name = "the agent turn-failure alert rule is provisioned against the harness's log line";
ok =
let
groups =
grafanaOldPath.containers.swarm-grafana.config.services.grafana.provision.alerting.rules.settings.groups;
rules = lib.concatMap (g: g.rules) groups;
rule = lib.findFirst (r: r.uid == "hh-agent-turn-failed") null rules;
expr = (lib.head rule.data).model.expr;
in
rule != null
&& lib.hasInfix ''_SYSTEMD_UNIT:="hive-agent.service"'' expr
&& lib.hasInfix ''_msg:="turn failed"'' expr
&& lib.hasInfix "stats by (hive, agent)" expr;
}
{
# 🩸 The regression this guards: moving bao's metrics scrape into its
# own in-container collector made that collector stamp every exported

View file

@ -30,8 +30,8 @@
# `./reserved-hive-fragments.nix` — read its header before merging the two.
[
# ---- message-layer senders -------------------------------------------
# The human at the dashboard: a broker recipient (the T4LK box sends
# `{from: "operator", ...}`) and the fallback attribution for an answered
# The human at the dashboard: the sender of operator-authored messages
# (`{from: "operator", ...}`) and the fallback attribution for an answered
# question.
"operator"
# Helper events (`container_crash`, `needs_update`) — the sender an