13 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. - The manager is
hm1nd(noh-prefix, fixed name). MAX_AGENT_NAMEinlifecycle.rsenforces the cap.- Per-agent web UI port =
WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE(8100..8999) for every agent including the manager; dashboardcfg.dashboardPort(default 7000).
Identity = socket
There are no auth tokens 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; the
manager has access to its privileged socket; hive-c0re owns the host
admin socket.
Wake injection
AgentRequest::Wake { from, body } (and the manager-flavour mirror)
is the wake-event-injection surface. 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.). Typical caller: an
in-container background task (the matrix daemon, a scraper, the
forge-notify webhook subscriber) that needs to signal "external work
has arrived" without going through the broker as a peer agent.
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
A few recipient names are reserved by the broker and 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 (agent_server::handle_sendfans out viaCoordinator::broadcast_send).operator— the human at the dashboard. Messages accumulate in the inbox view; no agent everrecv's them.<parent>— the sender's parent pertopology.json. Rewritten at send time bytopology::resolve_recipient: looks upparent_of(sender)and falls back tooperatorwhen the sender is a root agent (or absent from topology entirely). Lets agents address their parent without learning the label, so runtime reparenting propagates with zero agent-side restart.
When the resolver rewrites <parent>, the broker stores the
resolved label as the message's recipient — the dashboard and
recv side both see the real route. The sentinel is purely a
send-time addressing convenience.
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.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 32; values above clamp silently).
wait_seconds long-polls for the first message; once one arrives —
or one is already pending — the call drains up to max in total
before returning, so a single Recv call coalesces a burst.
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.
Question routing (Ask / Answer)
AgentRequest::Ask (and the manager-flavour mirror) surfaces a
structured question that either lands in the operator's dashboard
queue or in a peer agent's inbox. The recipient is the to field:
to = Noneorto = Some("operator")— routes to the operator-question queue. The dashboard renders the question with anyoptionsas a chip strip plus a free-text fallback (Other…) so the operator is never trapped by an incomplete list. The legacyAskOperatorvariant collapses into this case.to = Some(<agent>)— peer Q&A. The target agent receives aHelperEvent::QuestionAsked { id, asker, question, options, multi }in their inbox. They reply viaAgentRequest::Answer(orManagerRequest::Answerif they're the manager); the answer threads back to the asker as aHelperEvent::QuestionAnsweredevent.
Shape fields are uniform across both targets:
optionsis advisory — the dashboard chips are decoration over a free-text fallback; peer-agent recipients see the list in theirQuestionAskedevent and can return any string.multi = truelets the answerer pick multiple options (checkboxes in the dashboard, a hint in the peer-agent event). The answer comes back as a single string with selections joined by", ".ttl_secondsauto-cancels with answer[expired](andanswerer: "ttl-watchdog") when the wait becomes moot.None= wait indefinitely or until manual cancel.
Response shape is always QuestionQueued { id } — the asker stores
the id and correlates the asynchronous answer event when it lands.
Authorisation on Answer: only the question's target agent (or
the operator via the dashboard) is permitted to reply; an answer
attempt from anyone else fails the wire-side check.
Loose-ends wire shape
LooseEnd is the per-row response shape for GetLooseEnds (both
the agent-flavour and manager-flavour requests). Tagged enum so
new thread kinds (forge PRs, long-running approvals from a
privileged bot, 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.
Per-flavour scoping is uniform across the three variants:
- agent-flavour
GetLooseEndsonly surfaces rows the calling agent has standing in.Approvalrows only appear when the calling agent is the manager (sub-agents don't submit approvals).Questionrows surface where the agent isaskerORtarget(the routing semantics from the Ask/Answer subsection above).Reminderrows are scoped toowner == self. - manager-flavour
GetLooseEndslists every pending row in the swarm — full audit view.
Per-variant fields:
Approval { id, agent, commit_ref, description?, age_seconds }—agentis the affected agent (target of the spawn / config commit), not the asker.descriptionis the manager's free-text blurb shown on the dashboard card.commit_refis the kind-specific payload (seedocs/approvals.md::Approval kinds (wire shapes)).Question { id, asker, target?, question, age_seconds }—target = None= operator-routed (dashboard);Some(agent)= peer-to-peer thread.Reminder { id, owner, message, due_at, age_seconds }—due_atis the absolute unix timestamp the scheduler is targeting; clients compute time-until-fire asdue_at - now.
age_seconds saturates at zero on any clock anomaly (back-step,
unsynchronised wall clock, etc.) so the bulleted list never
shows nonsense ages.
CancelLooseEnd { kind, id } is the matching write surface. The
kind enum (Question / Reminder / Approval) selects which
underlying store the dispatcher reaches into. Question and
Reminder cancel from either surface subject to ownership
checks (asker for the question, scheduler for the reminder).
Approval is manager-only — sub-agents don't submit approvals
so they have nothing of their own to withdraw; their wire
surface returns a clear error if they try. Cancelling an approval
transitions the row to ApprovalStatus::Cancelled and fires
ApprovalResolved { status: "cancelled" } so the dashboard pulls
the card out of the pending pane.
Async forms
Dashboard + per-agent mutating forms carry data-async; a delegated
submit listener in assets/tabs.js 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) or data-prompt="…" (for a
window.prompt() whose answer goes into a hidden input named by
data-prompt-field, default note).
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
lifecycle::rebuild idempotently rewrites
/etc/nixos-containers/<C>.conf (PRIVATE_NETWORK=0, clears
HOST_ADDRESS / LOCAL_ADDRESS, sets EXTRA_NSPAWN_FLAGS),
regenerates applied/<name>/flake.nix, writes the systemd limits
drop-in, then nixos-container update + stop + start.
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 are factored
approve / deny / destroy (and the lifecycle helper) live in
actions.rs / dashboard.rs. 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 if non-obvious; 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.
Best-effort oneshot services
The harness ships a family of one-shot systemd services that configure agent-side surfaces from values hive-c0re writes into the state dir at provisioning time:
tea-login— writes~/.config/tea/config.ymlfrom theforge-tokenwritten byhive-c0re::forge::ensure_user_for, sotea repos create/tea pulls creatework without interactive prompts.forge-avatar-sync— uploadshyperhive.iconSVG to the agent's Forgejo profile, so the icon shows up on commits / PRs / issue comments.matrix-avatar-sync— same idea for matrix profile avatars (two-stepmedia upload→set avatar_urldance — seedocs/persistence.md::matrix-avatar-syncfor the protocol detail).
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 is not 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 (#571 — seedocs/persistence.md::matrix-avatar-sync).
The artefact lives under the agent user's home where applicable
(~/.config/tea/config.yml) and is chown'd to that user, but the
service itself 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 tea config,
no avatar) but never blocks the container from coming up. The
operator notices through journalctl -u <unit> rather than a
broken switch-to-configuration.