These three are per-host choices that hive-c0re reads and renders into its agents, so they belong under the hive controller's deploy namespace rather than swarm-wide: - c0re.dashboardPort -> deploy.hive-controller.dashboardPort - c0re.claudeCodePackage -> deploy.hive-controller.claudeCodePackage (the claude-login and review-classifier containers on the same host keep following it) - otel.extraResourceAttributes -> deploy.hive-controller.otel.extraResourceAttributes The old paths stay as mkRenamedOptionModule aliases, now in deploy.nix with the namespace's other renames. github.enable and the collector port stay swarm-wide.
24 KiB
Conventions
Code-style and process expectations across the workspace. Most of these exist because something already went wrong without them.
Naming
- Containers are length-bounded by
nixos-container(≤ 11 chars). - Sub-agents are
h-<name>with<name>≤ 9 chars. - One agent is the bootstrap/root container, with a fixed name (
ruthtoday). MAX_AGENT_NAMEinhive-c0re/src/lifecycle/mod.rsenforces the cap.- Per-agent web UI port =
WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE(8100..8999) for every agent; dashboardservices.hyperhive.deploy.hive-controller.dashboardPort(default 7000).
Hive identity (label + domain + display names)
Four env vars cover the identity surface, read by
hive-agent/src/identity.rs:
HIVE_LABEL— short, hive-local agent label (iris,damocles).label()returns it; falls back to empty string if the env var is missing so downstream callers can decide how to surface "unknown agent" rather than getting a panic from this module.HYPERHIVE_HIVE_DOMAIN— the hive's canonical DNS domain (e.g.darkest.space), set bynix/host-modules/hive-c0re/environment.nixfromservices.hyperhive.domain. When configured,qualify(label)returns${label}@${domain}(for exampleiris@darkest.space); when unset (single-hive deployments, dev/test) it degrades to just the short label so existing callers see no change. The qualified form surfaces in the per-agent web UI title, the system-prompt template, and/api/state.qualified_label.HYPERHIVE_HIVE_NAME— human display name of this hive (pr1ma). Read byhive_name();Nonewhen unset.HYPERHIVE_SWARM_NAME— human display name of the wider swarm this hive belongs to (constellat1on). Read byswarm_name(); federated hives at different DNS domains can share a swarm name.
Inside an agent container, the hive's name and domain don't come from the
agent's build. hive-priv writes them to /etc/hyperhive-hive-facts before
every container start. The agent's environment generator
(nix/agent-modules/hive-facts.nix) passes them to every unit, including
hivectl choom sessions, and /etc/set-environment exports them in login
shells that no unit starts (nixos-container root-login). Neither is part of
an agent's closure, and an agent started on another hive reports that hive.
hive_name + swarm_name are distinct from
HYPERHIVE_HIVE_DOMAIN: the domain may carry the hive name as
its leftmost label by convention, but the convention isn't
machine-readable, and federated hives at different DNS domains
can share a swarm name. Humans want both: the address
(@darkest.space) AND the prose name (pr1ma). Matrix MXIDs
use the domain-based convention.
Identity = socket
No auth tokens exist on the per-agent unix sockets. The socket
path identifies the principal; perms come from "who has the
bind-mount." A sub-agent only sees its own /run/hive/mcp.sock;
hive-c0re owns the host admin socket.
Wake injection
hive_core_agent_sock::Request::Wake { from, body } is the
wake-event-injection surface on the host-served per-agent socket.
Recipient is implicit — the agent the socket belongs to — and from
is caller-chosen so the wake prompt can label the source verbatim
("matrix: new message in #general", "forge: PR #42 opened", etc.).
hive-c0re handles it; matrix, bash and forge don't call it — their
notifications upsert a todo on the harness's in-agent socket
(HIVE_AGENT_SOCKET), which signals the same turn loop without a
hive-c0re round-trip. See
docs/turn-loop/mcp.md § Waking the agent from inside the
container.
Identity = socket means anything that can connect to
/run/hive/mcp.sock is implicitly trusted to inject wakes. That's
fine: the bind-mount only exposes the socket inside the agent's own
container, so the trust boundary is the container's process
namespace, not the wire surface.
Recipient sentinels
The broker reserves a few recipient names, which have special
meaning that ordinary agent labels can never collide with — agent
name validation rejects any character outside [a-z0-9_-], so the
angle-bracket and asterisk shapes below are structurally safe.
*— broadcast: deliver to every running agent except the sender (socket_server::handle_sendfans out viaCoordinator::broadcast_send).operator— the human at the dashboard. Reserved so no agent can take the name, and refused as a recipient: the broker answers asendto it withOPERATOR_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.
Wire protocol
JSON line-delimited over unix sockets in both directions (host admin
/ manager / agent). SSE streams (/dashboard/stream on hive-c0re,
/events/stream on the per-agent web UIs) are text/event-stream;
each frame carries a seq field for the snapshot-dedupe dance
(see docs/web-ui/shape.md). Request/response types live in hive-sh4re
— change them in one place. The dashboard event vocabulary lives
in hive-c0re::dashboard_events::DashboardEvent.
Broker delivery + ack cycle
AgentRequest::Recv is the only path that delivers messages to an
agent. Always returns a list (Messages { messages }) — empty when
nothing's pending, single-pop when max = None (default 1, the
single-message behaviour), batched up to max when caller asks for
more (server-side cap is 5; values above clamp silently). The wire
request still carries an optional wait_seconds (long-poll the first
message, once one arrives — or one is already pending — the call
drains up to max in total): the harness's own turn-driving loop
uses it internally (hive-agent's recv_next, 180s). The
agent-facing MCP recv tool doesn't expose this parameter — it
always passes wait_seconds: None, an immediate peek.
Per-row bookkeeping inside the broker:
delivered_at = NOWset on every popped row.- Each recipient has an in-memory
unacked_idslist of every row delivered since the lastAckTurn. redelivered = trueon a row ifRequeueInflightresurfaced it (the harness prepends a "may already be handled" hint when this flag is set so the per-message warning is visible).
AgentRequest::AckTurn closes out the in-memory list — the harness
fires it after TurnOutcome::Ok, marking every message popped since
the last ack as fully handled. Claude doesn't see this surface; it's
strictly a harness↔broker pairing. On TurnOutcome::Failed the
harness intentionally skips the ack so the unacked rows stay
in-flight in the DB and get picked up by the next requeue sweep.
AgentRequest::RequeueInflight is the recovery pair: fired by the
harness exactly once at boot, before the serve loop starts. Catches
the crashed-mid-turn / OOM-killed / container-restarted cases where
a previous harness session popped messages but never drove them to
a clean turn-end. Resets delivered_at back to NULL on every
unacked row (so the next Recv pops them again), and remembers
each id in a per-recipient in-memory set so the next Recv can tag
the row with redelivered: true. Idempotent + cheap when there's
nothing in flight, so the at-boot fire is unconditional.
AgentRequest::AckUntil { up_to } is the agent-facing bulk-triage
escape hatch (mcp__hyperhive__ack_until). Unlike AckTurn it's
visible to claude: each recv row and wake prompt carries a
[msg #<id>] marker (the broker row id; transient pings show no
marker — their sentinel id 0 has nothing to ack), and
ack_until(up_to: n) marks every one of the agent's rows with
id <= n handled in a single UPDATE — pending and delivered alike.
This bounds the redelivered-flood cost after a restart: instead of
popping dozens of already-handled messages one turn at a time, the
agent notes the highest id it has seen and acks up to it.
Recipient-scoped (an agent can only ack its own rows); also drains
the in-memory unacked_ids / requeued_ids bookkeeping below the
cutoff so a later AckTurn doesn't double-update and a stale
redelivery tag can't outlive its row. The operator-side sibling is
the dashboard's "mark all read" (unbounded, per-agent).
Loose-ends wire shape
LooseEnd is the per-row response shape for GetLooseEnds — one
request shape, no per-socket flavours. Tagged enum so new thread kinds
(forge PRs, etc.) can land later without breaking existing handlers.
Each row carries enough context that the caller renders
it directly as a bulleted list, no follow-up fetch needed.
GetLooseEnds takes no target — it always returns the caller's own
rows. Reminder rows always carry owner == self.
Per-variant fields:
Reminder { id, owner, message, due_at, age_seconds }—due_atis the absolute time the scheduler is targeting (RFC 3339 on the wire, see Timestamps on the wire below); clients compute time-until-fire against it.PendingMessages { count }— undelivered inbox messages the agent still owes itself arecvfor. Informational + not cancellable (drain withrecv); only emitted whencount > 0, and surfaced first in the list as the most actionable signal. Counted host-side from the broker (count_pending), so it reflects what's genuinely still queued — the wake-message that drove the current turn is already delivered and not counted.UnreadMatrix { rooms, summary }— unread matrix notifications. Informational + not cancellable (clear withmark_read). Unlike the others, the in-container harness injects this, not hive-c0re, because the matrix daemon lives inside the agent.
age_seconds saturates at zero on any clock anomaly (back-step,
unsynchronised wall clock, etc.) so the bulleted list never
shows nonsense ages.
cancel_loose_end is the matching write surface, handled in the
harness without a hive-c0re round-trip: reminder deletes one of the
caller's own pending reminders, todo clears one of its todos.
Agent metadata
AgentRequest::GetAgentMeta { name } returns identity + status for
an agent. Self-introspection when name = None; target query when
name = Some.
Response is AgentMeta { name, running, hyperhive_rev, status_text, status_set_at, hive_name, swarm_name, matrix_accounts }:
hyperhive_rev:Noneonly when the configured flake URL has no canonical path. Otherwise carries the rev the target is currently pinned at.running: whether the target's container is currently up. Whenfalse, the host clearsstatus_text/status_set_at— on-disk values from before the stop are stale snapshots, not live status. Defaults totrueon the wire: the deserializer treats a payload without the field as running.status_text/status_set_at: last value written viaSetStatus, plus its unix timestamp. BothNonewhen the target has never set a status, when the agent name is unknown, or whenrunning = false(see above).hive_name/swarm_name: display names read fromHYPERHIVE_HIVE_NAME/HYPERHIVE_SWARM_NAMEenv (sourced fromservices.hyperhive.hiveName/services.hyperhive.swarm.name). BothNonewhen the options aren't configured.matrix_accounts: oneMatrixIdentityper configured + live matrix account the agent can act as. Empty for agents with no matrix provisioning.
Timestamps on the wire
Timestamp fields that cross a JSON boundary (dashboard API + SSE,
the wire structs in hive-sh4re) serialize as RFC 3339 UTC strings
(2026-07-02T18:30:00Z) via hive_sh4re::wire_time — Rust keeps the
fields as i64 unix seconds internally, only the JSON representation
changes, and deserialization accepts both the string form and a bare
integer (rolling-deploy skew, persisted blobs).
Input-direction fields agents compute as epoch (remind's
at_unix_timestamp) stay integers. The
*_unix field names stay for now — renaming is the wire-types
refactor's concern. The dashboard frontend parses via
util.js::epochSec wherever it needs arithmetic and feeds the string
straight to new Date(s) for display.
HTTP error bodies
Every HTTP API in this repo answers failures with RFC 9457
application/problem+json ({ type, title, status, detail }), with the
human-readable cause in detail. An endpoint returning a bare string
or a bespoke error shape is a bug to file against the daemon that
returned it, not something for the caller to work around.
Use the problem_details crate (features = ["axum"]), which the daemons
already depend on: type a handler Result<_, ProblemDetails> and hand
ProblemDetails::from_status_code(...).with_detail(...) to Err.
The reason is the consumer, not tidiness. The UIs show errors through one shared component with a copy button, so a caller has to know which part of the body is the message. A bare string forces it to treat the whole payload as prose, which is the difference between offering "copy the cause" and dumping a response — and the cause is frequently the entire diagnosis (a JetStream permission refusal, a TLS chain failure) rather than a summary.
Not in scope: the hivectl host-admin and in-agent unix sockets. Those are a
JSON-line protocol with their own result types; RFC 9457 is an HTTP format.
Permissions
One set of named Permission values (hive_sh4re::permissions::Permission)
decides what an agent may do: which MCP and built-in tools it gets, and which
host access it has.
| Permission | Grants |
|---|---|
messaging |
send, recv, ack_until |
meta |
get_agent_meta (set_status is always-on, see below) |
inbox |
get_loose_ends, cancel_loose_end, remind |
execution |
the bash MCP server (mcp__bash__run/status/kill), rendered into the agent's MCP config only with it; a subagent gets Claude's own Bash instead. See docs/tools/bash.md. |
web_tools |
the Claude built-ins WebFetch/WebSearch |
manage_root_agent |
every agent's state (rw) + config (ro) bind-mounted into the holder's container, plus /applied and /meta (ro) |
Always-on tools — Permission::ALWAYS_ON_TOOLS exposes set_status,
compact, and mark_todos_done to every agent regardless of its
permissions. The operator dashboard depends on every agent being able to
report its status chip, and the server-side SetStatus handler has no
permission check (only length validation), so gating it would only desync
the --allowedTools list from what the host actually accepts. Revoking
meta therefore drops get_agent_meta but never set_status.
mark_todos_done is here because producers push todos to an agent whether
or not it holds inbox, and an agent without it still needs to clear them.
Where they live — in the swarm, not on the hive: the permissions field of
the agent's key in the agent-permissions KV bucket
(swarm-queue-client/src/agent_permissions.rs), which swarm-controller alone
writes. Set them with swarmctl agent permissions get|set|clear <agent>
(/api/agents/{name}/permissions), or on the swarm UI's create-agent screen
(permissions in POST /api/agents). A missing key or an unset field means
the default (Permission::DEFAULT): messaging, meta, inbox,
execution, for every agent whatever its name. "permissions": [] grants
none.
How a change applies —
- The agent's harness reads its own key every 10 seconds and builds every
turn's
--tools,--allowedToolsand MCP config from the last value, so a change reaches the next turn. An ACP agent's permission check reads it on each request. - The subagent daemon reads the key on each
start/continue; a running subagent keeps its tools. - hive-c0re reads each of its agents' keys every 30 seconds
(
workers/swarm_permissions.rs) into/var/lib/hyperhive/swarm-agent-permissions.json, outside the meta repo. nspawn fixes the bind mounts at container start, so when an agent'smanage_root_agentchanges hive-c0re restarts it after its current turn. - hive-c0re copies each agent's entry from that file into the agent's
runtime dir (
/run/hive/swarm-permissions.jsonin the container) at every start and after every 30-second pass, with whether the import below is still pending. The harness and the subagent daemon hold that copy until their own first read succeeds, so an agent whose queue is down starts on its last stored grant, not the default.
Every reader keeps its last good value on a failed read.
Moving off the meta repo — on its first boot with this code a hive copies
meta/capabilities.json and meta/tool-groups.json into the stored value,
sends them to swarm-controller once as one list per agent (without
lifecycle and forge, which gave no tools; the controller writes a key
only where there is none, and only for agents that hive's wanted state
declares), and deletes
both files from the meta repo when the controller answers
(workers/permissions_import.rs). Until then hive-c0re reads no keys, and an
agent that reads its own key unset keeps the grant in its copy.
Not configurable from agent.nix — that file changes through config PRs
an agent can open, so letting it declare its own permissions would let an
agent grant itself any tool by submitting a config commit, bypassing the
operator gate.
Adding to the set — add a variant to Permission in
hive-sh4re/src/permissions.rs and to Permission::ALL, and enforce it where
it applies. A new #[tool] fn on AgentServer (hive-agent-mcp/src/mcp/mod.rs)
goes into the matching Permission::tools() slice: that's the single source
of truth mcp_config::allowed_mcp_tools (hive-agent/src/mcp_config.rs)
reads each turn.
Async forms
Dashboard + per-agent mutating forms carry data-async; the shared
bindAsyncForms submit listener (frontend/packages/shared/src/forms.js,
imported as @hive/shared/forms.js and wired up from tabs.js on the
dashboard and app.js on the per-agent UI) intercepts, shows a spinner,
POSTs application/x-www-form-urlencoded (axum's Form extractor
rejects multipart), calls refreshState() on success. New mutating
forms should add data-async and optionally data-confirm (for a
JS-side confirm() prompt).
refreshState defers automatically when document.activeElement
sits inside a managed section so the operator's typing isn't lost;
collapsible <details data-restore-key=…> survive the re-render
via snapshotOpenDetails / restoreOpenDetails.
rebuild is the reconcile verb
job_queue::templates::rebuild builds the DAG that reconciles a
container to its wanted state: it folds write_dropins (the nspawn-conf
rewrite — PRIVATE_NETWORK=1, HOST_ADDRESS = the bridge gateway IP,
sets EXTRA_NSPAWN_FLAGS — plus the systemd resource-limits drop-in)
into the Swap node, then runs nixos-container update + stop +
start across the StopForUpdate → Swap → RebuildBookkeeping
brace and the tail Reconcile node. flake.nix itself lives in the agent's
proposed/applied repos and rides along on every fetch (see
docs/agent-lifecycle/config-changes.md::Two repos per agent).
Anything that changes per-container state on the host should be
re-applied here so a manual ↻ R3BU1LD from the dashboard is
sufficient to recover.
Actions live in one place
destroy (and the lifecycle helper) live in
actions.rs / hive-c0re/src/dashboard/. The admin socket and the dashboard
POST handlers both call into them so the two surfaces never drift.
Commit messages
Short, lowercase, no Co-Authored-By trailer. Imperative mood, no
period. Body explains why when the reasoning isn't self-evident;
otherwise the subject alone is fine. Wrap at ~72 cols.
Commit before test
Stage and commit when work looks ready, then run validation
(cargo check, nix flake check, real deploy). Failures get a
follow-up commit rather than an amend. The commit history is the
work log; rewriting it loses signal.
Building & local checks
Build through the flake devshell, not a bare toolchain — agent
containers ship no global rust. nix develop -c <cmd> runs one
command inside the project-pinned env (cargo/clippy/rustfmt plus the
C compiler + libsqlite3/ring link deps); outside it a bare
cargo build fails with failed to find tool "cc" / cannot find -lsqlite3. One command per invocation — agents run each task as a
fresh non-interactive process, so there's no persistent shell to
reuse.
nix develop -c cargo clippy --all-targets -- -D warnings
nix develop -c cargo test
nix fmt # treefmt — authoritative, NOT bare cargo fmt
nix fmt (treefmt) is the formatter CI gates on; bare cargo fmt
misses the non-rust files treefmt also covers, so always run nix fmt before pushing.
Clippy discipline — never add #[allow(clippy::…)]. All lints are
CI-fatal at -D warnings (pedantic included); every warning that fires
must be fixed, not silenced. Common patterns:
too_many_lines— extract a helper function or a sub-struct (theTurnAccumextraction instats.rsis a worked example).doc_markdown(brand name without backticks in a doc comment) — add backticks:`DOMPurify`instead ofDOMPurify.must_use/unused_results— actually handle or explicitly discard the return value (let _ = …is fine when intentional).
If a lint seems wrong for a specific call site, file an issue and ask
mara — don't add #[allow] speculatively. The gate is intentional.
The devshell checks aren't the full nix flake check. Clippy /
fmt / cargo test cover most gates, but nix flake check runs extra
check derivations they don't:
hivectl-docsregeneratesdocs/tools/hivectl-cli.mdfrom hivectl's clap tree and fails if the committed copy is stale. After any change to a hivectl verb or flag, regenerate it:
clippy / fmt /nix develop -c cargo run --bin hivectl -- markdown-docs > docs/tools/hivectl-cli.mdcargo testall pass without this — only the flake check catches the drift, andci-logoften can't show you why (it 500s on a fast failure), so you're left guessing "builder flake" when it's a stale doc.swarmctl-docsis the same check forswarmctl/docs/tools/swarmctl-cli.md:nix develop -c cargo run --bin swarmctl -- markdown-docs > docs/tools/swarmctl-cli.md- there's also a flake
cargo-testcheck and NixOS module evaluation in the set.
When local clippy/fmt/test pass but CI's nix flake check fails,
don't assume a transient builder problem — reproduce the real
gate locally: nix flake check (shares the build farm, use
sparingly) or build just the suspect check, for example nix build .#checks.x86_64-linux.hivectl-docs.
Best-effort oneshot services
The harness ships a family of one-shot systemd services that configure agent-side surfaces from values delivered to the agent at provisioning time:
forge-avatar-sync— uploadsservices.hyperhive.agent.iconSVG to the agent's Forgejo profile, so the icon shows up on commits / PRs / issue comments.
(The matrix profile avatar is not a oneshot — hive-matrix-daemon
sets it over its live authenticated Client; see
docs/agent-lifecycle/persistence.md::matrix avatar.)
Shape contract — every one of these:
- Always
exit 0, even on internal failure. A non-zero exit would mark the unitfailed, which in turn abortsnixos-container updateand blocks rebuilds. The agent's capability surface isn't allowed to gate the container build. - No
set -ein the script body. Subshell failures must not propagate. Use... || trueon every external call that can fail (forge unreachable, missing icon, parse error, etc.) - Skip silently when prerequisites are missing: no token
file, no icon, no reachable upstream →
echoa short skip line +exit 0. The next boot tries again. - Wired to
multi-user.targetso they run on every boot (lets a rotated token / new icon take effect withoutsystemctl restartgymnastics). - Re-runnable: a second invocation produces the same final
state (idempotent uploads, idempotent config rewrites). Used
by the
.pathwatchers that re-fire on token appearance (seedocs/agent-lifecycle/persistence.md::Matrix per-agent daemon).
The service stays root-owned so the bootstrap ordering doesn't need a user-existence check before each fire.
This pattern keeps the rebuild path resilient: any failure inside
these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The
operator notices through journalctl -u <unit> rather than a
broken switch-to-configuration.