Compare commits

..
Author SHA1 Message Date
Bailey Dixon 523794995a feat(dashboard): redesign Hermes-Relay plugin UI 2026-08-30 13:52:29 -04:00
29 changed files with 2120 additions and 1353 deletions
+1 -1
View File
@@ -13,11 +13,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Changed
- **Hermes-Relay Dashboard management is organized around operator tasks.** Overview, Devices, Activity, Remote Access, Git, and Settings now have separate native Dashboard surfaces; pairing is QR-first, paired clients use responsive cards, and token-backed media is labeled as a bounded diagnostic instead of a health counter.
- **Android What's New leads with one curated release highlight without interrupting startup.** A timed post-update toast can be swiped or closed, previews additional feature/fix counts when a release has meaningful secondary items, expands into the centered highlight view on request, and keeps the full technical history available. Each release can present one plain-language summary, up to three primary benefits, and up to two quieter improvements, while release checks keep the structured entry, fallback, Play copy, and public release records aligned.
### Fixed
- **Opening Android no longer claims or interrupts a turn already running in Hermes Desktop/TUI.** Passive foreground and session browsing now use read-only Gateway status plus profile-scoped history; live-session resume remains reserved for explicit Android actions and exact Android-owned recovery.
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
### Removed
-3
View File
@@ -26,9 +26,6 @@ model device-certified:
renders as Working.
- Run a background process that outlives its parent turn and verify Background
work remains separate from the conversation's Idle state.
- On a physical phone, open and repeatedly foreground Android while the same
session is working in official Desktop/TUI; verify Android sends no live
attach/interrupt RPC, the producer completes, and final history appears.
- Pursue an upstream `session.active_list` profile field/filter or an aggregate
activity route with explicit profile ownership so multi-profile clients do
not need to resolve process-wide rows from durable keys.
@@ -239,60 +239,6 @@ class GatewayForegroundRecoveryInstrumentedTest {
assertEquals(0, fixture.requestsTo("/v1/chat/completions"))
}
@Test
fun desktopOwnedTurn_remainsReadOnlyAcrossAndroidForegroundLifecycle() {
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val controlMethods = setOf(
"session.resume",
"session.activate",
"session.interrupt",
"prompt.submit",
)
val baseline = controlMethods.associateWith(fixture::rpcCount)
val baselineActiveList = fixture.rpcCount("session.active_list")
fixture.activeSessionStatus = "working"
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val okHttp = OkHttpClient()
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = okHttp,
),
okHttpClient = okHttp,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
viewModel.setChatTurnCheckpointStore(null)
viewModel.updateGatewayClient(gatewayClient)
viewModel.setChatVisible(true)
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
viewModel.setChatVisible(false)
viewModel.setChatVisible(true)
fixture.awaitRpcCount("session.active_list", baselineActiveList + 1)
controlMethods.forEach { method ->
assertEquals(
"passive lifecycle sent $method",
baseline.getValue(method),
fixture.rpcCount(method),
)
}
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
assertEquals(
"observer teardown interrupted the Desktop turn",
baseline.getValue("session.interrupt"),
fixture.rpcCount("session.interrupt"),
)
}
private companion object {
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val LIVE_SESSION_ID = "fixture-live-1"
@@ -317,9 +263,6 @@ internal class AndroidGatewayContractFixture {
@Volatile
var recoveryRunning = false
@Volatile
var activeSessionStatus: String? = null
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
sockets.add(webSocket)
@@ -339,18 +282,6 @@ internal class AndroidGatewayContractFixture {
"session.activate" -> sessionSnapshot(
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "fixture-live-1",
)
"session.active_list" -> buildJsonObject {
put("sessions", kotlinx.serialization.json.buildJsonArray {
activeSessionStatus?.let { status ->
add(buildJsonObject {
put("id", LIVE_SESSION_ID)
put("session_key", STORED_SESSION_ID)
put("status", status)
put("last_active", 1.0)
})
}
})
}
"prompt.submit", "session.interrupt" -> buildJsonObject { put("ok", true) }
else -> JsonObject(emptyMap())
}
@@ -414,15 +345,6 @@ internal class AndroidGatewayContractFixture {
error("Gateway RPC $method not observed; saw ${rpcLog.map { it.first }}")
}
fun awaitRpcCount(method: String, count: Int) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (System.nanoTime() < deadline) {
if (rpcCount(method) >= count) return
Thread.sleep(20)
}
error("Gateway RPC $method count $count not observed; saw ${rpcLog.map { it.first }}")
}
fun requestsTo(path: String): Int = requestPaths.count { it.startsWith(path) }
fun rpcCount(method: String): Int = rpcLog.count { it.first == method }
@@ -431,9 +353,4 @@ internal class AndroidGatewayContractFixture {
allSockets.forEach { socket -> runCatching { socket.close(1001, "teardown") } }
runCatching { server.shutdown() }
}
private companion object {
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val LIVE_SESSION_ID = "fixture-live-1"
}
}
@@ -906,30 +906,6 @@ class GatewayChatClient(
scope.launch { prewarmAwait(storedSessionId) }
}
/**
* Establish only the shared Gateway socket for read-only observation.
*
* `session.resume` and `session.activate` attach a live runtime to this
* transport. Opening Chat, foreground restoration, and selecting a saved
* transcript must not claim a turn that another Desktop/TUI client owns,
* so those paths use this socket-only warmup and observe through REST
* history plus `session.active_list` instead.
*/
fun observe(onReady: (() -> Unit)? = null) {
scope.launch {
if (observeAwait() && onReady != null) callbackDispatcher(onReady)
}
}
/** Suspending [observe]; returns true once the read-only socket is ready. */
suspend fun observeAwait(): Boolean = try {
connectMutex.withLock { ensureConnected() }
true
} catch (e: Exception) {
Log.d(TAG, "Gateway observation warmup skipped: ${e.message}")
false
}
/**
* Suspending [prewarm]: establishes the socket and (when [storedSessionId]
* is non-null) resumes the existing session, returning only once that work
@@ -1119,16 +1119,12 @@ fun ChatScreen(
}
// Recover any durable in-flight chat checkpoint whenever Chat returns to
// the foreground. setChatVisible owns that edge; an ordinary Gateway open
// warms only the observation socket and never attaches a saved session.
// the foreground. On Gateway this also pre-warms/re-attaches the socket;
// sessions-SSE falls back to bounded persisted-history reconciliation.
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
LaunchedEffect(isGatewayTransport, appForeground, chatReady) {
val chatVisible = appForeground && chatReady
val visibilityChanged = chatViewModel.setChatVisible(chatVisible)
if (isGatewayTransport && chatVisible && !visibilityChanged) {
// Gateway availability can settle after Chat was already visible.
// Repeat the socket-only warmup for that edge; ordinary observation
// still cannot resume or activate a session.
chatViewModel.setChatVisible(appForeground && chatReady)
if (appForeground && chatReady) {
chatViewModel.prewarmGateway()
}
if (isGatewayTransport && appForeground && chatReady) {
@@ -408,9 +408,6 @@ class ChatViewModel : ViewModel() {
private val sessionActivityGeneration = AtomicLong(0L)
private val sessionActivityPollMutex = Mutex()
private var sessionActivityPollJob: Job? = null
private var passiveGatewayHistoryRefreshJob: Job? = null
private var passivelyObservedGatewaySessionId: String? = null
private var passiveObservationCatchupPendingSessionId: String? = null
private var sessionActivityDirectory: Set<SessionActivityOwner> = emptySet()
private var lastProjectedProcessIds: Set<String> = emptySet()
private var lastProjectedProcessOwner: SessionActivityOwner? = null
@@ -2255,8 +2252,6 @@ class ChatViewModel : ViewModel() {
}
private suspend fun pollSessionActivity(client: GatewayChatClient) {
var hasPassiveCurrentLiveWork = false
var hasPassiveCatchupPending = false
sessionActivityPollMutex.withLock {
if (gatewayClient !== client || !chatVisible || streamingEndpoint != "gateway") return
val generation = sessionActivityGeneration.get()
@@ -2277,43 +2272,6 @@ class ChatViewModel : ViewModel() {
when (val result = client.listActiveSessions()) {
is GatewayActiveSessionsResult.Success -> {
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
val currentStoredId = currentOwner?.storedSessionId
val passiveCurrentRows = if (currentStoredId == null) {
emptyList()
} else {
result.sessions.filter { row ->
row.storedSessionId == currentStoredId &&
client.knownSessionOwner(row.runtimeSessionId) == null
}
}
hasPassiveCurrentLiveWork = passiveCurrentRows.any { row ->
row.status != GatewayActiveSessionStatus.Idle
}
val initialCatchupPending =
passiveObservationCatchupPendingSessionId == currentStoredId
val needsFinalPassiveRefresh =
passivelyObservedGatewaySessionId == currentStoredId &&
!hasPassiveCurrentLiveWork
if (hasPassiveCurrentLiveWork) {
currentStoredId?.let(::refreshPassivelyObservedGatewayHistory)
if (initialCatchupPending) {
passiveObservationCatchupPendingSessionId = null
}
} else if (needsFinalPassiveRefresh || initialCatchupPending) {
val scheduled = currentStoredId?.let { storedId ->
refreshPassivelyObservedGatewayHistory(
storedSessionId = storedId,
retryUntilChanged = true,
)
} == true
if (scheduled && initialCatchupPending) {
passiveObservationCatchupPendingSessionId = null
}
}
passivelyObservedGatewaySessionId =
currentStoredId?.takeIf { hasPassiveCurrentLiveWork }
hasPassiveCatchupPending =
passiveObservationCatchupPendingSessionId == currentStoredId
val resolved = resolveGatewayActiveSessions(
sessions = result.sessions,
directory = directory,
@@ -2375,18 +2333,6 @@ class ChatViewModel : ViewModel() {
GatewayActiveSessionsResult.Unsupported,
is GatewayActiveSessionsResult.TransientFailure -> {
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
val currentStoredId = currentOwner?.storedSessionId
if (passiveObservationCatchupPendingSessionId == currentStoredId) {
val scheduled = currentStoredId?.let { storedId ->
refreshPassivelyObservedGatewayHistory(
storedSessionId = storedId,
retryUntilChanged = true,
)
} == true
if (scheduled) passiveObservationCatchupPendingSessionId = null
}
hasPassiveCatchupPending =
passiveObservationCatchupPendingSessionId == currentStoredId
val scopes = directory.mapTo(mutableSetOf()) {
SessionActivityScope.of(it.connectionId, it.profile)
}.apply { add(currentScope) }
@@ -2405,9 +2351,7 @@ class ChatViewModel : ViewModel() {
record.freshness == SessionActivityFreshness.Confirmed &&
record.phase(System.currentTimeMillis()) != SessionActivityPhase.Idle
}
val delayMs = if (
hasConfirmedLiveWork || hasPassiveCurrentLiveWork || hasPassiveCatchupPending
) 1_500L else 30_000L
val delayMs = if (hasConfirmedLiveWork) 1_500L else 30_000L
sessionActivityPollJob = viewModelScope.launch {
delay(delayMs)
if (gatewayClient === client && chatVisible) pollSessionActivity(client)
@@ -2480,10 +2424,6 @@ class ChatViewModel : ViewModel() {
clearProjectedBackgroundProcesses()
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
passiveGatewayHistoryRefreshJob?.cancel()
passiveGatewayHistoryRefreshJob = null
passivelyObservedGatewaySessionId = null
passiveObservationCatchupPendingSessionId = null
sessionActivityGeneration.incrementAndGet()
sessionActivityDirectory = emptySet()
lastLocalActivityOwner = null
@@ -2603,9 +2543,8 @@ class ChatViewModel : ViewModel() {
// Foreground can race OkHttp's delayed close callback:
// the first prewarm sees the old socket as Ready, then
// the callback moves it to Idle. Re-run from this exact
// client transition so the observation socket is
// restored; only an exact Android checkpoint may
// resume/activate a live runtime.
// client transition so the visible durable session is
// resumed and its authoritative history reconciled.
prewarmGateway()
}
}
@@ -3060,103 +2999,6 @@ class ChatViewModel : ViewModel() {
}
}
/**
* Refresh a Desktop/TUI-owned turn through the profile-scoped history
* surface without attaching its live runtime. `session.active_list` drives
* the bounded cadence; one final read follows Working/Waiting -> Idle.
*/
private fun refreshPassivelyObservedGatewayHistory(
storedSessionId: String,
retryUntilChanged: Boolean = false,
): Boolean {
if (passiveGatewayHistoryRefreshJob?.isActive == true) return false
if (_isLoadingHistory.value) return false
val handler = chatHandler ?: return false
val contextKey = activeProfileContextKey
val profileName = currentSessionProfileName()
val refreshJob = viewModelScope.launch(start = CoroutineStart.LAZY) {
try {
repeat(if (retryUntilChanged) 8 else 1) { attempt ->
val serverMessages = runCatching {
loadGatewaySessionHistory(
sessionId = storedSessionId,
requireProfileScope = true,
profileName = profileName,
)
}.getOrNull() ?: return@launch
if (
chatHandler !== handler ||
activeProfileContextKey != contextKey ||
currentSessionProfileName() != profileName ||
handler.currentSessionId.value != storedSessionId ||
_isLoadingHistory.value ||
activeStream != null ||
handler.isStreaming.value
) return@launch
val visibleSignature = handler.messages.value
.filterNot { it.clientOnly }
.map { message ->
Triple(
message.role.name.lowercase(),
message.content,
message.thinkingContent,
)
}
val serverSignature = serverMessages.map { message ->
Triple(
message.role.lowercase(),
message.contentText.orEmpty(),
message.resolvedReasoning.orEmpty(),
)
}
if (visibleSignature != serverSignature) {
handler.loadMessageHistory(serverMessages)
refreshSessions()
scheduleTitleReconcile(storedSessionId)
return@launch
}
if (attempt < 7 && retryUntilChanged) delay(250L)
}
} finally {
if (passiveGatewayHistoryRefreshJob === coroutineContext[Job]) {
passiveGatewayHistoryRefreshJob = null
}
}
}
passiveGatewayHistoryRefreshJob = refreshJob
refreshJob.start()
return true
}
/** Open the read-only socket off Main, then publish observation ownership on Main. */
private fun observeGatewaySession(
client: GatewayChatClient?,
handler: ChatHandler,
storedSessionId: String,
) {
val observer = client ?: return
val contextKey = activeProfileContextKey
val profileName = currentSessionProfileName()
observer.observe {
if (
chatVisible &&
gatewayClient === observer &&
chatHandler === handler &&
activeProfileContextKey == contextKey &&
currentSessionProfileName() == profileName &&
handler.currentSessionId.value == storedSessionId
) {
passiveObservationCatchupPendingSessionId = storedSessionId
refreshPassivelyObservedGatewayHistory(
storedSessionId = storedSessionId,
retryUntilChanged = true,
)
requestSessionActivityRefresh()
}
}
}
/** One-shot `config.get personality` over a ready socket → drives the collector. */
private fun seedServerPersonality(client: GatewayChatClient) {
viewModelScope.launch {
@@ -3167,11 +3009,11 @@ class ChatViewModel : ViewModel() {
}
/**
* Warm the Gateway socket when Chat is visible without claiming a runtime
* that may belong to Desktop/TUI. Exact Android-owned checkpoints recover
* through `session.activate`/`session.resume`; an ordinary open observes
* through REST history and `session.active_list` until the user performs
* an explicit action that needs session ownership.
* Warm the gateway socket (and resume the current session) when the chat
* surface is visible and the gateway is the resolved transport, so the
* first send is warm instead of paying the cold connect + `session.resume`
* on the send path. No-op without a gateway client; idempotent when warm.
* Driven by a foreground/visibility effect in ChatScreen.
*/
fun prewarmGateway() {
val client = gatewayClient
@@ -3179,16 +3021,17 @@ class ChatViewModel : ViewModel() {
val sessionId = handler.currentSessionId.value
selectBackgroundProcessSession(sessionId)
if (sessionId == null) {
client?.observe()
client?.prewarm(null)
} else {
// Preserve the original warm-up path before persistence wiring is
// available (early composition and JVM tests). Production installs
// the store from initializeMedia before Chat becomes ready.
if (chatTurnCheckpointStore == null) {
val gateway = client ?: return
// GatewayChatClient owns the socket IO scope, so the dial can
// progress while a paused UI dispatcher is being recreated.
observeGatewaySession(gateway, handler, sessionId)
// GatewayChatClient owns an IO scope, so this can progress even
// while a paused/blocked UI dispatcher is being recreated.
// Its cold-ready listener performs history/process refresh.
gateway.prewarm(sessionId)
return
}
if (activeStream == null && (streamRecovery == null || client != null)) {
@@ -3200,29 +3043,26 @@ class ChatViewModel : ViewModel() {
chatHandler === handler &&
handler.currentSessionId.value == sessionId
) {
observeGatewaySession(client, handler, sessionId)
if (client?.prewarmAwait(sessionId) == true) {
gatewayProcessController.sessionReady(sessionId)
}
}
checkpointRecoveryJob = null
}
return
}
// A locally-owned live mapper may revalidate its existing binding.
// A passive transcript must remain socket-only: resuming it here
// can replace another client's transport and turn Android teardown
// into a later session.interrupt.
if (client?.hasActiveTurnForSession(sessionId) == true) {
viewModelScope.launch {
if (client.prewarmAwait(sessionId) &&
gatewayClient === client &&
chatHandler === handler &&
handler.currentSessionId.value == sessionId
) {
gatewayProcessController.sessionReady(sessionId)
requestSessionActivityRefresh()
}
// prewarm() only emits the existing "cold ready" callback when it
// had to resume. An already-live session still needs its initial
// process snapshot when Chat opens, so confirm it explicitly.
viewModelScope.launch {
if (
client?.prewarmAwait(sessionId) == true &&
gatewayClient === client &&
chatHandler === handler &&
handler.currentSessionId.value == sessionId
) {
gatewayProcessController.sessionReady(sessionId)
}
} else {
observeGatewaySession(client, handler, sessionId)
}
}
}
@@ -3232,7 +3072,7 @@ class ChatViewModel : ViewModel() {
* Gateway chat owns automatic idle-socket reattachment; other tabs and a
* backgrounded app retain the normal no-reconnect behavior.
*/
fun setChatVisible(visible: Boolean): Boolean {
fun setChatVisible(visible: Boolean) {
val changed = chatVisible != visible
chatVisible = visible
if (visible && changed) {
@@ -3241,12 +3081,7 @@ class ChatViewModel : ViewModel() {
} else if (!visible) {
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
passiveGatewayHistoryRefreshJob?.cancel()
passiveGatewayHistoryRefreshJob = null
passivelyObservedGatewaySessionId = null
passiveObservationCatchupPendingSessionId = null
}
return changed
}
// === Gateway desktop-parity state ===
@@ -4630,9 +4465,7 @@ class ChatViewModel : ViewModel() {
)
if (stillCurrent()) {
handler.loadMessageHistory(messages)
if (streamingEndpoint == "gateway") {
observeGatewaySession(gatewayClient, handler, sessionId)
}
if (streamingEndpoint == "gateway") gatewayClient?.prewarm(sessionId)
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
@@ -5143,9 +4976,7 @@ class ChatViewModel : ViewModel() {
handler.currentSessionId.value == sessionId
) {
handler.loadMessageHistory(messages)
if (streamingEndpoint == "gateway") {
observeGatewaySession(gatewayClient, handler, sessionId)
}
if (streamingEndpoint == "gateway") gatewayClient?.prewarm(sessionId)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
@@ -6735,10 +6566,6 @@ class ChatViewModel : ViewModel() {
* SSE cannot, so it retains the existing interrupt/cancel behavior.
*/
private fun releaseTurnForNavigation(handler: ChatHandler) {
passiveGatewayHistoryRefreshJob?.cancel()
passiveGatewayHistoryRefreshJob = null
passivelyObservedGatewaySessionId = null
passiveObservationCatchupPendingSessionId = null
val gateway = gatewayClient
val canBackground = streamingEndpoint == "gateway" &&
activeStreamIsGateway && activeStream != null && gateway != null
@@ -231,14 +231,11 @@ class GatewayClientHarness(
val suppressAckMethods: MutableSet<String> = ConcurrentHashMap.newKeySet()
val pendingAcks = LinkedBlockingQueue<PendingAck>()
@Volatile
var suppressGatewayReady: Boolean = false
private val wsListener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
serverSockets.add(webSocket)
allServerSockets.add(webSocket)
if (!suppressGatewayReady) sendGatewayReady(webSocket)
webSocket.send(eventFrame("gateway.ready", null, null))
}
override fun onMessage(webSocket: WebSocket, text: String) {
@@ -630,10 +627,6 @@ class GatewayClientHarness(
fun awaitServerSocket(): WebSocket =
serverSockets.poll(5, TimeUnit.SECONDS) ?: error("server socket never opened")
fun sendGatewayReady(webSocket: WebSocket) {
webSocket.send(eventFrame("gateway.ready", null, null))
}
fun awaitRpc(method: String): JsonObject {
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
@@ -1414,27 +1407,6 @@ class GatewayChatClientTest {
assertEquals(listOf("stored-session"), resumedSessions.toList())
}
@Test
fun `observation warmup never claims or interrupts a foreign runtime`() = runBlocking {
val registrations = AtomicInteger(0)
client.setUnsolicitedTurnProvider {
registrations.incrementAndGet()
GatewayInboundTurnRegistration(Recorder().callbacks) { true }
}
assertTrue(client.observeAwait())
val serverWs = harness.awaitServerSocket()
serverWs.send(harness.eventFrame("message.start", null, "foreign-runtime"))
delay(100)
client.shutdown()
assertEquals(0, registrations.get())
assertFalse(harness.rpcLog.any { it.first == "session.resume" })
assertFalse(harness.rpcLog.any { it.first == "session.activate" })
assertFalse(harness.rpcLog.any { it.first == "session.interrupt" })
assertFalse(harness.rpcLog.any { it.first == "prompt.submit" })
}
@Test
fun `newer prewarm selection wins when an older resume completes late`() = runBlocking {
harness.suppressAckMethods += "session.resume"
@@ -992,12 +992,11 @@ class ChatViewModelGatewayInboundTurnTest {
awaitCondition { handler.messages.value.any { it.content == "Partial A" } }
viewModel.switchSession(secondSession)
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition { handler.currentSessionId.value == secondSession && !handler.isStreaming.value }
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "session.resume" })
assertTrue(gatewayHarness.rpcLog.none { it.first == "session.interrupt" })
viewModel.sendMessage("Run task B")
gatewayHarness.awaitRpcCount("session.resume", 2)
gatewayHarness.awaitRpcCount("prompt.submit", 2)
serverWs.send(
gatewayHarness.eventFrame(
@@ -1825,12 +1824,11 @@ class ChatViewModelGatewayInboundTurnTest {
awaitCondition { viewModel.queuedMessages.value == listOf("Follow up A") }
viewModel.switchSession(secondSession)
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition { handler.currentSessionId.value == secondSession && !handler.isStreaming.value }
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "session.resume" })
assertTrue("session B must not show A's queue", viewModel.queuedMessages.value.isEmpty())
viewModel.sendMessage("Run task B")
gatewayHarness.awaitRpcCount("session.resume", 2)
gatewayHarness.awaitRpcCount("prompt.submit", 2)
serverWs.send(
gatewayHarness.eventFrame(
@@ -2033,16 +2031,13 @@ class ChatViewModelGatewayInboundTurnTest {
serverWs.close(1012, "test disconnect")
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Idle }
viewModel.setChatVisible(true)
viewModel.prewarmGateway()
gatewayHarness.awaitServerSocket()
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition {
handler.messages.value.singleOrNull()?.content == BACKGROUND_ANSWER
}
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "session.resume" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.activate" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.interrupt" })
assertFalse(handler.isStreaming.value)
}
@@ -2053,19 +2048,16 @@ class ChatViewModelGatewayInboundTurnTest {
// Foreground arrives while OkHttp still reports the old socket ready,
// so the one-shot prewarm is an intentional no-op. The delayed close
// callback must itself restore the observation socket and catch up
// history without attaching the live session.
// callback must itself trigger an exact-session reattach.
viewModel.prewarmGateway()
serverWs.close(1012, "late background close")
awaitCondition { gatewayHarness.ticketMints.get() >= 2 }
serverWs = gatewayHarness.awaitServerSocket()
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition {
handler.messages.value.singleOrNull()?.content == BACKGROUND_ANSWER
}
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "session.resume" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.activate" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.interrupt" })
assertFalse(handler.isStreaming.value)
}
@@ -2374,189 +2366,36 @@ class ChatViewModelGatewayInboundTurnTest {
}
@Test
fun reconnectCatchupClosesCompletionBetweenFirstReadAndIdleSnapshot() {
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
awaitCondition { !viewModel.isLoadingHistory.value }
val firstReadStarted = CompletableDeferred<Unit>()
val releaseFirstRead = CompletableDeferred<Unit>()
val readCount = AtomicInteger(0)
viewModel.setProfileMessageLoader {
if (readCount.incrementAndGet() == 1) {
firstReadStarted.complete(Unit)
releaseFirstRead.await()
Result.success(emptyList())
} else {
Result.success(persistedHistory)
}
}
fun reconnectAfterMissedStartRecoversOnExactSessionCompletion() {
serverWs.close(1012, "missed start")
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Idle }
viewModel.setChatVisible(true)
viewModel.prewarmGateway()
serverWs = gatewayHarness.awaitServerSocket()
awaitCondition { firstReadStarted.isCompleted }
// Completion persists after the reconnect's first catch-up read began,
// while the first active-list snapshot is already empty/idle. The
// pending final-read marker must close this exact ordering window.
gatewayHarness.awaitRpcCount("session.resume", 2)
// Reconnected midway through the synthetic turn: no message.start is
// replayed, so the delta is intentionally ignored and completion drives
// authoritative history recovery.
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", BACKGROUND_ANSWER) },
"live-resumed",
),
)
persistedHistory = persistedAnswerHistory()
releaseFirstRead.complete(Unit)
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", BACKGROUND_ANSWER) },
"live-resumed",
),
)
awaitCondition { handler.messages.value.any { it.content == BACKGROUND_ANSWER } }
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "session.resume" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.activate" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.interrupt" })
assertFalse(handler.isStreaming.value)
}
@Test
fun passiveForegroundObservationNeverClaimsOrInterruptsDesktopTurn() {
val observerProfile = Profile(
name = "observer",
model = "model-a",
description = "Observer",
)
viewModel.setSelectedProfileProvider { observerProfile }
viewModel.setSessionProfileNameProvider { observerProfile.name }
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
assertEquals(STORED_SESSION_ID, sessionId)
Result.success(
if (profileName == observerProfile.name) {
persistedHistory
} else {
listOf(
MessageItem(
id = "wrong-profile",
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive("Wrong profile history"),
),
)
},
)
}
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", observerProfile.name),
STORED_SESSION_ID,
)
awaitCondition { !viewModel.isLoadingHistory.value }
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val ownershipMethods = setOf(
"session.resume",
"session.activate",
"session.interrupt",
"prompt.submit",
)
val baseline = ownershipMethods.associateWith { method ->
gatewayHarness.rpcLog.count { it.first == method }
}
val baselineActiveList = gatewayHarness.rpcLog.count { it.first == "session.active_list" }
gatewayHarness.activeSessionListPayload = activeSessionPayload("working")
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = gatewayHarness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
okHttpClient = OkHttpClient(),
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
)
viewModel.setChatTurnCheckpointStore(MemoryCheckpointStore())
viewModel.updateGatewayClient(gatewayClient)
viewModel.setChatVisible(true)
viewModel.prewarmGateway()
awaitCondition {
gatewayHarness.rpcLog.count { it.first == "session.active_list" } > baselineActiveList
}
persistedHistory = listOf(
MessageItem(
id = "desktop-answer",
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive("Desktop completed without Android attachment."),
),
)
awaitCondition {
handler.messages.value.singleOrNull()?.content ==
"Desktop completed without Android attachment."
}
ownershipMethods.forEach { method ->
assertEquals(
"passive foreground sent $method",
baseline.getValue(method),
gatewayHarness.rpcLog.count { it.first == method },
)
}
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
assertEquals(
"observer teardown interrupted the Desktop turn",
baseline.getValue("session.interrupt"),
gatewayHarness.rpcLog.count { it.first == "session.interrupt" },
)
}
@Test
fun observerReadyAfterChatHidesCannotRestartPassiveWork() {
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
awaitCondition { !viewModel.isLoadingHistory.value }
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val historyReads = AtomicInteger(0)
viewModel.setProfileMessageLoader {
historyReads.incrementAndGet()
Result.success(persistedHistory)
}
val controlMethods = setOf(
"session.resume",
"session.activate",
"session.interrupt",
"prompt.submit",
)
val baseline = controlMethods.associateWith { method ->
gatewayHarness.rpcLog.count { it.first == method }
}
val baselineActiveList = gatewayHarness.rpcLog.count { it.first == "session.active_list" }
gatewayHarness.suppressGatewayReady = true
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = gatewayHarness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
okHttpClient = OkHttpClient(),
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
)
viewModel.setChatTurnCheckpointStore(MemoryCheckpointStore())
viewModel.updateGatewayClient(gatewayClient)
viewModel.setChatVisible(true)
val delayedSocket = gatewayHarness.awaitServerSocket()
viewModel.setChatVisible(false)
gatewayHarness.sendGatewayReady(delayedSocket)
shadowOf(Looper.getMainLooper()).idleFor(500, TimeUnit.MILLISECONDS)
Thread.sleep(100)
assertEquals(0, historyReads.get())
assertEquals(
baselineActiveList,
gatewayHarness.rpcLog.count { it.first == "session.active_list" },
)
controlMethods.forEach { method ->
assertEquals(baseline.getValue(method), gatewayHarness.rpcLog.count { it.first == method })
}
}
@Test
fun staleHistoryReadCannotEraseATurnCompletedDuringTheFetch() {
val loadCount = AtomicInteger(0)
-33
View File
@@ -3977,36 +3977,3 @@ disappearance, client-side profile isolation, and method-not-found; physical
and current-host certification remains tracked in `TODO.md`. An upstream
profile field/filter or explicitly owned aggregate activity route would remove
the remaining ambiguity for multi-profile clients.
---
## ADR 69 — Passive Android observation never attaches another client's Gateway turn
**Status:** Accepted (2026-08-28).
**Context.** `session.resume` and `session.activate` are live-runtime attachment
operations, not read-only subscriptions. Android previously called
`session.resume` while opening or foregrounding Chat and after loading a saved
session's history. When Desktop/TUI already owned a running turn, that passive
prewarm could rebind the runtime transport to Android. A later Android socket,
route, or client teardown could then strand the producer or promote the foreign
turn into an Android `GatewayTurn` whose cancellation sends `session.interrupt`.
The issue was distinct from the earlier stale-view and missing-terminal recovery
paths, which concern exact Android-owned checkpoints.
**Decision.** Ordinary visibility, foreground restoration, Idle-socket recovery,
and saved-session selection establish only the shared Gateway socket. They use
profile-scoped REST history plus process-wide `session.active_list`; while an
unowned row with the selected durable id is live, Android performs bounded
history refreshes and one final read after settlement. These observer paths send
no `session.resume`, `session.activate`, `prompt.submit`, or `session.interrupt`.
Exact Android-owned checkpoints retain `session.activate` with durable-resume
fallback, and explicit send or session-config actions may resume because the user
is intentionally taking control of that destination.
**Consequences.** Opening Android cannot replace, stop, or later cancel a turn
already running in Desktop/TUI. Live token frames remain with the producing
client; Android observes durable progress and final history without inventing a
multi-subscriber Gateway contract. The first explicit Android mutation may pay
the resume latency that passive prewarm previously hid. Cross-client fixtures
and Android lifecycle coverage enforce the no-control-RPC observation boundary.
-1
View File
@@ -41,7 +41,6 @@ the upstream contract identifiers it depends on.
| `active_status_lifecycle` | `session.active_list` reports starting, working, waiting, and idle, then a complete empty process-wide snapshot permits removal of unambiguously owned prior rows |
| `active_status_profile_scope` | A row has no profile metadata and a caller profile hint has no effect; the client must use exact client-held ownership and reject invented attribution |
| `active_status_unsupported` | An older Gateway returns JSON-RPC method-not-found; the client retains Unknown rather than inventing Idle or Working |
| `cross_client_observation` | A second client observes a Desktop-owned working session through active status and history without resume, activate, submit, or interrupt; the producing client receives the terminal event |
Fixture evidence is a bounded metadata-only ring. It records sequence,
connection number, RPC method, event type, scope classification, and outcome.
+10 -8
View File
@@ -481,7 +481,7 @@ Bottom navigation bar with 4 tabs:
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. The process-owned conversation binding is the single connection/profile/session identity for Chat; selecting an All Profiles row atomically makes its owner the selected agent and persists that profile/session, while merely browsing All Profiles changes no agent state. Lifecycle or locale-driven Activity recreation cannot replace an explicit binding with stale persisted state, and asynchronous list/history/mutation work is accepted only for the binding's exact namespace. A profile lock hides All Profiles and rejects stale/deep-linked cross-profile opens. The All Profiles browser mode otherwise survives Activity state restoration and refetches its rows after recreation. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
- **Authoritative session activity** — one composite registry keyed by connection, normalized profile, and durable session id drives the drawer, filters, grouping, animation, accessibility, and the visible composer. Exact pending approval/clarify/sudo/secret/MCP requests produce **Needs input**; the Gateway's process-wide `session.active_list` supplies **Starting**, **Working**, and **Idle**; exact terminal or `session.info {running:false}` can settle the matching generation. Because active-list rows normally have no profile metadata, Android assigns a row only through exact foreground/detached ownership already held by that client, or explicit profile metadata if a future upstream sends it. A bounded REST directory never proves global uniqueness. Unresolved rows create no status. Resolved rows from a partial snapshot may update their exact owners, but disappearance settles a scope only when the successful process-wide snapshot was completely and unambiguously resolved for it. Restart/checkpoint recovery is **Checking**; a failed or unsupported live refresh is **Unavailable**, never inferred Idle. REST `is_active` remains recency metadata only. `process.list` may add a separate **Background work** indicator and never keeps the parent conversation Working. Old socket generations, bare session ids from another profile, and delayed snapshots cannot revive newer settled state.
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible Android-owned turn without sending `session.interrupt`; each Android-owned running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Opening, foregrounding, or selecting a saved session without that exact checkpoint is read-only observation: Android warms only the socket, reads profile-scoped history, and polls `session.active_list` without `session.resume`, `session.activate`, `prompt.submit`, or `session.interrupt`. A Desktop/TUI-owned turn therefore remains owned by its producing client; Android refreshes persisted progress and performs one final history read when the runtime settles. Explicit send/config actions may resume the destination session, explicit Stop still interrupts, and SSE fallback stays single-stream and cancels on navigation.
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible turn without sending `session.interrupt`; each running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Explicit Stop still interrupts. SSE fallback stays single-stream and cancels on navigation.
- **Queued Gateway follow-ups** — every local queued item is immutably scoped to its originating connection, profile, stored session, transport, and run generation; only that run's completion can make it eligible, and switching sessions shows only that session's queue. Restored text queues retain the same scope, while unavailable/deleted destinations and non-restorable attachment queues fail visibly instead of following the current composer. Drained messages add `queued: true` to `prompt.submit`; ordinary sends omit the field. Authoritative submit rejections (`4004`, `4018`, `4028`, `4029`, `4030`, `4090`, `5008`, `5070`, and `5071`) preserve the server message and never fall through to API-server SSE.
- **Durable composer drafts** — each connection/profile/session owns one app-private draft containing text, quote/edit context, and pending attachment bytes. Metadata and content-addressed blobs live under Android's no-backup directory, are capped at 64 drafts and 128 MB of retained blobs outside the active draft, flush when Chat backgrounds, and are removed after a successful send. Session/profile/connection navigation saves the previous owner before restoring the destination; an opened cross-profile session uses its actual owning profile rather than the global picker.
- **Large paste review** — a default-on Chat setting converts any single insertion of at least 5,000 characters into a visible `pasted-text.txt` attachment before the normal message-length limit rejects it. Gateway uses upstream `file.attach`; API-server SSE and proactive Thread paths materialize the same UTF-8 text into the outgoing prompt and remove only the synthetic attachment from that transport, so the behavior never requires Relay or silently drops content.
@@ -1131,16 +1131,18 @@ Current Android dependency versions. Source of truth is `gradle/libs.versions.to
### 10.1 Dashboard plugin
Hermes-Relay ships a hermes-agent Dashboard Plugin that surfaces relay-specific state in the gateway's web UI. The plugin subtree at `plugin/dashboard/` is discovered when `~/.hermes/plugins/hermes-relay` points at `<repo>/plugin` or when the upstream plugin manager installs `Codename-11/hermes-relay/plugin`. The gateway scans `~/.hermes/plugins/<name>/dashboard/manifest.json` at startup. Manifest fields (`name: "hermes-relay"`, `label: "Relay"`, `icon: "Activity"`, `tab.path: "/relay"`, `tab.position: "after:skills"`) place the tab after Skills in the dashboard nav.
Hermes-Relay ships a hermes-agent Dashboard Plugin that surfaces Hermes-Relay-specific state in the gateway's web UI. The plugin subtree at `plugin/dashboard/` is discovered when `~/.hermes/plugins/hermes-relay` points at `<repo>/plugin` or when the upstream plugin manager installs `Codename-11/hermes-relay/plugin`. The gateway scans `~/.hermes/plugins/<name>/dashboard/manifest.json` at startup. Manifest fields (`name: "hermes-relay"`, `label: "Hermes-Relay"`, `icon: "Activity"`, `tab.path: "/relay"`, `tab.position: "after:skills"`) place the tab after Skills in the dashboard nav.
**Four internal tabs** render inside the single `/relay` route via a shadcn `Tabs` component:
**Six internal tabs** render inside the single `/relay` route with the upstream Dashboard Tabs primitive:
| Tab | Data source | What it shows |
|-----|-------------|---------------|
| **Relay Management** | `/api/plugins/hermes-relay/overview` + `/sessions` | Relay version + uptime + health, paired-device list (token prefix, device name, last-seen, expires-at, per-channel grants), per-row Revoke button (placeholder pending proxy route). |
| **Bridge Activity** | `/api/plugins/hermes-relay/bridge-activity` | Ring buffer of the most recent 100 bridge commands (`method`, `path`, redacted `params`, `decision`, `sent_at`, `response_status`, `error`). Filter chips: All / Executed / Blocked / Confirmed / Timeout / Error. Polls every 5s; pausable via header Auto-refresh toggle (persisted to `localStorage`). |
| **Push Console** | `/api/plugins/hermes-relay/push` | Stub — returns `{configured: false, reason: "FCM not yet wired; …"}`. Renders an FCM-not-configured banner + link to the deferred-items doc. Real data ships when FCM is wired. |
| **Media Inspector** | `/api/plugins/hermes-relay/media` | Active `MediaRegistry` tokens (basename-only file name — absolute paths never leave the server — plus `content_type`, `size`, `created_at`, `expires_at`, `last_accessed`). TTL countdown decrements in real time (`setInterval(1000)`, cleaned up on unmount). Polls every 15s. |
| **Overview** | `/overview`, `/sessions`, `/bridge-activity`, `/remote-access/status`, `/update-check` | Independently loaded service health, version, uptime, paired-device summary, primary remote route, latest Bridge activity, quick actions, and recent activity. Raw `pending_commands` and `media_entry_count` are not presented as health metrics. |
| **Devices** | `/sessions`, `/pairing`, `DELETE /sessions/{prefix}` | Side-by-side standard Dashboard setup and Hermes-Relay pairing, followed by responsive paired-device cards with expiry, transport, grants, copy, and confirmed revocation. |
| **Activity** | `/bridge-activity` + `/media` | Bridge activity and a nested **Media tokens** diagnostic view. Media tokens cover token-backed `MediaRegistry` entries only; bare-path delivery is explicitly outside this view. |
| **Remote Access** | `/remote-access/*` | Tailscale, Secure Link, public URL, reachability probes, and endpoint-aware pairing previews. |
| **Git** | `/git/*` | Opt-in repository state, diff, staging, commit, branch, and confirmed write operations. |
| **Settings** | `/phone/config`, `/agent-context`, `/update-check` | Config-style General, Agent Context, and Maintenance categories, with independently scoped failures and host-native feedback. |
**Three new loopback-gated relay routes** feed the plugin backend (plus a loopback-exempt branch on the existing `GET /sessions`). All are gated by a tiny `_require_loopback()` helper that rejects any `request.remote` other than `127.0.0.1` / `::1` with HTTP 403. Full wire-shape details in [`docs/relay-server.md`](relay-server.md#http-routes).
@@ -1153,7 +1155,7 @@ Hermes-Relay ships a hermes-agent Dashboard Plugin that surfaces relay-specific
**Auth model.** The dashboard plugin's FastAPI router mounts under `/api/plugins/hermes-relay/*` inside the gateway process (itself bound to localhost). It forwards to the relay at `http://127.0.0.1:{HERMES_RELAY_PORT}` (default 8767). Both hops are loopback-only — no bearer is minted and no new credentials are introduced. Media paths are sanitized to basename-only in `MediaRegistry.list_all()` so even a future decision to expose these routes externally wouldn't leak filesystem layout.
**Frontend.** Source under `plugin/dashboard/src/` (JSX + esbuild), committed pre-built IIFE at `plugin/dashboard/dist/index.js` (~16 KB minified). Uses the dashboard's `window.__HERMES_PLUGIN_SDK__` global for React + shadcn primitives + `fetchJSON()` — no external HTTP library, no bundled React. See ADR 19 in [`docs/decisions.md`](decisions.md) for the architectural rationale.
**Frontend.** Source under `plugin/dashboard/src/` (JSX + esbuild), committed pre-built IIFE at `plugin/dashboard/dist/index.js` (about 110 KB minified). Uses the dashboard's `window.__HERMES_PLUGIN_SDK__` global for React + Nous primitives + `fetchJSON()` — no external HTTP library and no bundled React. See ADR 19 in [`docs/decisions.md`](decisions.md) for the architectural rationale.
### 10.2 Official Desktop plugin
+18 -15
View File
@@ -6,10 +6,10 @@ inspection, and remote-access setup. The build output (`dist/index.js`) is
**committed to git** because the dashboard `<script src=...>` loads it verbatim
— operators never run the build.
The header's **Connect mobile app** action is intentionally independent of the
Relay service. It renders a tokenless setup QR containing only
The **Connect mobile app** action on Overview and Devices is intentionally
independent of the Hermes-Relay service. It renders a tokenless setup QR containing only
`{"dashboard_url":"<canonical dashboard base>"}` so Android can add and verify the
standard Dashboard/Gateway connection. Relay pairing remains a separate,
standard Dashboard/Gateway connection. Hermes-Relay pairing remains a separate,
explicit **Pair new device** flow.
## Requirements
@@ -63,7 +63,7 @@ All runtime dependencies come from two globals the dashboard shell injects:
|--------|----------|
| `window.__HERMES_PLUGIN_SDK__.React` | React namespace (we never bundle React) |
| `window.__HERMES_PLUGIN_SDK__.hooks` | `useState`, `useEffect`, `useCallback`, `useMemo` |
| `window.__HERMES_PLUGIN_SDK__.components` | shadcn primitives — `Tabs*`, `Card*`, `Table*`, `Button`, `Badge`, `Alert*`, `Switch`, `Label` |
| `window.__HERMES_PLUGIN_SDK__.components` | Nous primitives — `Tabs*`, `Dialog*`, `Card*`, `Button`, `Badge`, `ConfirmDialog`, `Input`, `Label`, `Toast` |
| `window.__HERMES_PLUGIN_SDK__.fetchJSON` | Session-token-authenticated JSON fetch |
| `window.__HERMES_PLUGINS__.register(name, Component)` | Registration hook |
@@ -94,7 +94,7 @@ where scan/read correctness requires them.
All proxied by `plugin_api.py` under `/api/plugins/hermes-relay/`:
- `GET /overview` — relay version, uptime, counters
- `GET /overview` — Hermes-Relay version, uptime, health, and compatibility counters
- `GET /sessions` — paired device list
- `GET /bridge-activity?limit=N` — ring buffer of recent bridge commands
- `GET /media?include_expired=true|false` — MediaRegistry snapshot
@@ -111,20 +111,23 @@ All proxied by `plugin_api.py` under `/api/plugins/hermes-relay/`:
A representative set when showcasing the plugin:
- Management tab with paired Android and desktop sessions
- Pairing dialog with QR code and endpoint controls
- Bridge Activity command stream
- Media Inspector token list
- Overview with service status, route summary, and recent Bridge activity
- Devices with standard Dashboard setup and paired Hermes-Relay clients
- Pairing dialog with QR-first layout and endpoint controls
- Activity with Bridge command stream and Media tokens diagnostic view
- Remote Access endpoint setup and probe results
- Settings with Home Channel, Agent Context, and maintenance categories
## Auto-refresh cadence
## Live refresh cadence
| Tab | Poll interval | Notes |
|-----|---------------|-------|
| Management | 10s | `/overview` + `/sessions` |
| Activity | 5s | `/bridge-activity` |
| Media | 15s | `/media`; TTL countdown ticks every 1s independently |
| Overview | 10s | `/overview`, `/sessions`, `/bridge-activity`, and remote-access status load independently |
| Devices | 10s | `/sessions` |
| Activity → Bridge activity | 5s | `/bridge-activity` |
| Activity → Media tokens | 15s | `/media`; TTL countdown ticks every 1s independently |
| Remote Access | 15s | `/remote-access/status`; endpoint probes run on demand |
| Settings | 15s | Home Channel and Agent Context refresh independently |
Toggle persists to `localStorage['hermes-relay-autorefresh']` (default: on).
When off, each tab surfaces a manual "Refresh" button.
@@ -132,8 +135,8 @@ When off, each tab surfaces a manual "Refresh" button.
## Notes
- Session revocation calls the dashboard backend's
`DELETE /sessions/{token_prefix}` proxy route and asks for operator
confirmation before sending the destructive request.
`DELETE /sessions/{token_prefix}` proxy route through the host
`ConfirmDialog` before sending the destructive request.
- Every tab handles loading / empty / error states. The error state shows the
backend's 502 detail verbatim so "relay unreachable" is debuggable without
opening devtools.
+7 -11
View File
File diff suppressed because one or more lines are too long
+505 -8
View File
@@ -31,8 +31,6 @@
.hermes-relay-plugin,
.hermes-relay-plugin * {
box-sizing: border-box;
letter-spacing: 0 !important;
text-transform: none !important;
}
.hermes-relay-plugin h1,
@@ -66,9 +64,9 @@
overflow-wrap: anywhere;
}
.hermes-relay-plugin input,
.hermes-relay-plugin select,
.hermes-relay-plugin textarea {
.hermes-relay-plugin textarea,
.hermes-relay-plugin input:not([class]) {
font: inherit;
color: inherit;
}
@@ -85,10 +83,6 @@
* colour explicitly via ``text-foreground`` / ``text-muted-foreground``, so they
* are unaffected by dropping the inherit.
*/
.hermes-relay-plugin button {
font: inherit;
}
.hermes-relay-plugin button {
display: inline-flex;
min-height: 2rem;
@@ -198,6 +192,10 @@
}
@media (min-width: 1024px) {
.hermes-relay-plugin .lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hermes-relay-plugin .lg\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -710,6 +708,505 @@
flex-wrap: wrap;
}
.hermes-relay-plugin .hr-switch {
position: relative;
display: inline-flex;
width: 2.25rem;
min-width: 2.25rem;
height: 1.25rem;
min-height: 1.25rem;
flex: 0 0 auto;
align-items: center;
padding: 0;
border: 1px solid var(--hr-border);
border-radius: 0;
background: var(--hr-bg);
transition: border-color 150ms ease, background 150ms ease;
}
.hermes-relay-plugin .hr-switch.checked {
border-color: color-mix(in srgb, var(--hr-success) 48%, transparent);
background: color-mix(in srgb, var(--hr-success) 18%, transparent);
}
.hermes-relay-plugin .hr-switch-thumb {
display: block;
width: 0.85rem;
height: 0.85rem;
transform: translateX(0.2rem);
background: color-mix(in srgb, var(--hr-muted) 70%, transparent);
transition: transform 150ms ease, background 150ms ease;
}
.hermes-relay-plugin .hr-switch.checked .hr-switch-thumb {
transform: translateX(1rem);
background: var(--hr-success);
}
.hermes-relay-plugin .hr-switch:focus-visible {
outline: 1px solid var(--hr-ring);
outline-offset: 2px;
}
/* Hermes-Relay information architecture ---------------------------------- */
.hermes-relay-plugin .hr-plugin-toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-plugin-tablist {
min-width: 0;
flex: 1 1 auto;
overflow-x: auto;
border-bottom: 0;
}
.hermes-relay-plugin .hr-live-control {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.5rem;
min-height: 2.25rem;
padding-bottom: 0.35rem;
color: var(--hr-muted);
font-size: 0.75rem;
}
.hermes-relay-plugin .hr-tab-content {
min-width: 0;
}
.hermes-relay-plugin .hr-service-card {
overflow: hidden;
}
.hermes-relay-plugin .hr-service-header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.hermes-relay-plugin .hr-service-details {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0;
}
.hermes-relay-plugin .hr-service-details > div {
display: grid;
min-width: 9rem;
gap: 0.2rem;
padding-right: 1.5rem;
margin-right: 1.5rem;
border-right: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-service-details span {
color: var(--hr-muted);
font-size: 0.75rem;
}
.hermes-relay-plugin .hr-service-details strong {
font-family: var(--theme-font-mono, ui-monospace, monospace);
font-size: 0.875rem;
font-weight: 500;
}
.hermes-relay-plugin .hr-inline-error {
padding: 0 1rem 1rem;
}
.hermes-relay-plugin .hr-overview-grid,
.hermes-relay-plugin .hr-connection-choice-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.hermes-relay-plugin .hr-overview-device {
display: grid;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-device-list {
display: grid;
gap: 0.75rem;
}
.hermes-relay-plugin .hr-device-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem;
border: 1px solid var(--hr-border);
background: color-mix(in srgb, var(--hr-surface-muted) 55%, transparent);
}
.hermes-relay-plugin .hr-device-card-compact {
padding: 0.75rem;
}
.hermes-relay-plugin .hr-device-main {
display: grid;
min-width: 0;
gap: 0.45rem;
}
.hermes-relay-plugin .hr-device-title-row,
.hermes-relay-plugin .hr-device-meta,
.hermes-relay-plugin .hr-grant-list,
.hermes-relay-plugin .hr-device-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.hermes-relay-plugin .hr-device-meta > span + span::before {
content: "·";
margin-right: 0.45rem;
color: var(--hr-border);
}
.hermes-relay-plugin .hr-device-actions {
flex: 0 0 auto;
justify-content: flex-end;
}
.hermes-relay-plugin .hr-status-dot {
width: 0.55rem;
height: 0.55rem;
flex: 0 0 auto;
border-radius: 999px;
}
.hermes-relay-plugin .hr-status-dot-success {
background: var(--hr-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--hr-success) 14%, transparent);
}
.hermes-relay-plugin .hr-empty-state {
display: grid;
place-items: center;
gap: 0.35rem;
min-height: 9rem;
padding: 1.5rem;
text-align: center;
border: 1px dashed var(--hr-border);
}
.hermes-relay-plugin .hr-activity-list {
display: grid;
}
.hermes-relay-plugin .hr-activity-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-activity-row:first-child {
padding-top: 0;
}
.hermes-relay-plugin .hr-activity-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.hermes-relay-plugin .hr-activity-method {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hermes-relay-plugin .hr-activity-tablist {
width: fit-content;
}
/* Settings follows the upstream Config page's rail + content pattern. */
.hermes-relay-plugin .hr-settings-layout {
display: grid;
grid-template-columns: 14rem minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.hermes-relay-plugin .hr-settings-nav {
display: grid;
gap: 1px;
padding: 0.5rem;
border: 1px solid var(--hr-border);
background: color-mix(in srgb, var(--hr-surface-muted) 55%, transparent);
}
.hermes-relay-plugin .hr-settings-nav-label {
padding: 0.4rem 0.55rem 0.6rem;
color: var(--hr-muted);
font-family: var(--theme-font-display, var(--theme-font-sans));
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hermes-relay-plugin .hr-settings-nav-item {
width: 100%;
justify-content: flex-start;
padding: 0.55rem 0.65rem;
border: 0;
border-radius: 0;
background: transparent;
color: var(--hr-muted);
font-family: var(--theme-font-sans);
font-size: 0.8rem;
text-align: left;
}
.hermes-relay-plugin .hr-settings-nav-item:hover,
.hermes-relay-plugin .hr-settings-nav-item.active {
background: var(--hr-surface-muted);
color: var(--hr-text);
}
.hermes-relay-plugin .hr-settings-nav-item.active {
box-shadow: inset 2px 0 0 var(--hr-text);
}
.hermes-relay-plugin .hr-settings-content {
min-width: 0;
}
.hermes-relay-plugin .hr-audit-details {
border: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-audit-details > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
cursor: pointer;
list-style: none;
}
.hermes-relay-plugin .hr-audit-details > summary::-webkit-details-marker {
display: none;
}
.hermes-relay-plugin .hr-audit-blocks,
.hermes-relay-plugin .hr-audit-empty {
padding: 0 0.75rem 0.75rem;
}
/* Pairing uses the host Dialog primitive; these rules only compose its body. */
.hermes-relay-plugin.hr-pair-dialog {
width: min(62rem, calc(100vw - 2rem));
max-width: 62rem;
max-height: min(90vh, 52rem);
overflow-x: hidden;
overflow-y: auto;
}
.hermes-relay-plugin.hr-pair-dialog [data-slot="dialog-header"] {
padding-right: 3rem;
}
.hermes-relay-plugin .hr-pair-body {
display: grid;
grid-template-columns: minmax(18rem, 0.9fr) minmax(22rem, 1.1fr);
gap: 1rem;
padding: 1rem;
}
.hermes-relay-plugin .hr-pair-qr-column,
.hermes-relay-plugin .hr-pair-options-column {
display: grid;
align-content: start;
gap: 0.75rem;
min-width: 0;
}
.hermes-relay-plugin .hr-pair-loading {
display: grid;
min-height: 20rem;
place-items: center;
border: 1px dashed var(--hr-border);
}
.hermes-relay-plugin .hr-pair-code-row,
.hermes-relay-plugin .hr-pair-connection-header,
.hermes-relay-plugin .hr-endpoint-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.hermes-relay-plugin .hr-pair-panel,
.hermes-relay-plugin .hr-pair-advanced {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid var(--hr-border);
background: color-mix(in srgb, var(--hr-surface-muted) 50%, transparent);
}
.hermes-relay-plugin .hr-pair-panel-title,
.hermes-relay-plugin .hr-pair-advanced > summary {
font-family: var(--theme-font-display, var(--theme-font-sans));
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hermes-relay-plugin .hr-endpoint-list {
display: grid;
border-top: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-endpoint-row {
padding: 0.65rem 0;
border-bottom: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-endpoint-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.hermes-relay-plugin .hr-endpoint-address {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hermes-relay-plugin .hr-pair-advanced {
padding: 0;
}
.hermes-relay-plugin .hr-pair-advanced > summary {
padding: 0.85rem;
cursor: pointer;
list-style: none;
}
.hermes-relay-plugin .hr-pair-advanced > summary::-webkit-details-marker {
display: none;
}
.hermes-relay-plugin .hr-pair-advanced-content {
padding: 0 0.85rem 0.85rem;
border-top: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-pair-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.25rem;
}
@media (max-width: 860px) {
.hermes-relay-plugin .hr-overview-grid,
.hermes-relay-plugin .hr-connection-choice-grid,
.hermes-relay-plugin .hr-settings-layout,
.hermes-relay-plugin .hr-pair-body {
grid-template-columns: 1fr;
}
.hermes-relay-plugin .hr-settings-nav {
display: flex;
overflow-x: auto;
}
.hermes-relay-plugin .hr-settings-nav-label {
display: none;
}
.hermes-relay-plugin .hr-settings-nav-item {
width: auto;
flex: 0 0 auto;
}
.hermes-relay-plugin .hr-settings-nav-item.active {
box-shadow: inset 0 -2px 0 var(--hr-text);
}
}
@media (max-width: 640px) {
.hermes-relay-plugin .hr-plugin-toolbar {
align-items: stretch;
flex-direction: column;
gap: 0.35rem;
}
.hermes-relay-plugin .hr-live-control {
align-self: flex-end;
padding-bottom: 0;
}
.hermes-relay-plugin .hr-service-header,
.hermes-relay-plugin .hr-device-card {
align-items: stretch;
flex-direction: column;
}
.hermes-relay-plugin .hr-service-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.hermes-relay-plugin .hr-service-details > div {
min-width: 0;
padding: 0;
margin: 0;
border-right: 0;
}
.hermes-relay-plugin .hr-device-actions {
justify-content: flex-start;
}
.hermes-relay-plugin.hr-pair-dialog {
width: calc(100vw - 1rem);
max-height: calc(100vh - 1rem);
}
.hermes-relay-plugin .hr-pair-body {
padding: 0.75rem;
}
.hermes-relay-plugin .hr-pair-connection-header {
align-items: stretch;
flex-direction: column;
}
.hermes-relay-plugin .hr-pair-connection-header select {
width: 100%;
}
}
.hermes-relay-plugin .fixed {
position: fixed;
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "hermes-relay",
"label": "Relay",
"description": "Paired devices, bridge activity, media inspection, and remote access for hermes-relay",
"label": "Hermes-Relay",
"description": "Paired devices, Bridge activity, media tokens, and remote access for Hermes-Relay",
"icon": "Activity",
"version": "1.10.0",
"tab": {
@@ -122,9 +122,9 @@ export default function MobileConnectDialog({ open, onClose }) {
Chat, Manage, sessions, and standard voice.
</p>
<p className="text-xs text-muted-foreground">
This QR does not pair Relay or enable Terminal, Bridge, device tools, or Relay
sessions. Use <strong>Pair new device</strong> separately when a reachable Relay
server is available.
This QR does not pair Hermes-Relay or enable Terminal, Bridge, device tools, or
Hermes-Relay sessions. Use <strong>Pair new device</strong> separately when a
reachable Hermes-Relay service is available.
</p>
</div>
+195 -209
View File
@@ -6,7 +6,16 @@ import QRCode from "qrcode";
import { mintPairingWithMode } from "../lib/api.js";
import { Button, Badge } from "../lib/ui-shims.jsx";
const { Input, Label } = SDK.components;
const {
Input,
Label,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} = SDK.components;
// localStorage keys — per-browser, not per-user. Sensible defaults on first
// open; stick with whatever the operator last used.
@@ -176,7 +185,15 @@ export default function PairDialog({ open, onClose }) {
}).catch(() => { /* canvas failure non-fatal */ });
}, [state.status, state.data]);
const updateSetting = useCallback((patch) => {
useEffect(() => {
if (open) return;
setState({ status: "idle" });
setCopyStatus("");
setAdvancedOpen(false);
setProxyConfirmed(false);
}, [open]);
const updateSetting = useCallback((patch, remint = true) => {
setSettings((prev) => {
const next = { ...prev, ...patch };
saveSettings(next);
@@ -189,7 +206,7 @@ export default function PairDialog({ open, onClose }) {
}
// Re-mint with the new settings. Debouncing isn't worth it — the
// dropdowns only fire on user action, not typing.
setState({ status: "idle" });
if (remint) setState({ status: "idle" });
}, []);
const regenerate = useCallback(() => {
@@ -224,236 +241,205 @@ export default function PairDialog({ open, onClose }) {
const blockForProxyConsent = hostLooksProxyFronted && !proxyConfirmed;
return (
<div
className="hermes-relay-plugin hr-modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="hr-pair-dialog-title"
>
<div className="hr-modal-card">
<div className="hr-modal-header">
<div>
<h2 id="hr-pair-dialog-title" className="hr-modal-title">Pair new device</h2>
<p className="text-sm text-muted-foreground mt-1">
Scan with Hermes-Relay Android, or copy the invite for Desktop CLI.
</p>
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
<DialogContent className="hermes-relay-plugin hr-pair-dialog">
<DialogHeader>
<div className="flex flex-wrap items-center gap-2">
<DialogTitle>Pair new device</DialogTitle>
<Badge variant="outline" className="text-xs">Hermes-Relay Plugin</Badge>
</div>
<Button variant="ghost" size="sm" className="hr-modal-close" onClick={onClose}>
Close
</Button>
</div>
<div className="hr-modal-body space-y-3">
{/* Mode + prefer controls — always visible, these are the
primary inputs now. Multi-endpoint candidates get derived
server-side from Tailscale + pinned Public URL. */}
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="pair-mode">Mode</Label>
<select
id="pair-mode"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
value={settings.mode}
onChange={(e) => updateSetting({ mode: e.target.value })}
>
{MODES.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
<DialogDescription>
Scan with Hermes-Relay Android or copy the invite for Desktop CLI.
</DialogDescription>
</DialogHeader>
<div className="hr-pair-body">
<section className="hr-pair-qr-column" aria-label="Hermes-Relay pairing code">
{blockForProxyConsent ? (
<div className="rounded-md border border-amber-500/60 bg-amber-500/15 p-3 text-sm space-y-2">
<div className="font-medium">Proxy-fronted host detected</div>
<p className="text-xs">
<span className="font-mono">{settings.host}</span> appears to require browser
authentication that Hermes-Relay Android cannot present to the API route.
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={confirmProxyAndMint}>Mint anyway</Button>
<Button
size="sm"
variant="outline"
onClick={() => updateSetting({ host: "", port: 8642, tls: false })}
>
Clear override
</Button>
</div>
</div>
) : state.status === "loading" ? (
<div className="hr-pair-loading text-sm text-muted-foreground">Minting a secure code…</div>
) : state.status === "error" ? (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="font-medium mb-1">Minting failed</div>
<div className="break-words">{state.error}</div>
<Button className="mt-2" size="sm" variant="outline" onClick={regenerate}>Retry</Button>
</div>
) : state.status === "ok" ? (
<>
<div className="hr-qr-frame">
<canvas ref={canvasRef} className="block" aria-label="Hermes-Relay pairing QR code" />
</div>
<div className="hr-pair-code-row">
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Pairing code</div>
<div className="font-mono text-2xl tracking-widest">{state.data.code}</div>
</div>
<div className="text-right">
<div className="text-xs uppercase tracking-wider text-muted-foreground">Expires in</div>
<Badge variant={countdown === "expired" ? "destructive" : "outline"}>
{countdown || "—"}
</Badge>
</div>
</div>
{state.data.pairing_url ? (
<div className="space-y-2">
<Button className="w-full" size="sm" variant="outline" onClick={copyInvite}>
Copy invite
</Button>
{copyStatus ? <div className="text-center text-xs text-muted-foreground">{copyStatus}</div> : null}
</div>
) : null}
</>
) : null}
</section>
<section className="hr-pair-options-column">
<div className="hr-pair-panel">
<div className="hr-pair-panel-title">What this adds</div>
<div className="hr-grant-list">
{['Terminal', 'Bridge', 'Media', 'Voice'].map((label) => (
<Badge key={label} variant="outline" className="text-xs">{label}</Badge>
))}
</select>
</div>
<p className="text-xs text-muted-foreground">
<strong>Auto</strong> embeds every reachable endpoint so the phone
switches as networks change. Configure Tailscale + Public URL on
the <em>Remote Access</em> tab.
Extends an existing Hermes Dashboard connection with Hermes-Relay capabilities.
</p>
</div>
<div className="space-y-1">
<Label htmlFor="pair-prefer">Prefer role</Label>
<select
id="pair-prefer"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
value={settings.prefer}
onChange={(e) => updateSetting({ prefer: e.target.value })}
>
{PREFER_ROLES.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
</div>
{blockForProxyConsent && (
<div className="rounded-md border border-amber-500/60 bg-amber-500/15 p-3 text-sm space-y-2">
<div className="font-medium">Proxy-fronted host detected — confirm before minting</div>
<div className="text-xs">
<span className="font-mono">{settings.host}</span> looks like a reverse-proxy
or forward-auth gateway (Authelia, Cloudflare Access, Traefik, …). The relay
WSS will pair fine, but the phone's API calls will likely return 401/403
because it has no way to present the gateway's session cookie. Result: the
paired device shows up in Management but the app silently drops the config.
</div>
<div className="text-xs">
Prefer: leave this field blank (use <code className="font-mono">mode=auto</code>)
or switch to a non-gated endpoint (Tailscale Serve / direct LAN). See
<em>docs/remote-access.md</em> &rarr; &ldquo;Forward-auth gateways&rdquo;.
</div>
<div className="flex gap-2 pt-1">
<Button size="sm" onClick={confirmProxyAndMint}>
Mint anyway
</Button>
<Button
size="sm"
variant="outline"
onClick={() => updateSetting({ host: "", port: 8642, tls: false })}
>
Clear override
</Button>
</div>
</div>
)}
{!blockForProxyConsent && state.status === "loading" && (
<div className="text-sm text-muted-foreground">Minting code…</div>
)}
{!blockForProxyConsent && state.status === "error" && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="font-medium mb-1">Minting failed</div>
<div className="break-words">{state.error}</div>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="outline" onClick={regenerate}>Retry</Button>
</div>
</div>
)}
{!blockForProxyConsent && state.status === "ok" && (
<>
<div className="hr-qr-frame">
<canvas ref={canvasRef} className="block" />
</div>
<div className="flex items-center justify-between gap-2">
<div className="hr-pair-panel">
<div className="hr-pair-connection-header">
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Code</div>
<div className="font-mono text-2xl tracking-widest">{state.data.code}</div>
</div>
<div className="text-right">
<div className="text-xs uppercase tracking-wider text-muted-foreground">Expires in</div>
<Badge variant={countdown === "expired" ? "destructive" : "outline"}>
{countdown || "—"}
</Badge>
<div className="hr-pair-panel-title">Connection</div>
<div className="text-xs text-muted-foreground">Best available route</div>
</div>
<select
id="pair-mode"
aria-label="Connection mode"
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={settings.mode}
onChange={(event) => updateSetting({ mode: event.target.value })}
>
{MODES.map((mode) => (
<option key={mode.value} value={mode.value}>{mode.label}</option>
))}
</select>
</div>
{state.data.pairing_url ? (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs space-y-2">
<div className="uppercase tracking-wider text-muted-foreground">
Copy/paste invite
</div>
<div className="font-mono break-all">{state.data.pairing_url}</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={copyInvite}>
Copy invite URL
</Button>
{copyStatus ? (
<span className="text-muted-foreground">{copyStatus}</span>
) : null}
</div>
</div>
) : null}
{/* Compact endpoint receipt — full preview + probes live on
the Remote Access tab. */}
{endpoints && endpoints.length > 0 ? (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-xs space-y-1">
<div className="uppercase tracking-wider text-muted-foreground">
Endpoints in this QR ({endpoints.length})
</div>
{endpoints.map((ep) => (
<div key={`${ep.role}-${ep.priority}`} className="flex items-center gap-2">
<Badge variant="outline" className="text-xs capitalize">{ep.role}</Badge>
<span className="font-mono">
{ep.api.host}{ep.api.port ? `:${ep.api.port}` : ""}
<div className="hr-endpoint-list">
{endpoints.map((endpoint) => (
<div key={`${endpoint.role}-${endpoint.priority}`} className="hr-endpoint-row">
<Badge variant="outline" className="text-xs capitalize">{endpoint.role}</Badge>
<span className="font-mono text-xs hr-endpoint-address">
{endpoint.api.host}{endpoint.api.port ? `:${endpoint.api.port}` : ""}
</span>
<span className="text-muted-foreground ml-auto">p{ep.priority}</span>
<span className="text-xs text-muted-foreground">p{endpoint.priority}</span>
</div>
))}
</div>
) : null}
<div className="flex gap-2 pt-1">
<Button size="sm" variant="outline" onClick={regenerate}>
New code
</Button>
<Button size="sm" onClick={onClose}>Done</Button>
</div>
</>
)}
) : (
<div className="text-xs text-muted-foreground">Automatic route selection will use server configuration.</div>
)}
</div>
{/* Advanced — API server override. Most operators never need
this; it's kept for edge cases. Warn when the host looks
proxy-fronted (Authelia etc.) because the phone has no way
to present that auth material. */}
<div className="border-t border-border pt-3">
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{advancedOpen ? "▾ Hide advanced" : "▸ Advanced · API-server override"}
</button>
{advancedOpen && (
<div className="mt-3 space-y-3">
<p className="text-xs text-muted-foreground">
Override the API-server host embedded in the QR (defaults to the
relay's configured API host). Relay URL is auto-derived server-side —
edit Tailscale / Public URL on the <em>Remote Access</em> tab instead.
</p>
{proxyWarning ? (
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-2 text-xs">
<strong>Heads-up:</strong> <span className="font-mono">{settings.host}</span> looks
like a reverse-proxy / forward-auth host. If it's fronted by
Authelia, Cloudflare Access, or similar, the phone will fail
to authenticate against the API even though the relay WSS
pairs fine. Leave this blank and let <code className="font-mono">mode=auto</code> pick.
</div>
) : null}
<div className="space-y-1">
<Label htmlFor="pair-host">API host (optional)</Label>
<Input
id="pair-host"
value={settings.host}
placeholder="leave blank to use server config"
onChange={(e) => updateSetting({ host: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<details className="hr-pair-advanced" open={advancedOpen}>
<summary onClick={(event) => { event.preventDefault(); setAdvancedOpen((value) => !value); }}>
Advanced connection options
</summary>
{advancedOpen ? (
<div className="hr-pair-advanced-content space-y-3">
<div className="space-y-1">
<Label htmlFor="pair-port">API port</Label>
<Label htmlFor="pair-prefer">Prefer role</Label>
<select
id="pair-prefer"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
value={settings.prefer}
onChange={(event) => updateSetting({ prefer: event.target.value })}
>
{PREFER_ROLES.map((role) => (
<option key={role.value} value={role.value}>{role.label}</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="pair-host">API host override</Label>
<Input
id="pair-port"
type="number"
min="1"
max="65535"
value={settings.port}
onChange={(e) => updateSetting({ port: parseInt(e.target.value, 10) || 8642 })}
id="pair-host"
value={settings.host}
placeholder="Use server configuration"
onChange={(event) => updateSetting({ host: event.target.value }, false)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="pair-tls">Scheme</Label>
<div className="flex items-center gap-2 pt-1">
<input
id="pair-tls"
type="checkbox"
className="h-4 w-4"
checked={!!settings.tls}
onChange={(e) => updateSetting({ tls: e.target.checked })}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="pair-port">API port</Label>
<Input
id="pair-port"
type="number"
min="1"
max="65535"
value={settings.port}
onChange={(event) => updateSetting({ port: parseInt(event.target.value, 10) || 8642 }, false)}
/>
<Label htmlFor="pair-tls" className="text-sm font-normal">
</div>
<div className="space-y-1">
<Label htmlFor="pair-tls">Scheme</Label>
<label className="hr-pair-checkbox text-sm" htmlFor="pair-tls">
<input
id="pair-tls"
type="checkbox"
className="h-4 w-4"
checked={!!settings.tls}
onChange={(event) => updateSetting({ tls: event.target.checked }, false)}
/>
https://
</Label>
</label>
</div>
</div>
{proxyWarning ? (
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-2 text-xs">
This host appears proxy-fronted and may reject API requests from Hermes-Relay Android.
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={regenerate}>Apply and mint</Button>
<Button
size="sm"
variant="ghost"
onClick={() => updateSetting({ host: "", port: 8642, tls: false })}
>
Clear override
</Button>
</div>
</div>
<Button size="sm" variant="ghost" onClick={() => updateSetting({ host: "", port: 8642, tls: false })}>
Clear override
</Button>
</div>
)}
</div>
) : null}
</details>
</section>
</div>
</div>
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={regenerate} disabled={state.status === "loading"}>
New code
</Button>
<Button size="sm" onClick={onClose}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -30,10 +30,10 @@ const POLL_MS = 15000;
// (label, tone) for each derived state. Tone strings are the host Badge
// contract; see web/node_modules/@nous-research/ui/.../badge.tsx.
const STATES = {
loading: { label: "Relay · …", tone: "secondary" },
offline: { label: "Relay · offline", tone: "warning" },
unpaired: { label: "Relay · unpaired", tone: "secondary" },
connected: { label: "Relay · connected", tone: "success" },
loading: { label: "Hermes-Relay · …", tone: "secondary" },
offline: { label: "Hermes-Relay · offline", tone: "warning" },
unpaired: { label: "Hermes-Relay · unpaired", tone: "secondary" },
connected: { label: "Hermes-Relay · connected", tone: "success" },
};
function deriveState(overview) {
@@ -73,7 +73,7 @@ export default function RelayStatusSlot() {
<Badge
tone={tone}
className="whitespace-nowrap text-xs"
title="hermes-relay status"
title="Hermes-Relay status"
>
{label}
</Badge>
+58 -64
View File
@@ -2,25 +2,28 @@ const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { useState, useEffect, useCallback } = SDK.hooks;
import RelayManagement from "./tabs/RelayManagement.jsx";
import BridgeActivity from "./tabs/BridgeActivity.jsx";
import MediaInspector from "./tabs/MediaInspector.jsx";
import RelayDevices, {
RelayOverview,
RelaySettings,
} from "./tabs/RelayManagement.jsx";
import ActivityHub from "./tabs/ActivityHub.jsx";
import RemoteAccess from "./tabs/RemoteAccess.jsx";
import GitState from "./tabs/GitState.jsx";
import RelayStatusSlot from "./components/RelayStatusSlot.jsx";
import MobileConnectDialog from "./components/MobileConnectDialog.jsx";
import { Button, Switch } from "./lib/ui-shims.jsx";
import { Switch } from "./lib/ui-shims.jsx";
const { Label } = SDK.components;
const { Label, Tabs, TabsList, TabsTrigger } = SDK.components;
const AUTO_REFRESH_KEY = "hermes-relay-autorefresh";
const TABS = [
{ key: "management", label: "Management" },
{ key: "overview", label: "Overview" },
{ key: "devices", label: "Devices" },
{ key: "activity", label: "Activity" },
{ key: "media", label: "Media" },
{ key: "remote", label: "Remote Access" },
{ key: "git", label: "Git" },
{ key: "settings", label: "Settings" },
];
function readAutoRefresh() {
@@ -41,20 +44,7 @@ function writeAutoRefresh(value) {
}
}
function TabButton({ active, onClick, children }) {
const base =
"px-4 py-2 text-sm font-medium border-b-2 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-ring";
const on = "border-foreground text-foreground";
const off = "border-transparent text-muted-foreground hover:text-foreground";
return (
<button type="button" onClick={onClick} className={`${base} ${active ? on : off}`}>
{children}
</button>
);
}
function RelayPluginRoot() {
const [tab, setTab] = useState("management");
const [mobileConnectOpen, setMobileConnectOpen] = useState(false);
const [autoRefresh, setAutoRefreshState] = useState(readAutoRefresh);
@@ -72,51 +62,55 @@ function RelayPluginRoot() {
const closeMobileConnect = useCallback(() => setMobileConnectOpen(false), []);
return (
<div className="hermes-relay-plugin space-y-4 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">Relay</h1>
<p className="text-sm text-muted-foreground">
Connect clients and manage Relay sessions, activity, media, and remote access.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
size="sm"
onClick={openMobileConnect}
>
Connect mobile app
</Button>
<div className="flex items-center gap-2">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label htmlFor="auto-refresh">Auto-refresh</Label>
</div>
</div>
</div>
<div className="hermes-relay-plugin p-4">
<Tabs defaultValue="overview" className="hr-plugin-tabs">
{(tab, setTab) => (
<>
<div className="hr-plugin-toolbar">
<TabsList className="hr-plugin-tablist">
{TABS.map((item) => (
<TabsTrigger
key={item.key}
active={tab === item.key}
value={item.key}
onClick={() => setTab(item.key)}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
<div className="hr-live-control">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label htmlFor="auto-refresh">Live</Label>
</div>
</div>
<div role="tablist" className="flex items-center gap-1 border-b border-border">
{TABS.map((t) => (
<TabButton
key={t.key}
active={tab === t.key}
onClick={() => setTab(t.key)}
>
{t.label}
</TabButton>
))}
</div>
<div className="mt-4">
{tab === "management" && <RelayManagement autoRefresh={autoRefresh} />}
{tab === "activity" && <BridgeActivity autoRefresh={autoRefresh} />}
{tab === "media" && <MediaInspector autoRefresh={autoRefresh} />}
{tab === "remote" && <RemoteAccess autoRefresh={autoRefresh} />}
{tab === "git" && <GitState autoRefresh={autoRefresh} />}
</div>
<div className="hr-tab-content">
{tab === "overview" && (
<RelayOverview
autoRefresh={autoRefresh}
onConnectMobile={openMobileConnect}
onNavigate={setTab}
/>
)}
{tab === "devices" && (
<RelayDevices
autoRefresh={autoRefresh}
onConnectMobile={openMobileConnect}
/>
)}
{tab === "activity" && <ActivityHub autoRefresh={autoRefresh} />}
{tab === "remote" && <RemoteAccess autoRefresh={autoRefresh} />}
{tab === "git" && <GitState autoRefresh={autoRefresh} />}
{tab === "settings" && <RelaySettings autoRefresh={autoRefresh} />}
</div>
</>
)}
</Tabs>
<MobileConnectDialog
open={mobileConnectOpen}
onClose={closeMobileConnect}
+11 -7
View File
@@ -122,15 +122,19 @@ export const CardDescription = C.CardDescription || (({ children, className = ""
<p className={`text-sm text-muted-foreground ${className}`}>{children}</p>
));
export const Switch = C.Switch || (({ checked, onCheckedChange, id, disabled }) => (
<input
export const Switch = C.Switch || (({ checked, onCheckedChange, id, disabled, ...rest }) => (
<button
id={id}
type="checkbox"
checked={!!checked}
type="button"
role="switch"
aria-checked={!!checked}
disabled={!!disabled}
onChange={(e) => onCheckedChange && onCheckedChange(e.target.checked)}
className="h-4 w-4"
/>
className={`hr-switch ${checked ? "checked" : ""}`}
onClick={() => onCheckedChange && onCheckedChange(!checked)}
{...rest}
>
<span className="hr-switch-thumb" aria-hidden="true" />
</button>
));
export const Table = C.Table || (({ children, className = "" }) => (
+505 -8
View File
@@ -31,8 +31,6 @@
.hermes-relay-plugin,
.hermes-relay-plugin * {
box-sizing: border-box;
letter-spacing: 0 !important;
text-transform: none !important;
}
.hermes-relay-plugin h1,
@@ -66,9 +64,9 @@
overflow-wrap: anywhere;
}
.hermes-relay-plugin input,
.hermes-relay-plugin select,
.hermes-relay-plugin textarea {
.hermes-relay-plugin textarea,
.hermes-relay-plugin input:not([class]) {
font: inherit;
color: inherit;
}
@@ -85,10 +83,6 @@
* colour explicitly via ``text-foreground`` / ``text-muted-foreground``, so they
* are unaffected by dropping the inherit.
*/
.hermes-relay-plugin button {
font: inherit;
}
.hermes-relay-plugin button {
display: inline-flex;
min-height: 2rem;
@@ -198,6 +192,10 @@
}
@media (min-width: 1024px) {
.hermes-relay-plugin .lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hermes-relay-plugin .lg\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -710,6 +708,505 @@
flex-wrap: wrap;
}
.hermes-relay-plugin .hr-switch {
position: relative;
display: inline-flex;
width: 2.25rem;
min-width: 2.25rem;
height: 1.25rem;
min-height: 1.25rem;
flex: 0 0 auto;
align-items: center;
padding: 0;
border: 1px solid var(--hr-border);
border-radius: 0;
background: var(--hr-bg);
transition: border-color 150ms ease, background 150ms ease;
}
.hermes-relay-plugin .hr-switch.checked {
border-color: color-mix(in srgb, var(--hr-success) 48%, transparent);
background: color-mix(in srgb, var(--hr-success) 18%, transparent);
}
.hermes-relay-plugin .hr-switch-thumb {
display: block;
width: 0.85rem;
height: 0.85rem;
transform: translateX(0.2rem);
background: color-mix(in srgb, var(--hr-muted) 70%, transparent);
transition: transform 150ms ease, background 150ms ease;
}
.hermes-relay-plugin .hr-switch.checked .hr-switch-thumb {
transform: translateX(1rem);
background: var(--hr-success);
}
.hermes-relay-plugin .hr-switch:focus-visible {
outline: 1px solid var(--hr-ring);
outline-offset: 2px;
}
/* Hermes-Relay information architecture ---------------------------------- */
.hermes-relay-plugin .hr-plugin-toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-plugin-tablist {
min-width: 0;
flex: 1 1 auto;
overflow-x: auto;
border-bottom: 0;
}
.hermes-relay-plugin .hr-live-control {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.5rem;
min-height: 2.25rem;
padding-bottom: 0.35rem;
color: var(--hr-muted);
font-size: 0.75rem;
}
.hermes-relay-plugin .hr-tab-content {
min-width: 0;
}
.hermes-relay-plugin .hr-service-card {
overflow: hidden;
}
.hermes-relay-plugin .hr-service-header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.hermes-relay-plugin .hr-service-details {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0;
}
.hermes-relay-plugin .hr-service-details > div {
display: grid;
min-width: 9rem;
gap: 0.2rem;
padding-right: 1.5rem;
margin-right: 1.5rem;
border-right: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-service-details span {
color: var(--hr-muted);
font-size: 0.75rem;
}
.hermes-relay-plugin .hr-service-details strong {
font-family: var(--theme-font-mono, ui-monospace, monospace);
font-size: 0.875rem;
font-weight: 500;
}
.hermes-relay-plugin .hr-inline-error {
padding: 0 1rem 1rem;
}
.hermes-relay-plugin .hr-overview-grid,
.hermes-relay-plugin .hr-connection-choice-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.hermes-relay-plugin .hr-overview-device {
display: grid;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-device-list {
display: grid;
gap: 0.75rem;
}
.hermes-relay-plugin .hr-device-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem;
border: 1px solid var(--hr-border);
background: color-mix(in srgb, var(--hr-surface-muted) 55%, transparent);
}
.hermes-relay-plugin .hr-device-card-compact {
padding: 0.75rem;
}
.hermes-relay-plugin .hr-device-main {
display: grid;
min-width: 0;
gap: 0.45rem;
}
.hermes-relay-plugin .hr-device-title-row,
.hermes-relay-plugin .hr-device-meta,
.hermes-relay-plugin .hr-grant-list,
.hermes-relay-plugin .hr-device-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.hermes-relay-plugin .hr-device-meta > span + span::before {
content: "·";
margin-right: 0.45rem;
color: var(--hr-border);
}
.hermes-relay-plugin .hr-device-actions {
flex: 0 0 auto;
justify-content: flex-end;
}
.hermes-relay-plugin .hr-status-dot {
width: 0.55rem;
height: 0.55rem;
flex: 0 0 auto;
border-radius: 999px;
}
.hermes-relay-plugin .hr-status-dot-success {
background: var(--hr-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--hr-success) 14%, transparent);
}
.hermes-relay-plugin .hr-empty-state {
display: grid;
place-items: center;
gap: 0.35rem;
min-height: 9rem;
padding: 1.5rem;
text-align: center;
border: 1px dashed var(--hr-border);
}
.hermes-relay-plugin .hr-activity-list {
display: grid;
}
.hermes-relay-plugin .hr-activity-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-activity-row:first-child {
padding-top: 0;
}
.hermes-relay-plugin .hr-activity-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.hermes-relay-plugin .hr-activity-method {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hermes-relay-plugin .hr-activity-tablist {
width: fit-content;
}
/* Settings follows the upstream Config page's rail + content pattern. */
.hermes-relay-plugin .hr-settings-layout {
display: grid;
grid-template-columns: 14rem minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.hermes-relay-plugin .hr-settings-nav {
display: grid;
gap: 1px;
padding: 0.5rem;
border: 1px solid var(--hr-border);
background: color-mix(in srgb, var(--hr-surface-muted) 55%, transparent);
}
.hermes-relay-plugin .hr-settings-nav-label {
padding: 0.4rem 0.55rem 0.6rem;
color: var(--hr-muted);
font-family: var(--theme-font-display, var(--theme-font-sans));
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hermes-relay-plugin .hr-settings-nav-item {
width: 100%;
justify-content: flex-start;
padding: 0.55rem 0.65rem;
border: 0;
border-radius: 0;
background: transparent;
color: var(--hr-muted);
font-family: var(--theme-font-sans);
font-size: 0.8rem;
text-align: left;
}
.hermes-relay-plugin .hr-settings-nav-item:hover,
.hermes-relay-plugin .hr-settings-nav-item.active {
background: var(--hr-surface-muted);
color: var(--hr-text);
}
.hermes-relay-plugin .hr-settings-nav-item.active {
box-shadow: inset 2px 0 0 var(--hr-text);
}
.hermes-relay-plugin .hr-settings-content {
min-width: 0;
}
.hermes-relay-plugin .hr-audit-details {
border: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-audit-details > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem;
cursor: pointer;
list-style: none;
}
.hermes-relay-plugin .hr-audit-details > summary::-webkit-details-marker {
display: none;
}
.hermes-relay-plugin .hr-audit-blocks,
.hermes-relay-plugin .hr-audit-empty {
padding: 0 0.75rem 0.75rem;
}
/* Pairing uses the host Dialog primitive; these rules only compose its body. */
.hermes-relay-plugin.hr-pair-dialog {
width: min(62rem, calc(100vw - 2rem));
max-width: 62rem;
max-height: min(90vh, 52rem);
overflow-x: hidden;
overflow-y: auto;
}
.hermes-relay-plugin.hr-pair-dialog [data-slot="dialog-header"] {
padding-right: 3rem;
}
.hermes-relay-plugin .hr-pair-body {
display: grid;
grid-template-columns: minmax(18rem, 0.9fr) minmax(22rem, 1.1fr);
gap: 1rem;
padding: 1rem;
}
.hermes-relay-plugin .hr-pair-qr-column,
.hermes-relay-plugin .hr-pair-options-column {
display: grid;
align-content: start;
gap: 0.75rem;
min-width: 0;
}
.hermes-relay-plugin .hr-pair-loading {
display: grid;
min-height: 20rem;
place-items: center;
border: 1px dashed var(--hr-border);
}
.hermes-relay-plugin .hr-pair-code-row,
.hermes-relay-plugin .hr-pair-connection-header,
.hermes-relay-plugin .hr-endpoint-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.hermes-relay-plugin .hr-pair-panel,
.hermes-relay-plugin .hr-pair-advanced {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid var(--hr-border);
background: color-mix(in srgb, var(--hr-surface-muted) 50%, transparent);
}
.hermes-relay-plugin .hr-pair-panel-title,
.hermes-relay-plugin .hr-pair-advanced > summary {
font-family: var(--theme-font-display, var(--theme-font-sans));
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hermes-relay-plugin .hr-endpoint-list {
display: grid;
border-top: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-endpoint-row {
padding: 0.65rem 0;
border-bottom: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-endpoint-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.hermes-relay-plugin .hr-endpoint-address {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hermes-relay-plugin .hr-pair-advanced {
padding: 0;
}
.hermes-relay-plugin .hr-pair-advanced > summary {
padding: 0.85rem;
cursor: pointer;
list-style: none;
}
.hermes-relay-plugin .hr-pair-advanced > summary::-webkit-details-marker {
display: none;
}
.hermes-relay-plugin .hr-pair-advanced-content {
padding: 0 0.85rem 0.85rem;
border-top: 1px solid var(--hr-border);
}
.hermes-relay-plugin .hr-pair-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.25rem;
}
@media (max-width: 860px) {
.hermes-relay-plugin .hr-overview-grid,
.hermes-relay-plugin .hr-connection-choice-grid,
.hermes-relay-plugin .hr-settings-layout,
.hermes-relay-plugin .hr-pair-body {
grid-template-columns: 1fr;
}
.hermes-relay-plugin .hr-settings-nav {
display: flex;
overflow-x: auto;
}
.hermes-relay-plugin .hr-settings-nav-label {
display: none;
}
.hermes-relay-plugin .hr-settings-nav-item {
width: auto;
flex: 0 0 auto;
}
.hermes-relay-plugin .hr-settings-nav-item.active {
box-shadow: inset 0 -2px 0 var(--hr-text);
}
}
@media (max-width: 640px) {
.hermes-relay-plugin .hr-plugin-toolbar {
align-items: stretch;
flex-direction: column;
gap: 0.35rem;
}
.hermes-relay-plugin .hr-live-control {
align-self: flex-end;
padding-bottom: 0;
}
.hermes-relay-plugin .hr-service-header,
.hermes-relay-plugin .hr-device-card {
align-items: stretch;
flex-direction: column;
}
.hermes-relay-plugin .hr-service-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.hermes-relay-plugin .hr-service-details > div {
min-width: 0;
padding: 0;
margin: 0;
border-right: 0;
}
.hermes-relay-plugin .hr-device-actions {
justify-content: flex-start;
}
.hermes-relay-plugin.hr-pair-dialog {
width: calc(100vw - 1rem);
max-height: calc(100vh - 1rem);
}
.hermes-relay-plugin .hr-pair-body {
padding: 0.75rem;
}
.hermes-relay-plugin .hr-pair-connection-header {
align-items: stretch;
flex-direction: column;
}
.hermes-relay-plugin .hr-pair-connection-header select {
width: 100%;
}
}
.hermes-relay-plugin .fixed {
position: fixed;
}
+40
View File
@@ -0,0 +1,40 @@
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
import BridgeActivity from "./BridgeActivity.jsx";
import MediaInspector from "./MediaInspector.jsx";
const { Tabs, TabsList, TabsTrigger } = SDK.components;
const VIEWS = [
{ key: "bridge", label: "Bridge activity" },
{ key: "media", label: "Media tokens" },
];
export default function ActivityHub({ autoRefresh }) {
return (
<Tabs defaultValue="bridge" className="hr-activity-tabs">
{(view, setView) => (
<>
<TabsList className="hr-activity-tablist" aria-label="Hermes-Relay activity views">
{VIEWS.map((item) => (
<TabsTrigger
key={item.key}
active={view === item.key}
value={item.key}
onClick={() => setView(item.key)}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
{view === "bridge" ? (
<BridgeActivity autoRefresh={autoRefresh} />
) : (
<MediaInspector autoRefresh={autoRefresh} />
)}
</>
)}
</Tabs>
);
}
+2 -2
View File
@@ -100,7 +100,7 @@ export default function BridgeActivity({ autoRefresh }) {
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>Relay unreachable</AlertTitle>
<AlertTitle>Hermes-Relay unreachable</AlertTitle>
<AlertDescription>
<pre className="whitespace-pre-wrap text-xs">{error}</pre>
{!autoRefresh ? (
@@ -118,7 +118,7 @@ export default function BridgeActivity({ autoRefresh }) {
<CardHeader>
<CardTitle>Bridge activity</CardTitle>
<CardDescription>
Most recent bridge commands routed through the relay. Newest first; capped at 100.
Most recent Bridge commands routed through Hermes-Relay. Newest first; capped at 100.
</CardDescription>
</CardHeader>
<CardContent>
+3 -3
View File
@@ -72,7 +72,7 @@ export default function MediaInspector({ autoRefresh }) {
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>Relay unreachable</AlertTitle>
<AlertTitle>Hermes-Relay unreachable</AlertTitle>
<AlertDescription>
<pre className="whitespace-pre-wrap text-xs">{error}</pre>
{!autoRefresh ? (
@@ -90,9 +90,9 @@ export default function MediaInspector({ autoRefresh }) {
return (
<Card>
<CardHeader>
<CardTitle>Media inspector</CardTitle>
<CardTitle>Media tokens</CardTitle>
<CardDescription>
Active MediaRegistry tokens. Expired entries are hidden by default.
Diagnostic view of token-backed Hermes-Relay media. Bare-path deliveries are not included.
</CardDescription>
</CardHeader>
<CardContent>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const read = (path) => readFileSync(new URL(path, import.meta.url), "utf8");
test("Hermes-Relay navigation separates overview, devices, activity, and settings", () => {
const source = read("../src/index.jsx");
for (const label of ["Overview", "Devices", "Activity", "Remote Access", "Git", "Settings"]) {
assert.match(source, new RegExp(`label: "${label}"`));
}
assert.doesNotMatch(source, /label: "Management"|label: "Media"/);
assert.match(source, /<Tabs defaultValue="overview"/);
});
test("overview does not present transient Bridge or raw media registry counters", () => {
const source = read("../src/tabs/RelayManagement.jsx");
assert.doesNotMatch(source, /pending_commands|media_entry_count|Pending \/ media/);
assert.match(source, /label="Last Bridge event"/);
assert.match(source, /label="Remote access"/);
});
test("pairing uses the host dialog and keeps advanced connection options secondary", () => {
const source = read("../src/components/PairDialog.jsx");
assert.match(source, /<Dialog open=\{open\}/);
assert.match(source, /<DialogContent className="hermes-relay-plugin hr-pair-dialog">/);
assert.match(source, /Advanced connection options/);
assert.doesNotMatch(source, /hr-modal-backdrop/);
});
test("media is labeled as a bounded token diagnostic", () => {
const source = read("../src/tabs/MediaInspector.jsx");
assert.match(source, /<CardTitle>Media tokens<\/CardTitle>/);
assert.match(source, /Bare-path deliveries are not included/);
});
test("switch fallback preserves switch semantics when the host omits the primitive", () => {
const source = read("../src/lib/ui-shims.jsx");
assert.match(source, /role="switch"/);
assert.match(source, /aria-checked=\{!!checked\}/);
assert.doesNotMatch(source, /type="checkbox"[\s\S]*onCheckedChange/);
});
@@ -100,47 +100,6 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(["user", "assistant"], [row["role"] for row in history["messages"]])
self.assertEqual(2, history["pagination"]["returned"])
async def test_cross_client_observer_never_claims_or_interrupts_producer(self) -> None:
fixture, base_url = await self.start("cross_client_observation")
producer, _ = await self.connect(base_url)
await self.rpc(producer, 1, "session.resume", {"session_id": fixture.scenario.stored_session_id})
await producer.receive_json()
await self.rpc(producer, 2, "prompt.submit", {"text": "producer-only content"})
producer_frames = await self.frames_until(
producer,
lambda frame: frame.get("params", {}).get("type") == "message.delta",
)
observer, _ = await self.connect(base_url)
await self.rpc(observer, 3, "session.active_list")
active = (await observer.receive_json())["result"]["sessions"]
self.assertEqual("working", active[0]["status"])
async with self.session.get(
f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages",
params={"profile": "default", "limit": 500, "offset": 0, "order": "asc"},
) as response:
self.assertEqual(200, response.status)
self.assertIsInstance((await response.json())["messages"], list)
await observer.close()
producer_frames += await self.frames_until(
producer,
lambda frame: frame.get("params", {}).get("type") == "message.complete",
)
self.assertIn(
"message.complete",
[frame.get("params", {}).get("type") for frame in producer_frames],
)
async with self.session.get(f"{base_url}/__fixture__/evidence") as response:
evidence = await response.json()
observer_methods = [
entry.get("method")
for entry in evidence["entries"]
if entry.get("kind") == "rpc" and entry.get("connection") == 2
]
self.assertEqual(["session.active_list"], observer_methods)
self.assertNotIn("session.interrupt", observer_methods)
async def test_rapid_chunks_tools_and_interims_keep_wire_order(self) -> None:
_, base_url = await self.start("rapid_tools_interims")
ws, _ = await self.connect(base_url)
@@ -306,7 +265,6 @@ class ScenarioTestCase(unittest.TestCase):
"active_status_lifecycle",
"active_status_profile_scope",
"active_status_unsupported",
"cross_client_observation",
"ordinary_turn",
"rapid_tools_interims",
"terminal_gap_activate",
@@ -346,10 +304,6 @@ class ScenarioTestCase(unittest.TestCase):
("gateway.settled_session_info",),
load_scenario("terminal_gap_session_info").contract_requirements,
)
self.assertEqual(
("gateway.message_complete", "gateway.session_active_list"),
load_scenario("cross_client_observation").contract_requirements,
)
def test_tls_arguments_must_be_paired(self) -> None:
with contextlib.redirect_stderr(io.StringIO()):
@@ -1,40 +0,0 @@
{
"name": "cross_client_observation",
"live_session_id": "fixture-desktop-live",
"stored_session_id": "fixture-shared-session",
"profile": "default",
"contract_requirements": [
"gateway.message_complete",
"gateway.session_active_list"
],
"initial_history": [],
"turns": [
{
"steps": [
{"op": "event", "type": "message.start"},
{"op": "event", "type": "message.delta", "payload": {"text": "Desktop still owns this turn."}},
{"op": "sleep", "milliseconds": 250},
{
"op": "persist",
"messages": [
{"id": 1, "role": "user", "content": "Desktop prompt.", "timestamp": 1.0},
{"id": 2, "role": "assistant", "content": "Desktop still owns this turn.", "timestamp": 2.0, "finish_reason": "stop"}
]
},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Desktop still owns this turn.", "status": "complete"}}
]
}
],
"active_list": {
"supported": true,
"snapshots": [
[
{"id": "fixture-desktop-live", "session_key": "fixture-shared-session", "status": "working", "current": false}
],
[
{"id": "fixture-desktop-live", "session_key": "fixture-shared-session", "status": "idle", "current": false}
]
]
}
}
+56 -39
View File
@@ -7,11 +7,11 @@ profile-scoped backend; neither creates a second Relay service or state store.
## What It Is
If your Hermes server runs the Dashboard Plugin System, the unified
Hermes-Relay plugin contributes a **Relay** page alongside Chat, Skills, Memory,
Hermes-Relay plugin contributes a **Hermes-Relay** page alongside Chat, Skills, Memory,
and the other Dashboard pages. The same package also contributes the official
Hermes Desktop pane and the `hermes relay` / `hermes pair` CLI commands.
The plugin is the browser operator surface for Relay. It reads health, sessions,
The plugin is the browser operator surface for Hermes-Relay. It reads health, sessions,
activity, media, and remote-access state, and performs explicit scoped actions:
minting invites, revoking sessions, changing Relay-owned settings, and managing
remote-access helpers. It never turns a viewed card into an implicit mutation;
@@ -47,11 +47,11 @@ API; Hermes-Relay does not use private hooks to imitate those features.
## Accessing the Dashboard
Open the hermes-agent dashboard in your browser (default: `http://localhost:<dashboard_port>`). The **Relay** tab sits between Skills and whatever you have next in your nav order — click it and you land on the four-tab shell.
Open the hermes-agent dashboard in your browser (default: `http://localhost:<dashboard_port>`). The **Hermes-Relay** tab sits between Skills and whatever you have next in your nav order — click it and you land on the six-tab shell.
Use the real dashboard/Manage surface for this URL: start it with `hermes dashboard` and point Android's Dashboard URL at that service (default `:9119`). `hermes serve` is a headless backend/API command; it is useful for programmatic clients, but it does not serve the Manage UI that Android uses for Skills, Models, Keys, Profiles, voice auth, or dashboard plugins. `hermes relay doctor` warns when the Dashboard URL looks like an API-server/headless URL instead of the dashboard surface.
The plugin's header shows the relay version, overall health (green / red dot), and an **Auto-refresh** toggle that persists to `localStorage`. Turn auto-refresh off if you're reading a specific activity row and don't want it to scroll out from under you.
The Dashboard header names the page **Hermes-Relay** once. The plugin's Overview shows service health, version, uptime, paired-device count, remote-route summary, and recent Bridge activity. The **Live** switch persists to `localStorage`; turn it off when you want the current diagnostic view to stay still.
## Android Manage Surface
@@ -89,14 +89,14 @@ Server-side dashboard auth is owned by upstream Hermes. For current provider reg
## Connect and pair clients
The Relay page exposes two different setup actions. They intentionally do not
The Hermes-Relay Overview and Devices tabs expose two different setup actions. They intentionally do not
share credentials:
### Connect mobile app — standard upstream connection
Use this first for Android:
1. Click **Connect mobile app** in the Relay page header.
1. Open **Hermes-Relay → Devices** and click **Show setup QR** under **Connect mobile app**.
2. In Android **Connect**, choose **Scan Hermes setup QR**.
3. Scan the tokenless QR and sign in if prompted.
@@ -110,7 +110,7 @@ Relay pairing code.
Use this after the standard Android connection, or whenever pairing Android,
the Desktop CLI, or another Relay client:
1. Click **Pair new device** on the Management tab.
1. Open **Hermes-Relay → Devices** and click **Pair new device**.
2. Keep **Auto** mode unless you specifically want LAN-only, Tailscale-only, or
a pinned public route.
3. Android scans the QR from **Settings → Connections → Pair Hermes Relay**.
@@ -120,44 +120,59 @@ the Desktop CLI, or another Relay client:
hermes-relay pair --pair-qr "hermes-relay://pair?payload=…" --grant-tools
```
Official Hermes Desktop exposes the same backend in its **Relay** pane. Its
Official Hermes Desktop exposes the same backend in its **Hermes-Relay** pane. Its
**Pair new device** action shows the one-time code and copyable invite for a CLI
or UI client; it does not need to render a camera QR.
The invite is one-time and credential-bearing. Keep it private and mint a new
one when it expires or has already been consumed.
## The Four Tabs
## The Six Tabs
### Relay Management
### Overview
The landing tab. Shows:
The landing tab is status-first. Its panels load independently, so an optional
remote-access or activity failure does not replace healthy service and device
state with a page-wide error. It shows:
- **Relay version + uptime + health** — served by the relay's `/relay/info` endpoint. Green dot = reachable, red = `relay unreachable at 127.0.0.1:8767` (the gateway can't see your relay process; check `systemctl --user status hermes-relay`).
- **Paired devices list** — one row per active session. Columns: device name (from the phone's `PairedDeviceInfo`), token prefix (first 8 chars — full tokens are never sent), created-at, last-seen, expires-at, labeled per-channel grants (chat / bridge / terminal / TUI / voice), transport hint (`wss` / `ws`).
- **Revoke button** per row — live. Click to pop a native browser confirm; on OK the button calls `DELETE /api/plugins/hermes-relay/sessions/{prefix}` which the plugin proxy forwards to the relay, and the list auto-reloads on success. Same effect as revoking from the Android app's Settings → Relay sessions or running `hermes pair --revoke <prefix>` on the server.
- **Pair new device** — button in the card header opens the [PairDialog](#pairing-a-new-device) described below.
- **Service status** — version, uptime, health, Live state, and update availability.
- **Paired devices** — the authoritative Hermes-Relay session count.
- **Remote access** — the primary configured route, such as Tailscale or Secure Link.
- **Last Bridge event** and a bounded recent-activity preview.
- **Quick actions** for standard Dashboard setup, Hermes-Relay pairing, and device management.
<!-- TODO: replace with real screenshot — dashboard Relay Management tab with a paired device row -->
The old combined Pending/Media counter is intentionally absent. Bridge pending
is momentary activity, while the media registry size is not an active-delivery
count.
### Devices
Devices keeps the two connection contracts together without conflating them:
- **Connect mobile app** creates the tokenless standard Dashboard/Gateway connection.
- **Pair with Hermes-Relay** grants Terminal, Bridge, media, remote-access, and extended voice capabilities.
- **Paired devices** renders responsive cards with client type, last seen, expiry, transport, grants, copy-prefix, and host-confirmed revoke actions.
<!-- TODO: replace with real screenshots — Hermes-Relay Overview and Devices tabs -->
#### Pairing a new device
The **Pair new device** button on Relay Management uses the same signed pairing
The **Pair new device** button on Devices uses the same signed pairing
contract as `/hermes-relay-pair` and `hermes pair`, driven from the browser
instead of a chat or shell.
**Click the button to open a PairDialog with:**
**Click the button to open a QR-first PairDialog with:**
- **Mode** — defaults to **Auto**, which derives every configured reachable
candidate. LAN-only, Tailscale-only, and public-only modes remain available.
- **Prefer role** — optionally promotes LAN, Tailscale, or public without
removing fallback candidates.
- **A freshly minted QR** — scan it from Android **Settings → Connections →
Pair Hermes Relay**.
- **The six-character code and copyable invite** — use these for manual Android
entry or Desktop CLI `--pair-qr` pairing.
- **Endpoint receipt and expiry** — the invite is one-time and single-use. Mint
a fresh one after it expires or is consumed.
- **Connection summary** — defaults to **Auto**, which derives every configured
reachable candidate. LAN-only, Tailscale-only, and public-only modes remain available.
- **Advanced connection options** — collapsed controls for role preference and
the unusual API-host override.
Leave **Auto** and natural ordering selected for the common case. Configure
Tailscale and a pinned public URL on the **Remote Access** tab; PairDialog folds
@@ -182,7 +197,7 @@ wrong service.
<!-- TODO: replace with real screenshot — PairDialog with QR and override fields expanded -->
### Bridge Activity
### Activity — Bridge activity
Real-time feed of what the agent just did to the phone. Backed by an in-memory ring buffer on the relay (`BridgeHandler.recent_commands`, max 100 entries) that records every bridge command round-trip as it happens — no database, no replay across restarts.
@@ -194,21 +209,17 @@ Each row shows:
- **`decision`** — `executed` (ran normally), `blocked` (phone-side safety-rail denied it), `confirmed` (destructive-verb confirmation accepted), `timeout` (no response in 30s), `error` (exception on either end), or `pending` (in-flight right now).
- **`response_status`** + `result_summary` + `error` — HTTP status from the phone + the first line of the result + any error string.
A filter-chip row above the table lets you narrow to `All | Executed | Blocked | Confirmed | Timeout | Error` at a glance. Polls every 5 seconds (pausable via the header Auto-refresh toggle).
A filter-chip row above the table lets you narrow to `All | Executed | Blocked | Confirmed | Timeout | Error` at a glance. Polls every 5 seconds (pausable via the Live switch).
<!-- TODO: replace with real screenshot — Bridge Activity tab mid-session, showing executed + one blocked row -->
### Push Console
### Activity — Media tokens
**Stub for now.** Renders an "FCM integration not configured" banner with a link to the deferred-items doc. The plugin backend returns `{configured: false, reason: "FCM not yet wired; …"}` without hitting the network.
When FCM lands, this tab will show outbound push delivery: target device, payload, delivery status, timestamps. The nav slot is reserved deliberately so the four-tab layout doesn't reshuffle when the feature ships — only `PushConsole.jsx` + the plugin's `/push` route change.
<!-- TODO: replace with real screenshot — Push Console stub banner -->
### Media Inspector
Lists active `MediaRegistry` tokens — the handles the relay mints when a host-local tool (e.g. `android_screenshot`) registers a file for the paired phone to download. Each row shows:
Media tokens is a diagnostic view nested under Activity. It lists active
`MediaRegistry` tokens — the handles Hermes-Relay mints when a host-local tool
(for example `android_screenshot`) registers a file for the paired phone to
download. Bare-path media deliveries do not create registry tokens and are
explicitly outside this view. Each row shows:
- **Token** — truncated display, hover to copy full.
- **`file_name`** — basename only. Absolute paths are never sent from the server; the inspector can't be used to enumerate your filesystem.
@@ -220,13 +231,19 @@ By default, expired entries are hidden. Click the **Show expired** toggle at the
Polls every 15 seconds.
<!-- TODO: replace with real screenshot — Media Inspector with a registered screenshot row, TTL counting down -->
<!-- TODO: replace with real screenshot — Activity → Media tokens with a registered screenshot row -->
### Remote Access, Git, and Settings
- **Remote Access** retains the supported-first Tailscale, Secure Link, public URL, probe, and endpoint-preview workflow.
- **Git** retains the opt-in repository workspace and confirmed write operations.
- **Settings** follows the Dashboard Config layout with General, Agent Context, and Maintenance categories.
## How It's Wired (Brief)
The plugin has three layers:
1. **Frontend** — a pre-built React IIFE at `plugin/dashboard/dist/index.js` (~16 KB minified), loaded verbatim by the dashboard shell. Source lives in `plugin/dashboard/src/` and is bundled with esbuild. Uses the dashboard's `window.__HERMES_PLUGIN_SDK__` global for React + shadcn primitives — no bundled React, no external HTTP library.
1. **Frontend** — a pre-built React IIFE at `plugin/dashboard/dist/index.js` (about 110 KB minified), loaded verbatim by the dashboard shell. Source lives in `plugin/dashboard/src/` and is bundled with esbuild. Uses the dashboard's `window.__HERMES_PLUGIN_SDK__` global for React + Nous primitives — no bundled React, no external HTTP library.
2. **Backend proxy** — a FastAPI router at `plugin/dashboard/plugin_api.py` mounted at `/api/plugins/hermes-relay/*` inside the gateway process. Forwards five routes (`/overview`, `/sessions`, `/bridge-activity`, `/media`, `/push`) to the relay at `http://127.0.0.1:{HERMES_RELAY_PORT}` via `httpx.AsyncClient` with a 5-second timeout. Translates relay connect-errors / timeouts / 5xx into `HTTP 502` with a human-readable detail so the UI can show "relay unreachable".
3. **Relay** — three new loopback-gated HTTP routes (`/bridge/activity`, `/media/inspect`, `/relay/info`) plus a loopback-exempt branch on the existing `/sessions`. Both the plugin backend and the relay are localhost-bound, so no bearer is minted and no new credentials are introduced.
@@ -236,17 +253,17 @@ For the full wire-shape of each route (query params, response schemas, redaction
**"Relay unreachable at 127.0.0.1:8767" on every tab.** The gateway can't see your relay process. Check `systemctl --user status hermes-relay` on the server; if the unit is inactive, `systemctl --user restart hermes-relay`. If you run the relay manually, confirm it's bound to `127.0.0.1:8767` and hasn't moved to a different port (override via `HERMES_RELAY_PORT` — the plugin reads this at import time).
**No "Relay" tab appears after gateway restart.** Confirm the unified plugin is
**No "Hermes-Relay" tab appears after gateway restart.** Confirm the unified plugin is
enabled with `hermes plugins list`, then re-run
`hermes plugins install Codename-11/hermes-relay/plugin --enable` and refresh or
restart the Dashboard/Gateway plugin catalog. Check the gateway log for
plugin-load errors if the manifest is installed but the page is absent.
**The Relay tab appears but text, colors, or cards are hard to read.** Update the Hermes-Relay plugin and restart or rescan the dashboard plugin list. The plugin stylesheet is loaded by the upstream dashboard and follows its active theme tokens; stale `dist/style.css` files from older installs can render poorly after Hermes dashboard theme changes.
**The Hermes-Relay tab appears but text, colors, or cards are hard to read.** Update the Hermes-Relay plugin and restart or rescan the dashboard plugin list. The plugin stylesheet is loaded by the upstream dashboard and follows its active theme tokens; stale `dist/style.css` files from older installs can render poorly after Hermes dashboard theme changes.
**Bridge Activity tab is empty but the phone is issuing commands.** The ring buffer is in-memory and wipes on relay restart. If you just restarted the relay, you need the phone to issue at least one command before the tab has anything to show. If commands are going through but not appearing, confirm they're reaching the relay (`journalctl --user -u hermes-relay -f` should show the command round-trips).
**Media Inspector shows tokens but files won't download.** That's a separate path — the inspector lists registered tokens but the actual download goes through `/media/{token}` (bearer-gated, via the phone). If the phone can't fetch a token, check the bearer's `media` grant and `RELAY_MEDIA_TTL_SECONDS` hasn't elapsed since registration.
**Media tokens shows entries but files won't download.** That's a separate path — the diagnostic view lists token-backed registry entries, while the actual download goes through `/media/{token}` (bearer-gated, via the phone). Bare-path deliveries are not listed. If the phone can't fetch a token, check the bearer's `media` grant and `RELAY_MEDIA_TTL_SECONDS` hasn't elapsed since registration.
**Revoke button fails silently.** Revoke is live as of the dashboard plugin release — `DELETE /api/plugins/hermes-relay/sessions/{prefix}` is proxied to the relay. If the click confirm fires but the list doesn't update, open the browser devtools network tab and re-click: a 502 means the relay itself is unreachable (see the "Relay unreachable" item above), a 404 means the token prefix is already gone (the list auto-reloaded between the button render and your click), and a 403 means the proxy is seeing a non-loopback caller (hermes-agent's dashboard shouldn't ever hit this — check `journalctl --user -u hermes-gateway -f` for the origin).