hive-forge
Private Forgejo instance running in a nixos-container, used as the
swarm's persistent code-collaboration surface (issues, PRs, reviews,
attachments). Configured via services.hyperhive.forge.*. Container
shape, ROOT_URL / sub-domain routing, and operator-vs-in-cluster URL
handling live in docs/gateway.md; this file owns the
per-agent integration story and the notification pump that wakes
each agent on relevant activity.
Token scopes
Two scope sets live in hive-c0re::forge:
TOKEN_SCOPES (per-agent tokens):
| Scope | Why |
|---|---|
write:repository |
Create, clone, push, delete repos; merge PRs. |
write:issue |
Open / comment / review issues and pull requests (Forgejo namespaces PR conversation under issues). |
write:user |
Edit own profile, create repos under own user. |
write:organization |
Create + manage orgs (lets agents share a forge namespace). |
read:user |
Token-owner endpoint used for self-identification at harness startup. |
write:misc |
Hooks, attachments, the rest of the long tail. |
read:notification |
Poll GET /notifications for unread events. |
write:notification |
Mark notifications read via PATCH /notifications/threads/{id}. |
CORE_TOKEN_SCOPES (hive-c0re's own core user): everything in
TOKEN_SCOPES plus read:admin and write:admin. Site-admin
membership alone isn't sufficient — Forgejo's token scope gate runs
before the user-permission check, so /api/v1/admin/* returns
403 Forbidden for any token without the admin scope bits, even when
the bearer is a site admin.
Migration note: if PATCH /api/v1/admin/users/{name} returns 403
on an existing deploy, the core token predates the admin-scope
addition. Delete /var/lib/hyperhive/forge-core-token and restart
hive-c0re to re-mint with the new scopes.
Per-agent forge accounts
Each agent gets its own Forgejo user + access token, provisioned at
boot by hive-c0re::forge. The provisioning flow is idempotent:
existing accounts + tokens are reused, so container destroy/recreate
doesn't lose forge identity. The token is written to
<state>/forge-token (one line, no trailing newline) inside the
agent container so hive-forge CLI + forge_notify poller can
read it without touching c0re's host-side credential store.
Two things live in the agent-configs Forgejo organization:
- A mirror repo per agent (
agent-configs/<name>) — c0re pushes the agent's applied config repo on each↻ R3BU1LD. Agents are read-only collaborators oncore/meta(the hive-c0re-owned meta flake) so they can fetch but never push. - The dashboard links each container's "config" anchor to this
mirror, so operators can click straight from the SW4RM tab into
the rendered repo without an extra
gitstep.
The hive-forge CLI (separate workspace crate, see
README.md file map) wraps the Forgejo REST API
with the per-agent token; agents call it for issue / PR / comment
ops as if it were a peer.
Notification poller (hive-ag3nt/src/forge_notify.rs)
Background task spawned once per harness boot. Polls
GET /api/v1/notifications?all=false every 30 seconds (Forgejo's
unread-only filter), formats each notification as a broker
Wake { from: "forge" } message, and delivers it to the agent's own
inbox so claude's normal turn loop picks it up. Mark-read happens
after successful delivery so a failed-delivery notification
resurfaces on the next tick.
Activation gates (graceful no-ops)
The poller starts disabled and stays that way for any of:
HIVE_FORGE_URLnot set (no forge configured for this hive).<state>/forge-tokenmissing or empty (agent has no forge account — pre-provisioning or destroy-without-purge race).- Initial
reqwest::Client::builderfails (extremely unlikely; treated as fatal-to-the-task only).
Disabled = the spawned task returns immediately. All other failure modes (HTTP errors, parse errors, mark-read failures) are best-effort: logged at debug/warn and retried next tick.
Self-notification filtering
Forgejo fires notifications for the agent's own actions (it opened a PR, posted a comment, submitted a review). Surfacing those would loop claude on its own writes. Two filter rules drop them silently (mark-read without delivery):
- Self-authored new items — notifications with
reason == "author"AND subject stateopen(or missing). State transitions (merge / close) on the agent's own PRs DO surface, since those are triggered by someone else. - Self-authored comments / reviews — comment payload's
user.loginmatchesown_login.
own_login is fetched once at startup via GET /api/v1/user. On
fetch failure the filter degrades open (no filtering) rather than
crashing the task — a noisy inbox beats a silently-stuck poller.
Body excerpt + truncation + heading escape
The wake message embeds the comment / review / new-item body so the agent sees actual content without a follow-up fetch. Three pipeline steps in order:
- Truncate to
BODY_TRUNCATE = 500chars at a char-boundary; appends…when cut. Truncation happens BEFORE escape so the mention-overflow diff (next step) compares like-for-like against the raw body. - Mention overflow extraction — when truncation actually
trimmed content, walk the full body line-by-line and surface any
@usernamelines that fell outside the embed window. Rendered as a trailingmentions (truncated from body):\n > <line>block. Mention detection requires the@to be at line start or following a non-username byte, so email-stylefoo@bar.comdoes NOT count. - ATX heading escape — for each line that's a strict
CommonMarkATX heading (1-6 leading#s followed by a space, tab, or end-of-line), prepend\so the embedded body doesn't blow into a top-level h1/h2 inside the wrapper message when the dashboard renders it. Lines like#tag,#123,#!/bin/bashare NOT headings — no escape, no cosmetic noise. Indented "headings" inside lists / nested quotes keep their leading whitespace.
The strict ATX rule is deliberate: \#tag and #tag render
identically, so an over-eager escape just adds visual clutter
without changing behavior. Setext-style headings (title\n====)
are not handled — rarer in practice, would need multi-line
lookahead.
Wrapper format
Four shapes, distinguished by the notification's classification:
| Trigger | Wrapper |
|---|---|
| Comment on issue / PR | [comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...\nreason: mention |
| Review submission | [PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...\nreason: review_requested |
| New issue / PR | [new PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...\nreason: subscribed |
| State change | [PR merged #N owner/repo] title\nurl: ...\nassignee: ...\nreason: subscribed |
Review labels come from the Forgejo state field: APPROVED →
approved, REQUEST_CHANGES → changes requested, COMMENT →
review comment. PENDING is dropped (review saved but not
submitted yet — no peer-visible event). Unknown states fall back to
the generic comment wrapper.
Number is extracted from subject.html_url's last path segment
(strips #anchor first); repo slug from repository.full_name.
Both degrade gracefully when absent (number → blank, repo → blank)
so unexpected Forgejo shapes don't crash the formatter.
Meta suffix
Every wrapper ends with one or more of:
assignee: <list>— always present;unassignedwhen empty so the line shape is stable.reviewer: <list>— PR notifications only, present only whenrequested_reviewersis non-empty.reason: <forgejo-reason>— always present when the notification carries a reason; absent when the field is null/missing.
The reason line distinguishes otherwise-identical messages: Forgejo
emits one notification per applicable reason for the same event
(e.g. both mention and subscribed arrive for a PR comment that
tags the agent). Without the suffix, the agent would see duplicated
wrapper text with no signal which Forgejo path triggered each copy.
Review-request override
For new PRs, the kind label flips to [review requested #N owner/repo] when own_login appears in requested_reviewers,
regardless of the Forgejo reason field. Forgejo doesn't reliably
set reason == "review_requested" (often null instead), so the
fallback checks the subject payload directly. Detection is gated on
is_new so the label only fires once on PR creation, not on every
subsequent comment.
Reason drop-list
HIVE_FORGE_NOTIFY_SKIP_REASONS (comma-separated) suppresses
notifications whose Forgejo reason matches an entry. Marked-read
silently, no delivery. Drop-list is intentionally chosen over an
allow-list:
- Allow-list would silently miss any directed signal Forgejo adds
later (
review_requested,mention, future kinds). - Drop-list explicitly identifies the noisy paths
(
subscribed,participating) and lets unknown / null reasons pass through.
Configured per-agent via hyperhive.forge.skipNotifyReasons in
agent.nix. Default is empty (deliver everything).
Auto-unsubscribe on broad watches
Default behavior: after delivering a reason == "subscribed"
notification, DELETE /api/v1/repos/<owner>/<repo>/subscription is
called to drop the agent's broad-watch on that repo. The agent
remains subscribed to specific issues/PRs it interacts with, but
stops receiving the firehose of every commit / new issue.
HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 disables this — triage agents and
other firehose consumers need to keep watching every repo activity.
Set via hyperhive.forge.keepSubscriptions = true in agent.nix.
The auto-unsub set is process-local (a HashSet<String> keyed by
owner/repo), so a single repo only gets one DELETE per harness
boot. After harness restart the agent might re-watch the repo via
some other path; the next subscribed notification re-triggers the
unsubscribe.