diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index 636d77dc..a8be30b5 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -165,9 +165,8 @@ Tree-shape version: `systemd.globalEnvironment` in `meta.rs` for every container (manager included), so all token/state paths resolve through `$HYPERHIVE_STATE_DIR`. The harness-base shell scripts -(tea-login, forge-avatar-sync, matrix-avatar-sync) simplified -from glob+for loops to a direct `$HYPERHIVE_STATE_DIR/` -read. +(tea-login, forge-avatar-sync) simplified from glob+for loops to a +direct `$HYPERHIVE_STATE_DIR/` read. ### E — prompt + tools diff --git a/docs/conventions.md b/docs/conventions.md index 56fe68d6..72ce9b31 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -474,10 +474,10 @@ the state dir at provisioning time: - `forge-avatar-sync` — uploads `hyperhive.icon` SVG 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-step `media upload` → `set avatar_url` dance — see - `docs/persistence.md::matrix-avatar-sync` for the protocol - detail). + +(The matrix profile avatar is **not** a oneshot — `hive-matrix-daemon` +sets it over its live authenticated Client; see +`docs/persistence.md::matrix avatar`.) Shape contract — every one of these: @@ -498,7 +498,7 @@ Shape contract — every one of these: 5. **Re-runnable**: a second invocation produces the same final state (idempotent uploads, idempotent config rewrites). Used by the `.path` watchers that re-fire on token appearance (see - `docs/persistence.md::matrix-avatar-sync`). + `docs/persistence.md::Matrix per-agent daemon`). The artefact lives under the agent user's home where applicable (`~/.config/tea/config.yml`) and is chown'd to that user, but the diff --git a/docs/persistence.md b/docs/persistence.md index 16e38e4c..6cae08b0 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -396,39 +396,35 @@ agent containers come up. Without the path-trigger sibling first time it ran and the MCP would have no backend until the next restart. The `.path` unit makes the appearance of the token re-fire the service so the daemon comes alive in the same boot cycle as -provisioning. `matrix-avatar-sync.path` uses the same pattern for -the icon-upload oneshot. +provisioning. The same token watcher also drives avatar setting: on a +restart the daemon re-runs each account's bring-up, which sets the +avatar (see below). -### matrix-avatar-sync (two-step media + profile dance) +### matrix avatar (set by the daemon over the live Client) -Mirrors the forge-avatar oneshot's shape (`docs/conventions.md:: -Best-effort oneshot services`) but differs in protocol: matrix -avatars are a two-step `POST /media/r0/upload` → `PUT -/profile//avatar_url` dance, both authenticated by the -`access_token` written by `hive-c0re::matrix::ensure_user_for` to -`/matrix-token`. +The agent icon (`hyperhive.icon`, an SVG) is published as each matrix +account's profile avatar by `hive-matrix-daemon` itself +(`hive-matrix-mcp::client::sync_avatar`), not a separate oneshot. After +the daemon builds + restores an account's `Client` (authenticated, +pointed at that account's resolved homeserver), it calls matrix-sdk's +`account().upload_avatar()` — one call that uploads the media and sets +`avatar_url`. Because it reuses the live Client, there is no hardcoded +homeserver URL, no token re-read, and no token-file globbing: the daemon +already iterates every configured + dashboard-discovered account in its +bring-up loop, so the avatar is set for **every** account. -The oneshot syncs the avatar for **every** account, not just the -hive-internal one: a `sync_one` shell function runs the upload + set -dance for the bare `matrix-token` (`main`, against `localhost:8008`) -and then for each dashboard-provisioned `matrix-token-`, reading -that account's homeserver from its `matrix-account-.json` sidecar -(mirrors `accounts::discover_token_accounts`). Idempotency is -**per-account**: a `matrix-avatar-icon-hash[-]` file each, because -the `mxc://` URI is homeserver-scoped so every account uploads to its own -homeserver independently. +Nix rasterizes the SVG to a 512x512 PNG at build time (`iconPng`, via +librsvg) and forwards its store path as `HIVE_ICON_PNG` on the daemon +unit, gated on `hyperhive.icon != null`. No icon configured → the env is +unset → `sync_avatar` returns early and no avatar is set. -Triggered by EITHER boot (`wantedBy = multi-user.target`) OR the -sibling `matrix-avatar-sync.path` firing on token appearance — its glob -covers both `matrix-token` and `matrix-token-*`, so an account logged in -after boot gets its avatar without a restart. Both paths re-run the -oneshot idempotently — running the avatar set twice is harmless. - -Critically: **`RemainAfterExit = false`** (not the more common -`true` for oneshots). systemd treats `RemainAfterExit = true` -oneshots as "still running" after the first exit, so the second -trigger from the `.path` watcher becomes a no-op. Setting it to -`false` lets re-fires actually re-execute. The trade-off is the -service unit shows `inactive (dead)` between fires — visible in -`journalctl` but harmless; the `.path` unit drives the lifecycle. +Idempotency is **per-account**: an `avatar-icon-hash` file in each +account's matrix-sdk `state_dir`. The daemon hashes the PNG bytes and +skips the upload when unchanged, because every upload mints a fresh +`mxc://` URI that emits a profile state event in every joined room — +re-uploading identical bytes is timeline spam. A dashboard-provisioned +account gets its avatar when the `systemd.paths.hive-matrix-daemon` token +watcher restarts the daemon (which re-runs the per-account bring-up), so +no separate avatar trigger is needed. Avatar failures are swallowed +(logged, non-fatal) so they never break account bring-up or sync. diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 797782eb..942762b1 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -317,9 +317,9 @@ hyperhive.icon = ./icon.svg; # default: null (falls back to shared hyperhive lo Path to an SVG file used as this agent's visual identity — shown in the per-agent page header, as the page favicon, and uploaded to the agent's Forgejo profile avatar (via the `forge-avatar-sync` boot -unit) and Matrix profile avatar (via `matrix-avatar-sync`). Commit -the SVG next to `agent.nix` in the config repo and reference it as a -relative path. +unit) and Matrix profile avatar (set by `hive-matrix-daemon` over its +live Client). Commit the SVG next to `agent.nix` in the config repo +and reference it as a relative path. When `null` (the default), the agent falls back to the shared hyperhive branding mark. The harness serves whichever icon is active diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 8977f160..22753764 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -1011,7 +1011,7 @@ where # `environment.variables` only writes /etc/environment (login # shells); `systemd.globalEnvironment` is the analogue for # systemd units so tea-login / forge-avatar-sync / - # matrix-avatar-sync etc. can read `$HYPERHIVE_STATE_DIR` + # hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR` # without each service having to redeclare it. environment.variables = { HIVE_LABEL = name; diff --git a/hive-matrix-mcp/src/client.rs b/hive-matrix-mcp/src/client.rs index 56891bba..9230158e 100644 --- a/hive-matrix-mcp/src/client.rs +++ b/hive-matrix-mcp/src/client.rs @@ -184,3 +184,57 @@ async fn whoami(homeserver: &str, token: &str) -> Result<(OwnedUserId, OwnedDevi let device_id: OwnedDeviceId = device_id_raw.into(); Ok((user_id, device_id)) } + +/// Best-effort: set this account's matrix avatar from the rasterized icon +/// PNG. The path comes from `HIVE_ICON_PNG` (a nix-built derivation the +/// harness forwards into the daemon env; absent when no icon resolves). +/// Idempotent via a per-account `avatar-icon-hash` file in the account's +/// sdk `state_dir` — a re-upload only happens when the icon bytes change, +/// so daemon restarts don't re-spam the homeserver. +/// +/// This replaces the old `matrix-avatar-sync` systemd curl oneshot: the +/// daemon already holds an authenticated `Client` pointed at the correct +/// homeserver, so it uploads over the live connection — no hardcoded URL, +/// no token re-read, no token-file globbing. Any failure is logged and +/// swallowed: avatar trouble must never break account bring-up or sync. +pub async fn sync_avatar(client: &Client, state_dir: &Path, account: &str) { + let Ok(png_path) = std::env::var("HIVE_ICON_PNG") else { + return; // no icon forwarded → nothing to sync + }; + let bytes = match fs::read(&png_path).await { + Ok(b) => b, + Err(e) => { + tracing::warn!(account, path = %png_path, error = %e, "matrix avatar: icon PNG unreadable; skipping"); + return; + } + }; + // A non-crypto content hash is enough to answer "did the icon change?". + let hash = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + format!("{:016x}", h.finish()) + }; + let hash_file = state_dir.join("avatar-icon-hash"); + if let Ok(prev) = fs::read_to_string(&hash_file).await + && prev.trim() == hash + { + tracing::debug!(account, "matrix avatar: icon unchanged; skipping upload"); + return; + } + match client + .account() + .upload_avatar(&mime::IMAGE_PNG, bytes) + .await + { + Ok(mxc) => { + tracing::info!(account, mxc = %mxc, "matrix avatar set"); + if let Err(e) = fs::write(&hash_file, &hash).await { + tracing::warn!(account, error = %e, "matrix avatar: set ok but hash write failed (re-uploads next start)"); + } + } + Err(e) => { + tracing::warn!(account, error = %format!("{e:#}"), "matrix avatar: upload failed; skipping (non-fatal)"); + } + } +} diff --git a/hive-matrix-mcp/src/main.rs b/hive-matrix-mcp/src/main.rs index 5d8d2e0e..57043435 100644 --- a/hive-matrix-mcp/src/main.rs +++ b/hive-matrix-mcp/src/main.rs @@ -196,6 +196,10 @@ async fn bring_up_account( client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary) .await .with_context(|| format!("build matrix client for account {}", cfg.name))?; + // Best-effort: sync the agent icon to this account's matrix avatar over + // the live (authenticated, correct-homeserver) Client. Replaces the old + // curl oneshot; failures are swallowed inside sync_avatar. + client::sync_avatar(&client, &cfg.state_dir, &cfg.name).await; timeline::install_message_handler(&client, hyperhive_socket.to_path_buf(), tag.clone()); let sync_client = client.clone(); diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 46f585d7..1da1bf1a 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -61,6 +61,15 @@ let # cannot drift). Matches the daemon's own built-in default # (`paths::DEFAULT_HOMESERVER`). matrixUrlDefault = "http://localhost:8008"; + # Rasterize the operator-set agent icon (`hyperhive.icon`, an SVG) to a + # 512x512 PNG so the matrix daemon can upload it as each account's avatar + # over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar). + # Replaces the old `matrix-avatar-sync` curl oneshot. Only forced when an + # icon is configured — the `HIVE_ICON_PNG` daemon-env entry is gated on + # `hyperhive.icon != null`, so this binding stays lazy when no icon is set. + iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } '' + rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out + ''; in { # Shared scaffolding for every hyperhive harness container. @@ -406,7 +415,7 @@ in daemon auto-skips when `/matrix-token` is missing, and a `systemd.paths` watcher restarts it the moment hive-c0re provisions the token (same path-trigger shape - as `matrix-avatar-sync`). + as `forge-avatar-sync`). - exposes the matrix tool surface (send_message, send_dm, send_reaction, send_reply, mark_read, list_rooms, list_room_members, read_room) to claude via an auto-injected @@ -1433,8 +1442,8 @@ in }; # Path-trigger sibling: re-fires forge-avatar-sync the moment - # `/forge-token` appears. Mirrors the matrix-avatar-sync - # pattern — on first agent deployment the container boots before + # `/forge-token` appears. Mirrors the hive-matrix-daemon + # token-watcher pattern — on first agent deployment the container boots before # hive-c0re has provisioned the forge-token, so the service fires # too early and exits with "no forge-token found". Without this path # unit, RemainAfterExit=true would prevent systemd from ever @@ -1553,6 +1562,12 @@ in // lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; } ) config.hyperhive.matrixAccounts ); + } + # Rasterized agent icon path for the daemon's avatar sync. Only set + # when an icon is configured; absent → the daemon skips avatar setting + # (hive-matrix-mcp::client::sync_avatar returns early on unset env). + // lib.optionalAttrs (config.hyperhive.icon != null) { + HIVE_ICON_PNG = "${iconPng}"; }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; @@ -1642,166 +1657,6 @@ in pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*"; }; - # Path-trigger sibling: re-fires matrix-avatar-sync the moment a - # token appears - both the hive-internal `matrix-token` and any - # dashboard-provisioned extra `matrix-token-` (so an external - # account logged in after boot gets its avatar without a restart). - # Same first-boot-ordering pattern as hive-matrix-daemon above. - systemd.paths.matrix-avatar-sync = { - description = "trigger matrix-avatar-sync when a matrix token appears"; - wantedBy = [ "multi-user.target" ]; - # Single `matrix-token*` glob (same as hive-matrix-daemon above) so - # both the hive-internal `matrix-token` and any dashboard-provisioned - # `matrix-token-` re-fire the sync. A list value here is - # unverified for `systemd.paths.*.pathConfig`, and the wildcard - # covers both cases anyway. - pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*"; - }; - - # One-shot: hyperhive.icon → matrix profile avatar (two-step media - # upload + set avatar_url). Shape contract: - # docs/conventions.md::Best-effort oneshot services. Protocol + - # why RemainAfterExit = false: - # docs/persistence.md::matrix-avatar-sync. - systemd.services.matrix-avatar-sync = { - description = "sync agent icon to matrix profile avatar (best-effort)"; - wantedBy = [ "multi-user.target" ]; - # No `after = [ "tea-login.service" ]` — matrix has no - # equivalent prerequisite; we just need the homeserver up. - serviceConfig = { - Type = "oneshot"; - # RemainAfterExit = false so the .path trigger can re-fire - # the unit (see docs/persistence.md::matrix-avatar-sync). - RemainAfterExit = false; - # Pin the journal identity (else it's the `script` store-path wrapper). - SyslogIdentifier = "matrix-avatar-sync"; - }; - path = [ - pkgs.curl - pkgs.coreutils - pkgs.jq - pkgs.librsvg - ]; - script = '' - ICON=/etc/hyperhive/icon.svg - if [ ! -f "$ICON" ]; then - echo "matrix-avatar-sync: no icon configured; skipping" - exit 0 - fi - # Hash-based idempotency: skip the upload if the icon hasn't - # changed since the last successful sync. Every upload mints a - # new mxc:// URI which triggers a profile state event in every - # joined room — uploading the same bytes again produces timeline - # spam without changing the visible avatar. Hash files live in - # $HYPERHIVE_STATE_DIR (survives restart, wiped on purge so - # purge + re-provision gets a fresh upload). One hash file per - # account (the mxc:// URI is homeserver-scoped, so each account - # uploads to its own homeserver independently). Delete to force - # a re-upload. - CURRENT_HASH=$(sha256sum "$ICON" | cut -d' ' -f1) - - # Rasterize SVG → PNG ONCE; the same bytes are reused for every - # account (matrix media accepts any image type but we already - # standardise on PNG for the forge sync). - PNG=$(mktemp --suffix=.png) - if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then - echo "matrix-avatar-sync: rsvg-convert failed; skipping" - rm -f "$PNG" - exit 0 - fi - - # sync_one