diff --git a/README.md b/README.md index 21f04414..d8e5f3ce 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 31125900..1a5ccbf8 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -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 diff --git a/docs/process/conventions.md b/docs/process/conventions.md index daaff426..8cca616d 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -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. diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 2ce2ca5b..72693dc0 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -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 diff --git a/docs/turn-loop/README.md b/docs/turn-loop/README.md index ee2327b1..2c8eed49 100644 --- a/docs/turn-loop/README.md +++ b/docs/turn-loop/README.md @@ -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] \`\` claude turn failed:\n` 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 diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index 73d42c5d..4c5fc0a0 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -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 #]` (broker row id; note the highest id seen, then pass diff --git a/docs/web-ui/README.md b/docs/web-ui/README.md index a9ea42cd..e469774d 100644 --- a/docs/web-ui/README.md +++ b/docs/web-ui/README.md @@ -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. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 07f2e4f9..c720b00f 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -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 `
` 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 ` / // 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 `

` 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`. - - -Only messages that arrive while the page is open count. Per-event tags -(`hyperhive:msg::`) 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. - - ## 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=`, `body=`) — drop an operator-authored message into ``'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` 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. diff --git a/docs/web-ui/shape.md b/docs/web-ui/shape.md index 91c60d6e..f09ca05b 100644 --- a/docs/web-ui/shape.md +++ b/docs/web-ui/shape.md @@ -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 diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 44a84400..99da2c1f 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -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` diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js deleted file mode 100644 index 41a34300..00000000 --- a/frontend/packages/dashboard/src/call.js +++ /dev/null @@ -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(); -} diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index 32699f14..22b9f871 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -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; diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index d0b36673..9f7baeab 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -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 diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index fa84305e..735b38d8 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -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; diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html index dff3f588..4bdf3d51 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -35,21 +35,6 @@ ◆ SW4RM ◆ - - ◆ Y3R C4LL ◆ - - - - diff --git a/frontend/packages/dashboard/src/flow.js b/frontend/packages/dashboard/src/flow.js index 454cb666..8ab591ae 100644 --- a/frontend/packages/dashboard/src/flow.js +++ b/frontend/packages/dashboard/src/flow.js @@ -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(), }); })(); diff --git a/frontend/packages/dashboard/src/state.js b/frontend/packages/dashboard/src/state.js index b23e256f..11bee7ef 100644 --- a/frontend/packages/dashboard/src/state.js +++ b/frontend/packages/dashboard/src/state.js @@ -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. diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js index 8a31ed33..07f38285 100644 --- a/frontend/packages/dashboard/src/swarm.js +++ b/frontend/packages/dashboard/src/swarm.js @@ -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 diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 2ce67a2b..f955453e 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -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 () 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()); diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index 00ffb768..ab175671 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -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, diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index aabd7b6e..58126ef0 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -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) -> 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 {} diff --git a/hive-agent-mcp/src/send_allow.rs b/hive-agent-mcp/src/send_allow.rs index 8bfc0e1f..3be360c4 100644 --- a/hive-agent-mcp/src/send_allow.rs +++ b/hive-agent-mcp/src/send_allow.rs @@ -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 ``, 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}"); + } +} diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index b55f8559..4b1b1983 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -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____` — 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/` 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. diff --git a/hive-agent/src/identity.rs b/hive-agent/src/identity.rs index 8f4f2cf2..878b4319 100644 --- a/hive-agent/src/identity.rs +++ b/hive-agent/src/identity.rs @@ -48,20 +48,9 @@ pub fn swarm_name() -> Option { 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"); }, ); } diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index 733d561c..2c58d6e1 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -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 `` 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() { - "".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; - /// 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 `` 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; - /// 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 = hive_sock_client::request_within( socket, @@ -548,11 +506,7 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { } } let files = turn::TurnFiles::prepare(socket, &label).await?; - // Plugin install failures come back as a Vec — 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, 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( @@ -1061,9 +1016,11 @@ async fn handle_turn( /// 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( outcome: &turn::TurnOutcome, bus: &Bus, @@ -1123,7 +1080,4 @@ async fn handle_turn_error_recovery( 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; - } } diff --git a/hive-agent/src/plugins.rs b/hive-agent/src/plugins.rs index 958a6977..5a1a3b09 100644 --- a/hive-agent/src/plugins.rs +++ b/hive-agent/src/plugins.rs @@ -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 { +/// 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 = 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 { } 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 { 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 } diff --git a/hive-agent/src/turn.rs b/hive-agent/src/turn.rs index 156e83ef..cb38dfe5 100644 --- a/hive-agent/src/turn.rs +++ b/hive-agent/src/turn.rs @@ -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"); } } diff --git a/hive-c0re/src/dashboard/misc_api.rs b/hive-c0re/src/dashboard/misc_api.rs index b9658b28..1d0d63ec 100644 --- a/hive-c0re/src/dashboard/misc_api.rs +++ b/hive-c0re/src/dashboard/misc_api.rs @@ -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, - in_reply_to: Option, - file_refs: Vec, -} - -#[derive(Serialize, ToSchema)] -pub(super) struct OperatorInboxBody { - messages: Vec, -} - -/// 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) -> Response { - const INBOX_LIMIT: u64 = 100; - match state - .coord - .broker - .unread_for_recipient("operator", INBOX_LIMIT) - { - Ok(messages) => { - let messages: Vec = 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() diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index aa04a331..8fa9a01c 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -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) -> Result<()> { +fn app(coord: Arc) -> 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) -> 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) -> 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) -> 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)) diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 057423d0..a000beea 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -491,7 +491,7 @@ fn handle_status(coord: &Arc, 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, 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; diff --git a/hive-c0re/src/stores/broker.rs b/hive-c0re/src/stores/broker.rs index f2df9265..ded2ad59 100644 --- a/hive-c0re/src/stores/broker.rs +++ b/hive-c0re/src/stores/broker.rs @@ -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> { - 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::>>() - .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. diff --git a/hive-sh4re/src/inbox.rs b/hive-sh4re/src/inbox.rs index db4512df..cdd4c79d 100644 --- a/hive-sh4re/src/inbox.rs +++ b/hive-sh4re/src/inbox.rs @@ -69,11 +69,9 @@ pub struct Message { pub in_reply_to: Option, } -/// 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, diff --git a/hive-sh4re/src/manager.rs b/hive-sh4re/src/manager.rs index 8b3e0e6c..9b9554e8 100644 --- a/hive-sh4re/src/manager.rs +++ b/hive-sh4re/src/manager.rs @@ -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"; diff --git a/nix/agent-modules/mcp.nix b/nix/agent-modules/mcp.nix index dee7b028..9611f73f 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -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. diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index e9b880a1..2e1a074a 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -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 diff --git a/nix/module-eval/grafana.nix b/nix/module-eval/grafana.nix index a920b3f6..ec538e98 100644 --- a/nix/module-eval/grafana.nix +++ b/nix/module-eval/grafana.nix @@ -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 diff --git a/nix/reserved-names.nix b/nix/reserved-names.nix index 56dd3196..0869f6fd 100644 --- a/nix/reserved-names.nix +++ b/nix/reserved-names.nix @@ -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