diff --git a/flake.nix b/flake.nix index 5309e657..8aaa2491 100644 --- a/flake.nix +++ b/flake.nix @@ -68,16 +68,16 @@ }; # Shared between buildDepsOnly + buildPackage + cargoClippy # so the three derivations see the same toolchain shape. - # librsvg: hive-c0re/build.rs invokes `rsvg-convert` to - # render branding/agent-configs.svg → PNG that the daemon - # `include_bytes!`s (#424); SVG stays source-of-truth. # git: naersk used to auto-include it; crane is more # minimal, so we add it explicitly so hive-c0re's # `lifecycle::tests::setup_proposed_*` (which shell out to # `git init` + commit) pass under `cargo test` in the # sandbox. + # `librsvg` used to live here for `hive-c0re/build.rs`'s + # rsvg-convert call — that whole codepath moved into the + # `hyperhive-assets` derivation in #555, so the rust + # derivation no longer needs the dependency. nativeBuildInputs = [ - pkgs.librsvg pkgs.git ]; } @@ -181,6 +181,7 @@ hive-c0re = import ./nix/modules/hive-c0re.nix { hyperhivePackage = system: self.packages.${system}.default; hyperhiveFrontend = system: self.packages.${system}.frontend; + hyperhiveAssets = system: self.packages.${system}.assets; hyperhiveFlake = "${self}"; # Per-container toplevels — wired into `system.extraDependencies` # when `services.hive-c0re.preBuildAgentTemplates` is on so the diff --git a/hive-ag3nt/src/prompt.rs b/hive-ag3nt/src/prompt.rs index c82b343f..b737a92f 100644 --- a/hive-ag3nt/src/prompt.rs +++ b/hive-ag3nt/src/prompt.rs @@ -29,22 +29,24 @@ use std::path::{Path, PathBuf}; -use anyhow::Result; +use anyhow::{Context, Result}; use crate::mcp::Flavor; -const TEMPLATE: &str = include_str!("../prompts/system.md"); - /// Assemble the system prompt for a given flavor + label + pronouns. /// Pure function — no I/O. Splits out from [`write_system_prompt`] so /// the marker logic + substitution is unit-testable in isolation. +/// The caller supplies the template body so tests can pass an inline +/// fixture and production reads it once at harness startup via +/// [`hive_sh4re::assets::prompt_template`] (`$HIVE_ASSETS_DIR/prompts/ +/// system.md`). #[must_use] -pub fn render(flavor: Flavor, label: &str, operator_pronouns: &str) -> String { +pub fn render(template: &str, flavor: Flavor, label: &str, operator_pronouns: &str) -> String { let target = match flavor { Flavor::Agent => "agent", Flavor::Manager => "manager", }; - let body = filter_role_blocks(TEMPLATE, target); + let body = filter_role_blocks(template, target); body.replace("{label}", label) .replace("{operator_pronouns}", operator_pronouns) } @@ -123,7 +125,11 @@ pub async fn write_system_prompt( let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); tokio::fs::create_dir_all(parent).await.ok(); let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned()); - let body = render(flavor, label, &pronouns); + let template_path = hive_sh4re::assets::prompt_template(); + let template = tokio::fs::read_to_string(&template_path) + .await + .with_context(|| format!("read claude system prompt template from {}", template_path.display()))?; + let body = render(&template, flavor, label, &pronouns); let path = parent.join("claude-system-prompt.md"); tokio::fs::write(&path, body).await?; tracing::info!(path = %path.display(), "wrote claude system prompt"); @@ -134,6 +140,18 @@ pub async fn write_system_prompt( mod tests { use super::*; + // #555: production reads the system-prompt template from + // `$HIVE_ASSETS_DIR/prompts/system.md` at startup. The test module + // still `include_str!`s it directly because: + // 1. the "real template still substitutes / still filters" tests + // below need the actual production wording to be honest; + // 2. embedding it at compile time keeps `cargo test --workspace` + // runnable without setting `HIVE_ASSETS_DIR`; + // 3. this is the ONLY remaining compile-time reference to + // `prompts/system.md` from the rust workspace — production + // code loads it at runtime. + const PRODUCTION_TEMPLATE: &str = include_str!("../prompts/system.md"); + const SAMPLE: &str = "\ shared opener @@ -233,7 +251,7 @@ shared closer // Real template's first agent line — keeps the renderer // honest about the {label} / {operator_pronouns} pair the // harness already relied on. - let rendered = render(Flavor::Agent, "alice", "they/them"); + let rendered = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "they/them"); assert!(rendered.contains("hyperhive agent `alice`")); assert!(rendered.contains("**they/them** pronouns")); assert!(!rendered.contains("{label}")); @@ -246,7 +264,7 @@ shared closer // kill, schedule_*) MUST NOT appear in the agent's rendered // prompt. Drift between flavor and tool surface bites every // time it happens (cf. #511 missing-allow-list bug). - let rendered = render(Flavor::Agent, "alice", "she/her"); + let rendered = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her"); assert!(!rendered.contains("request_init_config")); assert!(!rendered.contains("request_apply_commit")); assert!(!rendered.contains("get_logs")); @@ -257,7 +275,7 @@ shared closer #[test] fn render_manager_includes_manager_only_tools() { - let rendered = render(Flavor::Manager, "hm1nd", "she/her"); + let rendered = render(PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); assert!(rendered.contains("request_init_config")); assert!(rendered.contains("request_apply_commit")); assert!(rendered.contains("get_logs")); @@ -269,9 +287,9 @@ shared closer #[test] fn render_uses_correct_role_opener() { - let agent = render(Flavor::Agent, "alice", "she/her"); + let agent = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her"); assert!(agent.starts_with("You are hyperhive agent")); - let manager = render(Flavor::Manager, "hm1nd", "she/her"); + let manager = render(PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); assert!(manager.starts_with("You are the hyperhive manager")); } } diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 28f56205..d6b88d70 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; @@ -18,15 +18,14 @@ use crate::events::{Bus, LiveEvent}; use crate::login::LoginState; use crate::mcp; -/// `--settings` JSON applied to every claude invocation. Lives as a -/// properly-formatted file in `prompts/claude-settings.json` so it's easy -/// to read and edit; we ship it via `include_str!`. We turn off claude's -/// in-session auto-compaction and its cross-session auto-memory because -/// hyperhive owns those concerns (`/compact` on overflow, notes -/// persistence under `/state`). Unknown keys are silently ignored by -/// claude-code; if a key gets renamed we'll spot it because the -/// corresponding behavior will start firing mid-turn again. -const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json"); +// `--settings` JSON is read at runtime from +// `$HIVE_ASSETS_DIR/prompts/claude-settings.json` via +// `hive_sh4re::assets::claude_settings()` (#555). We turn off claude's +// in-session auto-compaction and its cross-session auto-memory because +// hyperhive owns those concerns (`/compact` on overflow, notes +// persistence under `/state`). Unknown keys are silently ignored by +// claude-code; if a key gets renamed we'll spot it because the +// corresponding behavior will start firing mid-turn again. /// Regex-ish marker claude-code emits when context overflows. Same string /// bitburner-agent watches for. Empirically reliable across claude-code @@ -159,7 +158,14 @@ pub async fn write_settings(socket: &Path) -> Result { let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); tokio::fs::create_dir_all(parent).await.ok(); let path = parent.join("claude-settings.json"); - tokio::fs::write(&path, CLAUDE_SETTINGS).await?; + // #555: source-of-truth is `$HIVE_ASSETS_DIR/prompts/claude-settings.json`; + // copy through the per-agent runtime dir so claude reads it from the + // same socket-adjacent location every time and so a future override + // (per-agent settings JSON layer) drops in cleanly. + let src = hive_sh4re::assets::claude_settings(); + tokio::fs::copy(&src, &path) + .await + .with_context(|| format!("copy claude settings from {} to {}", src.display(), path.display()))?; tracing::info!(path = %path.display(), "wrote claude settings"); Ok(path) } @@ -508,10 +514,10 @@ pub async fn wait_for_login( /// regular files + newest `mtime` across them. The two axes are both /// load-bearing for `wait_for_login`'s refresh check (`session_refreshed`): /// mtime catches the common case (re-login overwrites an existing -/// credentials file in-place), file_count catches the pathological case +/// credentials file in-place), `file_count` catches the pathological case /// where `meta.modified()` errors on every file (exotic fs, NFS quirks) /// so the mtime axis stays `None` forever but new files still trigger a -/// resume. Defaults to `{0, None}` on read_dir failure (missing or +/// resume. Defaults to `{0, None}` on `read_dir` failure (missing or /// unreadable dir) — `wait_for_login` then resumes when files first /// appear. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -542,9 +548,9 @@ fn snapshot_dir(dir: &Path) -> DirSnapshot { /// Has the credentials dir been written since `prev`? Used as the /// exit condition for `wait_for_login`: /// -/// - file_count changed → something was added or removed, treat as +/// - `file_count` changed → something was added or removed, treat as /// refresh (covers the "all files have unreadable mtime" edge case). -/// - newest_mtime advanced → existing file was rewritten in place +/// - `newest_mtime` advanced → existing file was rewritten in place /// (the common claude re-login path). /// - prev had no mtime (empty or all-unreadable) and now has one → /// first useful signal we've seen, treat as refresh. diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index d53c5637..e44124f3 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -221,9 +221,15 @@ fn try_bind(addr: SocketAddr) -> std::io::Result { /// Always returns an image, so consumers (dashboard, favicon) can hit /// `/icon` unconditionally without probing whether one is configured. async fn serve_icon() -> impl IntoResponse { - const DEFAULT_ICON: &str = include_str!("../../branding/hyperhive.svg"); - let body = std::fs::read_to_string("/etc/hyperhive/icon.svg") - .unwrap_or_else(|_| DEFAULT_ICON.to_string()); + // Per-agent icon overrides go through `/etc/hyperhive/icon.svg` (set + // via the `hyperhive.icon` agent.nix option); the bundled default is + // resolved at runtime from + // `$HIVE_ASSETS_DIR/branding/hyperhive.svg` (#555). If neither file + // can be read we serve an empty body — keeps the response a valid + // SVG content-type without a panic on a misconfigured container. + let body = std::fs::read_to_string("/etc/hyperhive/icon.svg").unwrap_or_else(|_| { + std::fs::read_to_string(hive_sh4re::assets::branding_svg()).unwrap_or_default() + }); ([("content-type", "image/svg+xml")], body) } diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index a97e91c9..308d0c42 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -2,13 +2,6 @@ name = "hive-c0re" edition.workspace = true version.workspace = true -# Render branding/agent-configs.svg → $OUT_DIR/agent-configs.png at -# compile time (#424). build.rs shells out to `rsvg-convert` -# (librsvg, pulled in via flake.nix' crane nativeBuildInputs); the -# baked PNG is included via include_bytes! from forge.rs so no -# raster gets checked into git. -build = "build.rs" - [lints] workspace = true diff --git a/hive-c0re/build.rs b/hive-c0re/build.rs deleted file mode 100644 index c8bf3fc4..00000000 --- a/hive-c0re/build.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Render `branding/agent-configs.svg` → `$OUT_DIR/agent-configs.png` -//! at compile time so the daemon can `include_bytes!` the PNG without -//! checking the raster into git (#424 mara: "generate png on the fly -//! or in build"). The SVG is the source of truth; the PNG is a build -//! artifact. -//! -//! Uses `rsvg-convert` from PATH (librsvg, already available in -//! nixpkgs and added to the crane derivation's `nativeBuildInputs` -//! in `flake.nix`). For dev builds outside Nix, install librsvg via -//! your system package manager (Debian/Ubuntu: `librsvg2-bin`, -//! macOS: `brew install librsvg`). - -use std::env; -use std::path::PathBuf; -use std::process::Command; - -const SVG_PATH: &str = "../branding/agent-configs.svg"; -const PNG_NAME: &str = "agent-configs.png"; -// 300×300 to match the existing branding/hyperhive.png, which the -// Forgejo avatar endpoint accepts without resizing on upload. -const PX: &str = "300"; - -fn main() { - // Re-run the build script when either the SVG itself or this - // script change. We deliberately don't watch every file in - // `branding/` — only the one PNG we generate. - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed={SVG_PATH}"); - - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set by cargo")); - let png_path = out_dir.join(PNG_NAME); - - let status = Command::new("rsvg-convert") - .args(["--width", PX, "--height", PX, "-o"]) - .arg(&png_path) - .arg(SVG_PATH) - .status(); - - match status { - Ok(s) if s.success() => {} - Ok(s) => panic!("rsvg-convert exited with {s} rendering {SVG_PATH}"), - Err(e) => panic!( - "failed to invoke rsvg-convert: {e}\n\ - install librsvg (Debian/Ubuntu: librsvg2-bin, macOS: brew install librsvg, \ - NixOS: pkgs.librsvg). The Nix derivation already pulls it in via \ - flake.nix → craneLib.buildPackage.nativeBuildInputs.", - ), - } -} diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 7eb33c10..96760b87 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -41,20 +41,14 @@ const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set"; /// Sibling marker for the `agent-configs` org avatar (#424). Same one- /// shot semantics — delete to force the upload to re-run. const CONFIG_ORG_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-agent-configs-avatar-set"; -/// Hyperhive logo bytes, baked into the daemon. Uploaded once via the -/// admin avatar API so the `core` Forgejo user shows the project mark -/// next to commits in `agent-configs/*`, `core/meta`, etc. instead of -/// the default hash identicon. -const CORE_AVATAR_PNG: &[u8] = include_bytes!("../../branding/hyperhive.png"); -/// `agent-configs` org logo bytes (#424). Sibling visual to the main -/// hyperhive mark — same dark base + outer ring + corner brackets, -/// with a stacked-config-files glyph in the centre so the operator -/// can distinguish the agent-configs namespace from the main -/// `hyperhive` org at a glance. Source-of-truth is -/// `branding/agent-configs.svg`; `hive-c0re/build.rs` renders it -/// into `$OUT_DIR/agent-configs.png` at compile time via -/// `rsvg-convert` so the raster never gets checked into git. -const CONFIG_ORG_AVATAR_PNG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/agent-configs.png")); +// Avatar PNGs are loaded at runtime from +// `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the +// helpers in `hive_sh4re::assets` (#555 — was `include_bytes!` of an +// in-source path and an OUT_DIR-rendered sibling, both of which +// invalidated the crane src cache on any branding edit). The +// `agent-configs.png` is rendered from its SVG during the +// `hyperhive-assets` derivation's build (was `hive-c0re/build.rs` +// + `rsvg-convert` on PATH; both gone now). /// Forgejo org grouping every agent's applied config repo. Core is a /// site admin and reads + writes every repo here; agents are NOT /// members and the repos are private, so no agent — not even the one @@ -298,9 +292,13 @@ async fn ensure_core_avatar(token: &str) -> Result<()> { if marker.exists() { return Ok(()); } + let png_path = hive_sh4re::assets::core_avatar_png(); + let png_bytes = tokio::fs::read(&png_path) + .await + .with_context(|| format!("read core avatar PNG from {}", png_path.display()))?; let body = format!( r#"{{"image":"{}"}}"#, - base64::engine::general_purpose::STANDARD.encode(CORE_AVATAR_PNG), + base64::engine::general_purpose::STANDARD.encode(&png_bytes), ); let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar"); let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; @@ -325,9 +323,13 @@ async fn ensure_config_org_avatar(token: &str) -> Result<()> { if marker.exists() { return Ok(()); } + let png_path = hive_sh4re::assets::config_org_avatar_png(); + let png_bytes = tokio::fs::read(&png_path) + .await + .with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?; let body = format!( r#"{{"image":"{}"}}"#, - base64::engine::general_purpose::STANDARD.encode(CONFIG_ORG_AVATAR_PNG), + base64::engine::general_purpose::STANDARD.encode(&png_bytes), ); let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar"); let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; diff --git a/hive-sh4re/src/assets.rs b/hive-sh4re/src/assets.rs new file mode 100644 index 00000000..b5fcc0e1 --- /dev/null +++ b/hive-sh4re/src/assets.rs @@ -0,0 +1,99 @@ +//! Resolve the on-disk path to hyperhive's static assets (branding + +//! claude prompts). Single source of truth for both the host daemon +//! (`hive-c0re`) and the in-container harness (`hive-ag3nt` / +//! `hive-m1nd`) so they agree on the lookup contract. +//! +//! At runtime, the path is read from `$HIVE_ASSETS_DIR`. In nix +//! builds that env var is set by the `hive-c0re` / `harness-base` modules +//! to `${pkgs.hyperhive-assets}/share/hyperhive` (see `nix/assets.nix`). +//! For `cargo run` outside nix, set it yourself: +//! +//! ```sh +//! HIVE_ASSETS_DIR=$(pwd)/dev-assets cargo run ... +//! ``` +//! +//! where `dev-assets/` is laid out the same as the nix output: +//! +//! ```text +//! dev-assets/ +//! branding/ +//! hyperhive.{svg,png} +//! agent-configs.{svg,png} +//! prompts/ +//! system.md +//! claude-settings.json +//! ``` +//! +//! A convenience `cargo xtask seed-dev-assets` could materialise this +//! by copying `branding/` + `hive-ag3nt/prompts/` + rendering +//! `agent-configs.png` via rsvg-convert, but it's intentionally not +//! shipped yet — dev setups vary too much for one helper to fit. +//! +//! Each crate that wants a specific asset goes through one of the +//! typed helpers (`branding_svg()`, `prompt_template()`, …) so the +//! lookup contract is centralised. Missing files panic at first +//! call with a clear "set `HIVE_ASSETS_DIR` + put the file at …" +//! message — same failure shape as the old `include_*!` macros +//! (which would have failed at compile time, not runtime, but the +//! diagnostic value is identical). + +use std::path::PathBuf; + +/// Read `$HIVE_ASSETS_DIR`. Panics with a clear remediation message +/// when unset — every code path that calls into this module is one +/// the binary cannot run without, so a hard early failure is the +/// right shape. +fn dir() -> PathBuf { + match std::env::var("HIVE_ASSETS_DIR") { + Ok(v) if !v.is_empty() => PathBuf::from(v), + _ => panic!( + "HIVE_ASSETS_DIR is not set. Inside the nix-built systemd \ + units this is wired automatically from \ + `pkgs.hyperhive-assets`; for `cargo run` outside nix, \ + set it to a directory laid out like \ + `nix/assets.nix`'s output (branding/ + prompts/ \ + subdirs). See hive-sh4re/src/assets.rs for the contract.", + ), + } +} + +/// `$HIVE_ASSETS_DIR/branding/hyperhive.svg` — the project's primary +/// mark. Loaded by the per-agent web UI as its default icon +/// (`/agents//icon.svg` falls through here when the agent didn't +/// override `hyperhive.icon` in its `agent.nix`). +#[must_use] +pub fn branding_svg() -> PathBuf { + dir().join("branding/hyperhive.svg") +} + +/// `$HIVE_ASSETS_DIR/branding/hyperhive.png` — the same mark +/// rasterised, used by `hive-c0re::forge::push_avatar` to upload +/// the forge org avatar. +#[must_use] +pub fn core_avatar_png() -> PathBuf { + dir().join("branding/hyperhive.png") +} + +/// `$HIVE_ASSETS_DIR/branding/agent-configs.png` — secondary org +/// mark for the `agent-configs/` mirror org. Rendered from +/// `agent-configs.svg` at asset-build time (was rendered in +/// `hive-c0re/build.rs` before #555). +#[must_use] +pub fn config_org_avatar_png() -> PathBuf { + dir().join("branding/agent-configs.png") +} + +/// `$HIVE_ASSETS_DIR/prompts/system.md` — the claude system prompt +/// template. `hive-ag3nt::prompt::render` filters the role markers +/// inside it per agent / manager flavor. +#[must_use] +pub fn prompt_template() -> PathBuf { + dir().join("prompts/system.md") +} + +/// `$HIVE_ASSETS_DIR/prompts/claude-settings.json` — the static +/// `--settings` JSON every claude invocation reads. +#[must_use] +pub fn claude_settings() -> PathBuf { + dir().join("prompts/claude-settings.json") +} diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index c69ad976..000edcc9 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; +pub mod assets; + // ----------------------------------------------------------------------------- // Host admin socket — /run/hyperhive/host.sock // ----------------------------------------------------------------------------- diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 276f7983..20e2e1bd 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -1,6 +1,7 @@ { hyperhivePackage, hyperhiveFrontend, + hyperhiveAssets, hyperhiveFlake, agentBaseToplevel, managerToplevel, @@ -66,6 +67,20 @@ in endpoints) is the source of truth for any replacement. ''; }; + assets = lib.mkOption { + type = lib.types.package; + default = hyperhiveAssets pkgs.stdenv.hostPlatform.system; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets"; + description = '' + Bundled static runtime assets (see `./nix/assets.nix`): the + project's branding family + the claude system-prompt template + + claude-settings JSON. Output has `share/hyperhive/{branding,prompts}/`; + passed to hive-c0re's systemd unit via `HIVE_ASSETS_DIR` + (`hive_sh4re::assets::*` resolve paths underneath). Override to + ship customised branding or prompts without rebuilding the + rust derivation. + ''; + }; hyperhiveFlake = lib.mkOption { type = lib.types.str; default = hyperhiveFlake; @@ -196,6 +211,10 @@ in # serves this via `tower_http::ServeDir` for any path it doesn't # match against an API/action route. HIVE_STATIC_DIR = "${cfg.frontend}/dashboard"; + # Path to the static runtime asset tree (branding + claude + # prompts). `hive_sh4re::assets::*` reads paths underneath. + # `forge.rs` reads the avatar PNGs from here on startup. + HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive"; } // lib.optionalAttrs config.hyperhive.forge.enable { # Agents poll this URL for Forgejo notifications. Derived from diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 1ae8c3fa..f3c718d5 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -531,10 +531,15 @@ # HIVE_DEFAULT_MODEL seeds the initial model selection when no persisted # model choice exists in the state dir. SHELL must be set so claude's # Bash tool finds a POSIX shell. + # HIVE_ASSETS_DIR points at the project's static runtime assets + # (branding + claude prompts; see `nix/assets.nix`). Set here so + # both the harness binary and any user-shell `cargo run` inside the + # container resolve them from the same path. # HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the # host-level `services.hive-c0re.contextWindowTokens` option — not set here. environment.variables = { HIVE_DEFAULT_MODEL = config.hyperhive.model; + HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; SHELL = "${pkgs.bashInteractive}/bin/bash"; } // lib.optionalAttrs (!config.hyperhive.autoCompact) { # Zero watermark disables proactive compaction; the reactive path