phase 8 step 3: needs-login partial-run mode + dashboard badge

This commit is contained in:
müde 2026-05-15 12:57:06 +02:00
parent c59fa8541c
commit 78fae44ee5
7 changed files with 191 additions and 11 deletions

View file

@ -240,8 +240,12 @@ See PLAN.md → "Phase 8" for the full design. Summary:
via `<meta refresh>` and renders a spinner row while via `<meta refresh>` and renders a spinner row while
`nixos-container create` + `update` + `start` is in flight. `nixos-container create` + `update` + `start` is in flight.
- **"needs login" partial-run state.** No valid session in `~/.claude/` - **"needs login" partial-run state.** No valid session in `~/.claude/`
harness binds the web UI but does NOT start the turn loop. Dashboard harness binds the web UI but does NOT start the turn loop. The harness
surfaces this state per-agent. 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 - **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 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

View file

@ -1,8 +1,10 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use hive_ag3nt::login::{self, LoginState};
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui}; use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui};
use hive_sh4re::{AgentRequest, AgentResponse}; use hive_sh4re::{AgentRequest, AgentResponse};
use tokio::process::Command; use tokio::process::Command;
@ -50,12 +52,30 @@ async fn main() -> Result<()> {
.and_then(|s| s.parse::<u16>().ok()) .and_then(|s| s.parse::<u16>().ok())
.unwrap_or(DEFAULT_WEB_PORT); .unwrap_or(DEFAULT_WEB_PORT);
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into()); 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 { 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"); 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 } => { Cmd::Send { to, body } => {
let resp: AgentResponse = 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<Mutex<LoginState>>,
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<Mutex<LoginState>>) -> Result<()> {
tracing::info!(socket = %socket.display(), "hive-ag3nt serve"); tracing::info!(socket = %socket.display(), "hive-ag3nt serve");
let _ = state; // reserved for future state transitions (turn-loop -> needs-login)
loop { loop {
let recv: Result<AgentResponse> = client::request(socket, &AgentRequest::Recv).await; let recv: Result<AgentResponse> = client::request(socket, &AgentRequest::Recv).await;
match recv { match recv {

View file

@ -4,10 +4,12 @@
//! plus a `serve` loop that logs the manager's inbox. //! plus a `serve` loop that logs the manager's inbox.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use hive_ag3nt::login::{self, LoginState};
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui}; use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui};
use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER}; use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER};
@ -59,12 +61,26 @@ async fn main() -> Result<()> {
.and_then(|s| s.parse::<u16>().ok()) .and_then(|s| s.parse::<u16>().ok())
.unwrap_or(DEFAULT_WEB_PORT); .unwrap_or(DEFAULT_WEB_PORT);
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into()); 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 { 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"); 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::Send { to, body } => one_shot(&cli.socket, ManagerRequest::Send { to, body }).await,
Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await, Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await,
@ -91,6 +107,25 @@ async fn one_shot(socket: &Path, req: ManagerRequest) -> Result<()> {
Ok(()) 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<Mutex<LoginState>>,
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<()> { async fn serve(socket: &Path, interval: Duration) -> Result<()> {
tracing::info!(socket = %socket.display(), "hive-m1nd serve"); tracing::info!(socket = %socket.display(), "hive-m1nd serve");
loop { loop {

View file

@ -2,6 +2,7 @@
//! `hive-m1nd` (manager) binaries. //! `hive-m1nd` (manager) binaries.
pub mod client; pub mod client;
pub mod login;
pub mod web_ui; pub mod web_ui;
/// Default socket path inside the container — bind-mounted by `hive-c0re`. /// Default socket path inside the container — bind-mounted by `hive-c0re`.

52
hive-ag3nt/src/login.rs Normal file
View file

@ -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
}
}
}

View file

@ -4,17 +4,26 @@
//! `hive-c0re`'s generated per-agent flake (deterministic from agent name). //! `hive-c0re`'s generated per-agent flake (deterministic from agent name).
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use axum::{Router, extract::State, response::Html, routing::get}; 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<Mutex<LoginState>>;
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
label: String, label: String,
login: LoginStateCell,
} }
pub async fn serve(label: String, port: u16) -> Result<()> { pub async fn serve(label: String, port: u16, login: LoginStateCell) -> Result<()> {
let state = AppState { label }; let state = AppState { label, login };
let app = Router::new().route("/", get(index)).with_state(state); let app = Router::new().route("/", get(index)).with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr) 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<AppState>) -> Html<String> { async fn index(State(state): State<AppState>) -> Html<String> {
let login = *state.login.lock().unwrap();
let (status_label, status_class, body_extra) = match login {
LoginState::Online => (
"▓█▓▒░ harness alive — turn loop running ▓█▓▒░",
"status-online",
"<p class=\"meta\">phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+</p>",
),
LoginState::NeedsLogin => (
"▓█▓▒░ NEEDS L0G1N ▓█▓▒░",
"status-needs-login",
"<p>No Claude session in <code>~/.claude/</code>. The harness is up and reachable on this UI, but the turn loop is paused until you log in.</p>\n<p class=\"meta\">Phase 8 step 4 will wire a login form here that drives <code>claude /login</code> over plain stdio pipes. Until then: <code>nixos-container root-login</code> the container and run <code>claude</code> interactively, then restart the harness.</p>",
),
};
Html(format!( Html(format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n<p>▓█▓▒░ harness alive ▓█▓▒░</p>\n<p class=\"meta\">phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+</p>\n</body>\n</html>\n", "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta http-equiv=\"refresh\" content=\"5\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n<p class=\"{status_class}\">{status_label}</p>\n{body_extra}\n</body>\n</html>\n",
label = state.label, label = state.label,
)) ))
} }
@ -71,5 +93,8 @@ const STYLE: &str = r#"
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
.meta { color: var(--muted); font-size: 0.85em; } .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; }
</style> </style>
"#; "#;

View file

@ -223,9 +223,15 @@ fn render_containers(
); );
} 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);
let claude_dir = Coordinator::agent_claude_dir(name);
let login_badge = if claude_has_session(&claude_dir) {
""
} else {
" <span class=\"role role-pending\">needs login</span>"
};
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> <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} <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>",
); );
} }
} }
@ -311,6 +317,19 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
.collect() .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 { async fn approval_diff(agent: &str, commit_ref: &str) -> String {
let proposed = Coordinator::agent_proposed_dir(agent); let proposed = Coordinator::agent_proposed_dir(agent);
if !proposed.exists() { if !proposed.exists() {