Compare commits

..
Author SHA1 Message Date
müde
4a06615c5c fix /state paths: sub-agents use /agents/<name>/state, not /state
sub-agent containers post-refactor bind their state at
/agents/<name>/state (manager keeps the legacy /state — see
lifecycle.rs:751). agent.md still said /state/forge-token; corrected
to /agents/{label}/state/forge-token (template-substituted at
boot). tea-login systemd unit now walks both candidates so the same
harness module works for the manager and sub-agents.
2026-05-16 23:37:49 +02:00
müde
9fc7cae132 prompts: tell agents + manager about the code forge; todo: shared docs repo
system prompts now describe the hyperhive Forgejo at localhost:3000,
the per-agent user, the pre-configured tea CLI, and the REST API
fallback with /state/forge-token. todo gains the shared docs/skills
RO-repo follow-up (org-shared + per-agent read membership).
2026-05-16 23:36:05 +02:00
müde
787c058c71 harness: install tea + auto-login from /state/forge-token
agents get `pkgs.tea` (gitea/forgejo CLI) and a tea-login oneshot
that runs `tea login add --url <hyperhive.forge.url> --token
$(cat /state/forge-token)` before the harness starts. idempotent:
exits 0 when the token file is absent (hive-forge not on) or when
~/.config/tea/config.yml already exists. new
`hyperhive.forge.url` option (default http://localhost:3000) so
operators can point at a non-default forge port. claude can now
shell out to `tea repos create`, `tea pulls create`, etc.
2026-05-16 23:35:28 +02:00
müde
dccbd99b0c forge: broaden token scopes for repo create / PRs / orgs / misc
bumped from (read:user,write:repository,write:issue) to also include
write:user (own profile + create repos under own namespace),
write:organization (share namespaces between agents), write:misc
(hooks/attachments). still excludes admin and package scopes.
2026-05-16 20:58:20 +02:00
müde
480d646f69 forge: auto-create a user + token per agent on spawn / startup
new forge module probes the hive-forge nixos-container (no-op when
absent), and ensures every agent + the manager has a forgejo user
named after them with an access token at `<state>/forge-token`
(visible inside the container as `/state/forge-token`).

idempotent: skips user creation when forgejo reports 'already
exists', skips token issuance when the file is present, scopes the
token to read:user,write:repository,write:issue. token-name suffixed
with a clock so re-issuing doesn't collide with a stale name. shells
out via `nixos-container run hive-forge -- runuser -u forgejo --
forgejo admin` (runuser instead of sudo since sudo isn't in the
container by default).

hooks: ensure_all sweeps existing containers at hive-c0re startup
(backgrounded), and the actions.rs spawn task calls ensure_user_for
the new agent right after lifecycle::spawn succeeds. failures log a
warning but don't abort spawn — a missing token is recoverable from
the next startup sweep.
2026-05-16 20:55:13 +02:00
müde
6e9c67dd94 hive-forge: wrap forgejo in a nixos-container
avoids fighting an operator-side `services.forgejo` over the
singleton module options. container shares host netns
(`privateNetwork = false`) so agents still dial the forge via
plain `localhost:<httpPort>` and the host firewall is the only
layer that matters. container name is `hive-forge` (no `h-`
prefix) so hive-c0re's lifecycle scanner ignores it — operator
manages it with the standard `nixos-container` CLI. state lives
at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` and
survives restarts.
2026-05-16 20:52:36 +02:00
müde
c2d176ed13 add hive-forge module: private forgejo for agents
new `services.hive-forge.enable` (off by default) wraps
`services.forgejo` with hyperhive-friendly defaults: sqlite (no
extra db service), built-in ssh on 2222 so it doesn't fight the
host's openssh, http on 3000 (outside hyperhive's 7000/8000/8100-8999
ranges), registration off (operator seeds agent users), private
repos by default. exported as `nixosModules.hive-forge` — operator
imports it on the host alongside hive-c0re. container-side wiring
(MCP tools or a bind-mounted token) is deferred; containers already
share the host netns so they can reach http://localhost:3000 today.
2026-05-16 20:50:36 +02:00
9 changed files with 415 additions and 0 deletions

View file

@ -7,6 +7,7 @@
- Move bind mounts in agents to `/agents/<name>/state` so path for agent = path for manager
- **Broadcast messaging**: allow sending messages with recipient "*" to all agents; deliver with hint "this was a broadcast and may not need any action from you"
- **Multi-agent restart coordination**: when rebuilding all agents, manager should start first so it can coordinate post-restart confusion (notify agents, suppress unnecessary retries, etc)
- **Shared docs/skills repo (RO)**: a single repo on the hive forge that every agent has read-only access to — common references, prompts, runbooks, "skills" the operator wants every agent to inherit without baking into the system prompt or `/shared`. Implementation likely: seed an `org-shared/docs` repo on first hive-forge boot, grant every per-agent user a read membership in the org. Agents `git clone` it (or use the API) to read; only the manager + operator can push.
## Reminder Tool

View file

@ -98,6 +98,7 @@
hyperhivePackage = system: self.packages.${system}.default;
hyperhiveFlake = "${self}";
};
hive-forge = ./nix/modules/hive-forge.nix;
};
nixosConfigurations =

View file

@ -15,6 +15,8 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across
**Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate.
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`); credentials for the `tea` CLI are pre-configured at boot. Use `tea repos create`, `tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`, etc. for any persistent code work — git repos that should outlive a single turn, code you want a peer or the operator to review, anything you'd otherwise jam into `/shared`. Falls back to plain `git`/`curl` if `tea` doesn't fit; the REST API is at `http://localhost:3000/api/v1/` with the same token (`Authorization: token $(cat /agents/{label}/state/forge-token)`).
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The manager + operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — go through the manager if a payload needs to reach another sub-agent.
When your inbox has a message, handle it and stop. Don't narrate intent — act.

View file

@ -83,6 +83,8 @@ Keep messages short — a few sentences each. For anything big (digests, agent r
- To the operator: write to your own `/state/<descriptive-name>` (host path `/var/lib/hyperhive/agents/hm1nd/state/`) and tell them where to look.
- For shared artifacts (coordination, common reference data): write to `/shared/<descriptive-name>`. Only put things here you're willing to lose — other agents may delete them.
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/state/forge-token` exists. You have your own user (`hm1nd`) and so does every sub-agent (one per name). The `tea` CLI is pre-configured at boot. Use it for code work that should survive a turn — a proposed refactor across sub-agents, scratch repos, PRs you want a sub-agent or the operator to review (`tea pulls create --base main --head <branch>`, `tea pulls list`, `tea issues create`). REST API at `http://localhost:3000/api/v1/` with `Authorization: token $(cat /state/forge-token)` for anything `tea` can't express.
A one-line headline + the file path beats a wall-of-text every time — it survives context compaction and the operator can read it in their own time.
When your inbox has a message, handle it and stop. Don't narrate intent — act.

View file

@ -77,6 +77,11 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
)
.await;
drop(guard);
if result.is_ok()
&& let Err(e) = crate::forge::ensure_user_for(&agent_bg).await
{
tracing::warn!(agent = %agent_bg, error = ?e, "forge: ensure_user after spawn failed");
}
if let Err(e) = finish_approval(&coord_bg, &approval_bg, result, None) {
tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed");
}

207
hive-c0re/src/forge.rs Normal file
View file

@ -0,0 +1,207 @@
//! Optional Forgejo wiring. When the `hive-forge` nixos-container is
//! present and running, hive-c0re ensures every agent (and the
//! manager) has a corresponding forgejo user with an API token
//! written to `<agent-state>/forge-token` — visible inside the
//! container as `/state/forge-token`. Idempotent: skips creation
//! when the user already exists, skips token issuance when the file
//! is already there.
//!
//! No-op when `hive-forge` isn't enabled (detected via
//! `nixos-container list`), so operators who don't run the bundled
//! forge pay nothing.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use tokio::process::Command;
use crate::coordinator::Coordinator;
const FORGE_CONTAINER: &str = "hive-forge";
const TOKEN_NAME_PREFIX: &str = "hyperhive";
/// Forgejo scopes the agent's token gets. Broad-but-not-admin: every
/// repo / PR / issue thing an agent needs day-to-day, no admin
/// surface.
/// - `write:repository` — create, clone, push, delete repos in the
/// user's own namespace; 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 clients call to introspect.
/// - `write:misc` — hooks, attachments, the rest of the long tail.
const TOKEN_SCOPES: &str =
"read:user,write:user,write:repository,write:issue,write:organization,write:misc";
/// Token file inside the agent's bind-mounted state dir (visible as
/// `/state/forge-token` from inside the container).
fn token_path(name: &str) -> PathBuf {
Coordinator::agent_notes_dir(name).join("forge-token")
}
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
/// `nixos-container list` is just a directory scan in /etc.
pub async fn is_present() -> bool {
let Ok(out) = Command::new("nixos-container")
.arg("list")
.output()
.await
else {
return false;
};
if !out.status.success() {
return false;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.any(|l| l.trim() == FORGE_CONTAINER)
}
/// Run `forgejo admin <args>` inside the hive-forge container as the
/// forgejo user (the only uid with write access to the state dir).
/// Returns stdout on success; bails with stderr context on failure.
async fn forge_admin(args: &[&str]) -> Result<String> {
let mut cmd = Command::new("nixos-container");
// `runuser` (util-linux, always present in a NixOS container)
// beats `sudo` here — sudo isn't installed unless `security.sudo`
// is enabled, and we don't want to depend on that.
cmd.args(["run", FORGE_CONTAINER, "--", "runuser", "-u", "forgejo", "--", "forgejo", "admin"]);
cmd.args(args);
let out = cmd
.output()
.await
.context("invoke nixos-container run hive-forge -- forgejo admin")?;
if !out.status.success() {
anyhow::bail!(
"forgejo admin {} failed ({}): {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim(),
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Pull the access token out of forgejo's success message. Format
/// has shifted across versions (table form vs. "Access token was
/// successfully created: <hex>"), so just hunt the output for the
/// first long hex-looking word.
fn extract_token(output: &str) -> Option<String> {
output
.split(|c: char| c.is_whitespace() || c == ',' || c == ':')
.find(|w| w.len() >= 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
.map(str::to_owned)
}
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
/// returns a "user already exists" error which we treat as success.
async fn ensure_user_exists(name: &str) -> Result<()> {
let result = forge_admin(&[
"user",
"create",
"--username",
name,
"--email",
&format!("{name}@hive.local"),
"--random-password",
"--must-change-password=false",
])
.await;
match result {
Ok(_) => {
tracing::info!(%name, "forge: created user");
Ok(())
}
Err(e) => {
// Forgejo's "already exists" error wording varies; just
// try the next step and let token issuance surface a
// real failure if the user truly isn't there.
let msg = format!("{e:#}");
if msg.contains("already exists") || msg.contains("user already") {
tracing::debug!(%name, "forge: user already exists");
Ok(())
} else {
tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway");
Ok(())
}
}
}
}
/// Mint a fresh access token for `name` and persist it to
/// `<state>/forge-token` (0600). Token name is suffixed with a
/// monotonic clock so re-issuing doesn't collide with an existing
/// token of the same name in the DB.
async fn mint_and_persist_token(name: &str, path: &Path) -> Result<()> {
let token_name = format!(
"{TOKEN_NAME_PREFIX}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
);
let stdout = forge_admin(&[
"user",
"generate-access-token",
"--username",
name,
"--token-name",
&token_name,
"--scopes",
TOKEN_SCOPES,
])
.await?;
let token = extract_token(&stdout)
.with_context(|| format!("parse token from forgejo output: {stdout:?}"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(path, format!("{token}\n"))
.with_context(|| format!("write token to {}", path.display()))?;
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
tracing::info!(%name, path = %path.display(), %token_name, "forge: persisted access token");
Ok(())
}
/// Ensure `name` has a forgejo user + token file. No-op when the
/// token file is already present. Safe to call on every spawn and
/// on every hive-c0re startup.
pub async fn ensure_user_for(name: &str) -> Result<()> {
if !is_present().await {
return Ok(());
}
let path = token_path(name);
if path.exists() {
return Ok(());
}
ensure_user_exists(name).await?;
mint_and_persist_token(name, &path).await
}
/// Sweep every existing container (manager + sub-agents) and ensure
/// each has a forgejo user + token. Called once at hive-c0re
/// startup. Per-agent failures are logged but don't abort the sweep.
pub async fn ensure_all() {
if !is_present().await {
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
return;
}
let Ok(containers) = crate::lifecycle::list().await else {
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
return;
};
for c in containers {
let name = if c == crate::lifecycle::MANAGER_NAME {
c
} else if let Some(n) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
n.to_owned()
} else {
continue;
};
if let Err(e) = ensure_user_for(&name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
}
}
}

View file

@ -15,6 +15,7 @@ mod coordinator;
mod crash_watch;
mod dashboard;
mod events_vacuum;
mod forge;
mod lifecycle;
mod manager_server;
mod meta;
@ -134,6 +135,13 @@ async fn main() -> Result<()> {
tracing::warn!(error = ?e, "auto-update task failed");
}
});
// Forge user sweep: ensure every existing container has a
// forgejo user + access token. No-op when the hive-forge
// container isn't running. Backgrounded — touches the
// forge state dir via `nixos-container run` which is slow.
tokio::spawn(async move {
forge::ensure_all().await;
});
// Periodic broker vacuum: drop delivered messages older than
// 30 days. Undelivered messages are always kept (still in
// flight). Runs hourly; first sweep happens immediately.

128
nix/modules/hive-forge.nix Normal file
View file

@ -0,0 +1,128 @@
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hive-forge;
in
{
# Private Forgejo for hyperhive agents, wrapped in a nixos-container
# so it doesn't fight any `services.forgejo` the operator already
# runs on the host. The container shares the host network namespace
# (`privateNetwork = false`) so agents reach the forge at
# `http://localhost:<httpPort>` without any extra plumbing —
# nixos-container is just here for state + systemd-unit isolation,
# not network isolation.
#
# Container name is `hive-forge` (not `h-*`), so hive-c0re's
# lifecycle scanner ignores it; the operator manages it via the
# standard `nixos-container` CLI.
#
# State lives at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/`
# and survives container restart / host reboot. To wipe, destroy the
# container.
options.services.hive-forge = {
enable = lib.mkEnableOption "hive-forge private Forgejo (in a nixos-container) for hyperhive agents";
httpPort = lib.mkOption {
type = lib.types.port;
default = 3000;
description = ''
TCP port the forge serves HTTP on. Default 3000 sits outside
hyperhive's claimed ranges (dashboard 7000, manager 8000,
sub-agents 8100..8999). Change this if you already have
another forgejo bound to 3000.
'';
};
sshPort = lib.mkOption {
type = lib.types.port;
default = 2222;
description = ''
TCP port the forge's built-in SSH server listens on. Kept off
22 so it doesn't clash with the host's openssh. Agents push
with `ssh -p <sshPort> git@<domain>:<owner>/<repo>.git`.
'';
};
domain = lib.mkOption {
type = lib.types.str;
default = "localhost";
example = "forge.internal";
description = ''
Hostname used in repo clone URLs the forge advertises. The
container shares host netns so `localhost` works for any
agent on the same host; set a real hostname when you want
clones from outside the host to look canonical.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Open `httpPort` + `sshPort` in the host firewall. Off when
the forge should only be reachable from inside the host.
(The container shares host netns, so this is the only
firewall layer that matters.)
'';
};
};
config = lib.mkIf cfg.enable {
containers.hive-forge = {
autoStart = true;
ephemeral = false;
# Share host netns — forgejo's HTTP / SSH listeners then look
# exactly like a host-side service, no port forwarding dance,
# and agent containers (which also share host netns) reach it
# via plain `localhost`.
privateNetwork = false;
config =
{ pkgs, ... }:
{
system.stateVersion = "25.11";
services.forgejo = {
enable = true;
database.type = "sqlite3";
lfs.enable = true;
settings = {
server = {
DOMAIN = cfg.domain;
ROOT_URL = "http://${cfg.domain}:${toString cfg.httpPort}/";
HTTP_PORT = cfg.httpPort;
START_SSH_SERVER = true;
SSH_PORT = cfg.sshPort;
SSH_LISTEN_PORT = cfg.sshPort;
BUILTIN_SSH_SERVER_USER = "git";
DISABLE_SSH = false;
};
# Registration off — operator seeds agent users via
# `nixos-container run hive-forge -- forgejo admin
# user create …`.
service = {
DISABLE_REGISTRATION = true;
REQUIRE_SIGNIN_VIEW = false;
};
repository = {
DEFAULT_BRANCH = "main";
DEFAULT_PRIVATE = "private";
};
log.LEVEL = "Warn";
};
};
environment.systemPackages = [ pkgs.forgejo ];
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
cfg.httpPort
cfg.sshPort
];
};
};
}

View file

@ -82,6 +82,20 @@
'';
};
options.hyperhive.forge.url = lib.mkOption {
type = lib.types.str;
default = "http://localhost:3000";
example = "http://forge.internal:3000";
description = ''
Base URL of the hyperhive-managed Forgejo. Used at container
boot by a oneshot systemd unit that calls
`tea login add --url <this> --token "$(cat /state/forge-token)"`
so the agent's claude can shell out to `tea` without an extra
auth dance. No-op when `/state/forge-token` is missing (i.e.
hive-forge isn't running on the host).
'';
};
options.hyperhive.claudePlugins = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
@ -124,8 +138,55 @@
# procps for pkill — used by the web UI's /api/cancel to SIGINT the
# in-flight claude turn.
procps
# tea: gitea/forgejo CLI client. Configured at boot by the
# tea-login oneshot below if /state/forge-token is present, so
# claude can `tea repos create`, `tea pulls create`, etc.
tea
];
# One-shot: configure tea with the agent's forge token if
# hive-c0re seeded one and tea hasn't been configured yet.
# Runs before the harness service so the first turn can already
# `tea repos create`. Idempotent — exits 0 if config already
# exists, exits 0 if no token file (hive-forge not enabled).
systemd.services.tea-login = {
description = "configure tea CLI from /state/forge-token";
wantedBy = [ "multi-user.target" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.tea pkgs.coreutils ];
script = ''
set -eu
CONFIG=/root/.config/tea/config.yml
# Manager keeps the legacy /state bind; sub-agents have
# /agents/<name>/state. Glob covers both — there's exactly one
# hit either way (manager: /state, sub-agent: its own
# /agents/* mount), since each container only sees its own
# state dir.
TOKEN_FILE=""
for f in /state/forge-token /agents/*/state/forge-token; do
[ -f "$f" ] && TOKEN_FILE="$f" && break
done
if [ -z "$TOKEN_FILE" ]; then
echo "tea-login: no forge-token (hive-forge not seeded); skipping"
exit 0
fi
if [ -f "$CONFIG" ]; then
echo "tea-login: $CONFIG already present; skipping"
exit 0
fi
mkdir -p "$(dirname "$CONFIG")"
tea login add \
--name forge \
--url ${lib.escapeShellArg config.hyperhive.forge.url} \
--token "$(cat "$TOKEN_FILE")"
echo "tea-login: configured for ${config.hyperhive.forge.url} from $TOKEN_FILE"
'';
};
# Git is needed by claude's Bash tool (for the agent <-> manager config
# request flow) and by hive-c0re's own setup_applied / setup_proposed.
# The per-agent `applied/<name>/flake.nix` overrides `user.name` and