//! Per-container HTTP UI. Phase 6 minimum — a status page on a host port. //! Containers share the host's network namespace (privateNetwork = false), so //! each instance must bind a distinct port. `HIVE_PORT` is set per agent by //! `hive-c0re`'s generated per-agent flake (deterministic from agent name). use std::convert::Infallible; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; use axum::{ Form, Router, extract::State, response::{ Html, IntoResponse, Redirect, Response, sse::{Event, KeepAlive, Sse}, }, routing::{get, post}, }; use serde::Deserialize; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; use crate::client; use crate::events::Bus; use crate::login::LoginState; use crate::login_session::{LoginSession, drop_if_finished}; /// 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, session: Arc>>>, bus: Bus, socket: PathBuf, flavor: Flavor, } /// Which wire protocol the per-agent UI's `/send` handler should speak. /// Sub-agent → `AgentRequest::OperatorMsg`; manager → `ManagerRequest::OperatorMsg`. #[derive(Debug, Clone, Copy)] pub enum Flavor { Agent, Manager, } pub async fn serve( label: String, port: u16, login: LoginStateCell, bus: Bus, socket: PathBuf, flavor: Flavor, ) -> Result<()> { let state = AppState { label, login, session: Arc::new(Mutex::new(None)), bus, socket, flavor, }; let app = Router::new() .route("/", get(index)) .route("/events/stream", get(events_stream)) .route("/send", post(post_send)) .route("/login/start", post(post_login_start)) .route("/login/code", post(post_login_code)) .route("/login/cancel", post(post_login_cancel)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr) .await .with_context(|| format!("bind web UI on port {port}"))?; tracing::info!(%port, "web UI listening"); axum::serve(listener, app).await?; Ok(()) } async fn index(State(state): State) -> Html { drop_if_finished(&state.session); let login = *state.login.lock().unwrap(); let session_snapshot = state.session.lock().unwrap().clone(); let body = match (login, session_snapshot) { (LoginState::Online, _) => render_online(&state.label), (LoginState::NeedsLogin, None) => render_needs_login_idle(), (LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session), }; let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(7000); Html(format!( "\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆ ↻ R3BU1LD

\n
══════════════════════════════════════════════════════════════
\n{body}\n\n\n\n", label = state.label, )) } fn render_online(label: &str) -> String { format!( "

▓█▓▒░ harness alive — turn loop running ▓█▓▒░

\n\
\n \ \n \ \n\
\n\

enqueued with from: operator on this agent's inbox; the next turn picks it up.

\n\ {LIVE_PANEL}", ) } /// Live event tail rendered into every `/` response when the agent is online. /// JS opens an `EventSource` on `/events/stream` and appends rows; no full-page /// reload, so the login flow and other forms aren't clobbered. const LIVE_PANEL: &str = r#"

live

connecting…
"#; 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() } fn render_login_in_progress(session: &Arc) -> String { let url_block = match session.url() { Some(url) => format!( "

{url}

\n

open this URL in a browser, complete the OAuth flow, paste the resulting code below.

", url = html_escape(&url), ), None => "

waiting for claude to emit an OAuth URL on stdout… (output below)

".into(), }; let exit_badge = if session.finished() { let note = session.exit_note().unwrap_or_else(|| "exited".into()); format!( "

claude process exited: {note}. Start over if needed.

", note = html_escape(¬e), ) } else { String::new() }; let output = session.output(); let code_form = if session.finished() { String::new() } else { "
\n \n \n
".into() }; let cancel_form = "
\n \n
".to_owned(); format!( "

▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░

\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n

output

\n
{output}
", output = html_escape(&output), ) } #[derive(Deserialize)] struct SendForm { body: String, } async fn post_send(State(state): State, Form(form): Form) -> Response { let body = form.body.trim().to_owned(); if body.is_empty() { return error_response("send: `body` required"); } let result = match state.flavor { Flavor::Agent => match client::request::<_, hive_sh4re::AgentResponse>( &state.socket, &hive_sh4re::AgentRequest::OperatorMsg { body }, ) .await { Ok(hive_sh4re::AgentResponse::Ok) => Ok(()), Ok(hive_sh4re::AgentResponse::Err { message }) => Err(message), Ok(other) => Err(format!("unexpected response: {other:?}")), Err(e) => Err(format!("transport: {e:#}")), }, Flavor::Manager => match client::request::<_, hive_sh4re::ManagerResponse>( &state.socket, &hive_sh4re::ManagerRequest::OperatorMsg { body }, ) .await { Ok(hive_sh4re::ManagerResponse::Ok) => Ok(()), Ok(hive_sh4re::ManagerResponse::Err { message }) => Err(message), Ok(other) => Err(format!("unexpected response: {other:?}")), Err(e) => Err(format!("transport: {e:#}")), }, }; match result { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("send failed: {e}")), } } async fn events_stream( State(state): State, ) -> Sse>> { tracing::info!("sse: client subscribed"); let rx = state.bus.subscribe(); // Drop a "hello" note into the bus so every new subscriber sees at // least one event immediately and can clear the connecting placeholder. state .bus .emit(crate::events::LiveEvent::Note("live stream attached".into())); let stream = BroadcastStream::new(rx).filter_map(|res| { let ev = res.ok()?; let json = serde_json::to_string(&ev).ok()?; Some(Ok(Event::default().data(json))) }); Sse::new(stream).keep_alive(KeepAlive::default()) } async fn post_login_start(State(state): State) -> Response { drop_if_finished(&state.session); { let guard = state.session.lock().unwrap(); if guard.is_some() { return Redirect::to("/").into_response(); } } match LoginSession::start() { Ok(session) => { *state.session.lock().unwrap() = Some(Arc::new(session)); Redirect::to("/").into_response() } Err(e) => error_response(&format!("login start failed: {e:#}")), } } #[derive(Deserialize)] struct CodeForm { code: String, } async fn post_login_code( State(state): State, Form(form): Form, ) -> Response { let session = state.session.lock().unwrap().clone(); let Some(session) = session else { return error_response("no login session running"); }; if let Err(e) = session.submit_code(&form.code).await { return error_response(&format!("submit code failed: {e:#}")); } Redirect::to("/").into_response() } async fn post_login_cancel(State(state): State) -> Response { let session = state.session.lock().unwrap().take(); if let Some(session) = session { session.close_stdin().await; session.kill(); } Redirect::to("/").into_response() } fn error_response(message: &str) -> Response { ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, Html(format!( "\n{STYLE}

error

{msg}

← back

", msg = html_escape(message), )), ) .into_response() } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } const STYLE: &str = r#" "#;