diff --git a/README.md b/README.md index d8e5f3ce..21f04414 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 (the operator is on matrix), +- **messaging** — `send` / `recv` to other agents and the operator, `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 1a5ccbf8..31125900 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-authored message. +- `messages` — every inter-agent / operator-bound 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 8cca616d..daaff426 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, `qualify(label)` + from `services.hyperhive.domain`. When configured, `qualified_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,6 +46,11 @@ 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 @@ -82,10 +87,8 @@ 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. 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. +- `operator` — the human at the dashboard. Messages accumulate in the + inbox view; no agent ever `recv`'s them. The broker stores the recipient exactly as the agent passed it. diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 72693dc0..2ce2ca5b 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()` semantics: +The env-var chain and `qualify()` / `qualified_label()` 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 2c8eed49..ee2327b1 100644 --- a/docs/turn-loop/README.md +++ b/docs/turn-loop/README.md @@ -126,7 +126,9 @@ 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: the harness logs each at `warn` and skips it. +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. ### Turn outcomes @@ -142,7 +144,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))` | 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 | +| `Err(Failed(err))` | route `[system] \`\` claude turn failed:\n` to `operator` via `send_to_operator` | `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 ea7b9bdd..73d42c5d 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -32,15 +32,14 @@ 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 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"`. The error - points an agent that has the matrix MCP tools at matrix - ([matrix](../tools/matrix.md)), and tells one without them that the - operator reads its `set_status` line, its turn output on the dashboard, - and its logs. +- `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. - `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 e469774d..a9ea42cd 100644 --- a/docs/web-ui/README.md +++ b/docs/web-ui/README.md @@ -16,10 +16,12 @@ 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 three tabs: +of your time. It's a single page with exactly four 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. @@ -28,7 +30,9 @@ 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). +**Core** (`/core.html`, tombstones and container resource use). Your local +browser preferences (notifications) live in the dashboard's Y3R C4LL +tab. Each agent also has its own page — a full terminal view of that agent's session, reachable by selecting its name anywhere in the @@ -43,6 +47,10 @@ 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 c720b00f..07f2e4f9 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`, +one tab pane at a time. The URL hash (`#swarm`, `#call`, `#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,12 +19,13 @@ 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 ◆`, `◆ P3RM1SS10NS ◆`, +- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ 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) and SCH3DUL3S (active schedules left to copy). + (container count), Y3R C4LL (unread + operator messages), 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 @@ -45,7 +46,12 @@ 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. + 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. 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, @@ -65,6 +71,41 @@ 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 @@ -396,6 +437,14 @@ 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 @@ -403,6 +452,10 @@ 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 @@ -714,6 +767,30 @@ 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 @@ -735,6 +812,13 @@ 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 @@ -866,7 +950,8 @@ 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. + row on selection. Used by the message-flow terminal renderer and + the operator-inbox derived state. - `transient_set` (name, transient_kind, since_unix) / `transient_cleared` (name) — lifecycle action spinners. The client ticks the elapsed-seconds badge off `since_unix` @@ -922,7 +1007,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, message flow — +containers, operator inbox, 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 f09ca05b..91c60d6e 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`, `schedules.js`, etc.) render function. +relevant domain module (`swarm.js`, `call.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 99da2c1f..44a84400 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, -// path linkification) independently — esbuild inlines the shared +// NOTIF, 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 new file mode 100644 index 00000000..41a34300 --- /dev/null +++ b/frontend/packages/dashboard/src/call.js @@ -0,0 +1,114 @@ +// 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 22b9f871..32699f14 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -337,8 +337,46 @@ 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 the /flow.html full-page terminal. */ + msgrow / msg-* rules below power both the dashboard CALL tab and + 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 9f7baeab..d0b36673 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -1,6 +1,7 @@ // Shared dashboard helpers used by both dashboard.html (./tabs.js) and // flow.html (./flow.js): pure DOM helpers, the side-panel singleton, -// and the path-link / file-preview infrastructure for the side panel. +// the OS-notification module, 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"; @@ -562,6 +563,93 @@ 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 735b38d8..fa84305e 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -125,6 +125,11 @@ 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; @@ -438,12 +443,41 @@ 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. */ + hive-warn.css for the actual styling now. + + Notification controls below sit between the banner and the containers. */ /* 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 4bdf3d51..dff3f588 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -35,6 +35,21 @@ ◆ SW4RM ◆ + + ◆ Y3R C4LL ◆ + + + + diff --git a/frontend/packages/dashboard/src/flow.js b/frontend/packages/dashboard/src/flow.js index 8ab591ae..454cb666 100644 --- a/frontend/packages/dashboard/src/flow.js +++ b/frontend/packages/dashboard/src/flow.js @@ -1,7 +1,9 @@ // /flow.html entry point. Owns the full-page broker terminal and the // @-mention compose box. Pulls shared infrastructure (DOM helpers, -// side panel, path linkification) from `./common.js`. FL0W is the pure -// event firehose. +// 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. // // Does NOT contain the dashboard's tab renderers, mutation-event // dispatchers, or refreshState — that's `./tabs.js`, loaded only by @@ -12,6 +14,7 @@ import { create as termCreate } from "@hive/shared/terminal.js"; import { $, + NOTIF, appendLinkified, openStream, initServerWarnings, @@ -20,6 +23,7 @@ import { el } from "@hive/shared/dom.js"; import { epochSec } from "./util.js"; (() => { + NOTIF.bind(); initServerWarnings(); // ─── local containers cache (for compose autocomplete) ────────────────── @@ -90,7 +94,8 @@ 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 effect (banner pulse). + // side effects (banner pulse, OS notifications on operator-bound + // traffic). (() => { const flow = $("msgflow"); if (!flow) return; @@ -278,7 +283,21 @@ import { epochSec } from "./util.js"; }) .catch(() => {}); }, - onLiveEvent: () => pulseBanner(), + 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), + ); + } + }, }); })(); diff --git a/frontend/packages/dashboard/src/state.js b/frontend/packages/dashboard/src/state.js index 11bee7ef..b23e256f 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, and the selection bar), so +// container-load poll, the selection bar, and the operator inbox), 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 07f38285..8a31ed33 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 - // follow. + // (renderOperatorInbox, etc.) 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 f955453e..2ce67a2b 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, path linkification) -// live in `./common.js`; the flow-page surface (broker +// Pure helpers (DOM, side panel, OS notifications, path linkification) +// live in `./common.js`; the flow-page surface (operator inbox, 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 { $, openStream, renderServerWarnings } from "./common.js"; +import { $, NOTIF, 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,6 +24,12 @@ import { initPermissions, } from "./permissions.js"; import { refreshSchedules, activeScheduleCount } from "./schedules.js"; +import { + initCall, + refreshOperatorInbox, + operatorInboxAppendFromEvent, + operatorInboxCount, +} from "./call.js"; import { initJobqRollup, syncTransientsFromSnapshot, @@ -132,7 +138,10 @@ window.marked = marked; const label = swarm && hive ? `${swarm} / ${hive}` : hive || swarm; hiveId.textContent = label; hiveId.hidden = false; - document.title = label + " // h1ve-c0re"; + // 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"; } } const openDetails = snapshotOpenDetails(); @@ -172,10 +181,22 @@ 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 / SYST3M panes update without an + // events so the SW4RM / Y3R C4LL / SYST3M panes update without an // operator action triggering a refreshState. // // Bare `EventSource` (no terminal infrastructure needed — the @@ -188,7 +209,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 FL0W ignores mutation events. + // and the inbox ignores mutation events. const MUTATION_HANDLERS = { transient_set: applyTransientSet, transient_cleared: applyTransientCleared, @@ -210,10 +231,14 @@ window.marked = marked; // EventSource-shaped facade with a graceful direct-EventSource // fallback when SharedWorker isn't supported. // - // kinds= matches MUTATION_HANDLERS verbatim, so the stream carries - // only the 7 wire kinds this page acts on. + // 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). const es = openStream( - "/api/dashboard/stream?kinds=" + + "/api/dashboard/stream?kinds=sent," + "transient_set,transient_cleared," + "container_state_changed,container_removed,rebuild_queue_changed," + "capabilities_changed,tool_groups_changed", @@ -225,6 +250,12 @@ 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 { @@ -248,7 +279,7 @@ window.marked = marked; })(); // ─── tab routing ─────────────────────────────────────────────────────── - // Hash-based: `#swarm` / `#system` / `#permissions` / + // Hash-based: `#swarm` / `#call` / `#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 @@ -280,6 +311,9 @@ window.marked = marked; fetchAndRenderCapabilities(); fetchAndRenderToolGroups(); } + if (target === "call") { + refreshOperatorInbox(); + } } // Wire the shared tab strip now that activateTab + the lazy-load fns it @@ -288,6 +322,11 @@ 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 @@ -310,6 +349,15 @@ 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 cce44c42..00ffb768 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -7,8 +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"`). `"operator"` is refused: reach the operator over matrix - /// if you have it. + /// `"alice"`, or the literal `"operator"` for the dashboard's T4LK box). 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 166fd0c5..aabd7b6e 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 on this hive. \ - Not for the operator: reach them over matrix if you have the matrix MCP tools." + 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." )] 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); reach the operator over matrix if you have it. Use `recv` to drain your inbox one \ + name) or to the operator (recipient `operator`). 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 585c1ac3..8bfc0e1f 100644 --- a/hive-agent-mcp/src/send_allow.rs +++ b/hive-agent-mcp/src/send_allow.rs @@ -12,14 +12,24 @@ 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, 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. +/// 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). pub fn check_send_allowed(to: &str) -> Result<(), String> { if to == hive_sh4re::manager::OPERATOR_RECIPIENT { - return Err(hive_sh4re::manager::OPERATOR_SEND_REFUSAL.to_owned()); + // 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(()); } let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else { return Ok(()); // file missing → no policy configured → unrestricted @@ -43,20 +53,9 @@ 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:?}. To reach the operator, use \ - the matrix MCP tools if you have them." + (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 )) } - -#[cfg(test)] -mod tests { - use super::check_send_allowed; - - #[test] - fn operator_is_refused_naming_matrix_and_the_no_matrix_fallback() { - let refusal = check_send_allowed(hive_sh4re::manager::OPERATOR_RECIPIENT) - .expect_err("operator must be refused"); - assert!(refusal.contains("mcp__matrix__"), "{refusal}"); - assert!(refusal.contains("set_status"), "{refusal}"); - } -} diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index 6167fe6f..b55f8559 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 (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 (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. 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`. `send` reaches peers on this hive only; **reach the operator over matrix** if you have the matrix MCP tools (`mcp__matrix__send_dm` or `mcp__matrix__send_message`); without them the operator reads your `set_status` line and turn output on the dashboard, and your logs. `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. +- **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. - **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. diff --git a/hive-agent/src/identity.rs b/hive-agent/src/identity.rs index 878b4319..8f4f2cf2 100644 --- a/hive-agent/src/identity.rs +++ b/hive-agent/src/identity.rs @@ -48,9 +48,20 @@ pub fn swarm_name() -> Option { non_empty_env("HYPERHIVE_SWARM_NAME") } -/// 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`. +/// 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`. #[must_use] pub fn qualify(label: &str) -> String { match hive_domain() { @@ -140,27 +151,27 @@ mod tests { } #[test] - fn qualify_with_domain_set() { + fn qualified_label_with_domain_set() { with_env(Some("iris"), Some("darkest.space"), || { - assert_eq!(qualify(&label()), "iris@darkest.space"); + assert_eq!(qualified_label(), "iris@darkest.space"); assert_eq!(hive_domain().as_deref(), Some("darkest.space")); }); } #[test] - fn qualify_falls_back_to_short_when_domain_unset() { + fn qualified_label_falls_back_to_short_when_domain_unset() { with_env(Some("iris"), None, || { - assert_eq!(qualify(&label()), "iris"); + assert_eq!(qualified_label(), "iris"); assert!(hive_domain().is_none()); }); } #[test] - fn qualify_falls_back_to_short_when_domain_empty() { + fn qualified_label_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!(qualify(&label()), "iris"); + assert_eq!(qualified_label(), "iris"); assert!(hive_domain().is_none()); }); } @@ -236,8 +247,8 @@ mod tests { assert!(hive_domain().is_none()); assert_eq!(hive_name().as_deref(), Some("pr1ma")); assert_eq!(swarm_name().as_deref(), Some("constellat1on")); - // qualify still degrades to the short label without a domain. - assert_eq!(qualify(&label()), "iris"); + // qualified_label still degrades to short label without domain. + assert_eq!(qualified_label(), "iris"); }, ); } diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index 2c58d6e1..733d561c 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -137,6 +137,22 @@ 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. @@ -277,6 +293,16 @@ 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 @@ -361,6 +387,22 @@ 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, @@ -506,7 +548,11 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { } } let files = turn::TurnFiles::prepare(socket, &label).await?; - plugins::install_configured().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; + } // 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 @@ -898,8 +944,7 @@ 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 / log `turn failed` on a hard -/// failure (the line the `hh-agent-turn-failed` Grafana rule alerts on), +/// success / requeue on rate-limit-or-401 / notify parent on failure, /// 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( @@ -1016,11 +1061,9 @@ 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). 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. +/// 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. async fn handle_turn_error_recovery( outcome: &turn::TurnOutcome, bus: &Bus, @@ -1080,4 +1123,7 @@ 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 5a1a3b09..958a6977 100644 --- a/hive-agent/src/plugins.rs +++ b/hive-agent/src/plugins.rs @@ -96,20 +96,23 @@ async fn update_marketplaces() { } /// Install every plugin in `/etc/hyperhive/claude-plugins.json`. -/// A failed install is logged and skipped; the harness starts without it. -pub async fn install_configured() { +/// 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 { let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else { - return; + return Vec::new(); }; 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; + return Vec::new(); } }; if specs.is_empty() { - return; + return Vec::new(); } add_marketplaces().await; if auto_update_enabled().await { @@ -117,6 +120,7 @@ pub async fn install_configured() { } 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]) @@ -134,10 +138,18 @@ pub async fn install_configured() { 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 cb38dfe5..156e83ef 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. Logged as `turn failed`, the exact - /// message the `hh-agent-turn-failed` Grafana alert matches. + /// A hard failure with no recovery — the serve loop escalates it to the + /// operator (`send_to_operator`). Failed(anyhow::Error), } @@ -604,8 +604,6 @@ 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 1d0d63ec..b9658b28 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: mark-all-read, operator -//! compose (`op-send`), spawn-request, hive-wide turn stats, and container -//! resources. +//! 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. use axum::{ extract::{Form, Path as AxumPath, State}, @@ -10,10 +10,80 @@ use axum::{ use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; -use super::{AppState, Ident, error_response}; +use super::{AppState, Ident, error_response, scan_validated_paths}; 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`. @@ -157,7 +227,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 derives from that stream — so the + // and the page's terminal + inbox derive 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 8fa9a01c..aa04a331 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 = "mark-all-read, compose, spawn-request, hive stats"), + (name = "misc_api", description = "operator inbox, 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" )] -fn app(coord: Arc) -> axum::Router { +pub async fn serve(port: u16, coord: Arc) -> Result<()> { // 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,6 +115,7 @@ fn app(coord: Arc) -> axum::Router { .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)) @@ -155,21 +156,18 @@ fn app(coord: Arc) -> axum::Router { // the nix store (see the module doc comment above `ApiDoc`). `api` // is `Clone`; each request gets its own owned copy for `Json` to // serialize. - router + let app = router .route( "/api/openapi.json", get(move || async move { Json(api.clone()) }), ) - .with_state(AppState { coord }) -} - -pub async fn serve(port: u16, coord: Arc) -> Result<()> { + .with_state(AppState { coord }); // 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(coord)).await?; + axum::serve(listener, app).await?; Ok(()) } @@ -289,47 +287,6 @@ 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 @@ -372,6 +329,7 @@ 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 525a961f..057423d0 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 (sender `operator`). +/// inbox (from the `operator` recipient). fn handle_operator_msg( coord: &Arc, agent: &str, @@ -603,11 +603,12 @@ pub(crate) fn handle_send( } }; } - // Validate that the recipient is a known local agent. Without this - // check a typo in `to` silently queues a message nobody will ever read. + // 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. // - // Cross-hive messaging (`name@hive` qualified names) and the operator - // are not reached through the broker — the Matrix MCP tools are. + // Cross-hive messaging (`name@hive` qualified names) is not routed + // through the broker — use the Matrix MCP tools for that instead. if to.contains('@') { return Response::Err { message: format!( @@ -616,23 +617,20 @@ pub(crate) fn handle_send( ), }; } - 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)" - ), - }; + 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)" + ), + }; + } } match coord.broker.send(&Message { from: hive_sh4re::manager::trusted_sender(agent), @@ -666,23 +664,6 @@ mod tests { (dir, Arc::new(coord)) } - #[test] - fn send_to_operator_is_refused_naming_matrix_and_the_no_matrix_fallback() { - 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__") && message.contains("set_status")), - "{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 ded2ad59..f2df9265 100644 --- a/hive-c0re/src/stores/broker.rs +++ b/hive-c0re/src/stores/broker.rs @@ -301,6 +301,44 @@ 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 cdd4c79d..db4512df 100644 --- a/hive-sh4re/src/inbox.rs +++ b/hive-sh4re/src/inbox.rs @@ -69,9 +69,11 @@ pub struct Message { pub in_reply_to: Option, } -/// 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. +/// 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. #[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 f879c980..8b3e0e6c 100644 --- a/hive-sh4re/src/manager.rs +++ b/hive-sh4re/src/manager.rs @@ -9,20 +9,11 @@ use serde::{Deserialize, Serialize}; /// Logical name the broker uses for the manager. pub const MANAGER_AGENT: &str = "ruth"; -/// Logical name of the human operator: the sender of operator-authored -/// messages. Not a recipient — `send` to it fails with -/// [`OPERATOR_SEND_REFUSAL`]. +/// 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. pub const OPERATOR_RECIPIENT: &str = "operator"; -/// The error a `send` to [`OPERATOR_RECIPIENT`] returns. One text for every -/// agent, so it covers both an agent with the matrix MCP tools and one -/// without them. -pub const OPERATOR_SEND_REFUSAL: &str = "send failed: `operator` is not a send recipient. \ - If you have the matrix MCP tools, reach the operator over matrix \ - (`mcp__matrix__send_dm`, or `mcp__matrix__send_message` to a room the operator is in). \ - Without them there is no direct channel to the operator: they read your `set_status` \ - line and turn output on the dashboard, and your logs."; - /// 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 9611f73f..dee7b028 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -51,13 +51,14 @@ 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 or the - manager. Non-empty list constrains the + unrestricted — the agent can message any peer, the + operator, 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. - `operator` is refused either way: agents reach the operator - over matrix. + 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. 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 2e1a074a..e9b880a1 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -996,20 +996,6 @@ 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 ec538e98..a920b3f6 100644 --- a/nix/module-eval/grafana.nix +++ b/nix/module-eval/grafana.nix @@ -288,25 +288,6 @@ 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 0869f6fd..56dd3196 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: the sender of operator-authored messages - # (`{from: "operator", ...}`) and the fallback attribution for an answered + # The human at the dashboard: a broker recipient (the T4LK box sends + # `{from: "operator", ...}`) and the fallback attribution for an answered # question. "operator" # Helper events (`container_crash`, `needs_update`) — the sender an