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:

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:

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):

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:

  1. Truncate to BODY_TRUNCATE = 500 chars 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.
  2. Mention overflow extraction — when truncation actually trimmed content, walk the full body line-by-line and surface any @username lines that fell outside the embed window. Rendered as a trailing mentions (truncated from body):\n > <line> block. Mention detection requires the @ to be at line start or following a non-username byte, so email-style foo@bar.com does NOT count.
  3. ATX heading escape — for each line that's a strict CommonMark ATX 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/bash are 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: APPROVEDapproved, REQUEST_CHANGESchanges requested, COMMENTreview 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:

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:

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.