Compare commits

..
Author SHA1 Message Date
Bailey Dixon 523794995a feat(dashboard): redesign Hermes-Relay plugin UI 2026-08-30 13:52:29 -04:00
27 changed files with 2085 additions and 1046 deletions
+1 -1
View File
@@ -13,12 +13,12 @@ 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
- **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.
- **Android Assistant sessions explain when no speech was captured instead of appearing stuck at Ready.** Retry feedback survives the separate system overlay process, recreated session UI requests the current turn state, and locked sessions keep transcript, response, and technical error text private.
### Removed
+1 -6
View File
@@ -1300,12 +1300,7 @@ and whether the agent is waiting on the user.
permissions; exercise compact, expanded, collapsed, and full-Voice handoff
states, background tap-through, rotation and insets, cancel/back, microphone
denial, network failure, process kill/recreation, and wake→voice→wake
resumption. For background and keyguard capture, record `AudioRecord`, AppOps,
and foreground-service state: the user-installed app owns capture outside the
separate session process, so confirm whether the selected Assistant role is
sufficient on each target OS or whether activation needs an explicit,
activation-scoped microphone foreground-service lease. Measure idle battery
drain because third-party assistants do not
resumption. Measure idle battery drain because third-party assistants do not
receive Google's dedicated low-power hotword hardware.
- **Audio quality guardrails** — normalize output volume across realtime and
@@ -39,38 +39,14 @@ enum class AssistantSessionPhase {
Closed,
}
enum class AssistantSessionNotice {
NoSpeech,
}
data class AssistantSessionSnapshot(
val phase: AssistantSessionPhase = AssistantSessionPhase.Launching,
val transcript: String? = null,
val response: String = "",
val notice: AssistantSessionNotice? = null,
val error: String? = null,
val screenContextSupported: Boolean = false,
)
internal fun assistantSnapshotForPresentation(
snapshot: AssistantSessionSnapshot,
locked: Boolean,
): AssistantSessionSnapshot = if (locked) {
snapshot.copy(
transcript = null,
response = "",
error = null,
screenContextSupported = false,
)
} else {
snapshot
}
internal fun assistantSnapshotMatchesActivation(
expectedActivationId: String?,
receivedActivationId: String?,
): Boolean = expectedActivationId != null && expectedActivationId == receivedActivationId
object AssistantRole {
fun status(context: Context): AssistantRoleStatus {
val component = ComponentName(context, HermesVoiceInteractionService::class.java)
@@ -233,7 +209,6 @@ object AssistantSessionProtocol {
onFailure = { failure ->
publish(
application,
activation.id,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = failure.message ?: "Hermes voice could not start",
@@ -244,19 +219,13 @@ object AssistantSessionProtocol {
return true
}
fun publish(
context: Context,
activationId: String,
snapshot: AssistantSessionSnapshot,
) {
fun publish(context: Context, snapshot: AssistantSessionSnapshot) {
context.sendBroadcast(
Intent(context, AssistantSessionStateReceiver::class.java).apply {
action = ACTION_STATUS
putExtra(EXTRA_ACTIVATION_ID, activationId)
putExtra(EXTRA_PHASE, snapshot.phase.name)
putExtra(EXTRA_TRANSCRIPT, snapshot.transcript)
putExtra(EXTRA_RESPONSE, snapshot.response)
putExtra(EXTRA_NOTICE, snapshot.notice?.name)
putExtra(EXTRA_ERROR, snapshot.error)
putExtra(EXTRA_SCREEN_CONTEXT_SUPPORTED, snapshot.screenContextSupported)
}
@@ -269,6 +238,10 @@ object AssistantSessionProtocol {
}
}
fun publish(context: Context, state: VoiceUiState) {
publish(context, snapshotFromVoiceState(state))
}
internal fun snapshotFromVoiceState(state: VoiceUiState): AssistantSessionSnapshot {
val phase = when {
!state.voiceMode -> AssistantSessionPhase.Closed
@@ -283,10 +256,7 @@ object AssistantSessionProtocol {
phase = phase,
transcript = state.transcribedText?.take(MAX_SESSION_TEXT_CHARS),
response = state.responseText.take(MAX_SESSION_TEXT_CHARS),
notice = state.assistantNotice,
error = state.error
?.takeIf { phase == AssistantSessionPhase.Error }
?.take(MAX_SESSION_ERROR_CHARS),
error = state.error?.take(MAX_SESSION_ERROR_CHARS),
)
}
@@ -380,9 +350,6 @@ object AssistantSessionProtocol {
phase = phase,
transcript = intent.getStringExtra(EXTRA_TRANSCRIPT),
response = intent.getStringExtra(EXTRA_RESPONSE).orEmpty(),
notice = intent.getStringExtra(EXTRA_NOTICE)?.let { raw ->
runCatching { AssistantSessionNotice.valueOf(raw) }.getOrNull()
},
error = intent.getStringExtra(EXTRA_ERROR),
screenContextSupported = intent.getBooleanExtra(
EXTRA_SCREEN_CONTEXT_SUPPORTED,
@@ -393,33 +360,24 @@ object AssistantSessionProtocol {
private const val MAX_SESSION_TEXT_CHARS = 4_000
private const val MAX_SESSION_ERROR_CHARS = 1_000
private const val EXTRA_NOTICE = "notice"
}
object AssistantSessionState {
private val _snapshot = MutableStateFlow(AssistantSessionSnapshot())
val snapshot: StateFlow<AssistantSessionSnapshot> = _snapshot.asStateFlow()
@Volatile private var activationId: String? = null
internal fun update(receivedActivationId: String?, snapshot: AssistantSessionSnapshot) {
if (!assistantSnapshotMatchesActivation(activationId, receivedActivationId)) return
internal fun update(snapshot: AssistantSessionSnapshot) {
_snapshot.value = snapshot
}
internal fun reset(activationId: String) {
this.activationId = activationId
internal fun reset() {
_snapshot.value = AssistantSessionSnapshot()
}
}
class AssistantSessionStateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
AssistantSessionState.update(
receivedActivationId = intent.getStringExtra(
AssistantSessionProtocol.EXTRA_ACTIVATION_ID
),
snapshot = AssistantSessionProtocol.readSnapshot(intent),
)
AssistantSessionState.update(AssistantSessionProtocol.readSnapshot(intent))
}
}
@@ -465,7 +423,6 @@ class AssistantSessionLifecycleReceiver : BroadcastReceiver() {
onFailure = { failure ->
AssistantSessionProtocol.publish(
application,
id,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = failure.message ?: "Hermes voice could not start",
@@ -473,7 +430,6 @@ class AssistantSessionLifecycleReceiver : BroadcastReceiver() {
)
},
)
application.runtime.republishAssistantSnapshot(id)
return
}
if (AssistantSessionProtocol.isStartAction(intent.action)) {
@@ -3,11 +3,6 @@ package com.hermesandroid.relay.assistant
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.ColorDrawable
import android.app.KeyguardManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
import android.service.voice.VoiceInteractionSessionService
@@ -72,7 +67,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
@@ -114,11 +108,6 @@ internal fun shouldCancelVoiceWhenSessionUiEnds(
presentation: AssistantSessionPresentation,
): Boolean = presentation == AssistantSessionPresentation.Overlay
internal fun assistantPresentationLocked(
currentKeyguardLocked: Boolean?,
fallbackLocked: Boolean,
): Boolean = currentKeyguardLocked ?: fallbackLocked
private class HermesVoiceInteractionSession(
private val service: HermesVoiceInteractionSessionService,
) : VoiceInteractionSession(service) {
@@ -129,19 +118,12 @@ private class HermesVoiceInteractionSession(
private var surfaceExpanded by mutableStateOf(false)
private var activationId: String? = null
private var manualMic = false
private var keyguardLocked by mutableStateOf(false)
private var expectScreenContext: Boolean? = null
private var pendingSemantic = AssistantSemanticContext()
private var pendingScreenshot: ByteArray? = null
private var screenContextUi by mutableStateOf(AssistantScreenContextUi())
private val contextStore = assistantContextStore(service)
private var heartbeatJob: Job? = null
private var keyguardReceiverRegistered = false
private val keyguardReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
refreshKeyguardState()
}
}
init {
scope.launch {
@@ -157,17 +139,6 @@ private class HermesVoiceInteractionSession(
override fun onCreate() {
super.onCreate()
ContextCompat.registerReceiver(
service,
keyguardReceiver,
IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_USER_PRESENT)
},
ContextCompat.RECEIVER_NOT_EXPORTED,
)
keyguardReceiverRegistered = true
window.window?.apply {
setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
@@ -184,7 +155,6 @@ private class HermesVoiceInteractionSession(
PersistedHermesRelayTheme {
AssistantSessionSurface(
expanded = surfaceExpanded,
locked = keyguardLocked,
screenContext = screenContextUi,
onExpandedChange = { surfaceExpanded = it },
onCancel = { finishSession(cancelVoice = true) },
@@ -213,22 +183,11 @@ private class HermesVoiceInteractionSession(
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
refreshKeyguardState(
fallbackLocked = args?.getBoolean(
HermesVoiceInteractionService.EXTRA_FROM_KEYGUARD,
false,
) == true,
)
if (keyguardLocked) {
if (args?.getBoolean(HermesVoiceInteractionService.EXTRA_FROM_KEYGUARD, false) == true) {
window.window?.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
} else {
window.window?.clearFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
setUiEnabled(true)
val startsNewLifecycle = presentation == AssistantSessionPresentation.Inactive
@@ -236,10 +195,10 @@ private class HermesVoiceInteractionSession(
if (!startsNewLifecycle) return
surfaceExpanded = false
AssistantSessionState.reset()
screenContextUi = AssistantScreenContextUi()
activationId = args?.getString(AssistantSessionProtocol.EXTRA_ACTIVATION_ID)
?: UUID.randomUUID().toString()
AssistantSessionState.reset(activationId!!)
manualMic = args?.getBoolean(AssistantSessionProtocol.EXTRA_MANUAL_MIC, false) ?: false
expectScreenContext = args?.getBoolean(
AssistantSessionProtocol.EXTRA_EXPECT_SCREEN_CONTEXT,
@@ -341,10 +300,6 @@ private class HermesVoiceInteractionSession(
pendingSemantic = AssistantSemanticContext()
pendingScreenshot = null
screenContextUi = AssistantScreenContextUi()
if (keyguardReceiverRegistered) {
runCatching { service.unregisterReceiver(keyguardReceiver) }
keyguardReceiverRegistered = false
}
viewOwner.stop()
scope.cancel()
super.onDestroy()
@@ -364,7 +319,6 @@ private class HermesVoiceInteractionSession(
)
}.onFailure {
AssistantSessionState.update(
activationId,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = it.message ?: "Hermes could not open the voice session.",
@@ -383,7 +337,6 @@ private class HermesVoiceInteractionSession(
setUiEnabled(false)
}.onFailure {
AssistantSessionState.update(
activationId,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = it.message ?: "Hermes could not open full voice.",
@@ -424,14 +377,6 @@ private class HermesVoiceInteractionSession(
}
}
private fun refreshKeyguardState(fallbackLocked: Boolean = keyguardLocked) {
keyguardLocked = assistantPresentationLocked(
currentKeyguardLocked = service.getSystemService(KeyguardManager::class.java)
?.isKeyguardLocked,
fallbackLocked = fallbackLocked,
)
}
@RequiresApi(android.os.Build.VERSION_CODES.Q)
private fun stageAssistState(state: AssistState) {
stageAssistData(state.assistStructure, state.assistContent)
@@ -518,7 +463,6 @@ private class AssistantSessionViewOwner :
@Composable
private fun AssistantSessionSurface(
expanded: Boolean,
locked: Boolean,
screenContext: AssistantScreenContextUi,
onExpandedChange: (Boolean) -> Unit,
onCancel: () -> Unit,
@@ -527,8 +471,7 @@ private fun AssistantSessionSurface(
onOpenFullVoice: () -> Unit,
onSurfaceBoundsChanged: (android.graphics.Rect) -> Unit,
) {
val rawSnapshot by AssistantSessionState.snapshot.collectAsState()
val snapshot = assistantSnapshotForPresentation(rawSnapshot, locked)
val snapshot by AssistantSessionState.snapshot.collectAsState()
val status = assistantStatus(snapshot.phase)
val transmittedScreenContext = if (snapshot.screenContextSupported) {
screenContext
@@ -707,13 +650,6 @@ private fun ExpandedAssistantSurface(
color = MaterialTheme.colorScheme.onSurface,
)
}
snapshot.notice?.let { notice ->
Text(
text = assistantNoticeText(notice),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
snapshot.error?.let { error ->
Text(
text = error,
@@ -960,11 +896,5 @@ private fun assistantStatus(phase: AssistantSessionPhase): String = when (phase)
private fun compactAssistantText(snapshot: AssistantSessionSnapshot): String =
snapshot.transcript?.takeIf { it.isNotBlank() }
?: snapshot.response.takeIf { it.isNotBlank() }
?: snapshot.notice?.let { assistantNoticeText(it) }
?: snapshot.error?.takeIf { it.isNotBlank() }
?: assistantStatus(snapshot.phase)
@Composable
private fun assistantNoticeText(notice: AssistantSessionNotice): String = when (notice) {
AssistantSessionNotice.NoSpeech -> stringResource(R.string.voice_no_speech_try_again)
}
@@ -240,25 +240,6 @@ class HermesProcessRuntime internal constructor(
}
}
fun republishAssistantSnapshot(activationId: String) {
val snapshot = synchronized(activationLock) {
if (currentActivationId != activationId ||
_initializationState.value != HermesRuntimeInitializationState.Ready
) {
null
} else {
binder.assistantSnapshot.value
}
} ?: return
if (snapshot.phase != com.hermesandroid.relay.assistant.AssistantSessionPhase.Closed) {
com.hermesandroid.relay.assistant.AssistantSessionProtocol.publish(
application,
activationId,
snapshot,
)
}
}
fun recordAssistantHeartbeat(
activationId: String,
nowElapsedMs: Long = SystemClock.elapsedRealtime(),
@@ -376,9 +376,7 @@ internal class HermesRuntimeBinder(
if (!AssistantAppSessionState.active.value) return@collect
if (state.voiceMode) AssistantAppSessionState.markVoiceStarted()
if (state.voiceMode || AssistantAppSessionState.hasVoiceStarted()) {
state.assistantActivationId?.let { activationId ->
AssistantSessionProtocol.publish(application, activationId, snapshot)
}
AssistantSessionProtocol.publish(application, snapshot)
}
}
}
@@ -52,7 +52,6 @@ import com.hermesandroid.relay.voice.VoiceCommandInterpreter
import com.hermesandroid.relay.voice.SpokenInterruptionLatch
import com.hermesandroid.relay.voice.voiceInterfaceContextPrompt
import com.hermesandroid.relay.assistant.assistantContextStore
import com.hermesandroid.relay.assistant.AssistantSessionNotice
import com.hermesandroid.relay.assistant.buildAssistantVoiceTurnPayload
// === PHASE3-voice-intents: voice→bridge intent routing ===
import com.hermesandroid.relay.voice.IntentResult
@@ -127,25 +126,6 @@ internal fun voiceSubmissionRetryState(state: VoiceUiState): VoiceUiState = stat
error = null,
)
internal fun voiceNoSpeechState(state: VoiceUiState): VoiceUiState = state.copy(
state = VoiceState.Idle,
amplitude = 0f,
outputAudioActive = false,
transcribedText = null,
error = null,
assistantNotice = AssistantSessionNotice.NoSpeech,
)
internal fun voiceCaptureCancellationState(
state: VoiceUiState,
notice: AssistantSessionNotice? = null,
): VoiceUiState = state.copy(
state = VoiceState.Idle,
amplitude = 0f,
outputAudioActive = false,
assistantNotice = notice,
)
internal data class AssistantContextTurnDisposition(
val retireForLaterTurns: Boolean,
val consumeOnTransportAcceptance: Boolean,
@@ -348,10 +328,6 @@ data class VoiceUiState(
val responseText: String = "",
/** Human-readable error surfaced in the overlay. */
val error: String? = null,
/** Content-free retry status safe for the system Assistant surface. */
val assistantNotice: AssistantSessionNotice? = null,
/** Stable owner for cross-process Assistant status; null for ordinary voice. */
val assistantActivationId: String? = null,
/** Currently-selected interaction mode. */
val interactionMode: InteractionMode = InteractionMode.TapToTalk,
/**
@@ -464,7 +440,6 @@ internal fun voiceSessionExitState(state: VoiceUiState): VoiceUiState =
transcribedText = null,
responseText = "",
error = null,
assistantNotice = null,
destructiveCountdown = null,
hermesConfirmation = null,
handoffStatus = null,
@@ -1655,7 +1630,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
state = VoiceState.Idle,
outputAudioActive = false,
error = null,
assistantActivationId = activationId,
hermesConfirmation = null,
backgroundRun = if (orphanedRun != null) null else it.backgroundRun,
)
@@ -2093,7 +2067,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
state = VoiceState.Listening,
outputAudioActive = false,
error = null,
assistantNotice = null,
responseText = "",
// v0.4.1 — fresh turn, drop any stale JIT permission chip
// from the previous dispatch.
@@ -2203,11 +2176,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private fun shouldDiscardVoiceCaptureBeforeStop(durationMs: Long): Boolean =
durationMs < MIN_VOICE_CAPTURE_DURATION_MS
private fun cancelListeningWithoutProcessing(
title: String,
detail: String? = null,
notice: AssistantSessionNotice? = null,
) {
private fun cancelListeningWithoutProcessing(title: String, detail: String? = null) {
responseInterruptedForVoiceCommand = false
silenceWatchdogJob?.cancel()
silenceWatchdogJob = null
@@ -2219,7 +2188,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
title = title,
detail = detail,
)
_uiState.update { voiceCaptureCancellationState(it, notice) }
_uiState.update {
it.copy(state = VoiceState.Idle, amplitude = 0f, outputAudioActive = false)
}
}
/** Reconcile microphone state after the Activity returns to foreground. */
@@ -2356,7 +2327,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
cancelListeningWithoutProcessing(
title = getApplication<Application>().getString(R.string.voice_status_no_speech),
detail = "No speech within ${IDLE_NO_SPEECH_MS / 1000}s",
notice = AssistantSessionNotice.NoSpeech,
)
return@launch
}
@@ -6527,7 +6497,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
detail = detail,
)
_uiState.update {
voiceNoSpeechState(it)
it.copy(
state = VoiceState.Idle,
amplitude = 0f,
outputAudioActive = false,
error = null,
transcribedText = null,
)
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@@ -65,122 +65,6 @@ class AssistantSessionProtocolTest {
assertEquals("Microphone unavailable", error.error)
}
@Test
fun idleNoSpeech_isAVisibleRetryNotice() {
val snapshot = AssistantSessionProtocol.snapshotFromVoiceState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Idle,
assistantNotice = AssistantSessionNotice.NoSpeech,
)
)
assertEquals(AssistantSessionPhase.Idle, snapshot.phase)
assertEquals(AssistantSessionNotice.NoSpeech, snapshot.notice)
assertNull(snapshot.error)
}
@Test
fun lockedPresentation_redactsConversationButKeepsGenericNotice() {
val presented = assistantSnapshotForPresentation(
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
transcript = "private request",
response = "private response",
notice = AssistantSessionNotice.NoSpeech,
error = "private route detail",
),
locked = true,
)
assertNull(presented.transcript)
assertEquals("", presented.response)
assertNull(presented.error)
assertEquals(AssistantSessionNotice.NoSpeech, presented.notice)
}
@Test
fun unlockedPresentation_restoresConversationContent() {
val snapshot = AssistantSessionSnapshot(
phase = AssistantSessionPhase.Speaking,
transcript = "request",
response = "response",
)
assertEquals(snapshot, assistantSnapshotForPresentation(snapshot, locked = false))
}
@Test
fun liveKeyguardState_overridesLaunchFallbackInBothDirections() {
assertTrue(
assistantPresentationLocked(
currentKeyguardLocked = true,
fallbackLocked = false,
)
)
assertFalse(
assistantPresentationLocked(
currentKeyguardLocked = false,
fallbackLocked = true,
)
)
}
@Test
fun statusSnapshots_areFencedToTheCurrentActivation() {
assertTrue(assistantSnapshotMatchesActivation("activation-b", "activation-b"))
assertFalse(assistantSnapshotMatchesActivation("activation-b", "activation-a"))
assertFalse(assistantSnapshotMatchesActivation("activation-b", null))
assertFalse(assistantSnapshotMatchesActivation(null, "activation-b"))
}
@Test
fun voiceStateRetainsItsOwningActivationAcrossLaterRuntimeChanges() {
val activationA = VoiceUiState(
voiceMode = true,
state = VoiceState.Listening,
assistantActivationId = "activation-a",
)
val currentRuntimeActivation = "activation-b"
assertEquals("activation-a", activationA.assistantActivationId)
assertFalse(
assistantSnapshotMatchesActivation(
expectedActivationId = currentRuntimeActivation,
receivedActivationId = activationA.assistantActivationId,
)
)
}
@Test
fun terminalVoiceStateRetainsActivationForClosedPublication() {
val exited = com.hermesandroid.relay.viewmodel.voiceSessionExitState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Speaking,
assistantActivationId = "activation-a",
)
)
assertFalse(exited.voiceMode)
assertEquals("activation-a", exited.assistantActivationId)
assertEquals(
AssistantSessionPhase.Closed,
AssistantSessionProtocol.snapshotFromVoiceState(exited).phase,
)
}
@Test
fun subsequentOrdinaryVoiceEntryClearsPreviousAssistantOwner() {
val ordinaryEntry = VoiceUiState(
voiceMode = true,
state = VoiceState.Idle,
assistantActivationId = null,
)
assertNull(ordinaryEntry.assistantActivationId)
}
@Test
fun persistedSessionMarker_expiresAfterBoundedRecoveryWindow() {
val now = 2_000_000L
@@ -1,9 +1,6 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.assistant.AssistantSessionNotice
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -23,35 +20,4 @@ class VoiceCaptureGuardTest {
fun acceptsSettledShortUtterance() {
assertFalse(shouldDiscardVoiceCapture(durationMs = 420L, pcmBytes = 12_000))
}
@Test
fun noSpeechReturnsToRetryableIdleWithDurableFeedback() {
val state = voiceNoSpeechState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Transcribing,
transcribedText = "stale",
)
)
assertEquals(VoiceState.Idle, state.state)
assertEquals(AssistantSessionNotice.NoSpeech, state.assistantNotice)
assertNull(state.error)
assertNull(state.transcribedText)
assertTrue(state.voiceMode)
}
@Test
fun unrelatedCaptureCancellationDoesNotClaimNoSpeech() {
val state = voiceCaptureCancellationState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Listening,
assistantNotice = AssistantSessionNotice.NoSpeech,
)
)
assertEquals(VoiceState.Idle, state.state)
assertNull(state.assistantNotice)
}
}
-5
View File
@@ -2495,8 +2495,6 @@ boundary.
- Activation heartbeats let the main runtime clean up after assistant-process loss.
Finish and show-failure paths clear pending/watchdog state, while Full Voice
explicitly transfers ownership before the session overlay stops heartbeats.
A recreated session process requests the current activation-fenced voice
snapshot rather than treating its empty local state as authoritative.
- Connection, chat, and voice runtime ownership is application-lifetime in the
main process rather than Activity-owned. The assistant service may initialize
that graph and start a turn while no Activity exists; the app UI later binds
@@ -2510,9 +2508,6 @@ boundary.
- Background and locked-screen invocation is mediated by Android's selected
assistant UI/session rather than an ordinary background Activity launch.
- Locked assistant UI exposes only generic phase and retry status. Transcript,
response, route-specific errors, diagnostics, and screen context remain hidden
until the device is unlocked; no-speech retry copy is deliberately content-free.
- Users can leave Hermes selected for gesture/power-button invocation while
turning continuous KWS off, or remove Hermes through Android's Assistant
settings.
+10 -13
View File
@@ -1015,10 +1015,7 @@ utilities.
application-lifetime owner, allowing assistant activation to start cold
without constructing or foregrounding `MainActivity`; full Voice later binds
that same runtime. Cancel, error, app/process recreation, and session finish
use the same scoped protocol. Recreated session UI requests an
activation-fenced snapshot from the app runtime, and capture/no-speech exits
retain a generic retry notice instead of collapsing to an unexplained Ready
state. The
use the same scoped protocol. The
wake recorder is released before the established voice recorder opens, and
assistant listening resumes only after the session exits. This mode is
mutually exclusive with the experimental notification-based foreground
@@ -1049,8 +1046,6 @@ utilities.
Realtime Agent sessions do not claim inclusion. The mic control follows the
active voice state, close remains separate, and **Open full voice** explicitly
transfers ownership so assistant-process cleanup cannot cancel the main-app flow.
While keyguard is active, the surface keeps only generic phase and retry copy;
transcript, response, route-specific errors, and screen context remain hidden.
- Stable voice integrates with `ChatViewModel` by **observing** `messages: StateFlow`; transcribed text goes through normal `chatVm.sendMessage(text)` so voice utterances appear as regular user messages in chat history. Experimental Realtime Agent creates a mirrored chat turn and applies broker events directly so tool state, transcript text, assistant deltas, and final responses appear without leaving voice mode.
- `VoiceModeOverlay` — full-screen UI with the MorphingSphere at 60% height in `voiceMode=true`, transcribed + response text, mic button supporting Tap / Hold / Continuous interaction modes.
- The optional `SYSTEM_ALERT_WINDOW` Voice control is user-invoked from an
@@ -1136,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).
@@ -1158,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/);
});
+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).