agents: remove send(operator) and the operator inbox; alert on turn failures
Agents reach the operator over matrix now (#5115). A `send` to `operator` is refused, by the broker and by the MCP allow-list check, with an error that names the matrix MCP tools. The dashboard's Y3R C4LL tab goes with it: the operator inbox, `GET /api/operator-inbox`, `Broker::unread_for_recipient`, and the browser notifications that only ever fired on operator-bound messages. The harness no longer posts turn failures and plugin-install failures to the operator inbox. Both are already logged at warn; a new Grafana rule, `hh-agent-turn-failed`, fires per agent on the harness's `turn failed` line. Dashboard-to-agent messaging (op-send, the agent web UI's message box, mark-all-read for agent inboxes) is unchanged. Decisions per mara on #5115: q1 a, q2 a plus a grafana alert, q3 keep dashboard-to-agent messaging and leave swarm-level code alone.
This commit is contained in:
parent
2bf809cbe5
commit
7e6a4c7727
37 changed files with 246 additions and 848 deletions
|
|
@ -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
|
sudo inside the cage) and a durable, self-compacting session. Out of the
|
||||||
box it gets:
|
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
|
`remind` to wake itself, scheduled prompts the operator sets for it at
|
||||||
swarm level, and a todo list that forge notifications and other
|
swarm level, and a todo list that forge notifications and other
|
||||||
producers feed. → [schedules](docs/swarm/schedules.md)
|
producers feed. → [schedules](docs/swarm/schedules.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
|
table, the read-only schedule header/targets split, and the per-agent
|
||||||
power-intent registry:
|
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 /
|
`sender / recipient / body / sent_at / delivered_at / acked_at /
|
||||||
in_reply_to / priority`. `in_reply_to` links a reply to its parent
|
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
|
row id; the dashboard and per-agent inbox render these as threaded
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ Four env vars cover the identity surface, read by
|
||||||
module.
|
module.
|
||||||
- `HYPERHIVE_HIVE_DOMAIN` — the hive's canonical DNS domain (e.g.
|
- `HYPERHIVE_HIVE_DOMAIN` — the hive's canonical DNS domain (e.g.
|
||||||
`darkest.space`), set by `nix/host-modules/hive-c0re/environment.nix`
|
`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
|
returns `${label}@${domain}` (for example `iris@darkest.space`); when
|
||||||
unset (single-hive deployments, dev/test) it degrades to just
|
unset (single-hive deployments, dev/test) it degrades to just
|
||||||
the short label so existing callers see no change. The
|
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
|
(`@darkest.space`) AND the prose name (`pr1ma`). Matrix MXIDs
|
||||||
use the domain-based convention.
|
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
|
## Identity = socket
|
||||||
|
|
||||||
No auth tokens exist on the per-agent unix sockets. The 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
|
- `*` — broadcast: deliver to every running agent except the sender
|
||||||
(`socket_server::handle_send` fans out via `Coordinator::broadcast_send`).
|
(`socket_server::handle_send` fans out via `Coordinator::broadcast_send`).
|
||||||
- `operator` — the human at the dashboard. Messages accumulate in the
|
- `operator` — the human at the dashboard. Reserved so no agent can take
|
||||||
inbox view; no agent ever `recv`'s them.
|
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.
|
The broker stores the recipient exactly as the agent passed it.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
`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.
|
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.
|
[`conventions.md`](../process/conventions.md) § Hive identity.
|
||||||
|
|
||||||
Trust inside a swarm comes from the secret store's `pki` root
|
Trust inside a swarm comes from the secret store's `pki` root
|
||||||
|
|
|
||||||
|
|
@ -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`
|
what each built-in todo producer watches and how the store + `get_loose_ends`
|
||||||
merge work.
|
merge work.
|
||||||
|
|
||||||
Plugin install failures aren't fatal: each entry comes back as a
|
Plugin install failures aren't fatal: the harness logs each at `warn` and skips it.
|
||||||
human-readable failure string that gets routed via
|
|
||||||
`Surface::send_to_operator` to the operator.
|
|
||||||
|
|
||||||
### Turn outcomes
|
### 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(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(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(AgentStall(note))` | the same, for an ACP agent: after `HIVE_TURN_IDLE_SECS` with no `session/update` the harness sent the agent `session/cancel`, and SIGINT if it ignored that; `note` says which |
|
||||||
| `Err(Failed(err))` | route `[system] \`<qualified-label>\` claude turn failed:\n<err>` to `operator` via `send_to_operator` |
|
| `Err(Failed(err))` | the harness logs it at `warn` as `turn failed`, the line the Grafana alert rule `hh-agent-turn-failed` (`nix/host-modules/swarm-grafana.nix`) fires on |
|
||||||
|
|
||||||
`ApiStall` catches an Anthropic API stall — a multi-retry connection storm where
|
`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
|
the stream goes silent for minutes. On `claude` the idle watchdog lives in
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,13 @@ is opt-in via the P3RM1SS10NS tab.
|
||||||
**Messaging** (`messaging` group): `send(to, body, in_reply_to?)`,
|
**Messaging** (`messaging` group): `send(to, body, in_reply_to?)`,
|
||||||
`recv(max?)`, `ack_until(up_to)`.
|
`recv(max?)`, `ack_until(up_to)`.
|
||||||
|
|
||||||
- `send` — message a peer (logical name) or the operator
|
- `send` — message a peer on this hive (logical name). Optional
|
||||||
(`to: "operator"`). Optional `in_reply_to: i64` links the
|
`in_reply_to: i64` links the message to a prior id for thread
|
||||||
message to a prior id for thread rendering. Per-agent
|
rendering. Per-agent `services.hyperhive.agent.allowedRecipients`
|
||||||
`services.hyperhive.agent.allowedRecipients` (default: empty = unrestricted) limits
|
(default: empty = unrestricted) limits which names `send` accepts —
|
||||||
which names `send` accepts — useful for sandboxing: set
|
useful for sandboxing. The broker refuses `to: "operator"` with an error
|
||||||
`[ "operator" ]` to restrict a sub-agent to operator messages only.
|
that points at the matrix MCP tools: agents reach the operator over
|
||||||
The operator stays reachable regardless of this list, so an agent can
|
matrix ([matrix](../tools/matrix.md)).
|
||||||
always report a block.
|
|
||||||
- `recv` — drain inbox. Always an immediate peek, never blocks. `max`
|
- `recv` — drain inbox. Always an immediate peek, never blocks. `max`
|
||||||
(default 1, cap 5) drains up to N rows. `recv` prefixes each returned row
|
(default 1, cap 5) drains up to N rows. `recv` prefixes each returned row
|
||||||
with `[msg #<id>]` (broker row id; note the highest id seen, then pass
|
with `[msg #<id>]` (broker row id; note the highest id seen, then pass
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,10 @@ Settings, Core). Every page links back to H0M3, so you're
|
||||||
never more than one step from the hub.
|
never more than one step from the hub.
|
||||||
|
|
||||||
The **dashboard** itself (`/dashboard.html`) is where you'll spend most
|
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
|
- **SW4RM** — every agent, live. This is the default tab and the one
|
||||||
you'll check most.
|
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.
|
- **P3RM1SS10NS** — what tools and system-level access each agent has.
|
||||||
- **SCH3DUL3S** — a read-only list of the scheduled prompts left in the
|
- **SCH3DUL3S** — a read-only list of the scheduled prompts left in the
|
||||||
hive, due for removal; operators set schedules at swarm level now.
|
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
|
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
|
||||||
the whole swarm), **Stats** (`/stats.html`, swarm-wide usage stats),
|
the whole swarm), **Stats** (`/stats.html`, swarm-wide usage stats),
|
||||||
**Builds** (`/builds.html`, the rebuild queue and build history), and
|
**Builds** (`/builds.html`, the rebuild queue and build history), and
|
||||||
**Core** (`/core.html`, tombstones and container resource use). Your local
|
**Core** (`/core.html`, tombstones and container resource use).
|
||||||
browser preferences (notifications) live in the dashboard's Y3R C4LL
|
|
||||||
tab.
|
|
||||||
|
|
||||||
Each agent also has its own page — a full terminal view of that
|
Each agent also has its own page — a full terminal view of that
|
||||||
agent's session, reachable by selecting its name anywhere in the
|
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
|
and quick links (stats, screen, forge profile). Select the name to open
|
||||||
its terminal and watch it work in real time.
|
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
|
**Review a config change.** Agent config changes (new packages, env
|
||||||
vars, MCP servers) are pull requests on the agent's `agent-configs`
|
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.
|
repo. Review and merge them on the forge; the merge deploys the change.
|
||||||
|
|
|
||||||
|
|
@ -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 `/`).
|
hive-c0re serves the dashboard at `/dashboard.html` (with the home page at `/`).
|
||||||
It has a fixed chrome header at the top and a `<main>` that shows exactly
|
It has a fixed chrome header at the top and a `<main>` that shows exactly
|
||||||
one tab pane at a time. The URL hash (`#swarm`, `#call`,
|
one tab pane at a time. The URL hash (`#swarm`,
|
||||||
`#permissions`, `#schedules`) drives which pane is
|
`#permissions`, `#schedules`) drives which pane is
|
||||||
active; hash changes don't reload the page. FL0W and the other
|
active; hash changes don't reload the page. FL0W and the other
|
||||||
standalone pages are reachable from the H0M3 hub at `/`, not
|
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):
|
**Chrome header** (fixed, overlays the active tab pane):
|
||||||
- **← home back-link**: top-left of the chrome, links to the H0M3 hub
|
- **← 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.
|
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
|
`◆ SCH3DUL3S ◆`. In-page tabs only — the SYST3M panels live on the
|
||||||
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
|
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
|
||||||
live on their own pages too, all reachable from the
|
live on their own pages too, all reachable from the
|
||||||
H0M3 hub (not the tab strip). Count pills on SW4RM
|
H0M3 hub (not the tab strip). Count pills on SW4RM
|
||||||
(container count), Y3R C4LL (unread
|
(container count) and SCH3DUL3S (active schedules left to copy).
|
||||||
operator messages), and SCH3DUL3S (active schedules left to copy).
|
|
||||||
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
|
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
|
||||||
— sits below the tab strip.
|
— sits below the tab strip.
|
||||||
- **Server-warnings banner** — a generic, sticky top-of-page strip shown
|
- **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.
|
snapshot. Hidden when there are no warnings.
|
||||||
- **Browser tab title** — `hyperhive // h1ve-c0re` by default; updated
|
- **Browser tab title** — `hyperhive // h1ve-c0re` by default; updated
|
||||||
to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name`
|
to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name`
|
||||||
arrive in the state snapshot. When there are unread operator messages,
|
arrive in the state snapshot.
|
||||||
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
|
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,
|
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
|
`↻ UPD4TE 4LL` button appears above the containers list when any
|
||||||
agent is stale.
|
agent is stale.
|
||||||
|
|
||||||
## Y3R C4LL tab
|
|
||||||
|
|
||||||
Things blocked on operator decision — the operator inbox.
|
|
||||||
|
|
||||||
**0PER4T0R 1NB0X** — messages agents have sent to `to="operator"` but
|
|
||||||
the operator hasn't read yet. Cold-loaded from `/api/operator-inbox` on
|
|
||||||
tab activation + page load; appended live from the broker `sent` stream
|
|
||||||
(deduped on row id). Each row shows sender · timestamp · body (with
|
|
||||||
file-path linkification). A `✓ mark all read` button on the right acks
|
|
||||||
all rows via `POST /api/agent/operator/mark-all-read` (reuses the existing
|
|
||||||
mark-read endpoint). Unread count folds into the Y3R C4LL tab pill so
|
|
||||||
messages are visible from any tab even while inactive. Backed by
|
|
||||||
`GET /api/operator-inbox` → `{ messages: [...] }` (id, from, body, at,
|
|
||||||
in_reply_to, file_refs).
|
|
||||||
|
|
||||||
**◆ PR3F3R3NC3S ◆** — operator-local preferences. State lives in the
|
|
||||||
browser's `localStorage` — preferences **don't** sync between devices and
|
|
||||||
**don't** survive a profile wipe. One section today (browser
|
|
||||||
notifications); future preferences land here as sibling `<h3>` blocks
|
|
||||||
in `dashboard.html` — a standalone page for one toggle wouldn't earn
|
|
||||||
its own tile.
|
|
||||||
|
|
||||||
**◇ browser notifications** — `🔔 enable notifications` button when
|
|
||||||
permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted
|
|
||||||
(mute silences the dispatch without revoking the OS-level
|
|
||||||
permission). On unsupported origins (non-secure context, or
|
|
||||||
browsers without the `Notification` API) the controls hide and a
|
|
||||||
single status line explains why. See `### Browser notifications`
|
|
||||||
below for the dispatch model + the three signals the dashboard
|
|
||||||
emits OS notifications on.
|
|
||||||
|
|
||||||
The "expand tool output panels" toggle lives on the per-agent page's
|
|
||||||
own settings popover (`SettingsMenu`, `@hive/shared/settings-menu.js`)
|
|
||||||
instead — it's agent-terminal-only, unrelated to this section.
|
|
||||||
|
|
||||||
## C0R3 page (`/core.html`)
|
## C0R3 page (`/core.html`)
|
||||||
|
|
||||||
Passive / rare-interaction state, reached from the **Core** tile on the
|
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
|
`{ input, output, cache_read, cache_write }` USD-per-million-token
|
||||||
prices. Models not covered fall back to hive-c0re's built-in estimate.
|
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`)
|
## FL0W page (`/flow.html`)
|
||||||
|
|
||||||
A dedicated full-page terminal (not a tab pane — a separate 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
|
title, and the agent-filter select (see below). No dashboard tab
|
||||||
strip — FL0W is a standalone surface reached from the hub.
|
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`.
|
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
|
||||||
Cold load backfills the last ~200 messages from `/api/dashboard/history`;
|
Cold load backfills the last ~200 messages from `/api/dashboard/history`;
|
||||||
live frames arrive on `/api/dashboard/stream`. Each row is one broker
|
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
|
Every render prunes stale selections (agents destroyed while
|
||||||
selected) before the bar appears.
|
selected) before the bar appears.
|
||||||
|
|
||||||
## Browser notifications
|
|
||||||
|
|
||||||
Pure frontend (`Notification` API), triggered by a new broker message
|
|
||||||
sent `to: "operator"` (live via SSE).
|
|
||||||
|
|
||||||
The toggle controls live in the Y3R C4LL tab's ◆ PR3F3R3NC3S ◆
|
|
||||||
section; see that section above for the user-facing shape. Dispatch
|
|
||||||
logic lives in `common.js::NOTIF`.
|
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
|
||||||
Only messages that arrive while the page is open count. Per-event tags
|
|
||||||
(`hyperhive:msg:<at>:<rand>`) so distinct events stack in the OS
|
|
||||||
notification center instead of overwriting each other.
|
|
||||||
`console.debug` logs at every block point (unsupported,
|
|
||||||
permission ungranted, muted) for in-browser debugging. Selecting it
|
|
||||||
focuses the dashboard tab. The localStorage key
|
|
||||||
`hyperhive.notify.muted` (`"1"` = muted, absent = unmuted) backs
|
|
||||||
the toggle and silences dispatch without revoking the OS
|
|
||||||
permission. Requires a secure context (HTTPS or localhost); on
|
|
||||||
other origins the controls hide themselves. Browsers typically
|
|
||||||
suppress notifications while the originating tab is focused —
|
|
||||||
that's a browser-level decision, not the dashboard's.
|
|
||||||
<!-- vale write-good.Passive = YES -->
|
|
||||||
|
|
||||||
## Dashboard endpoints
|
## Dashboard endpoints
|
||||||
|
|
||||||
Also browsable interactively at `/api/docs` (a Swagger UI, linked from
|
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
|
not yet delivered and sets `acked_at = now`. Returns
|
||||||
`{ "marked": N }`. Agent name validated against
|
`{ "marked": N }`. Agent name validated against
|
||||||
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
|
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
|
||||||
- `GET /api/operator-inbox` — list unread messages addressed to
|
|
||||||
`to="operator"` (broker rows with `acked_at = NULL`). Cold-loaded
|
|
||||||
for the Y3R C4LL tab's ◆ 1NB0X ◆ section on page load + tab activation;
|
|
||||||
live updates fed from the broker `sent` stream. Returns
|
|
||||||
`{ messages: [{ id, from, body, at, in_reply_to, file_refs }, …] }`,
|
|
||||||
newest-first. Reuses `/api/agent/operator/mark-all-read` to ack (filters
|
|
||||||
are identical so every listed row is exactly what mark-read clears).
|
|
||||||
- `POST /api/op-send` (`to=<name>`, `body=<text>`) — drop an
|
- `POST /api/op-send` (`to=<name>`, `body=<text>`) — drop an
|
||||||
operator-authored message into `<name>`'s inbox. `to=*` fans
|
operator-authored message into `<name>`'s inbox. `to=*` fans
|
||||||
out to every registered agent. Returns 200; the broker
|
out to every registered agent. Returns 200; the broker
|
||||||
|
|
@ -950,8 +866,7 @@ payload):
|
||||||
(the broker row id) and `in_reply_to: Option<i64>` for thread
|
(the broker row id) and `in_reply_to: Option<i64>` for thread
|
||||||
rendering. The dashboard message-flow terminal renders reply
|
rendering. The dashboard message-flow terminal renders reply
|
||||||
rows with a `↳ reply` tag that scroll-highlights the parent
|
rows with a `↳ reply` tag that scroll-highlights the parent
|
||||||
row on selection. Used by the message-flow terminal renderer and
|
row on selection. Used by the message-flow terminal renderer.
|
||||||
the operator-inbox derived state.
|
|
||||||
- `transient_set` (name, transient_kind, since_unix) /
|
- `transient_set` (name, transient_kind, since_unix) /
|
||||||
`transient_cleared` (name) — lifecycle action spinners. The
|
`transient_cleared` (name) — lifecycle action spinners. The
|
||||||
client ticks the elapsed-seconds badge off `since_unix`
|
client ticks the elapsed-seconds badge off `since_unix`
|
||||||
|
|
@ -1007,7 +922,7 @@ payload):
|
||||||
forms that mutate non-event-derived state** (PURG3 +
|
forms that mutate non-event-derived state** (PURG3 +
|
||||||
meta-update, since tombstones + meta_inputs aren't event-
|
meta-update, since tombstones + meta_inputs aren't event-
|
||||||
shaped yet). Every other section — transients,
|
shaped yet). Every other section — transients,
|
||||||
containers, operator inbox, message flow —
|
containers, message flow —
|
||||||
derives from `/api/dashboard/stream` after the initial snapshot,
|
derives from `/api/dashboard/stream` after the initial snapshot,
|
||||||
maintaining its own client-side store and applying events on
|
maintaining its own client-side store and applying events on
|
||||||
top.
|
top.
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ re-render), call `refreshState()`. State shapes live in
|
||||||
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
|
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
|
||||||
`web_ui/state.rs::StateSnapshot` — when
|
`web_ui/state.rs::StateSnapshot` — when
|
||||||
adding state fields, plumb through the snapshot struct and the
|
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
|
**Focus preservation:** `refreshState` checks whether
|
||||||
`document.activeElement` sits inside one of the managed sections
|
`document.activeElement` sits inside one of the managed sections
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
// avoid this comment drifting out of sync with the real build steps.
|
// avoid this comment drifting out of sync with the real build steps.
|
||||||
//
|
//
|
||||||
// Each JS entry bundles `./common.js` (DOM helpers, Panel singleton,
|
// 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
|
// module into each bundle rather than emitting a shared chunk (no
|
||||||
// `splitting: true`). The Rust binary mounts `dist/` as a
|
// `splitting: true`). The Rust binary mounts `dist/` as a
|
||||||
// `tower_http::ServeDir` fallback (`append_index_html_on_directories`
|
// `tower_http::ServeDir` fallback (`append_index_html_on_directories`
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -337,46 +337,8 @@ code {
|
||||||
pages' sticky back-link bar) moved to @hive/shared/chrome.css, imported
|
pages' sticky back-link bar) moved to @hive/shared/chrome.css, imported
|
||||||
at the top of this file. */
|
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
|
/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap`. The
|
||||||
msgrow / msg-* rules below power both the dashboard CALL tab and
|
msgrow / msg-* rules below power the /flow.html full-page terminal. */
|
||||||
the /flow.html full-page terminal. */
|
|
||||||
.live .msgrow {
|
.live .msgrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// Shared dashboard helpers used by both dashboard.html (./tabs.js) and
|
// Shared dashboard helpers used by both dashboard.html (./tabs.js) and
|
||||||
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
|
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
|
||||||
// the OS-notification module, and the path-link / file-preview
|
// and the path-link / file-preview infrastructure for the side panel.
|
||||||
// infrastructure for the side panel.
|
|
||||||
|
|
||||||
import { linkify as termLinkify } from "@hive/shared/terminal.js";
|
import { linkify as termLinkify } from "@hive/shared/terminal.js";
|
||||||
import { el } from "@hive/shared/dom.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 ──────────────────────────────────────────
|
// ─── server warnings banner ──────────────────────────────────────────
|
||||||
// A generic top-of-page banner shown on every page (dashboard + the
|
// A generic top-of-page banner shown on every page (dashboard + the
|
||||||
// stand-alone FL0W / H0M3 pages). The backend decides what to
|
// stand-alone FL0W / H0M3 pages). The backend decides what to
|
||||||
|
|
|
||||||
|
|
@ -125,11 +125,6 @@ body.dashboard-shell {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: bold;
|
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
|
/* Tab pane visibility — createTabStrip (@hive/shared/tabs.js) sets the
|
||||||
`hidden` attribute on every inactive pane (resolved from the URL hash;
|
`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
|
the standing caveat (a pulsing banner that's always present just
|
||||||
teaches you to stop seeing it), `severity="red" pulse` for the port
|
teaches you to stop seeing it), `severity="red" pulse` for the port
|
||||||
collision (an incident, not a caveat). See core.js/swarm.js and
|
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
|
/* Build-queue summary banner on the SW4RM tab moved to the shared
|
||||||
`JobqRollup` Preact component (`.jqr-*` classes, imported above) —
|
`JobqRollup` Preact component (`.jqr-*` classes, imported above) —
|
||||||
mounted by swarm.js::initJobqRollup into #jobq-rollup-section. */
|
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 {
|
.pending-state {
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|
|
||||||
|
|
@ -35,21 +35,6 @@
|
||||||
<span class="tab-label">◆ SW4RM ◆</span>
|
<span class="tab-label">◆ SW4RM ◆</span>
|
||||||
<span class="tab-count" id="tab-count-swarm" hidden></span>
|
<span class="tab-count" id="tab-count-swarm" hidden></span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
class="tab"
|
|
||||||
id="tab-call"
|
|
||||||
href="#call"
|
|
||||||
role="tab"
|
|
||||||
aria-controls="tab-pane-call"
|
|
||||||
data-tab="call"
|
|
||||||
>
|
|
||||||
<span class="tab-label">◆ Y3R C4LL ◆</span>
|
|
||||||
<span
|
|
||||||
class="tab-count tab-count-attn"
|
|
||||||
id="tab-count-call"
|
|
||||||
hidden
|
|
||||||
></span>
|
|
||||||
</a>
|
|
||||||
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
|
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
|
||||||
reached from the H0M3 hub — keeps the dashboard tab strip lean.
|
reached from the H0M3 hub — keeps the dashboard tab strip lean.
|
||||||
The rebuild-queue STATE still lives in tabs.js (it drives the
|
The rebuild-queue STATE still lives in tabs.js (it drives the
|
||||||
|
|
@ -132,74 +117,6 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Y3R C4LL: things blocked on operator decision — the operator
|
|
||||||
inbox; surfaces full bodies inline so the operator can decide
|
|
||||||
without leaving the pane. -->
|
|
||||||
<section
|
|
||||||
class="tab-pane"
|
|
||||||
id="tab-pane-call"
|
|
||||||
data-tab-pane="call"
|
|
||||||
hidden
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="tab-call"
|
|
||||||
>
|
|
||||||
<!-- 1NB0X: unread agent→operator messages. Fetched on
|
|
||||||
tab activation + cold load, appended live from the broker
|
|
||||||
stream, cleared via "mark all read". Count folds into the
|
|
||||||
Y3R C4LL pill so messages aren't missed. -->
|
|
||||||
<h2>◆ 1NB0X ◆</h2>
|
|
||||||
<div class="divider">
|
|
||||||
══════════════════════════════════════════════════════════════
|
|
||||||
</div>
|
|
||||||
<div id="operator-inbox-section">
|
|
||||||
<p class="meta">loading…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Operator-local preferences (browser notifications). Was its own
|
|
||||||
S3TT1NGS page (`/settings.html`); mara moved it in here since a
|
|
||||||
standalone page for one toggle didn't earn its own tile. State
|
|
||||||
lives in the browser's localStorage — NOTIF.bind() (common.js)
|
|
||||||
wires the buttons, same dispatch NOTIF.show() already used for
|
|
||||||
inbox notifications above. -->
|
|
||||||
<h2>◆ PR3F3R3NC3S ◆</h2>
|
|
||||||
<div class="divider">
|
|
||||||
══════════════════════════════════════════════════════════════
|
|
||||||
</div>
|
|
||||||
<p class="meta">
|
|
||||||
operator-local preferences. these live in the browser's localStorage
|
|
||||||
— they do not sync between devices and do not survive a profile
|
|
||||||
wipe.
|
|
||||||
</p>
|
|
||||||
<h3>◇ browser notifications</h3>
|
|
||||||
<p class="meta">
|
|
||||||
desktop notifications for broker messages addressed to you. requires
|
|
||||||
a secure context (https or localhost). mute silences the
|
|
||||||
notifications without revoking the OS-level permission.
|
|
||||||
</p>
|
|
||||||
<div id="notif-row" class="notif-row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="notif-enable"
|
|
||||||
class="btn btn-notif"
|
|
||||||
hidden
|
|
||||||
>
|
|
||||||
🔔 enable notifications
|
|
||||||
</button>
|
|
||||||
<button type="button" id="notif-mute" class="btn btn-notif" hidden>
|
|
||||||
🔕 mute
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="notif-unmute"
|
|
||||||
class="btn btn-notif"
|
|
||||||
hidden
|
|
||||||
>
|
|
||||||
🔔 unmute
|
|
||||||
</button>
|
|
||||||
<span id="notif-status" class="meta" hidden></span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
|
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
|
||||||
meta inputs, rebuild queue, kept state, container load. Reached
|
meta inputs, rebuild queue, kept state, container load. Reached
|
||||||
from the H0M3 hub. -->
|
from the H0M3 hub. -->
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
// /flow.html entry point. Owns the full-page broker terminal and the
|
// /flow.html entry point. Owns the full-page broker terminal and the
|
||||||
// @-mention compose box. Pulls shared infrastructure (DOM helpers,
|
// @-mention compose box. Pulls shared infrastructure (DOM helpers,
|
||||||
// side panel, OS notifications, path linkification) from `./common.js`.
|
// side panel, path linkification) from `./common.js`. FL0W is the pure
|
||||||
//
|
// event firehose.
|
||||||
// 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
|
// Does NOT contain the dashboard's tab renderers, mutation-event
|
||||||
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
|
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
|
||||||
|
|
@ -14,7 +12,6 @@
|
||||||
import { create as termCreate } from "@hive/shared/terminal.js";
|
import { create as termCreate } from "@hive/shared/terminal.js";
|
||||||
import {
|
import {
|
||||||
$,
|
$,
|
||||||
NOTIF,
|
|
||||||
appendLinkified,
|
appendLinkified,
|
||||||
openStream,
|
openStream,
|
||||||
initServerWarnings,
|
initServerWarnings,
|
||||||
|
|
@ -23,7 +20,6 @@ import { el } from "@hive/shared/dom.js";
|
||||||
import { epochSec } from "./util.js";
|
import { epochSec } from "./util.js";
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
NOTIF.bind();
|
|
||||||
initServerWarnings();
|
initServerWarnings();
|
||||||
|
|
||||||
// ─── local containers cache (for compose autocomplete) ──────────────────
|
// ─── local containers cache (for compose autocomplete) ──────────────────
|
||||||
|
|
@ -94,8 +90,7 @@ import { epochSec } from "./util.js";
|
||||||
// ─── message flow: shared terminal pane ────────────────────────────────
|
// ─── message flow: shared terminal pane ────────────────────────────────
|
||||||
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
|
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
|
||||||
// What stays here is the broker-message renderer + the page-local
|
// What stays here is the broker-message renderer + the page-local
|
||||||
// side effects (banner pulse, OS notifications on operator-bound
|
// side effect (banner pulse).
|
||||||
// traffic).
|
|
||||||
(() => {
|
(() => {
|
||||||
const flow = $("msgflow");
|
const flow = $("msgflow");
|
||||||
if (!flow) return;
|
if (!flow) return;
|
||||||
|
|
@ -283,21 +278,7 @@ import { epochSec } from "./util.js";
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
},
|
},
|
||||||
onLiveEvent: (ev) => {
|
onLiveEvent: () => pulseBanner(),
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
//
|
//
|
||||||
// The roster is read by nearly every tab (the container tree, the
|
// The roster is read by nearly every tab (the container tree, the
|
||||||
// capabilities + tool-group matrices, the schedule target-chips, 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
|
// it lives here as a single source of truth that each domain module
|
||||||
// imports rather than threading through call signatures. Everything
|
// imports rather than threading through call signatures. Everything
|
||||||
// else in the dashboard is domain-local and lives with its own module.
|
// else in the dashboard is domain-local and lives with its own module.
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,7 @@ export function renderContainers(s) {
|
||||||
// Belt-and-suspenders for any future page that adds tabs.js
|
// Belt-and-suspenders for any future page that adds tabs.js
|
||||||
// without a #containers-section — matches the
|
// without a #containers-section — matches the
|
||||||
// no-op-when-target-absent convention the other renderers
|
// no-op-when-target-absent convention the other renderers
|
||||||
// (renderOperatorInbox, etc.) follow.
|
// follow.
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
|
||||||
// Containers come from the derived map (event-driven) rather than
|
// Containers come from the derived map (event-driven) rather than
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@
|
||||||
// dashboard event stream (subscribed via `openStream` from common.js). See
|
// dashboard event stream (subscribed via `openStream` from common.js). See
|
||||||
// docs/web-ui/shape.md::Shape (shared by both) for the broader contract.
|
// docs/web-ui/shape.md::Shape (shared by both) for the broader contract.
|
||||||
//
|
//
|
||||||
// Pure helpers (DOM, side panel, OS notifications, path linkification)
|
// Pure helpers (DOM, side panel, path linkification)
|
||||||
// live in `./common.js`; the flow-page surface (operator inbox, broker
|
// live in `./common.js`; the flow-page surface (broker
|
||||||
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
|
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
|
||||||
// `dashboard.html` each load their own bundle.
|
// `dashboard.html` each load their own bundle.
|
||||||
// SW4RM (containers) domain lives in `./swarm.js`.
|
// SW4RM (containers) domain lives in `./swarm.js`.
|
||||||
|
|
||||||
import { marked } from "marked";
|
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 { el } from "@hive/shared/dom.js";
|
||||||
import { bindAsyncForms } from "@hive/shared/forms.js";
|
import { bindAsyncForms } from "@hive/shared/forms.js";
|
||||||
import { createTabStrip } from "@hive/shared/tabs.js";
|
import { createTabStrip } from "@hive/shared/tabs.js";
|
||||||
|
|
@ -24,12 +24,6 @@ import {
|
||||||
initPermissions,
|
initPermissions,
|
||||||
} from "./permissions.js";
|
} from "./permissions.js";
|
||||||
import { refreshSchedules, activeScheduleCount } from "./schedules.js";
|
import { refreshSchedules, activeScheduleCount } from "./schedules.js";
|
||||||
import {
|
|
||||||
initCall,
|
|
||||||
refreshOperatorInbox,
|
|
||||||
operatorInboxAppendFromEvent,
|
|
||||||
operatorInboxCount,
|
|
||||||
} from "./call.js";
|
|
||||||
import {
|
import {
|
||||||
initJobqRollup,
|
initJobqRollup,
|
||||||
syncTransientsFromSnapshot,
|
syncTransientsFromSnapshot,
|
||||||
|
|
@ -138,10 +132,7 @@ window.marked = marked;
|
||||||
const label = swarm && hive ? `${swarm} / ${hive}` : hive || swarm;
|
const label = swarm && hive ? `${swarm} / ${hive}` : hive || swarm;
|
||||||
hiveId.textContent = label;
|
hiveId.textContent = label;
|
||||||
hiveId.hidden = false;
|
hiveId.hidden = false;
|
||||||
// Preserve any (N) call-count prefix already applied by
|
document.title = label + " // h1ve-c0re";
|
||||||
// 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();
|
const openDetails = snapshotOpenDetails();
|
||||||
|
|
@ -181,22 +172,10 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshState();
|
refreshState();
|
||||||
// Cold-load the operator inbox so the Y3R C4LL pill + browser
|
|
||||||
// title reflect unread agent->operator messages immediately, before the
|
|
||||||
// operator opens the tab. Live updates arrive via the broker stream.
|
|
||||||
refreshOperatorInbox();
|
|
||||||
// NOTIF.bind() wires the enable/mute/unmute toggle buttons that now
|
|
||||||
// live in the Y3R C4LL tab's ◆ PR3F3R3NC3S ◆ section. NOTIF.show()
|
|
||||||
// (inbox notifications) reads the same browser permission +
|
|
||||||
// localStorage mute flag regardless of which page fires it — bind()
|
|
||||||
// only needs to run once, here, where the buttons live. The side
|
|
||||||
// panel (<hive-side-panel>) wires its own internals in
|
|
||||||
// connectedCallback — no bind() step needed for it.
|
|
||||||
NOTIF.bind();
|
|
||||||
|
|
||||||
// ─── live updates: dashboard event stream ──────────────────────────────
|
// ─── live updates: dashboard event stream ──────────────────────────────
|
||||||
// The dashboard subscribes to /dashboard/stream for live mutation
|
// 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.
|
// operator action triggering a refreshState.
|
||||||
//
|
//
|
||||||
// Bare `EventSource` (no terminal infrastructure needed — the
|
// 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`
|
// Both /dashboard.html and /flow.html subscribe to `/api/dashboard/stream`
|
||||||
// and filter client-side — the dashboard ignores broker traffic
|
// and filter client-side — the dashboard ignores broker traffic
|
||||||
// and the inbox ignores mutation events.
|
// and FL0W ignores mutation events.
|
||||||
const MUTATION_HANDLERS = {
|
const MUTATION_HANDLERS = {
|
||||||
transient_set: applyTransientSet,
|
transient_set: applyTransientSet,
|
||||||
transient_cleared: applyTransientCleared,
|
transient_cleared: applyTransientCleared,
|
||||||
|
|
@ -231,14 +210,10 @@ window.marked = marked;
|
||||||
// EventSource-shaped facade with a graceful direct-EventSource
|
// EventSource-shaped facade with a graceful direct-EventSource
|
||||||
// fallback when SharedWorker isn't supported.
|
// fallback when SharedWorker isn't supported.
|
||||||
//
|
//
|
||||||
// kinds= matches MUTATION_HANDLERS below verbatim, plus `sent`
|
// kinds= matches MUTATION_HANDLERS verbatim, so the stream carries
|
||||||
// (checked separately, just above, for the operator inbox) —
|
// only the 7 wire kinds this page acts on.
|
||||||
// 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(
|
const es = openStream(
|
||||||
"/api/dashboard/stream?kinds=sent," +
|
"/api/dashboard/stream?kinds=" +
|
||||||
"transient_set,transient_cleared," +
|
"transient_set,transient_cleared," +
|
||||||
"container_state_changed,container_removed,rebuild_queue_changed," +
|
"container_state_changed,container_removed,rebuild_queue_changed," +
|
||||||
"capabilities_changed,tool_groups_changed",
|
"capabilities_changed,tool_groups_changed",
|
||||||
|
|
@ -250,12 +225,6 @@ window.marked = marked;
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
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];
|
const h = MUTATION_HANDLERS[ev.kind];
|
||||||
if (!h) return; // broker rows + future kinds — dashboard doesn't care
|
if (!h) return; // broker rows + future kinds — dashboard doesn't care
|
||||||
try {
|
try {
|
||||||
|
|
@ -279,7 +248,7 @@ window.marked = marked;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ─── tab routing ───────────────────────────────────────────────────────
|
// ─── tab routing ───────────────────────────────────────────────────────
|
||||||
// Hash-based: `#swarm` / `#call` / `#system` / `#permissions` /
|
// Hash-based: `#swarm` / `#system` / `#permissions` /
|
||||||
// `#schedules` activate the matching pane on the dashboard. Empty
|
// `#schedules` activate the matching pane on the dashboard. Empty
|
||||||
// hash defaults to SW4RM. FL0W and ST4TS are NOT tabs —
|
// hash defaults to SW4RM. FL0W and ST4TS are NOT tabs —
|
||||||
// they're separate pages (`/flow.html`, `/stats.html`) reached from
|
// they're separate pages (`/flow.html`, `/stats.html`) reached from
|
||||||
|
|
@ -311,9 +280,6 @@ window.marked = marked;
|
||||||
fetchAndRenderCapabilities();
|
fetchAndRenderCapabilities();
|
||||||
fetchAndRenderToolGroups();
|
fetchAndRenderToolGroups();
|
||||||
}
|
}
|
||||||
if (target === "call") {
|
|
||||||
refreshOperatorInbox();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire the shared tab strip now that activateTab + the lazy-load fns it
|
// 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.
|
// fires activateTab for the per-tab side-effects on every change.
|
||||||
createTabStrip($("tabbar"), { defaultId: "swarm", onShow: activateTab });
|
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
|
// Tab count pills — pure derived data from the existing state
|
||||||
// stores so SSE-driven updates flow through without extra plumbing.
|
// stores so SSE-driven updates flow through without extra plumbing.
|
||||||
// Set `hidden` when the count is zero so the pill doesn't draw
|
// 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++;
|
if (c.needs_update) swarm++;
|
||||||
}
|
}
|
||||||
setTabCount("swarm", 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
|
// SCH3DUL3S — count of schedules with at least one still-active
|
||||||
// target: the ones left to recreate with `swarmctl schedule`.
|
// target: the ones left to recreate with `swarmctl schedule`.
|
||||||
setTabCount("schedules", activeScheduleCount());
|
setTabCount("schedules", activeScheduleCount());
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use rmcp::schemars;
|
||||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
pub struct SendArgs {
|
pub struct SendArgs {
|
||||||
/// Logical agent name to deliver the message to (e.g. `"manager"`,
|
/// 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,
|
pub to: String,
|
||||||
/// Message body. Plain text; the broker doesn't parse it.
|
/// Message body. Plain text; the broker doesn't parse it.
|
||||||
pub body: String,
|
pub body: String,
|
||||||
|
|
|
||||||
|
|
@ -141,8 +141,8 @@ impl AgentServer {
|
||||||
#[tool_router]
|
#[tool_router]
|
||||||
impl AgentServer {
|
impl AgentServer {
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "Send a message to another hyperhive agent (or to the operator). \
|
description = "Send a message to another hyperhive agent on this hive. \
|
||||||
Use this to talk to peers or to surface output for the human at the dashboard."
|
Not for the operator: reach them over matrix with the matrix MCP tools."
|
||||||
)]
|
)]
|
||||||
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
|
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
|
||||||
let log = format!("{args:?}");
|
let log = format!("{args:?}");
|
||||||
|
|
@ -500,7 +500,7 @@ impl AgentServer {
|
||||||
|
|
||||||
#[tool_handler(
|
#[tool_handler(
|
||||||
instructions = "You are a hyperhive agent. Use `send` to talk to peers (by their logical \
|
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."
|
message at a time. Use `remind` to schedule a future wake-up message for yourself."
|
||||||
)]
|
)]
|
||||||
impl ServerHandler for AgentServer {}
|
impl ServerHandler for AgentServer {}
|
||||||
|
|
|
||||||
|
|
@ -12,24 +12,14 @@
|
||||||
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
|
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
|
||||||
|
|
||||||
/// Enforce the per-agent send allow-list. Returns `Ok` when the
|
/// Enforce the per-agent send allow-list. Returns `Ok` when the
|
||||||
/// recipient is permitted (no list configured, the operator, or `to` is
|
/// recipient is permitted (no list configured, or `to` is in the list);
|
||||||
/// in the list); returns `Err(refusal)` with a claude-readable string
|
/// returns `Err(refusal)` with a claude-readable string when blocked —
|
||||||
/// when blocked — the harness surfaces the refusal as the tool result so
|
/// the harness surfaces the refusal as the tool result so claude knows
|
||||||
/// claude knows the message didn't land and can react (e.g. route to the
|
/// the message didn't land and can react. `operator` is refused whatever
|
||||||
/// operator instead).
|
/// the list says, with the same matrix-pointing text the broker returns.
|
||||||
pub fn check_send_allowed(to: &str) -> Result<(), String> {
|
pub fn check_send_allowed(to: &str) -> Result<(), String> {
|
||||||
if to == hive_sh4re::manager::OPERATOR_RECIPIENT {
|
if to == hive_sh4re::manager::OPERATOR_RECIPIENT {
|
||||||
// Always allow the operator — the allow-list constrains peer
|
return Err(hive_sh4re::manager::OPERATOR_SEND_REFUSAL.to_owned());
|
||||||
// chatter, not the reporting line out, and an agent with no way
|
|
||||||
// to say "I am stuck" is an agent that fails silently.
|
|
||||||
//
|
|
||||||
// This bypass used to be spelled `<parent>`, which the broker
|
|
||||||
// resolved per `topology.json` and which fell back to `operator`
|
|
||||||
// for a root agent. The parent field is gone now, so every
|
|
||||||
// agent is what that fallback called a root — the exemption is
|
|
||||||
// now written as the name it always resolved to. Same reachable
|
|
||||||
// set, one fewer indirection.
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
|
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
|
||||||
return Ok(()); // file missing → no policy configured → unrestricted
|
return Ok(()); // file missing → no policy configured → unrestricted
|
||||||
|
|
@ -53,9 +43,19 @@ pub fn check_send_allowed(to: &str) -> Result<(), String> {
|
||||||
}
|
}
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"send refused: recipient '{to}' not in services.hyperhive.agent.allowedRecipients \
|
"send refused: recipient '{to}' not in services.hyperhive.agent.allowedRecipients \
|
||||||
(configured in agent.nix). Allowed: {allow:?}. The operator is always \
|
(configured in agent.nix). Allowed: {allow:?}. To reach the operator, use \
|
||||||
reachable — route through `send(to: \"{}\", …)` if you need to reach \
|
the matrix MCP tools."
|
||||||
someone outside the allow-list.",
|
|
||||||
hive_sh4re::manager::OPERATOR_RECIPIENT
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look:
|
||||||
|
|
||||||
- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at.
|
- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. `send` reaches peers on this hive only; **reach the operator over matrix** (the matrix MCP tools, `mcp__matrix__send_dm` or `mcp__matrix__send_message`) — `send(to: "operator")` is refused. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at.
|
||||||
- **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time.
|
- **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time.
|
||||||
|
|
||||||
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — open a PR on your `agent-configs/<name>` repo, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits.
|
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — open a PR on your `agent-configs/<name>` repo, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits.
|
||||||
|
|
@ -13,8 +13,8 @@ Operator boundary: starting, stopping and rebuilding containers is the operator'
|
||||||
|
|
||||||
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `container_crash`, `needs_update`. Use these to react to lifecycle changes:
|
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.
|
- `needs_update` — agent's flake rev is stale. Ask the operator over matrix to rebuild it.
|
||||||
- `container_crash` — ask the operator to start it again; if it keeps crashing, say so with what you saw.
|
- `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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,20 +48,9 @@ pub fn swarm_name() -> Option<String> {
|
||||||
non_empty_env("HYPERHIVE_SWARM_NAME")
|
non_empty_env("HYPERHIVE_SWARM_NAME")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hive-qualified agent identity. When the hive domain is configured, returns
|
/// Apply hive qualification to an agent label (e.g. a peer name from the
|
||||||
/// `${label}@${domain}` (e.g. `iris@darkest.space`); when not, returns just
|
/// broker). Returns `${label}@${domain}` when the hive domain is set, else
|
||||||
/// the short label so callers can render a single string regardless of
|
/// just `label`.
|
||||||
/// 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]
|
#[must_use]
|
||||||
pub fn qualify(label: &str) -> String {
|
pub fn qualify(label: &str) -> String {
|
||||||
match hive_domain() {
|
match hive_domain() {
|
||||||
|
|
@ -151,27 +140,27 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn qualified_label_with_domain_set() {
|
fn qualify_with_domain_set() {
|
||||||
with_env(Some("iris"), Some("darkest.space"), || {
|
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"));
|
assert_eq!(hive_domain().as_deref(), Some("darkest.space"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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, || {
|
with_env(Some("iris"), None, || {
|
||||||
assert_eq!(qualified_label(), "iris");
|
assert_eq!(qualify(&label()), "iris");
|
||||||
assert!(hive_domain().is_none());
|
assert!(hive_domain().is_none());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
// Empty string is treated the same as unset — a misconfigured
|
||||||
// module shouldn't surface `iris@` (no domain) to the operator.
|
// module shouldn't surface `iris@` (no domain) to the operator.
|
||||||
with_env(Some("iris"), Some(""), || {
|
with_env(Some("iris"), Some(""), || {
|
||||||
assert_eq!(qualified_label(), "iris");
|
assert_eq!(qualify(&label()), "iris");
|
||||||
assert!(hive_domain().is_none());
|
assert!(hive_domain().is_none());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -247,8 +236,8 @@ mod tests {
|
||||||
assert!(hive_domain().is_none());
|
assert!(hive_domain().is_none());
|
||||||
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
|
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
|
||||||
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
|
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
|
||||||
// qualified_label still degrades to short label without domain.
|
// qualify still degrades to the short label without a domain.
|
||||||
assert_eq!(qualified_label(), "iris");
|
assert_eq!(qualify(&label()), "iris");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,22 +137,6 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body string for the turn-failure notification we route to the
|
|
||||||
/// operator on `TurnError::Failed`. Reads the hive-qualified
|
|
||||||
/// identity so the receiver sees `agent@hive` rather than relying on
|
|
||||||
/// the caller threading a `label` through every turn-handling layer.
|
|
||||||
/// Falls back to `<unknown>` when `HIVE_LABEL` is missing so a
|
|
||||||
/// misconfigured harness still produces a parseable line.
|
|
||||||
fn format_turn_failure(err: &anyhow::Error) -> String {
|
|
||||||
let who = crate::identity::qualified_label();
|
|
||||||
let who = if who.is_empty() {
|
|
||||||
"<unknown>".to_owned()
|
|
||||||
} else {
|
|
||||||
who
|
|
||||||
};
|
|
||||||
format!("[system] `{who}` claude turn failed:\n{err:#}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a finished turn tells the serve loop to do next.
|
/// What a finished turn tells the serve loop to do next.
|
||||||
struct TurnControl {
|
struct TurnControl {
|
||||||
/// The turn ended in `AuthFailed` — caller parks on login.
|
/// The turn ended in `AuthFailed` — caller parks on login.
|
||||||
|
|
@ -293,16 +277,6 @@ trait Surface {
|
||||||
/// fallback. Same shape as `graceful_stop_complete`.
|
/// fallback. Same shape as `graceful_stop_complete`.
|
||||||
fn pause_acknowledged(socket: &Path) -> impl Future<Output = ()>;
|
fn pause_acknowledged(socket: &Path) -> impl Future<Output = ()>;
|
||||||
|
|
||||||
/// Send a message addressed to the operator. The reporting line out
|
|
||||||
/// of a container: this is where a turn failure or a plugin-install
|
|
||||||
/// failure surfaces when nothing inside the harness can act on it.
|
|
||||||
///
|
|
||||||
/// Was `<parent>` while the hierarchy existed, a sentinel the broker resolved per
|
|
||||||
/// `topology.json` and which already fell through to `operator` for a
|
|
||||||
/// root agent. With the parent field gone every agent takes that
|
|
||||||
/// branch, so the recipient is written out rather than resolved.
|
|
||||||
fn send_to_operator(socket: &Path, body: String) -> impl Future<Output = ()>;
|
|
||||||
|
|
||||||
/// Long-poll the broker for the next message. Wraps the
|
/// Long-poll the broker for the next message. Wraps the
|
||||||
/// `Messages`/empty/error trichotomy in `RecvOutcome` so the
|
/// `Messages`/empty/error trichotomy in `RecvOutcome` so the
|
||||||
/// generic `serve_loop` doesn't need the per-role Response enum
|
/// generic `serve_loop` doesn't need the per-role Response enum
|
||||||
|
|
@ -387,22 +361,6 @@ impl Surface for AgentSurface {
|
||||||
(threads, reminders)
|
(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 {
|
async fn recv_next(socket: &Path) -> RecvOutcome {
|
||||||
let recv: Result<Response> = hive_sock_client::request_within(
|
let recv: Result<Response> = hive_sock_client::request_within(
|
||||||
socket,
|
socket,
|
||||||
|
|
@ -548,11 +506,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let files = turn::TurnFiles::prepare(socket, &label).await?;
|
let files = turn::TurnFiles::prepare(socket, &label).await?;
|
||||||
// Plugin install failures come back as a Vec<String> — route each
|
plugins::install_configured().await;
|
||||||
// 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
|
// 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
|
// process now (`hive-forge-notify`, its own systemd unit) so a harness
|
||||||
// restart doesn't take forge notifications down with it; it reaches the
|
// restart doesn't take forge notifications down with it; it reaches the
|
||||||
|
|
@ -944,7 +898,8 @@ fn apply_todo_wake_checked(checked: Option<bool>, todo_miss_streak: &mut u32, bu
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive a single turn: emit boot-of-turn events, run claude, ack on
|
/// 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
|
/// record stats. Returns a `TurnControl` carrying the auth-failed flag and
|
||||||
/// the todo-wake-checked signal — the serve loop decides what to do next.
|
/// the todo-wake-checked signal — the serve loop decides what to do next.
|
||||||
async fn handle_turn<S: Surface>(
|
async fn handle_turn<S: Surface>(
|
||||||
|
|
@ -1061,9 +1016,11 @@ async fn handle_turn<S: Surface>(
|
||||||
/// The non-happy-path half of `handle_turn`: react to each `TurnError`
|
/// 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/
|
/// 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-
|
/// 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
|
/// found). A hard failure needs nothing here: `turn::emit_turn_end` has
|
||||||
/// `handle_turn` itself under clippy's line-count lint — no behavior
|
/// already logged `turn failed`, which the `hh-agent-turn-failed` Grafana
|
||||||
/// change from when this lived inline.
|
/// rule alerts on. Split out purely to keep `handle_turn` itself under
|
||||||
|
/// clippy's line-count lint — no behavior change from when this lived
|
||||||
|
/// inline.
|
||||||
async fn handle_turn_error_recovery<S: Surface>(
|
async fn handle_turn_error_recovery<S: Surface>(
|
||||||
outcome: &turn::TurnOutcome,
|
outcome: &turn::TurnOutcome,
|
||||||
bus: &Bus,
|
bus: &Bus,
|
||||||
|
|
@ -1123,7 +1080,4 @@ async fn handle_turn_error_recovery<S: Surface>(
|
||||||
tracing::warn!("session-not-found; requeueing message for a fresh turn");
|
tracing::warn!("session-not-found; requeueing message for a fresh turn");
|
||||||
S::requeue_inflight(socket).await;
|
S::requeue_inflight(socket).await;
|
||||||
}
|
}
|
||||||
if let Err(turn::TurnError::Failed(e)) = outcome {
|
|
||||||
S::send_to_operator(socket, format_turn_failure(e)).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,23 +96,20 @@ async fn update_marketplaces() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
|
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
|
||||||
/// Returns a list of human-readable failure messages so the caller can
|
/// A failed install is logged and skipped; the harness starts without it.
|
||||||
/// route them through their own per-role surface (turn-failure-style
|
pub async fn install_configured() {
|
||||||
/// notification, see `Surface::send_to_operator`). Wire-agnostic: the
|
|
||||||
/// caller picks the recipient, the same way failure-notify does.
|
|
||||||
pub async fn install_configured() -> Vec<String> {
|
|
||||||
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
||||||
return Vec::new();
|
return;
|
||||||
};
|
};
|
||||||
let specs: Vec<String> = match serde_json::from_str(&raw) {
|
let specs: Vec<String> = match serde_json::from_str(&raw) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
|
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
|
||||||
return Vec::new();
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if specs.is_empty() {
|
if specs.is_empty() {
|
||||||
return Vec::new();
|
return;
|
||||||
}
|
}
|
||||||
add_marketplaces().await;
|
add_marketplaces().await;
|
||||||
if auto_update_enabled().await {
|
if auto_update_enabled().await {
|
||||||
|
|
@ -120,7 +117,6 @@ pub async fn install_configured() -> Vec<String> {
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
|
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
|
||||||
}
|
}
|
||||||
let mut failures = Vec::new();
|
|
||||||
for spec in specs {
|
for spec in specs {
|
||||||
match Command::new("claude")
|
match Command::new("claude")
|
||||||
.args(["plugin", "install", &spec])
|
.args(["plugin", "install", &spec])
|
||||||
|
|
@ -138,18 +134,10 @@ pub async fn install_configured() -> Vec<String> {
|
||||||
stderr = %stderr,
|
stderr = %stderr,
|
||||||
"claude plugin install failed",
|
"claude plugin install failed",
|
||||||
);
|
);
|
||||||
failures.push(format!(
|
|
||||||
"claude plugin install failed for `{spec}`:\n{}",
|
|
||||||
stderr.trim()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
|
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
|
||||||
failures.push(format!(
|
|
||||||
"claude plugin install spawn failed for `{spec}`: {e}"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
failures
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,8 +201,8 @@ pub enum TurnError {
|
||||||
/// provider (e.g. HTTP 429) without reporting it ends up here. Handled
|
/// provider (e.g. HTTP 429) without reporting it ends up here. Handled
|
||||||
/// like [`Self::ApiStall`].
|
/// like [`Self::ApiStall`].
|
||||||
AgentStall(String),
|
AgentStall(String),
|
||||||
/// A hard failure with no recovery — the serve loop escalates it to the
|
/// A hard failure with no recovery. Logged as `turn failed`, the exact
|
||||||
/// operator (`send_to_operator`).
|
/// message the `hh-agent-turn-failed` Grafana alert matches.
|
||||||
Failed(anyhow::Error),
|
Failed(anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -604,6 +604,8 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
|
||||||
ok: false,
|
ok: false,
|
||||||
note: Some(note.clone()),
|
note: Some(note.clone()),
|
||||||
});
|
});
|
||||||
|
// The `hh-agent-turn-failed` alert in swarm-grafana.nix matches
|
||||||
|
// this message exactly.
|
||||||
tracing::warn!(error = %note, "turn failed");
|
tracing::warn!(error = %note, "turn failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Remaining single-endpoint dashboard handlers: the operator inbox
|
//! Remaining single-endpoint dashboard handlers: mark-all-read, operator
|
||||||
//! (`Y3R C4LL`) + mark-all-read, operator compose (`op-send`),
|
//! compose (`op-send`), spawn-request, hive-wide turn stats, and container
|
||||||
//! spawn-request, hive-wide turn stats, and container resources.
|
//! resources.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Form, Path as AxumPath, State},
|
extract::{Form, Path as AxumPath, State},
|
||||||
|
|
@ -10,80 +10,10 @@ use axum::{
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::{IntoParams, ToSchema};
|
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::container_stats::ContainerResource;
|
||||||
use crate::hive_stats::HiveStats;
|
use crate::hive_stats::HiveStats;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
|
||||||
pub(super) struct OperatorInboxItem {
|
|
||||||
id: i64,
|
|
||||||
from: String,
|
|
||||||
body: String,
|
|
||||||
at: chrono::DateTime<chrono::Utc>,
|
|
||||||
in_reply_to: Option<i64>,
|
|
||||||
file_refs: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
|
||||||
pub(super) struct OperatorInboxBody {
|
|
||||||
messages: Vec<OperatorInboxItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unread operator-directed messages for the dashboard's Y3R C4LL inbox.
|
|
||||||
///
|
|
||||||
/// Returns messages addressed to `"operator"` that haven't been
|
|
||||||
/// acked yet (the operator clears them via the existing
|
|
||||||
/// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped
|
|
||||||
/// tokens are validated so the client renders file links like the
|
|
||||||
/// terminal does.
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/operator-inbox",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "unread operator-directed messages", body = OperatorInboxBody),
|
|
||||||
(status = 500, description = "broker read failed"),
|
|
||||||
),
|
|
||||||
tag = "misc_api"
|
|
||||||
)]
|
|
||||||
pub(super) async fn api_operator_inbox(State(state): State<AppState>) -> Response {
|
|
||||||
const INBOX_LIMIT: u64 = 100;
|
|
||||||
match state
|
|
||||||
.coord
|
|
||||||
.broker
|
|
||||||
.unread_for_recipient("operator", INBOX_LIMIT)
|
|
||||||
{
|
|
||||||
Ok(messages) => {
|
|
||||||
let messages: Vec<OperatorInboxItem> = messages
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|m| {
|
|
||||||
let crate::broker::MessageEvent::Sent {
|
|
||||||
id,
|
|
||||||
from,
|
|
||||||
body,
|
|
||||||
at,
|
|
||||||
in_reply_to,
|
|
||||||
..
|
|
||||||
} = m
|
|
||||||
else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let file_refs = scan_validated_paths(&body);
|
|
||||||
Some(OperatorInboxItem {
|
|
||||||
id,
|
|
||||||
from,
|
|
||||||
at: hive_sh4re::wire_time::from_secs(at),
|
|
||||||
body,
|
|
||||||
in_reply_to,
|
|
||||||
file_refs,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
axum::Json(OperatorInboxBody { messages }).into_response()
|
|
||||||
}
|
|
||||||
Err(e) => error_response(&format!("operator-inbox failed: {e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, IntoParams)]
|
#[derive(Deserialize, IntoParams)]
|
||||||
pub(super) struct StatsHiveQuery {
|
pub(super) struct StatsHiveQuery {
|
||||||
/// Stats window; defaults to `24h`.
|
/// 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
|
// 200 instead of 303 → the client doesn't refetch /api/state. The
|
||||||
// broker `send` already emitted a `MessageEvent` which the
|
// broker `send` already emitted a `MessageEvent` which the
|
||||||
// dashboard channel forwarder mirrors as `DashboardEvent::Sent`,
|
// 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
|
// operator's send shows up the same way an agent's send does, with
|
||||||
// no full-state refresh in between.
|
// no full-state refresh in between.
|
||||||
(axum::http::StatusCode::OK, "ok").into_response()
|
(axum::http::StatusCode::OK, "ok").into_response()
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ use crate::lifecycle;
|
||||||
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
|
(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 = "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 = "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 = "permissions", description = "tool-group + capability assignment for agents"),
|
||||||
(name = "schedules", description = "read-only scheduled-prompt list + rebuild-queue cancel"),
|
(name = "schedules", description = "read-only scheduled-prompt list + rebuild-queue cancel"),
|
||||||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
(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 \
|
handler; splitting that exhaustive list across helpers would \
|
||||||
obscure the route map for no readability gain"
|
obscure the route map for no readability gain"
|
||||||
)]
|
)]
|
||||||
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
fn app(coord: Arc<Coordinator>) -> axum::Router {
|
||||||
// API-only: the gateway static-serves the dashboard dist and proxies
|
// API-only: the gateway static-serves the dashboard dist and proxies
|
||||||
// non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`).
|
// non-static requests here (see `nix/host-modules/hive-gateway/vhosts.nix`).
|
||||||
// Unmatched paths 404.
|
// Unmatched paths 404.
|
||||||
|
|
@ -115,7 +115,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.routes(routes!(health::get_health_ready))
|
.routes(routes!(health::get_health_ready))
|
||||||
.routes(routes!(state_snapshot::api_state))
|
.routes(routes!(state_snapshot::api_state))
|
||||||
.routes(routes!(state_files::get_state_file))
|
.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_stats_hive))
|
||||||
.routes(routes!(misc_api::api_container_resources))
|
.routes(routes!(misc_api::api_container_resources))
|
||||||
.routes(routes!(misc_api::post_mark_all_read))
|
.routes(routes!(misc_api::post_mark_all_read))
|
||||||
|
|
@ -156,18 +155,21 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
// the nix store (see the module doc comment above `ApiDoc`). `api`
|
// the nix store (see the module doc comment above `ApiDoc`). `api`
|
||||||
// is `Clone`; each request gets its own owned copy for `Json` to
|
// is `Clone`; each request gets its own owned copy for `Json` to
|
||||||
// serialize.
|
// serialize.
|
||||||
let app = router
|
router
|
||||||
.route(
|
.route(
|
||||||
"/api/openapi.json",
|
"/api/openapi.json",
|
||||||
get(move || async move { Json(api.clone()) }),
|
get(move || async move { Json(api.clone()) }),
|
||||||
)
|
)
|
||||||
.with_state(AppState { coord });
|
.with_state(AppState { coord })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
// Binds loopback-only; external access via gateway.
|
// Binds loopback-only; external access via gateway.
|
||||||
// Rationale: docs/networking/gateway.md::Firewall posture.
|
// Rationale: docs/networking/gateway.md::Firewall posture.
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||||
let listener = bind_with_retry(addr).await?;
|
let listener = bind_with_retry(addr).await?;
|
||||||
tracing::info!(%addr, "dashboard listening");
|
tracing::info!(%addr, "dashboard listening");
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app(coord)).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,6 +289,47 @@ fn error_response(message: &str) -> Response {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn problem_details_carry_rfc9457_status_and_detail() {
|
fn problem_details_carry_rfc9457_status_and_detail() {
|
||||||
// Contract the frontend depends on: the problem_details crate
|
// 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!(health::get_health_ready))
|
||||||
.routes(routes!(state_snapshot::api_state))
|
.routes(routes!(state_snapshot::api_state))
|
||||||
.routes(routes!(state_files::get_state_file))
|
.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_stats_hive))
|
||||||
.routes(routes!(misc_api::api_container_resources))
|
.routes(routes!(misc_api::api_container_resources))
|
||||||
.routes(routes!(misc_api::post_mark_all_read))
|
.routes(routes!(misc_api::post_mark_all_read))
|
||||||
|
|
|
||||||
|
|
@ -491,7 +491,7 @@ fn handle_status(coord: &Arc<Coordinator>, agent: &str) -> hive_core_agent_sock:
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `OperatorMsg` — deliver an operator-authored message into `agent`'s
|
/// `OperatorMsg` — deliver an operator-authored message into `agent`'s
|
||||||
/// inbox (from the `operator` recipient).
|
/// inbox (sender `operator`).
|
||||||
fn handle_operator_msg(
|
fn handle_operator_msg(
|
||||||
coord: &Arc<Coordinator>,
|
coord: &Arc<Coordinator>,
|
||||||
agent: &str,
|
agent: &str,
|
||||||
|
|
@ -603,12 +603,11 @@ pub(crate) fn handle_send(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Validate that the recipient is a known local agent or the
|
// Validate that the recipient is a known local agent. Without this
|
||||||
// special "operator" recipient. Without this check a typo in `to`
|
// check a typo in `to` silently queues a message nobody will ever read.
|
||||||
// silently queues a message nobody will ever read.
|
|
||||||
//
|
//
|
||||||
// Cross-hive messaging (`name@hive` qualified names) is not routed
|
// Cross-hive messaging (`name@hive` qualified names) and the operator
|
||||||
// through the broker — use the Matrix MCP tools for that instead.
|
// are not reached through the broker — the Matrix MCP tools are.
|
||||||
if to.contains('@') {
|
if to.contains('@') {
|
||||||
return Response::Err {
|
return Response::Err {
|
||||||
message: format!(
|
message: format!(
|
||||||
|
|
@ -617,20 +616,23 @@ pub(crate) fn handle_send(
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if to != hive_sh4re::manager::OPERATOR_RECIPIENT {
|
if to == hive_sh4re::manager::OPERATOR_RECIPIENT {
|
||||||
// A name that doesn't parse as an Ident can't be a local agent, so
|
return Response::Err {
|
||||||
// it collapses into the same "unknown recipient" error as a valid
|
message: hive_sh4re::manager::OPERATOR_SEND_REFUSAL.to_owned(),
|
||||||
// name with no state dir.
|
};
|
||||||
let exists = hive_types::Ident::parse(to)
|
}
|
||||||
.is_ok_and(|id| crate::paths::agent_state_dir(&id).exists());
|
// A name that doesn't parse as an Ident can't be a local agent, so
|
||||||
if !exists {
|
// it collapses into the same "unknown recipient" error as a valid
|
||||||
return Response::Err {
|
// name with no state dir.
|
||||||
message: format!(
|
let exists =
|
||||||
"send failed: unknown recipient `{to}` \
|
hive_types::Ident::parse(to).is_ok_and(|id| crate::paths::agent_state_dir(&id).exists());
|
||||||
(no agent with that name exists on this hive)"
|
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 {
|
match coord.broker.send(&Message {
|
||||||
from: hive_sh4re::manager::trusted_sender(agent),
|
from: hive_sh4re::manager::trusted_sender(agent),
|
||||||
|
|
@ -664,6 +666,22 @@ mod tests {
|
||||||
(dir, Arc::new(coord))
|
(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]
|
#[tokio::test]
|
||||||
async fn over_long_line_is_rejected_after_reading_only_the_bound() {
|
async fn over_long_line_is_rejected_after_reading_only_the_bound() {
|
||||||
let total = 2 * REQUEST_LINE_MAX_BYTES as u64;
|
let total = 2 * REQUEST_LINE_MAX_BYTES as u64;
|
||||||
|
|
|
||||||
|
|
@ -301,44 +301,6 @@ impl Broker {
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unacknowledged messages addressed to `recipient`, newest-first.
|
|
||||||
/// Backs the dashboard's operator inbox: the operator never
|
|
||||||
/// `recv`s over an agent socket, so messages to `"operator"` sit in
|
|
||||||
/// the broker with `acked_at IS NULL` until the operator hits "mark
|
|
||||||
/// all read" (which calls [`Broker::mark_all_read`]). This read
|
|
||||||
/// mirrors that filter EXACTLY — `recipient = ?1 AND acked_at IS
|
|
||||||
/// NULL`, with no `delivered_at` condition — so everything listed
|
|
||||||
/// here is precisely what `mark_all_read` will clear (operator rows
|
|
||||||
/// may never get `delivered_at` set). Returned as
|
|
||||||
/// [`MessageEvent::Sent`] so the dashboard reuses its live renderer.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns `Err` if the `SQLite` prepare or query fails.
|
|
||||||
pub fn unread_for_recipient(&self, recipient: &str, limit: u64) -> Result<Vec<MessageEvent>> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX);
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT id, sender, recipient, body, sent_at, in_reply_to
|
|
||||||
FROM messages
|
|
||||||
WHERE recipient = ?1 AND acked_at IS NULL
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT ?2",
|
|
||||||
)?;
|
|
||||||
let rows = stmt.query_map(params![recipient, limit_i], |row| {
|
|
||||||
Ok(MessageEvent::Sent {
|
|
||||||
id: row.get(0)?,
|
|
||||||
from: row.get(1)?,
|
|
||||||
to: row.get(2)?,
|
|
||||||
body: row.get(3)?,
|
|
||||||
at: row.get(4)?,
|
|
||||||
in_reply_to: row.get(5)?,
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
|
||||||
.map_err(Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of undelivered messages addressed to `recipient`. Non-mutating
|
/// Number of undelivered messages addressed to `recipient`. Non-mutating
|
||||||
/// — used by the harness to surface "N unread" in tool-result status
|
/// — used by the harness to surface "N unread" in tool-result status
|
||||||
/// lines without popping the queue.
|
/// lines without popping the queue.
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,9 @@ pub struct Message {
|
||||||
pub in_reply_to: Option<i64>,
|
pub in_reply_to: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of a broker inbox query — what the dashboard renders in
|
/// One row of a broker inbox query — what a per-agent web UI returns
|
||||||
/// 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
|
||||||
/// from a `Recent` request. Lives in `hive_sh4re` so it can travel
|
/// the agent socket without an internal-to-wire conversion.
|
||||||
/// over both the dashboard's `/api/state` and the agent socket
|
|
||||||
/// without an internal-to-wire conversion.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct InboxRow {
|
pub struct InboxRow {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,17 @@ use serde::{Deserialize, Serialize};
|
||||||
/// Logical name the broker uses for the manager.
|
/// Logical name the broker uses for the manager.
|
||||||
pub const MANAGER_AGENT: &str = "ruth";
|
pub const MANAGER_AGENT: &str = "ruth";
|
||||||
|
|
||||||
/// Logical name the broker uses for the human operator. Messages with
|
/// Logical name of the human operator: the sender of operator-authored
|
||||||
/// `to = OPERATOR_RECIPIENT` accumulate in sqlite and surface on the
|
/// messages. Not a recipient — `send` to it fails with
|
||||||
/// dashboard's inbox view — they are never `recv`'d by an agent harness.
|
/// [`OPERATOR_SEND_REFUSAL`].
|
||||||
pub const OPERATOR_RECIPIENT: &str = "operator";
|
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.
|
/// Sender hive-c0re uses for events it pushes into the manager's inbox.
|
||||||
/// Manager harness recognises this and parses the body as a `HelperEvent`.
|
/// Manager harness recognises this and parses the body as a `HelperEvent`.
|
||||||
pub const SYSTEM_SENDER: &str = "system";
|
pub const SYSTEM_SENDER: &str = "system";
|
||||||
|
|
|
||||||
|
|
@ -51,14 +51,13 @@ in
|
||||||
description = ''
|
description = ''
|
||||||
Names this agent is allowed to `send` to via
|
Names this agent is allowed to `send` to via
|
||||||
`mcp__hyperhive__send`. Empty list (the default) means
|
`mcp__hyperhive__send`. Empty list (the default) means
|
||||||
unrestricted — the agent can message any peer, the
|
unrestricted — the agent can message any peer or the
|
||||||
operator, or the manager. Non-empty list constrains the
|
manager. Non-empty list constrains the
|
||||||
surface: only the listed names + the manager (always
|
surface: only the listed names + the manager (always
|
||||||
allowed) get through; anything else returns an error
|
allowed) get through; anything else returns an error
|
||||||
string to claude without touching the broker. The
|
string to claude without touching the broker.
|
||||||
operator (`operator`) needs to be in the list if the
|
`operator` is refused either way: agents reach the operator
|
||||||
agent should be able to surface output on the
|
over matrix.
|
||||||
dashboard.
|
|
||||||
|
|
||||||
Useful for sandboxing untrusted sub-agents — set
|
Useful for sandboxing untrusted sub-agents — set
|
||||||
`[ "manager" ]` to scope them to manager-only chatter.
|
`[ "manager" ]` to scope them to manager-only chatter.
|
||||||
|
|
|
||||||
|
|
@ -996,6 +996,20 @@ in
|
||||||
for = "5m";
|
for = "5m";
|
||||||
summary = "{{ $labels.hive }}/{{ $labels.agent }} finds nothing at its queue credential path and cannot connect to the queue";
|
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
|
# Same cadence as the forge rule: one pass every 5m
|
||||||
# (`RECONCILE_INTERVAL` in swarm-controller's
|
# (`RECONCILE_INTERVAL` in swarm-controller's
|
||||||
# agent_renewal.rs), one line per failed pass, so one
|
# agent_renewal.rs), one line per failed pass, so one
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,25 @@ let
|
||||||
&& lib.all (r: r.field == "severity_text" && r.enabled == true && lib.elem r.level canonical) rules
|
&& 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;
|
&& 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
|
# 🩸 The regression this guards: moving bao's metrics scrape into its
|
||||||
# own in-container collector made that collector stamp every exported
|
# own in-container collector made that collector stamp every exported
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@
|
||||||
# `./reserved-hive-fragments.nix` — read its header before merging the two.
|
# `./reserved-hive-fragments.nix` — read its header before merging the two.
|
||||||
[
|
[
|
||||||
# ---- message-layer senders -------------------------------------------
|
# ---- message-layer senders -------------------------------------------
|
||||||
# The human at the dashboard: a broker recipient (the T4LK box sends
|
# The human at the dashboard: the sender of operator-authored messages
|
||||||
# `{from: "operator", ...}`) and the fallback attribution for an answered
|
# (`{from: "operator", ...}`) and the fallback attribution for an answered
|
||||||
# question.
|
# question.
|
||||||
"operator"
|
"operator"
|
||||||
# Helper events (`container_crash`, `needs_update`) — the sender an
|
# Helper events (`container_crash`, `needs_update`) — the sender an
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue