diff --git a/docs/web-ui.md b/docs/web-ui.md index 69b0c47f..f1bbea9f 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -358,6 +358,10 @@ Layout, top to bottom: - Banner (gradient shimmer while state=thinking). - Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`. +- Meta links row: `📊 stats →`, `🖥 screen →` (shown when + `gui_enabled`), and `⬡ forge ↗` (shown when `/api/state` + supplies a non-null `forge_url` — the agent's Forgejo profile, + assembled server-side from the request `Host` header, new tab). - Status section: empty when online (alive-badge in the state row carries the signal), populated with the login form / OAuth URL when `status` is `needs_login_*`. diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 5eec2d5e..4bc8d3e5 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -677,6 +677,17 @@ // Show the screen link when the weston VNC compositor is enabled. const screenLink = $('screen-link'); if (screenLink) screenLink.style.display = s.gui_enabled ? '' : 'none'; + // Forge profile link — the backend hands us the full URL (or + // null when this agent has no forge account). + const forgeLink = $('forge-link'); + if (forgeLink) { + if (s.forge_url) { + forgeLink.href = s.forge_url; + forgeLink.style.display = ''; + } else { + forgeLink.style.display = 'none'; + } + } renderTermInput(s.label, s.status === 'online'); renderInbox(s.inbox || []); // Authoritative state comes from the harness via /api/state. diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 0b801723..c38ef602 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -16,6 +16,9 @@ 📊 stats → +

diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 123a1ae9..e49ab107 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -14,7 +14,7 @@ use anyhow::{Context, Result}; use axum::{ Form, Router, extract::State, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::{ IntoResponse, Response, sse::{Event, KeepAlive, Sse}, @@ -383,6 +383,11 @@ struct StateSnapshot { /// (i.e. `/etc/hyperhive/gui.json` was present at harness startup). /// When true, the UI may render a `🖥 screen` link to `/screen`. gui_enabled: bool, + /// Full URL of this agent's Forgejo profile, or `null` when the + /// agent has no forge account (`forge-token` absent). Assembled + /// server-side from the request `Host` header so the UI just + /// links it — no client-side URL construction. + forge_url: Option, } #[derive(Serialize)] @@ -440,7 +445,7 @@ async fn api_loose_ends(State(state): State) -> Response { axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() } -async fn api_state(State(state): State) -> axum::Json { +async fn api_state(headers: HeaderMap, State(state): State) -> axum::Json { // Capture seq *before* any reads so the dedupe contract is // "events with seq > snapshot.seq are post-snapshot, never missed." let seq = state.bus.current_seq(); @@ -485,9 +490,28 @@ async fn api_state(State(state): State) -> axum::Json { ctx_usage, cost_usage, gui_enabled: state.gui_vnc_port.is_some(), + forge_url: forge_profile_url(&headers, &state.label), }) } +/// This agent's Forgejo profile URL — `Some` only when the agent has a +/// forge account (its `forge-token` file exists). The host is taken +/// from the request `Host` header so the link resolves against +/// whatever host the operator reached the page on; the forge always +/// listens on `:3000`, and the per-agent forge user is named after +/// the agent (`label`). +fn forge_profile_url(headers: &HeaderMap, label: &str) -> Option { + if !crate::paths::state_dir().join("forge-token").is_file() { + return None; + } + let host = headers + .get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost"); + let hostname = host.split(':').next().unwrap_or(host); + Some(format!("http://{hostname}:3000/{label}")) +} + /// Best-effort: pull the last 30 messages addressed to us via the /// per-agent / manager socket. Empty list on any transport / decode /// failure — the inbox section is decorative, not authoritative.