From 6ab667901da186a06b58a04948ace8e4e12cc81a Mon Sep 17 00:00:00 2001
From: iris
Date: Thu, 21 May 2026 20:13:30 +0200
Subject: [PATCH 1/2] agent page: link to the agent's forge profile
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a '⬡ forge ↗' link to the per-agent page's meta row, next to
the stats + screen links. It opens the agent's Forgejo profile
(http://:3000/
diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs
index 123a1ae9..6e170241 100644
--- a/hive-ag3nt/src/web_ui.rs
+++ b/hive-ag3nt/src/web_ui.rs
@@ -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,
+ /// Whether this agent has a Forgejo account wired — its
+ /// `forge-token` file exists in the state dir. When true the UI
+ /// links to the agent's forge profile at `:3000/`
+ /// (the per-agent forge user is named after the agent).
+ forge_present: bool,
}
#[derive(Serialize)]
@@ -485,6 +490,7 @@ async fn api_state(State(state): State) -> axum::Json {
ctx_usage,
cost_usage,
gui_enabled: state.gui_vnc_port.is_some(),
+ forge_present: crate::paths::state_dir().join("forge-token").is_file(),
})
}
From fc3490086b9a3ae614421eeab61c81847091b060 Mon Sep 17 00:00:00 2001
From: iris
Date: Thu, 21 May 2026 20:23:26 +0200
Subject: [PATCH 2/2] agent page: assemble forge URL backend-side
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per review: build the full forge profile URL in the harness instead
of the client. /api/state now returns forge_url: Option
(assembled from the request Host header — resolves against whatever
host the operator reached the page on), replacing the forge_present
bool. The JS just links forge_url when present — no client-side URL
construction.
---
docs/web-ui.md | 6 +++---
hive-ag3nt/assets/app.js | 8 ++++----
hive-ag3nt/src/web_ui.rs | 34 ++++++++++++++++++++++++++--------
3 files changed, 33 insertions(+), 15 deletions(-)
diff --git a/docs/web-ui.md b/docs/web-ui.md
index 99326942..f1bbea9f 100644
--- a/docs/web-ui.md
+++ b/docs/web-ui.md
@@ -359,9 +359,9 @@ 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 `forge_present` —
- links to the agent's Forgejo profile at `:3000/`,
- new tab).
+ `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 7cec6618..4bc8d3e5 100644
--- a/hive-ag3nt/assets/app.js
+++ b/hive-ag3nt/assets/app.js
@@ -677,12 +677,12 @@
// Show the screen link when the weston VNC compositor is enabled.
const screenLink = $('screen-link');
if (screenLink) screenLink.style.display = s.gui_enabled ? '' : 'none';
- // Show the forge profile link when this agent has a forge account.
- // The forge runs on :3000 of the same host; the user is the label.
+ // 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_present) {
- forgeLink.href = `http://${window.location.hostname}:3000/${s.label}`;
+ if (s.forge_url) {
+ forgeLink.href = s.forge_url;
forgeLink.style.display = '';
} else {
forgeLink.style.display = 'none';
diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs
index 6e170241..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,11 +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,
- /// Whether this agent has a Forgejo account wired — its
- /// `forge-token` file exists in the state dir. When true the UI
- /// links to the agent's forge profile at `:3000/`
- /// (the per-agent forge user is named after the agent).
- forge_present: 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)]
@@ -445,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();
@@ -490,10 +490,28 @@ async fn api_state(State(state): State) -> axum::Json {
ctx_usage,
cost_usage,
gui_enabled: state.gui_vnc_port.is_some(),
- forge_present: crate::paths::state_dir().join("forge-token").is_file(),
+ 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.