From 78fae44ee5627f6b2d28a11ce5ac8c7d4f3c07c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 12:57:06 +0200 Subject: [PATCH] phase 8 step 3: needs-login partial-run mode + dashboard badge --- CLAUDE.md | 8 +++-- hive-ag3nt/src/bin/hive-ag3nt.rs | 50 ++++++++++++++++++++++++++++-- hive-ag3nt/src/bin/hive-m1nd.rs | 39 ++++++++++++++++++++++-- hive-ag3nt/src/lib.rs | 1 + hive-ag3nt/src/login.rs | 52 ++++++++++++++++++++++++++++++++ hive-ag3nt/src/web_ui.rs | 31 +++++++++++++++++-- hive-c0re/src/dashboard.rs | 21 ++++++++++++- 7 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 hive-ag3nt/src/login.rs diff --git a/CLAUDE.md b/CLAUDE.md index c238f1f..5a7e663 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,8 +240,12 @@ See PLAN.md → "Phase 8" for the full design. Summary: via `` and renders a spinner row while `nixos-container create` + `update` + `start` is in flight. - **"needs login" partial-run state.** No valid session in `~/.claude/` → - harness binds the web UI but does NOT start the turn loop. Dashboard - surfaces this state per-agent. + harness binds the web UI but does NOT start the turn loop. The harness + polls the dir; as soon as a login lands it transitions into the turn loop + without a restart. Dashboard surfaces the state per-agent via a `needs + 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 /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 diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 6796bca..17d76fa 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -1,8 +1,10 @@ use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; +use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui}; use hive_sh4re::{AgentRequest, AgentResponse}; use tokio::process::Command; @@ -50,12 +52,30 @@ async fn main() -> Result<()> { .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_WEB_PORT); let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into()); + let claude_dir = PathBuf::from(login::DEFAULT_CLAUDE_DIR); + let initial = LoginState::from_dir(&claude_dir); + tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot"); + let login_state = Arc::new(Mutex::new(initial)); + let ui_state = login_state.clone(); tokio::spawn(async move { - if let Err(e) = web_ui::serve(label, port).await { + if let Err(e) = web_ui::serve(label, port, ui_state).await { tracing::error!(error = ?e, "web ui failed"); } }); - serve(&cli.socket, Duration::from_millis(poll_ms)).await + match initial { + LoginState::Online => { + serve(&cli.socket, Duration::from_millis(poll_ms), login_state).await + } + LoginState::NeedsLogin => { + // Partial-run mode: keep the harness alive (so the web UI + // 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 /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 + } + } } Cmd::Send { to, body } => { let resp: AgentResponse = @@ -71,8 +91,32 @@ async fn main() -> Result<()> { } } -async fn serve(socket: &Path, interval: Duration) -> Result<()> { +/// Re-checks `claude_dir` every `poll_ms` ms. As soon as it contains a session +/// (login completed), flips `state` to `Online` and enters the turn loop. +async fn needs_login_loop( + socket: &Path, + claude_dir: &Path, + state: Arc>, + poll_ms: u64, +) -> Result<()> { + tracing::warn!( + claude_dir = %claude_dir.display(), + "no claude session — staying in partial-run mode (web UI only)" + ); + let probe = Duration::from_millis(poll_ms.max(2000)); + loop { + tokio::time::sleep(probe).await; + if login::has_session(claude_dir) { + tracing::info!("claude session detected — entering turn loop"); + *state.lock().unwrap() = LoginState::Online; + return serve(socket, Duration::from_millis(poll_ms), state).await; + } + } +} + +async fn serve(socket: &Path, interval: Duration, state: Arc>) -> Result<()> { tracing::info!(socket = %socket.display(), "hive-ag3nt serve"); + let _ = state; // reserved for future state transitions (turn-loop -> needs-login) loop { let recv: Result = client::request(socket, &AgentRequest::Recv).await; match recv { diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index 3e1705f..e3dd7aa 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -4,10 +4,12 @@ //! plus a `serve` loop that logs the manager's inbox. use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; +use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui}; use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER}; @@ -59,12 +61,26 @@ async fn main() -> Result<()> { .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_WEB_PORT); let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into()); + let claude_dir = PathBuf::from(login::DEFAULT_CLAUDE_DIR); + let initial = LoginState::from_dir(&claude_dir); + tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "hm1nd boot"); + let login_state = Arc::new(Mutex::new(initial)); + let ui_state = login_state.clone(); tokio::spawn(async move { - if let Err(e) = web_ui::serve(label, port).await { + if let Err(e) = web_ui::serve(label, port, ui_state).await { tracing::error!(error = ?e, "web ui failed"); } }); - serve(&cli.socket, Duration::from_millis(poll_ms)).await + match initial { + LoginState::Online => serve(&cli.socket, Duration::from_millis(poll_ms)).await, + LoginState::NeedsLogin => { + tracing::warn!( + claude_dir = %claude_dir.display(), + "manager has no claude session — staying in partial-run mode" + ); + needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms).await + } + } } Cmd::Send { to, body } => one_shot(&cli.socket, ManagerRequest::Send { to, body }).await, Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await, @@ -91,6 +107,25 @@ async fn one_shot(socket: &Path, req: ManagerRequest) -> Result<()> { Ok(()) } +/// Manager-side mirror of hive-ag3nt's needs-login loop: keep the web UI +/// alive, poll the claude dir, enter `serve` once login lands. +async fn needs_login_loop( + socket: &Path, + claude_dir: &Path, + state: Arc>, + poll_ms: u64, +) -> Result<()> { + let probe = Duration::from_millis(poll_ms.max(2000)); + loop { + tokio::time::sleep(probe).await; + if login::has_session(claude_dir) { + tracing::info!("manager claude session detected — entering inbox loop"); + *state.lock().unwrap() = LoginState::Online; + return serve(socket, Duration::from_millis(poll_ms)).await; + } + } +} + async fn serve(socket: &Path, interval: Duration) -> Result<()> { tracing::info!(socket = %socket.display(), "hive-m1nd serve"); loop { diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index a5608ad..4a62049 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -2,6 +2,7 @@ //! `hive-m1nd` (manager) binaries. pub mod client; +pub mod login; pub mod web_ui; /// Default socket path inside the container — bind-mounted by `hive-c0re`. diff --git a/hive-ag3nt/src/login.rs b/hive-ag3nt/src/login.rs new file mode 100644 index 0000000..6f8ae69 --- /dev/null +++ b/hive-ag3nt/src/login.rs @@ -0,0 +1,52 @@ +//! Login-state probe for the bind-mounted `~/.claude/` dir. The dir is +//! provided by hive-c0re (Phase 8 step 1) and persists across container +//! 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 /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. + +use std::path::Path; + +/// Mount point of the per-agent Claude credentials dir inside the container. +/// Matches `hive_c0re::lifecycle::CONTAINER_CLAUDE_MOUNT`. +pub const DEFAULT_CLAUDE_DIR: &str = "/root/.claude"; + +/// Returns `true` if `dir` exists and contains any regular file. Used at +/// startup to decide whether to enter the turn loop (logged in) or stay in +/// the partial-run "needs login" state. +#[must_use] +pub fn has_session(dir: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|t| t.is_file()) { + return true; + } + } + false +} + +/// Login state the harness reports to its web UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginState { + /// `~/.claude/` has credentials; turn loop is running. + Online, + /// `~/.claude/` is empty; harness is up, web UI is bound, turn loop is NOT + /// running. Operator needs to complete login from the web UI. + NeedsLogin, +} + +impl LoginState { + #[must_use] + pub fn from_dir(dir: &Path) -> Self { + if has_session(dir) { + Self::Online + } else { + Self::NeedsLogin + } + } +} diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 132304a..4eb02f9 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -4,17 +4,26 @@ //! `hive-c0re`'s generated per-agent flake (deterministic from agent name). use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; use axum::{Router, extract::State, response::Html, routing::get}; +use crate::login::LoginState; + +/// Live login state for the web UI. The harness updates this in place as it +/// transitions between `NeedsLogin` and `Online`; the UI reads on each +/// render. +pub type LoginStateCell = Arc>; + #[derive(Clone)] struct AppState { label: String, + login: LoginStateCell, } -pub async fn serve(label: String, port: u16) -> Result<()> { - let state = AppState { label }; +pub async fn serve(label: String, port: u16, login: LoginStateCell) -> Result<()> { + let state = AppState { label, login }; let app = Router::new().route("/", get(index)).with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr) @@ -26,8 +35,21 @@ pub async fn serve(label: String, port: u16) -> Result<()> { } async fn index(State(state): State) -> Html { + let login = *state.login.lock().unwrap(); + let (status_label, status_class, body_extra) = match login { + LoginState::Online => ( + "▓█▓▒░ harness alive — turn loop running ▓█▓▒░", + "status-online", + "

phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+

", + ), + LoginState::NeedsLogin => ( + "▓█▓▒░ NEEDS L0G1N ▓█▓▒░", + "status-needs-login", + "

No Claude session in ~/.claude/. The harness is up and reachable on this UI, but the turn loop is paused until you log in.

\n

Phase 8 step 4 will wire a login form here that drives claude /login over plain stdio pipes. Until then: nixos-container root-login the container and run claude interactively, then restart the harness.

", + ), + }; Html(format!( - "\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆

\n
══════════════════════════════════════════════════════════════
\n

▓█▓▒░ harness alive ▓█▓▒░

\n

phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+

\n\n\n", + "\n\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆

\n
══════════════════════════════════════════════════════════════
\n

{status_label}

\n{body_extra}\n\n\n", label = state.label, )) } @@ -71,5 +93,8 @@ const STYLE: &str = r#" margin-bottom: 0.5em; } .meta { color: var(--muted); font-size: 0.85em; } + .status-online { color: #66ff99; text-shadow: 0 0 6px rgba(102, 255, 153, 0.5); } + .status-needs-login { color: #ffb84d; text-shadow: 0 0 6px rgba(255, 184, 77, 0.6); } + code { background: rgba(204, 102, 255, 0.1); padding: 0.05em 0.3em; border-radius: 2px; } "#; diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index e42f325..14428a3 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -223,9 +223,15 @@ fn render_containers( ); } else if let Some(name) = container.strip_prefix(AGENT_PREFIX) { let port = lifecycle::agent_web_port(name); + let claude_dir = Coordinator::agent_claude_dir(name); + let login_badge = if claude_has_session(&claude_dir) { + "" + } else { + " needs login" + }; let _ = writeln!( out, - "
  • ▒░▒░░ {name} ag3nt {container} :{port}\n
    \n
  • ", + "
  • ▒░▒░░ {name} ag3nt{login_badge} {container} :{port}\n
    \n
  • ", ); } } @@ -311,6 +317,19 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { .collect() } +/// 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 +/// (Phase 8 step 4) reflect within one auto-refresh cycle. +fn claude_has_session(dir: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + entries + .flatten() + .any(|e| e.file_type().is_ok_and(|t| t.is_file())) +} + async fn approval_diff(agent: &str, commit_ref: &str) -> String { let proposed = Coordinator::agent_proposed_dir(agent); if !proposed.exists() {