diff --git a/CLAUDE.md b/CLAUDE.md index ef10b1f6..1a42edb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,28 +159,16 @@ docs/damocles-migration.md options for moving damocles onto hyperhive ## Auto-update on startup `hive-c0re serve` runs `auto_update::run` in a background task right after -opening the coordinator. It enumerates managed containers and rebuilds any -whose recorded hyperhive rev differs from the current one: - -- **Sub-agents** rebuild via `lifecycle::rebuild` (regenerates - `applied//flake.nix`, sets nspawn flags, `nixos-container update --flake`). -- **Manager** runs `nixos-container update hm1nd` (no `--flake`). The - manager's config lives in the host's NixOS module; this is belt-and-braces - on top of NixOS's own container activation. Idempotent when nothing has - actually changed. - -"Rev" = canonical filesystem path of `cfg.hyperhiveFlake` (so `/etc/hyperhive` -resolving to a new `/nix/store/...-source` triggers a rebuild). Marker file: +opening the coordinator. It enumerates sub-agent containers (manager +excluded — its config comes from the host's NixOS module) and rebuilds any +whose recorded hyperhive rev differs from the current one. Rev = canonical +filesystem path of `cfg.hyperhiveFlake` (so `/etc/hyperhive` resolving to a +new `/nix/store/...-source` triggers a rebuild). Marker file: `/var/lib/hyperhive/applied/..hyperhive-rev`. If the flake input has no canonical path (e.g. a `github:` URL), auto-update is a no-op — rebuild manually. The task is async and never blocks the admin socket; failures are logged and don't take the daemon down. -The dashboard surfaces pending updates per agent: a clickable "needs update -↻" badge appears whenever the marker differs from current rev. The badge -POSTs `/rebuild/`, calling the same `auto_update::rebuild_agent` / -`rebuild_manager` path so manual triggers and the startup scan can't drift. - ## Build / deploy / test ```sh @@ -271,7 +259,7 @@ See PLAN.md → "Phase 8" for the full design. Summary: login` badge in the container list. "Valid session" today is a heuristic (any regular file inside `/root/.claude/`); we may refine once the filename layout claude writes is locked in. -- **Login from the per-agent web UI.** Spawn `claude auth login` with plain +- **Login from the per-agent web UI.** Spawn `claude /login` with plain stdio pipes (no PTY initially), surface the OAuth URL from stdout on the page, accept the resulting code via a paste field, write it to the process stdin. Once `~/.claude/` populates, the existing needs-login polling loop diff --git a/PLAN.md b/PLAN.md index e0f8002e..8aecbbb8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -263,7 +263,7 @@ knows where to click. **Login over the per-agent web UI.** No more `nixos-container root-login` for the common case. The agent's web UI exposes a "log in" action that: -1. Spawns `claude auth login` (or equivalent) inside the container with plain +1. Spawns `claude /login` (or equivalent) inside the container with plain stdio pipes — no PTY unless we discover we need one. 2. Reads the OAuth URL from the process stdout and shows it on the page. 3. Provides a paste field for the resulting code; writes it to the process diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 0dd9d5c6..17d76fa6 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -71,7 +71,7 @@ async fn main() -> Result<()> { // stays bound) but don't drive the turn loop. Poll the // claude dir periodically so a successful login (whether // from the dashboard PTY path in step 4 or via - // `root-login` + `claude auth login` in the meantime) + // `root-login` + `claude /login` in the meantime) // transitions us into the turn loop without a restart. needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms).await } diff --git a/hive-ag3nt/src/login.rs b/hive-ag3nt/src/login.rs index 208045b0..6f8ae699 100644 --- a/hive-ag3nt/src/login.rs +++ b/hive-ag3nt/src/login.rs @@ -3,7 +3,7 @@ //! destroy/recreate so OAuth tokens survive. //! //! "Has session" today means "the dir contains at least one regular file." -//! That's a heuristic: a fresh bind-mount starts empty, and `claude auth login` +//! That's a heuristic: a fresh bind-mount starts empty, and `claude /login` //! writes credentials into the dir. We may refine later (probe for the //! specific credentials filename, or run a no-op `claude` call) once the //! exact layout is locked in. diff --git a/hive-ag3nt/src/login_session.rs b/hive-ag3nt/src/login_session.rs index 68aca36c..100b5b1c 100644 --- a/hive-ag3nt/src/login_session.rs +++ b/hive-ag3nt/src/login_session.rs @@ -1,4 +1,4 @@ -//! `claude auth login` driver. Spawns the login command under plain stdio pipes, +//! `claude /login` driver. Spawns the login command under plain stdio pipes, //! accumulates stdout+stderr in a shared buffer (so the web UI can show //! whatever URL/prompt claude emits), and writes paste-back codes from the //! UI into the child's stdin. @@ -16,7 +16,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; const DEFAULT_CMD: &str = "claude"; -const DEFAULT_ARGS: &[&str] = &["auth", "login"]; +const DEFAULT_ARGS: &[&str] = &["/login"]; #[derive(Default)] struct State { @@ -33,7 +33,7 @@ struct State { exit_note: Option, } -/// A running `claude auth login` subprocess. +/// A running `claude /login` subprocess. pub struct LoginSession { child: Mutex, /// Tokio mutex because we hold the guard across the `write_all().await` @@ -46,7 +46,7 @@ pub struct LoginSession { impl LoginSession { /// Spawn the login command. The exact binary/args are configurable via /// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by - /// default we run `claude auth login`. Failing to spawn returns an error + /// default we run `claude /login`. Failing to spawn returns an error /// before any state is registered. pub fn start() -> Result { let (cmd, args) = resolve_command(); @@ -146,7 +146,7 @@ impl LoginSession { fn resolve_command() -> (String, Vec) { if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") { - // Whitespace-only split — no quote handling. Fine for "claude auth login" + // Whitespace-only split — no quote handling. Fine for "claude /login" // style overrides; if we need anything with embedded spaces we'll // switch to shell-words. let mut parts = raw.split_whitespace().map(str::to_owned); diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 689e8ea1..e09adb2b 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -71,7 +71,7 @@ fn render_online() -> String { } fn render_needs_login_idle() -> String { - "

▓█▓▒░ NEEDS L0G1N ▓█▓▒░

\n

No Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.

\n
\n \n
\n

Spawns claude auth login over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.

".into() + "

▓█▓▒░ NEEDS L0G1N ▓█▓▒░

\n

No Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.

\n
\n \n
\n

Spawns claude /login over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.

".into() } fn render_login_in_progress(session: &Arc) -> String { diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index 235004d9..609e064e 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -11,25 +11,22 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use anyhow::{Context, Result, bail}; -use tokio::process::Command; +use anyhow::Result; use crate::coordinator::Coordinator; -use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME}; +use crate::lifecycle::{self, AGENT_PREFIX}; /// Marker file recording the hyperhive rev a sub-agent's container was last /// built against. Sibling of `applied//` (rather than inside it) to -/// keep it out of the applied repo's git history. Uses a leading dot so a -/// glob over `applied/*` doesn't include it. -pub fn rev_marker_path(name: &str) -> PathBuf { +/// keep it out of the applied repo's git history. +fn rev_marker_path(name: &str) -> PathBuf { PathBuf::from(format!("/var/lib/hyperhive/applied/.{name}.hyperhive-rev")) } /// Resolve the current rev of `hyperhive_flake`. For a path on disk we /// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/... /// update yields a different string. For anything else we return None. -#[must_use] -pub fn current_flake_rev(hyperhive_flake: &str) -> Option { +fn current_flake_rev(hyperhive_flake: &str) -> Option { let path = Path::new(hyperhive_flake); if !path.exists() { return None; @@ -39,62 +36,6 @@ pub fn current_flake_rev(hyperhive_flake: &str) -> Option { .map(|p| p.display().to_string()) } -/// Read the marker for `name` and return whether the recorded rev matches -/// `current_rev`. Missing/unreadable marker counts as out-of-date. -#[must_use] -pub fn agent_needs_update(name: &str, current_rev: &str) -> bool { - let prev = std::fs::read_to_string(rev_marker_path(name)) - .ok() - .map(|s| s.trim().to_owned()); - prev.as_deref() != Some(current_rev) -} - -/// Rebuild one sub-agent and refresh its marker. Used by both the startup -/// scanner and the dashboard's manual "update" button so the two paths -/// can't diverge. -pub async fn rebuild_agent(coord: &Arc, name: &str, current_rev: &str) -> Result<()> { - tracing::info!(%name, rev = %current_rev, "rebuild agent"); - let agent_dir = coord - .register_agent(name) - .with_context(|| format!("register_agent {name}"))?; - let applied_dir = Coordinator::agent_applied_dir(name); - let claude_dir = Coordinator::agent_claude_dir(name); - lifecycle::rebuild( - name, - &coord.hyperhive_flake, - &agent_dir, - &applied_dir, - &claude_dir, - ) - .await?; - std::fs::write(rev_marker_path(name), current_rev) - .with_context(|| format!("write rev marker for {name}"))?; - Ok(()) -} - -/// Apply the manager's host-declared config: `nixos-container update hm1nd` -/// (no `--flake`) re-reads `/etc/nixos-containers/hm1nd.conf`, which the -/// host's `nixos-rebuild switch` rewrites to point at the new `SYSTEM_PATH`. -/// Idempotent when nothing has changed. -pub async fn rebuild_manager(current_rev: &str) -> Result<()> { - tracing::info!(rev = %current_rev, "rebuild manager (nixos-container update hm1nd)"); - let out = Command::new("nixos-container") - .args(["update", MANAGER_NAME]) - .output() - .await - .context("invoke nixos-container update hm1nd")?; - if !out.status.success() { - bail!( - "nixos-container update {MANAGER_NAME} failed ({}): {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - std::fs::write(rev_marker_path(MANAGER_NAME), current_rev) - .with_context(|| format!("write rev marker for {MANAGER_NAME}"))?; - Ok(()) -} - /// Rebuild every sub-agent whose marker differs from the current rev. Logs /// per-agent outcomes and continues past failures. Returns Ok even if some /// rebuilds failed — startup shouldn't be blocked by a broken agent. @@ -117,38 +58,55 @@ pub async fn run(coord: Arc) -> Result<()> { }; let mut tasks = Vec::new(); - let mut manager_present = false; for container in containers { - if container == MANAGER_NAME { - manager_present = true; - continue; - } let Some(name) = container.strip_prefix(AGENT_PREFIX) else { continue; }; let name = name.to_owned(); - if !agent_needs_update(&name, ¤t_rev) { + let marker = rev_marker_path(&name); + let prev = std::fs::read_to_string(&marker).ok(); + if prev.as_deref().map(str::trim) == Some(current_rev.as_str()) { tracing::debug!(%name, "auto-update: up-to-date"); continue; } + let coord = coord.clone(); let current_rev = current_rev.clone(); tasks.push(tokio::spawn(async move { - if let Err(e) = rebuild_agent(&coord, &name, ¤t_rev).await { - tracing::warn!(%name, error = ?e, "auto-update: rebuild failed"); - } - })); - } - - // Manager runs unconditionally when its marker differs: even if the host - // hasn't been rebuilt yet, `nixos-container update hm1nd` is a no-op, so - // there's no harm. The host's own activation already updates declarative - // containers — this is belt-and-braces for hive-c0re restarts. - if manager_present && agent_needs_update(MANAGER_NAME, ¤t_rev) { - let current_rev = current_rev.clone(); - tasks.push(tokio::spawn(async move { - if let Err(e) = rebuild_manager(¤t_rev).await { - tracing::warn!(error = ?e, "auto-update: manager rebuild failed"); + tracing::info!( + %name, + prev = ?prev, + rev = %current_rev, + "auto-update: rebuilding agent", + ); + let agent_dir = match coord.register_agent(&name) { + Ok(d) => d, + Err(e) => { + tracing::warn!(%name, error = ?e, "auto-update: register_agent failed"); + return; + } + }; + let applied_dir = Coordinator::agent_applied_dir(&name); + let claude_dir = Coordinator::agent_claude_dir(&name); + match lifecycle::rebuild( + &name, + &coord.hyperhive_flake, + &agent_dir, + &applied_dir, + &claude_dir, + ) + .await + { + Ok(()) => { + if let Err(e) = std::fs::write(&marker, ¤t_rev) { + tracing::warn!(%name, error = ?e, "auto-update: write rev marker failed"); + } else { + tracing::info!(%name, "auto-update: agent rebuilt"); + } + } + Err(e) => { + tracing::warn!(%name, error = ?e, "auto-update: rebuild failed"); + } } })); } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 1450d34a..06e47f92 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -42,7 +42,6 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) .route("/destroy/{name}", post(post_destroy)) - .route("/rebuild/{name}", post(post_rebuild)) .route("/request-spawn", post(post_request_spawn)) .route("/send", post(post_send)) .route("/messages/stream", get(messages_stream)) @@ -65,7 +64,6 @@ async fn index(headers: HeaderMap, State(state): State) -> Html) -> Html\n\n\n\nhyperhive // h1ve-c0re\n{refresh}\n{STYLE}\n\n\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n\n\n", - containers = render_containers(&containers, &transient, current_rev.as_deref(), &hostname), + containers = render_containers(&containers, &transient, &hostname), talk = render_talk(&containers), )) } @@ -165,24 +163,6 @@ async fn post_request_spawn( } } -async fn post_rebuild(State(state): State, AxumPath(name): AxumPath) -> Response { - let Some(current_rev) = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake) - else { - return error_response( - "rebuild: hyperhive_flake has no canonical path; manual rebuild only via `hive-c0re rebuild`", - ); - }; - let result = if name == lifecycle::MANAGER_NAME { - crate::auto_update::rebuild_manager(¤t_rev).await - } else { - crate::auto_update::rebuild_agent(&state.coord, &name, ¤t_rev).await - }; - match result { - Ok(()) => Redirect::to("/").into_response(), - Err(e) => error_response(&format!("rebuild {name} failed: {e:#}")), - } -} - async fn post_destroy(State(state): State, AxumPath(name): AxumPath) -> Response { match actions::destroy(&state.coord, &name).await { Ok(()) => Redirect::to("/").into_response(), @@ -204,7 +184,6 @@ fn error_response(message: &str) -> Response { fn render_containers( containers: &[String], transient: &std::collections::HashMap, - current_rev: Option<&str>, hostname: &str, ) -> String { let mut out = String::from( @@ -238,10 +217,9 @@ fn render_containers( out.push_str("
    \n"); for container in containers { if container == MANAGER_NAME { - let update_badge = update_badge_for(MANAGER_NAME, current_rev); let _ = writeln!( out, - "
  • ▓█▓▒░ {container} m1nd{update_badge} :{MANAGER_PORT}
  • ", + "
  • ▓█▓▒░ {container} m1nd :{MANAGER_PORT}
  • ", ); } else if let Some(name) = container.strip_prefix(AGENT_PREFIX) { let port = lifecycle::agent_web_port(name); @@ -253,10 +231,9 @@ fn render_containers( " needs login →", ) }; - let update_badge = update_badge_for(name, current_rev); let _ = writeln!( out, - "
  • ▒░▒░░ {name} ag3nt{login_badge}{update_badge} {container} :{port}\n
    \n
  • ", + "
  • ▒░▒░░ {name} ag3nt{login_badge} {container} :{port}\n
    \n
  • ", ); } } @@ -342,20 +319,6 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { .collect() } -/// Returns either an empty string (agent is up-to-date / no rev known) or -/// a clickable "needs update" badge whose form POSTs to /rebuild/. -fn update_badge_for(name: &str, current_rev: Option<&str>) -> String { - let Some(rev) = current_rev else { - return String::new(); - }; - if !crate::auto_update::agent_needs_update(name, rev) { - return String::new(); - } - format!( - "
    ", - ) -} - /// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true if the /// agent's bound `~/.claude/` dir on disk contains any regular file. The /// dashboard reads this each render so logins driven from the agent web UI @@ -587,13 +550,6 @@ const STYLE: &str = r#" .spawnform input::placeholder { color: var(--muted); } .spawnform input:focus { outline: 1px solid var(--purple); } .role-pending { color: var(--amber); border-color: var(--amber); } - .btn-inline { - font-family: inherit; - background: transparent; - cursor: pointer; - margin-left: 0.4em; - } - .btn-inline:hover { background: rgba(255, 184, 77, 0.1); } .kind { display: inline-block; margin-left: 0.4em;