Compare commits

...
Author SHA1 Message Date
müde
d81a845dbe login: default command is claude auth login 2026-05-15 13:32:51 +02:00
müde
e777576528 auto-update: surface pending updates in dashboard + include manager 2026-05-15 13:31:33 +02:00
8 changed files with 159 additions and 61 deletions

View file

@ -159,16 +159,28 @@ docs/damocles-migration.md options for moving damocles onto hyperhive
## Auto-update on startup ## Auto-update on startup
`hive-c0re serve` runs `auto_update::run` in a background task right after `hive-c0re serve` runs `auto_update::run` in a background task right after
opening the coordinator. It enumerates sub-agent containers (manager opening the coordinator. It enumerates managed containers and rebuilds any
excluded — its config comes from the host's NixOS module) and rebuilds any whose recorded hyperhive rev differs from the current one:
whose recorded hyperhive rev differs from the current one. Rev = canonical
filesystem path of `cfg.hyperhiveFlake` (so `/etc/hyperhive` resolving to a - **Sub-agents** rebuild via `lifecycle::rebuild` (regenerates
new `/nix/store/...-source` triggers a rebuild). Marker file: `applied/<name>/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:
`/var/lib/hyperhive/applied/.<name>.hyperhive-rev`. If the flake input has `/var/lib/hyperhive/applied/.<name>.hyperhive-rev`. If the flake input has
no canonical path (e.g. a `github:` URL), auto-update is a no-op — rebuild 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 manually. The task is async and never blocks the admin socket; failures are
logged and don't take the daemon down. 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/<name>`, calling the same `auto_update::rebuild_agent` /
`rebuild_manager` path so manual triggers and the startup scan can't drift.
## Build / deploy / test ## Build / deploy / test
```sh ```sh
@ -259,7 +271,7 @@ See PLAN.md → "Phase 8" for the full design. Summary:
login` badge in the container list. "Valid session" today is a heuristic login` badge in the container list. "Valid session" today is a heuristic
(any regular file inside `/root/.claude/`); we may refine once the (any regular file inside `/root/.claude/`); we may refine once the
filename layout claude writes is locked in. filename layout claude writes is locked in.
- **Login from the per-agent web UI.** Spawn `claude /login` with plain - **Login from the per-agent web UI.** Spawn `claude auth login` with plain
stdio pipes (no PTY initially), surface the OAuth URL from stdout on the 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 page, accept the resulting code via a paste field, write it to the process
stdin. Once `~/.claude/` populates, the existing needs-login polling loop stdin. Once `~/.claude/` populates, the existing needs-login polling loop

View file

@ -263,7 +263,7 @@ knows where to click.
**Login over the per-agent web UI.** No more `nixos-container root-login` for **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: the common case. The agent's web UI exposes a "log in" action that:
1. Spawns `claude /login` (or equivalent) inside the container with plain 1. Spawns `claude auth login` (or equivalent) inside the container with plain
stdio pipes — no PTY unless we discover we need one. 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. 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 3. Provides a paste field for the resulting code; writes it to the process

View file

@ -71,7 +71,7 @@ async fn main() -> Result<()> {
// stays bound) but don't drive the turn loop. Poll the // stays bound) but don't drive the turn loop. Poll the
// claude dir periodically so a successful login (whether // claude dir periodically so a successful login (whether
// from the dashboard PTY path in step 4 or via // from the dashboard PTY path in step 4 or via
// `root-login` + `claude /login` in the meantime) // `root-login` + `claude auth login` in the meantime)
// transitions us into the turn loop without a restart. // transitions us into the turn loop without a restart.
needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms).await needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms).await
} }

View file

@ -3,7 +3,7 @@
//! destroy/recreate so OAuth tokens survive. //! destroy/recreate so OAuth tokens survive.
//! //!
//! "Has session" today means "the dir contains at least one regular file." //! "Has session" today means "the dir contains at least one regular file."
//! That's a heuristic: a fresh bind-mount starts empty, and `claude /login` //! That's a heuristic: a fresh bind-mount starts empty, and `claude auth login`
//! writes credentials into the dir. We may refine later (probe for the //! writes credentials into the dir. We may refine later (probe for the
//! specific credentials filename, or run a no-op `claude` call) once the //! specific credentials filename, or run a no-op `claude` call) once the
//! exact layout is locked in. //! exact layout is locked in.

View file

@ -1,4 +1,4 @@
//! `claude /login` driver. Spawns the login command under plain stdio pipes, //! `claude auth login` driver. Spawns the login command under plain stdio pipes,
//! accumulates stdout+stderr in a shared buffer (so the web UI can show //! 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 //! whatever URL/prompt claude emits), and writes paste-back codes from the
//! UI into the child's stdin. //! UI into the child's stdin.
@ -16,7 +16,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command}; use tokio::process::{Child, ChildStdin, Command};
const DEFAULT_CMD: &str = "claude"; const DEFAULT_CMD: &str = "claude";
const DEFAULT_ARGS: &[&str] = &["/login"]; const DEFAULT_ARGS: &[&str] = &["auth", "login"];
#[derive(Default)] #[derive(Default)]
struct State { struct State {
@ -33,7 +33,7 @@ struct State {
exit_note: Option<String>, exit_note: Option<String>,
} }
/// A running `claude /login` subprocess. /// A running `claude auth login` subprocess.
pub struct LoginSession { pub struct LoginSession {
child: Mutex<Child>, child: Mutex<Child>,
/// Tokio mutex because we hold the guard across the `write_all().await` /// Tokio mutex because we hold the guard across the `write_all().await`
@ -46,7 +46,7 @@ pub struct LoginSession {
impl LoginSession { impl LoginSession {
/// Spawn the login command. The exact binary/args are configurable via /// Spawn the login command. The exact binary/args are configurable via
/// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by /// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by
/// default we run `claude /login`. Failing to spawn returns an error /// default we run `claude auth login`. Failing to spawn returns an error
/// before any state is registered. /// before any state is registered.
pub fn start() -> Result<Self> { pub fn start() -> Result<Self> {
let (cmd, args) = resolve_command(); let (cmd, args) = resolve_command();
@ -146,7 +146,7 @@ impl LoginSession {
fn resolve_command() -> (String, Vec<String>) { fn resolve_command() -> (String, Vec<String>) {
if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") { if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") {
// Whitespace-only split — no quote handling. Fine for "claude /login" // Whitespace-only split — no quote handling. Fine for "claude auth login"
// style overrides; if we need anything with embedded spaces we'll // style overrides; if we need anything with embedded spaces we'll
// switch to shell-words. // switch to shell-words.
let mut parts = raw.split_whitespace().map(str::to_owned); let mut parts = raw.split_whitespace().map(str::to_owned);

View file

@ -71,7 +71,7 @@ fn render_online() -> String {
} }
fn render_needs_login_idle() -> String { fn render_needs_login_idle() -> String {
"<p class=\"status-needs-login\">▓█▓▒░ NEEDS L0G1N ▓█▓▒░</p>\n<p>No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.</p>\n<form method=\"POST\" action=\"/login/start\">\n <button type=\"submit\" class=\"btn btn-login\">◆ ST4RT L0G1N</button>\n</form>\n<p class=\"meta\">Spawns <code>claude /login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.</p>".into() "<p class=\"status-needs-login\">▓█▓▒░ NEEDS L0G1N ▓█▓▒░</p>\n<p>No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.</p>\n<form method=\"POST\" action=\"/login/start\">\n <button type=\"submit\" class=\"btn btn-login\">◆ ST4RT L0G1N</button>\n</form>\n<p class=\"meta\">Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.</p>".into()
} }
fn render_login_in_progress(session: &Arc<LoginSession>) -> String { fn render_login_in_progress(session: &Arc<LoginSession>) -> String {

View file

@ -11,22 +11,25 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::{Context, Result, bail};
use tokio::process::Command;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX}; use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
/// Marker file recording the hyperhive rev a sub-agent's container was last /// Marker file recording the hyperhive rev a sub-agent's container was last
/// built against. Sibling of `applied/<name>/` (rather than inside it) to /// built against. Sibling of `applied/<name>/` (rather than inside it) to
/// keep it out of the applied repo's git history. /// keep it out of the applied repo's git history. Uses a leading dot so a
fn rev_marker_path(name: &str) -> PathBuf { /// glob over `applied/*` doesn't include it.
pub fn rev_marker_path(name: &str) -> PathBuf {
PathBuf::from(format!("/var/lib/hyperhive/applied/.{name}.hyperhive-rev")) PathBuf::from(format!("/var/lib/hyperhive/applied/.{name}.hyperhive-rev"))
} }
/// Resolve the current rev of `hyperhive_flake`. For a path on disk we /// Resolve the current rev of `hyperhive_flake`. For a path on disk we
/// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/... /// canonicalize (following symlinks) so a /etc/hyperhive → /nix/store/...
/// update yields a different string. For anything else we return None. /// update yields a different string. For anything else we return None.
fn current_flake_rev(hyperhive_flake: &str) -> Option<String> { #[must_use]
pub fn current_flake_rev(hyperhive_flake: &str) -> Option<String> {
let path = Path::new(hyperhive_flake); let path = Path::new(hyperhive_flake);
if !path.exists() { if !path.exists() {
return None; return None;
@ -36,6 +39,62 @@ fn current_flake_rev(hyperhive_flake: &str) -> Option<String> {
.map(|p| p.display().to_string()) .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<Coordinator>, 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 /// Rebuild every sub-agent whose marker differs from the current rev. Logs
/// per-agent outcomes and continues past failures. Returns Ok even if some /// per-agent outcomes and continues past failures. Returns Ok even if some
/// rebuilds failed — startup shouldn't be blocked by a broken agent. /// rebuilds failed — startup shouldn't be blocked by a broken agent.
@ -58,55 +117,38 @@ pub async fn run(coord: Arc<Coordinator>) -> Result<()> {
}; };
let mut tasks = Vec::new(); let mut tasks = Vec::new();
let mut manager_present = false;
for container in containers { for container in containers {
if container == MANAGER_NAME {
manager_present = true;
continue;
}
let Some(name) = container.strip_prefix(AGENT_PREFIX) else { let Some(name) = container.strip_prefix(AGENT_PREFIX) else {
continue; continue;
}; };
let name = name.to_owned(); let name = name.to_owned();
let marker = rev_marker_path(&name); if !agent_needs_update(&name, &current_rev) {
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"); tracing::debug!(%name, "auto-update: up-to-date");
continue; continue;
} }
let coord = coord.clone(); let coord = coord.clone();
let current_rev = current_rev.clone(); let current_rev = current_rev.clone();
tasks.push(tokio::spawn(async move { tasks.push(tokio::spawn(async move {
tracing::info!( if let Err(e) = rebuild_agent(&coord, &name, &current_rev).await {
%name, tracing::warn!(%name, error = ?e, "auto-update: rebuild failed");
prev = ?prev, }
rev = %current_rev, }));
"auto-update: rebuilding agent", }
);
let agent_dir = match coord.register_agent(&name) { // Manager runs unconditionally when its marker differs: even if the host
Ok(d) => d, // hasn't been rebuilt yet, `nixos-container update hm1nd` is a no-op, so
Err(e) => { // there's no harm. The host's own activation already updates declarative
tracing::warn!(%name, error = ?e, "auto-update: register_agent failed"); // containers — this is belt-and-braces for hive-c0re restarts.
return; if manager_present && agent_needs_update(MANAGER_NAME, &current_rev) {
} let current_rev = current_rev.clone();
}; tasks.push(tokio::spawn(async move {
let applied_dir = Coordinator::agent_applied_dir(&name); if let Err(e) = rebuild_manager(&current_rev).await {
let claude_dir = Coordinator::agent_claude_dir(&name); tracing::warn!(error = ?e, "auto-update: manager rebuild failed");
match lifecycle::rebuild(
&name,
&coord.hyperhive_flake,
&agent_dir,
&applied_dir,
&claude_dir,
)
.await
{
Ok(()) => {
if let Err(e) = std::fs::write(&marker, &current_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");
}
} }
})); }));
} }

View file

@ -42,6 +42,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/approve/{id}", post(post_approve)) .route("/approve/{id}", post(post_approve))
.route("/deny/{id}", post(post_deny)) .route("/deny/{id}", post(post_deny))
.route("/destroy/{name}", post(post_destroy)) .route("/destroy/{name}", post(post_destroy))
.route("/rebuild/{name}", post(post_rebuild))
.route("/request-spawn", post(post_request_spawn)) .route("/request-spawn", post(post_request_spawn))
.route("/send", post(post_send)) .route("/send", post(post_send))
.route("/messages/stream", get(messages_stream)) .route("/messages/stream", get(messages_stream))
@ -64,6 +65,7 @@ async fn index(headers: HeaderMap, State(state): State<AppState>) -> Html<String
let containers = lifecycle::list().await.unwrap_or_default(); let containers = lifecycle::list().await.unwrap_or_default();
let transient = state.coord.transient_snapshot(); let transient = state.coord.transient_snapshot();
let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake);
let approvals = gc_orphans( let approvals = gc_orphans(
&state.coord, &state.coord,
state.coord.approvals.pending().unwrap_or_default(), state.coord.approvals.pending().unwrap_or_default(),
@ -82,7 +84,7 @@ async fn index(headers: HeaderMap, State(state): State<AppState>) -> Html<String
Html(format!( Html(format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{refresh}\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n</body>\n</html>\n", "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{refresh}\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n</body>\n</html>\n",
containers = render_containers(&containers, &transient, &hostname), containers = render_containers(&containers, &transient, current_rev.as_deref(), &hostname),
talk = render_talk(&containers), talk = render_talk(&containers),
)) ))
} }
@ -163,6 +165,24 @@ async fn post_request_spawn(
} }
} }
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> 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(&current_rev).await
} else {
crate::auto_update::rebuild_agent(&state.coord, &name, &current_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<AppState>, AxumPath(name): AxumPath<String>) -> Response { async fn post_destroy(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
match actions::destroy(&state.coord, &name).await { match actions::destroy(&state.coord, &name).await {
Ok(()) => Redirect::to("/").into_response(), Ok(()) => Redirect::to("/").into_response(),
@ -184,6 +204,7 @@ fn error_response(message: &str) -> Response {
fn render_containers( fn render_containers(
containers: &[String], containers: &[String],
transient: &std::collections::HashMap<String, crate::coordinator::TransientState>, transient: &std::collections::HashMap<String, crate::coordinator::TransientState>,
current_rev: Option<&str>,
hostname: &str, hostname: &str,
) -> String { ) -> String {
let mut out = String::from( let mut out = String::from(
@ -217,9 +238,10 @@ fn render_containers(
out.push_str("<ul>\n"); out.push_str("<ul>\n");
for container in containers { for container in containers {
if container == MANAGER_NAME { if container == MANAGER_NAME {
let update_badge = update_badge_for(MANAGER_NAME, current_rev);
let _ = writeln!( let _ = writeln!(
out, out,
"<li><span class=\"glyph\">▓█▓▒░</span> <a href=\"http://{hostname}:{MANAGER_PORT}/\">{container}</a> <span class=\"role role-m1nd\">m1nd</span> <span class=\"meta\">:{MANAGER_PORT}</span></li>", "<li><span class=\"glyph\">▓█▓▒░</span> <a href=\"http://{hostname}:{MANAGER_PORT}/\">{container}</a> <span class=\"role role-m1nd\">m1nd</span>{update_badge} <span class=\"meta\">:{MANAGER_PORT}</span></li>",
); );
} else if let Some(name) = container.strip_prefix(AGENT_PREFIX) { } else if let Some(name) = container.strip_prefix(AGENT_PREFIX) {
let port = lifecycle::agent_web_port(name); let port = lifecycle::agent_web_port(name);
@ -231,9 +253,10 @@ fn render_containers(
" <a class=\"role role-pending\" href=\"http://{hostname}:{port}/\">needs login →</a>", " <a class=\"role role-pending\" href=\"http://{hostname}:{port}/\">needs login →</a>",
) )
}; };
let update_badge = update_badge_for(name, current_rev);
let _ = writeln!( let _ = writeln!(
out, out,
"<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span>{login_badge} <span class=\"meta\">{container} :{port}</span>\n <form method=\"POST\" action=\"/destroy/{name}\" class=\"inline\" onsubmit=\"return confirm('destroy {name}? container is removed; state + creds kept.');\"><button class=\"btn btn-destroy\" type=\"submit\">DESTR0Y</button></form>\n</li>", "<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span>{login_badge}{update_badge} <span class=\"meta\">{container} :{port}</span>\n <form method=\"POST\" action=\"/destroy/{name}\" class=\"inline\" onsubmit=\"return confirm('destroy {name}? container is removed; state + creds kept.');\"><button class=\"btn btn-destroy\" type=\"submit\">DESTR0Y</button></form>\n</li>",
); );
} }
} }
@ -319,6 +342,20 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
.collect() .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/<name>.
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!(
" <form method=\"POST\" action=\"/rebuild/{name}\" class=\"inline\" onsubmit=\"return confirm('rebuild {name}? hot-reloads the container.');\"><button class=\"role role-pending btn-inline\" type=\"submit\" title=\"agent's last build is older than current hyperhive rev\">needs update ↻</button></form>",
)
}
/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true if the /// 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 /// 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 /// dashboard reads this each render so logins driven from the agent web UI
@ -550,6 +587,13 @@ const STYLE: &str = r#"
.spawnform input::placeholder { color: var(--muted); } .spawnform input::placeholder { color: var(--muted); }
.spawnform input:focus { outline: 1px solid var(--purple); } .spawnform input:focus { outline: 1px solid var(--purple); }
.role-pending { color: var(--amber); border-color: var(--amber); } .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 { .kind {
display: inline-block; display: inline-block;
margin-left: 0.4em; margin-left: 0.4em;