mara on #5170: "dont split caps and perms. its all perms. / dont include the now empty tool groups / nothing set -> default / also add perms to agent creation screen". - hive-sh4re: one `Permission` enum (messaging, meta, inbox, execution, web_tools, manage_root_agent) replaces `ToolGroup` and `Capability`; `lifecycle`/`forge` are gone. `Permission::default_for` is the default for an agent the swarm sets nothing for. - The record is `{ "permissions": [...] }`. Missing key, unset field or no successful read yet = default; `[]` = none; a failed read after a good one keeps the last value. - hive-agent reads its own key every 10s and builds each turn's --tools, --allowedTools and MCP config (rewritten before every turn) from it; the subagent daemon reads it on each start/continue. Agents get a read-only grant on their own key. - hive-c0re restarts an agent only when its manage_root_agent changes. The import now merges meta/capabilities.json and meta/tool-groups.json into one list per agent and deletes both; the "import pending, don't read" decision is `ImportOutcome::read_allowed`, table-tested. - swarm-controller: `GET /api/permissions` (names + default) and an optional `permissions` on `POST /api/agents`, stored before the job is queued. swarm-ui's create-agent form gets a permissions field. - Deleted: HIVE_TOOL_GROUPS and its forward to the subagent daemon, tool_groups.rs, ruth's tool-group seed, the permissions rebuild job (PermPayload/WritePermFile/perm_change), the dashboard permissions tab and its stale-entries panel. Part of #5170.
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.c0re.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, on both the agent and manager sockets alike (the manager, ruth,
is a normal agent here, just deployed automatically with different
default permissions). 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, an unset field, or no
successful read yet means the default (Permission::default_for):
messaging, meta, inbox, execution, plus manage_root_agent for
ruth. "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.
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 it reads no keys.
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.