Compare commits

..
Author SHA1 Message Date
Bailey Dixon 524e319f95 release(android): android-v1.13.1 2026-08-25 13:04:28 -04:00
Bailey Dixon 647d1f9aea fix(android): make session activity authoritative 2026-08-25 12:56:22 -04:00
97 changed files with 3283 additions and 5032 deletions
+6 -1
View File
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [Android 1.13.1] - 2026-08-25
### Fixed
- **Android session activity now follows live Hermes runtime truth.** Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work no longer come from the Dashboard's five-minute recency hint, and only complete, unambiguously resolved live snapshots clear stale state.
## [Android 1.13.0] - 2026-08-25
### Added
@@ -13,7 +19,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Provider usage and limits are available from top-level Settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden presentation modes. Provider credentials remain on the Hermes host.
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
### Changed
+8 -19
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.13.0
# Hermes-Relay Android v1.13.1
**Release Date:** August 25, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.13.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.13.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,28 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This feature release adds Bot Mode across saved Hermes gateways, provider usage and limits, and bounded Assistant screen context. It also settles stale Gateway composer state, improves onboarding, and keeps idle Sphere motion efficient.
## Added
- Use Bot Mode as one messenger-style workspace across saved Hermes gateways, with exact gateway/profile ownership and read-only group rooms.
- Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage & limits screen.
- Start a compatible unlocked Assistant invocation with bounded visible text and an available screenshot in the first Standard voice turn.
## Changed
- Follow the Dashboard-first setup path with current screenshots and clearer separation between standard Hermes and optional Relay extensions.
- Use clear `Hermes-Relay Android` and isolated `HR Candidate` product names without changing package identities or update behavior.
This patch makes Android session activity follow live Hermes runtime state instead of a five-minute recency estimate. It keeps Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work accurate while preserving stale state until a complete, unambiguous snapshot can safely replace it.
## Fixed
- Settle orphaned Gateway busy state automatically while preserving active or detached turns owned by another session.
- Keep the visible idle Sphere gently animated without running hidden, backgrounded, or motion-disabled loops.
- Retry Windows-hosted `MEDIA:` attachments through the Relay by-path route instead of treating drive-letter paths as expired tokens.
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency.
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped.
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
## Install / Verify
- App version: **1.13.0** (versionCode **49**).
- App version: **1.13.1** (versionCode **50**).
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
- The optional Relay plugin enhances provider usage, media retry, and device surfaces but remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
+26
View File
@@ -6,6 +6,32 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Certify Android session activity across lifecycle and profile boundaries
The contract fixture now covers every upstream live status, complete-snapshot
disappearance, client-side ownership of duplicate durable ids across profiles,
and older Gateways without `session.active_list`. Before calling the status
model device-certified:
- Exercise working, quiet tool-heavy work, each pending-input surface, normal
completion, Stop, reconnect, app restart, and process recreation against
current vanilla upstream.
- Verify All Profiles with duplicate session ids across two profiles and two
saved connections; no late snapshot or old socket generation may mark the
wrong row live.
- Confirm failed/unsupported refresh becomes Unavailable, restart revalidation
remains Checking, ambiguous or partially
resolved process-wide snapshots infer no absence, a complete empty snapshot
settles every unambiguously owned scope, and REST `is_active=true` never
renders as Working.
- Run a background process that outlives its parent turn and verify Background
work remains separate from the conversation's Idle state.
- Pursue an upstream `session.active_list` profile field/filter or an aggregate
activity route with explicit profile ownership so multi-profile clients do
not need to resolve process-wide rows from durable keys.
---
## Bot Mode follow-ups after multi-gateway aggregation
Android Bot Mode now has an all-gateway roster, typed `(connectionId, profile)`
@@ -1 +1 @@
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
@@ -1 +1 @@
Bot 模式现在可将已保存 Hermes 网关中的机器人汇集到一个消息式工作区。设置新增统一的 Codex、Nous 和 OpenCode Go 用量视图。兼容的助手启动可在首个语音回合中包含受限的可见文本和可用截图。Gateway 聊天会自动清除过期的忙碌状态,引导更清晰,空闲 Sphere 动画也更省电。
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
+14
View File
@@ -1,5 +1,19 @@
{
"versions": [
{
"version": "1.13.1",
"title": "Accurate session activity",
"date": "2026-08-25",
"sections": [
{
"header": "Follow live Hermes state",
"bullets": [
"Show Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work from live runtime state instead of a recent-activity estimate.",
"Keep stale activity visible until a complete, unambiguous snapshot safely clears it."
]
}
]
},
{
"version": "1.13.0",
"title": "Bots, usage, and reliable chat",
+4 -5
View File
@@ -1,6 +1,5 @@
v1.13.0 - Bots, usage, and reliable chat
v1.13.1 - Accurate session activity
* Use Bot Mode across saved Hermes gateways without changing the foreground connection.
* Review Codex, Nous, and OpenCode Go usage from one provider-neutral screen.
* Include bounded visible text and an available screenshot in compatible Assistant turns.
* Keep the composer accurate when Gateway completion frames and visible bubbles settle separately.
* Follow live Hermes runtime state for Working, Starting, Needs input, and Idle.
* Keep stale activity visible until a complete, unambiguous snapshot clears it.
* Distinguish Checking, Unavailable, and Background work in the session drawer.
@@ -11,7 +11,6 @@ import com.hermesandroid.relay.data.replaceHermesReachCredential
import com.hermesandroid.relay.data.sameBrokerAuthority
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.isSafeProfileUiMeta
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.models.Envelope
@@ -19,8 +18,6 @@ import com.hermesandroid.relay.network.shared.InvalidCredentialException
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -56,39 +53,6 @@ sealed class AuthState {
data class Failed(val reason: String) : AuthState()
}
internal fun relaySupervisedModePayload(policy: SupervisedModePolicy): JsonObject {
if (!policy.isActive) return buildJsonObject { put("active", false) }
val capabilities = buildList {
add("text_chat")
if (policy.capabilities.newChat) add("new_chat")
if (policy.capabilities.cancelResponse) add("cancel")
if (policy.capabilities.steerResponse) add("steer")
if (policy.capabilities.attachments) add("attachments")
if (policy.capabilities.voice) add("voice")
if (policy.capabilities.generatedImages) add("generated_images")
if (policy.capabilities.shareGeneratedImages) add("share_images")
if (policy.capabilities.copyResponses) add("copy")
if (policy.capabilities.retryResponse) add("retry")
if (policy.capabilities.quoteReplies) add("quote_reply")
if (policy.visibility.resolved().showTimestamps) add("timestamps")
}.take(12)
return buildJsonObject {
put("active", true)
put("profile_label", policy.pinnedProfileName.orEmpty().take(80))
put("capabilities", JsonArray(capabilities.map(::JsonPrimitive)))
}
}
internal fun relaySupervisedModeUpdateEnvelope(
policy: SupervisedModePolicy,
): Envelope = Envelope(
channel = "system",
type = "supervised.update",
payload = buildJsonObject {
put("supervised_mode", relaySupervisedModePayload(policy))
},
)
@Serializable
data class ConnectionAuthSecrets(
val sessionToken: String? = null,
@@ -156,60 +120,6 @@ class AuthManager(
private val eagerHydrate: Boolean = true,
) : ChannelMultiplexer.ChannelHandler {
@Volatile
private var supervisedMode: SupervisedModePolicy = SupervisedModePolicy()
@Volatile
private var supervisedMetadataReconnectFallback: (() -> Unit)? = null
private var pendingSupervisedUpdateId: String? = null
private var supervisedUpdateFallbackJob: Job? = null
/**
* Update the public client-mode tag sent on Relay auth. This does not grant
* authority: Relay labels enforcement_owner=android_client and the Android
* policy remains the enforcing surface.
*/
fun updateSupervisedMode(policy: SupervisedModePolicy) {
if (supervisedMode == policy) return
supervisedMode = policy
if (_authState.value is AuthState.Paired) sendSupervisedModeUpdate()
}
/**
* Install the narrow compatibility path used when an older Relay ignores
* `system/supervised.update`. Reopening the authenticated socket causes
* the current policy to travel through the legacy `system/auth` payload.
*/
fun setSupervisedMetadataReconnectFallback(callback: () -> Unit) {
supervisedMetadataReconnectFallback = callback
}
private fun sendSupervisedModeUpdate() {
val envelope = relaySupervisedModeUpdateEnvelope(supervisedMode)
pendingSupervisedUpdateId = envelope.id
supervisedUpdateFallbackJob?.cancel()
multiplexer.send(envelope)
supervisedUpdateFallbackJob = scope.launch {
delay(SUPERVISED_UPDATE_ACK_TIMEOUT_MS)
if (pendingSupervisedUpdateId == envelope.id) {
pendingSupervisedUpdateId = null
Log.i(TAG, "supervised.update unsupported or unacknowledged; refreshing Relay socket")
supervisedMetadataReconnectFallback?.invoke()
}
}
}
private fun settleSupervisedModeUpdate(envelope: Envelope, unsupported: Boolean) {
if (envelope.id != pendingSupervisedUpdateId) return
pendingSupervisedUpdateId = null
supervisedUpdateFallbackJob?.cancel()
supervisedUpdateFallbackJob = null
if (unsupported) {
Log.i(TAG, "supervised.update rejected; refreshing Relay socket for compatibility")
supervisedMetadataReconnectFallback?.invoke()
}
}
companion object {
private const val TAG = "AuthManager"
private const val KEY_SESSION_TOKEN = "session_token"
@@ -224,7 +134,6 @@ class AuthManager(
// migration has run, so we never rebuild the legacy keyset to re-check.
private const val KEY_LEGACY_MIGRATED = "legacy_migrated"
private const val PAIRING_CODE_LENGTH = 6
private const val SUPERVISED_UPDATE_ACK_TIMEOUT_MS = 2_000L
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
/**
@@ -926,10 +835,6 @@ class AuthManager(
put("device_form_factor", "phone")
}
private fun JsonObjectBuilder.putSupervisedMode() {
put("supervised_mode", relaySupervisedModePayload(supervisedMode))
}
private fun relayDeviceName(): String {
val configured = runCatching {
Settings.Global.getString(context.contentResolver, "device_name")
@@ -985,7 +890,6 @@ class AuthManager(
put("device_id", deviceId)
putRelayDeviceIdentity()
putRelayClientSupports()
putSupervisedMode()
}
}
else -> {
@@ -1002,7 +906,6 @@ class AuthManager(
put("device_id", deviceId)
putRelayDeviceIdentity()
putRelayClientSupports()
putSupervisedMode()
pendingTtlSeconds?.let { put("ttl_seconds", it) }
pendingGrants?.let { grants ->
val obj = buildJsonObject {
@@ -1082,8 +985,6 @@ class AuthManager(
when (envelope.type) {
"auth.ok" -> handleAuthOk(envelope)
"auth.fail" -> handleAuthFail(envelope)
"supervised.updated" -> settleSupervisedModeUpdate(envelope, unsupported = false)
"error" -> settleSupervisedModeUpdate(envelope, unsupported = true)
// `profiles.updated` push — sent by the v0.7.1+ relay on
// the "pairing" channel whenever its in-memory profile
// snapshot changes (file-watcher, SIGHUP, or a manual
@@ -1228,11 +1129,6 @@ class AuthManager(
get() = _authState.value is AuthState.Paired
private fun handleAuthOk(envelope: Envelope) {
// A successful auth always carries the latest client report, including
// after the compatibility reconnect used for older Relay versions.
pendingSupervisedUpdateId = null
supervisedUpdateFallbackJob?.cancel()
supervisedUpdateFallbackJob = null
scope.launch {
try {
val payload = envelope.payload
@@ -451,7 +451,8 @@ data class ChatSession(
val outputTokens: Int = 0,
val actualCostUsd: Double? = null,
val estimatedCostUsd: Double? = null,
val isActive: Boolean = false,
/** Upstream REST five-minute recency hint; never evidence that a turn is running. */
val recentlyActive: Boolean = false,
val updatedAt: Long = 0L,
val startedAt: Long = 0L,
val lastActivityAt: Long = 0L,
@@ -0,0 +1,550 @@
package com.hermesandroid.relay.data
import java.util.Locale
/** Stable ownership boundary for live activity. Runtime ids are aliases, never owners. */
@ConsistentCopyVisibility
data class SessionActivityOwner private constructor(
val connectionId: String,
val profile: String,
val storedSessionId: String,
) {
companion object {
fun of(connectionId: String, profile: String, storedSessionId: String) =
SessionActivityOwner(
connectionId = connectionId.trim(),
profile = profile.trim().lowercase(Locale.ROOT),
storedSessionId = storedSessionId.trim(),
).also {
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
require(it.profile.isNotEmpty()) { "profile must not be blank" }
require(it.storedSessionId.isNotEmpty()) { "storedSessionId must not be blank" }
}
}
}
@ConsistentCopyVisibility
data class SessionActivityScope private constructor(
val connectionId: String,
val profile: String,
) {
companion object {
fun of(connectionId: String, profile: String) = SessionActivityScope(
connectionId = connectionId.trim(),
profile = profile.trim().lowercase(Locale.ROOT),
).also {
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
require(it.profile.isNotEmpty()) { "profile must not be blank" }
}
}
}
enum class SessionActivityPhase {
Starting,
Working,
NeedsInput,
BackgroundWork,
Idle,
}
enum class SessionActivityFreshness {
Confirmed,
Revalidating,
Unavailable,
}
enum class SessionActivityEvidenceSource {
Directory,
LocalSend,
ActiveList,
SessionEvent,
PendingInput,
Terminal,
Checkpoint,
Process,
}
data class SessionActivityEvidence(
val source: SessionActivityEvidenceSource,
val generation: Long,
val observedAtMillis: Long,
)
data class SessionActivityRecord(
val owner: SessionActivityOwner,
/** Authoritative turn state before exact pending-input and background-process overlays. */
val turnPhase: SessionActivityPhase,
val freshness: SessionActivityFreshness,
val evidence: SessionActivityEvidence,
val runtimeId: String? = null,
val pendingInputs: Map<String, Long?> = emptyMap(),
val backgroundProcessIds: Set<String> = emptySet(),
) {
fun phase(nowMillis: Long = Long.MIN_VALUE): SessionActivityPhase {
val hasPendingInput = pendingInputs.any { (_, expiresAt) -> expiresAt == null || expiresAt > nowMillis }
return when {
hasPendingInput -> SessionActivityPhase.NeedsInput
turnPhase != SessionActivityPhase.Idle -> turnPhase
backgroundProcessIds.isNotEmpty() -> SessionActivityPhase.BackgroundWork
else -> SessionActivityPhase.Idle
}
}
/** Presentation projection that never labels uncertain or background activity as Working. */
fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
SessionActivityFreshness.Revalidating -> SessionActivityState.Checking
SessionActivityFreshness.Unavailable -> SessionActivityState.Unavailable
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
SessionActivityPhase.Starting -> SessionActivityState.Starting
SessionActivityPhase.Working -> SessionActivityState.Working
SessionActivityPhase.NeedsInput -> SessionActivityState.NeedsInput
SessionActivityPhase.BackgroundWork -> SessionActivityState.BackgroundWork
SessionActivityPhase.Idle -> null
}
}
}
enum class SessionLiveStatus {
Starting,
Working,
Waiting,
Idle,
}
data class SessionLiveRuntime(
/** Null when transport data cannot be resolved uniquely to a stored session owner. */
val owner: SessionActivityOwner?,
val runtimeId: String,
val status: SessionLiveStatus,
)
sealed interface SessionActivityUpdate {
val generation: Long
val observedAtMillis: Long
data class BeginGeneration(
val scope: SessionActivityScope,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class ObserveOwner(
val owner: SessionActivityOwner,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class LocalSend(
val owner: SessionActivityOwner,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class LiveState(
val owner: SessionActivityOwner,
val runtimeId: String?,
val status: SessionLiveStatus,
val source: SessionActivityEvidenceSource = SessionActivityEvidenceSource.SessionEvent,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class RuntimeState(
val scope: SessionActivityScope,
val runtimeId: String,
val status: SessionLiveStatus,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class ActiveList(
val scope: SessionActivityScope,
val runtimes: List<SessionLiveRuntime>,
/** True only when every upstream row was safely attributable for this scope. */
val isCompleteForScope: Boolean,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class StatusUnavailable(
val scope: SessionActivityScope,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class Terminal(
val owner: SessionActivityOwner,
val runtimeId: String? = null,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class RestoreCheckpoint(
val owner: SessionActivityOwner,
val runtimeId: String?,
val phase: SessionActivityPhase,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class PendingInputOpened(
val owner: SessionActivityOwner,
val requestId: String,
val expiresAtMillis: Long? = null,
val confirmed: Boolean = true,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class PendingInputClosed(
val owner: SessionActivityOwner,
val requestId: String,
val confirmed: Boolean = true,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class ProcessState(
val owner: SessionActivityOwner,
val processId: String,
val running: Boolean,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class Tick(
val nowMillis: Long,
override val generation: Long = Long.MAX_VALUE,
override val observedAtMillis: Long = nowMillis,
) : SessionActivityUpdate
}
/**
* Pure reducer for session activity. Every update is generation-gated per connection/profile.
* An unsuccessful/unsupported refresh never manufactures an idle result.
*/
data class SessionActivityRegistry(
val records: Map<SessionActivityOwner, SessionActivityRecord> = emptyMap(),
private val runtimeAliases: Map<RuntimeAlias, SessionActivityOwner> = emptyMap(),
private val generations: Map<SessionActivityScope, Long> = emptyMap(),
) {
fun record(owner: SessionActivityOwner): SessionActivityRecord? = records[owner]
fun ownerForRuntime(scope: SessionActivityScope, runtimeId: String): SessionActivityOwner? =
runtimeAliases[RuntimeAlias(scope, runtimeId.trim(), generations[scope] ?: return null)]
fun presentationStates(nowMillis: Long = Long.MIN_VALUE): Map<SessionActivityOwner, SessionActivityState> =
records.mapNotNull { (owner, record) -> record.presentationState(nowMillis)?.let { owner to it } }.toMap()
fun reduce(update: SessionActivityUpdate): SessionActivityRegistry {
if (update is SessionActivityUpdate.Tick) return expirePendingInputs(update.nowMillis)
val scope = update.scope()
val currentGeneration = generations[scope]
if (currentGeneration != null && update.generation < currentGeneration) return this
var state = this
if (currentGeneration == null || update.generation > currentGeneration) {
state = state.beginGeneration(scope, update.generation)
}
return when (update) {
is SessionActivityUpdate.BeginGeneration -> state
is SessionActivityUpdate.ObserveOwner -> state.observeOwner(update)
is SessionActivityUpdate.LocalSend -> state.putTurn(
update.owner, null, SessionActivityPhase.Starting, SessionActivityFreshness.Confirmed,
SessionActivityEvidenceSource.LocalSend, update.generation, update.observedAtMillis,
)
is SessionActivityUpdate.LiveState -> state.putLiveState(update)
is SessionActivityUpdate.RuntimeState -> {
val owner = state.ownerForRuntime(update.scope, update.runtimeId) ?: return state
state.putTurn(
owner, update.runtimeId, update.status.phase(), SessionActivityFreshness.Confirmed,
SessionActivityEvidenceSource.SessionEvent, update.generation, update.observedAtMillis,
)
}
is SessionActivityUpdate.ActiveList -> state.applyActiveList(update)
is SessionActivityUpdate.StatusUnavailable -> state.markUnavailable(update.scope)
is SessionActivityUpdate.Terminal -> state.settleTerminal(update)
is SessionActivityUpdate.RestoreCheckpoint -> state.restoreCheckpoint(update)
is SessionActivityUpdate.PendingInputOpened -> state.updatePendingInput(
update.owner, update.requestId, update.expiresAtMillis, true,
update.confirmed, update.generation, update.observedAtMillis,
)
is SessionActivityUpdate.PendingInputClosed -> state.updatePendingInput(
update.owner, update.requestId, null, false,
update.confirmed, update.generation, update.observedAtMillis,
)
is SessionActivityUpdate.ProcessState -> state.updateProcess(update)
is SessionActivityUpdate.Tick -> state
}
}
private fun beginGeneration(scope: SessionActivityScope, generation: Long): SessionActivityRegistry {
val refreshedRecords = records.mapValues { (owner, record) ->
if (owner.scope() == scope) {
record.copy(freshness = SessionActivityFreshness.Revalidating)
} else record
}
return copy(
records = refreshedRecords,
runtimeAliases = runtimeAliases.filterKeys { it.scope != scope },
generations = generations + (scope to generation),
)
}
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
val existing = records[update.owner]
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
val observed = SessionActivityRecord(
owner = update.owner,
turnPhase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Revalidating,
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.Directory,
update.generation,
update.observedAtMillis,
),
)
return copy(records = records + (update.owner to observed))
}
private fun putLiveState(update: SessionActivityUpdate.LiveState): SessionActivityRegistry = putTurn(
owner = update.owner,
runtimeId = update.runtimeId,
phase = update.status.phase(),
freshness = SessionActivityFreshness.Confirmed,
source = update.source,
generation = update.generation,
observedAtMillis = update.observedAtMillis,
)
private fun putTurn(
owner: SessionActivityOwner,
runtimeId: String?,
phase: SessionActivityPhase,
freshness: SessionActivityFreshness,
source: SessionActivityEvidenceSource,
generation: Long,
observedAtMillis: Long,
): SessionActivityRegistry {
val previous = records[owner]
val record = SessionActivityRecord(
owner = owner,
turnPhase = phase,
freshness = freshness,
evidence = SessionActivityEvidence(source, generation, observedAtMillis),
runtimeId = runtimeId ?: previous?.runtimeId,
pendingInputs = previous?.pendingInputs.orEmpty(),
backgroundProcessIds = previous?.backgroundProcessIds.orEmpty(),
)
val alias = runtimeId?.trim()?.takeIf { it.isNotEmpty() }
return copy(
records = records + (owner to record),
runtimeAliases = if (alias == null) runtimeAliases else {
runtimeAliases + (RuntimeAlias(owner.scope(), alias, generation) to owner)
},
)
}
private fun applyActiveList(update: SessionActivityUpdate.ActiveList): SessionActivityRegistry {
require(update.runtimes.all { it.owner == null || it.owner.scope() == update.scope }) {
"Active-list rows must belong to the snapshot scope"
}
var state = copy(runtimeAliases = runtimeAliases.filterKeys { it.scope != update.scope })
val resolvedRuntimes = update.runtimes.filter { it.owner != null }
val observedOwners = resolvedRuntimes.mapTo(mutableSetOf()) { requireNotNull(it.owner) }
resolvedRuntimes.forEach { runtime ->
val resolvedOwner = requireNotNull(runtime.owner)
state = state.putTurn(
resolvedOwner, runtime.runtimeId, runtime.status.phase(), SessionActivityFreshness.Confirmed,
SessionActivityEvidenceSource.ActiveList, update.generation, update.observedAtMillis,
)
if (runtime.status == SessionLiveStatus.Idle) {
val idleRecord = requireNotNull(state.records[resolvedOwner]).copy(pendingInputs = emptyMap())
state = state.copy(records = state.records + (resolvedOwner to idleRecord))
}
}
val snapshotCanSettle = update.isCompleteForScope && resolvedRuntimes.size == update.runtimes.size
if (!snapshotCanSettle) return state
val settled = state.records.mapValues { (owner, record) ->
if (
owner.scope() == update.scope && owner !in observedOwners &&
record.shouldSettleWhenAbsent()
) {
record.copy(
turnPhase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Confirmed,
runtimeId = null,
pendingInputs = emptyMap(),
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.ActiveList,
update.generation,
update.observedAtMillis,
),
)
} else record
}
return state.copy(records = settled)
}
private fun markUnavailable(scope: SessionActivityScope): SessionActivityRegistry = copy(
records = records.mapValues { (owner, record) ->
if (
owner.scope() == scope && record.evidence.source in setOf(
SessionActivityEvidenceSource.ActiveList,
SessionActivityEvidenceSource.Directory,
SessionActivityEvidenceSource.Checkpoint,
)
) {
record.copy(freshness = SessionActivityFreshness.Unavailable)
} else record
},
)
private fun settleTerminal(update: SessionActivityUpdate.Terminal): SessionActivityRegistry {
val settled = putTurn(
update.owner,
runtimeId = null,
phase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Confirmed,
source = SessionActivityEvidenceSource.Terminal,
generation = update.generation,
observedAtMillis = update.observedAtMillis,
)
val record = requireNotNull(settled.records[update.owner]).copy(
runtimeId = null,
pendingInputs = emptyMap(),
)
return settled.copy(
records = settled.records + (update.owner to record),
runtimeAliases = settled.runtimeAliases.filterNot { (alias, owner) ->
alias.scope == update.owner.scope() && owner == update.owner &&
(update.runtimeId == null || alias.runtimeId == update.runtimeId)
},
)
}
private fun restoreCheckpoint(update: SessionActivityUpdate.RestoreCheckpoint): SessionActivityRegistry {
val existing = records[update.owner]
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
return putTurn(
update.owner, update.runtimeId, update.phase, SessionActivityFreshness.Revalidating,
SessionActivityEvidenceSource.Checkpoint, update.generation, update.observedAtMillis,
)
}
private fun updatePendingInput(
owner: SessionActivityOwner,
requestId: String,
expiresAtMillis: Long?,
opened: Boolean,
confirmed: Boolean,
generation: Long,
observedAtMillis: Long,
): SessionActivityRegistry {
val previous = records[owner] ?: SessionActivityRecord(
owner = owner,
turnPhase = SessionActivityPhase.Idle,
freshness = if (confirmed) {
SessionActivityFreshness.Confirmed
} else {
SessionActivityFreshness.Revalidating
},
evidence = SessionActivityEvidence(
if (confirmed) {
SessionActivityEvidenceSource.PendingInput
} else {
SessionActivityEvidenceSource.Checkpoint
},
generation,
observedAtMillis,
),
)
val pending = if (opened) {
previous.pendingInputs + (requestId to expiresAtMillis)
} else {
previous.pendingInputs - requestId
}
return copy(records = records + (owner to previous.copy(
pendingInputs = pending,
freshness = if (confirmed) SessionActivityFreshness.Confirmed else previous.freshness,
evidence = if (confirmed) {
SessionActivityEvidence(
SessionActivityEvidenceSource.PendingInput,
generation,
observedAtMillis,
)
} else previous.evidence,
)))
}
private fun updateProcess(update: SessionActivityUpdate.ProcessState): SessionActivityRegistry {
val previous = records[update.owner] ?: SessionActivityRecord(
owner = update.owner,
turnPhase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Confirmed,
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.Process,
update.generation,
update.observedAtMillis,
),
)
val processes = if (update.running) {
previous.backgroundProcessIds + update.processId
} else {
previous.backgroundProcessIds - update.processId
}
return copy(records = records + (update.owner to previous.copy(
backgroundProcessIds = processes,
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.Process,
update.generation,
update.observedAtMillis,
),
)))
}
private fun expirePendingInputs(nowMillis: Long): SessionActivityRegistry = copy(
records = records.mapValues { (_, record) ->
record.copy(pendingInputs = record.pendingInputs.filterValues { it == null || it > nowMillis })
},
)
private fun SessionActivityRecord.shouldSettleWhenAbsent(): Boolean =
runtimeId != null || evidence.source in setOf(
SessionActivityEvidenceSource.ActiveList,
SessionActivityEvidenceSource.Checkpoint,
SessionActivityEvidenceSource.Directory,
)
private fun SessionActivityUpdate.scope(): SessionActivityScope = when (this) {
is SessionActivityUpdate.BeginGeneration -> scope
is SessionActivityUpdate.ObserveOwner -> owner.scope()
is SessionActivityUpdate.RuntimeState -> scope
is SessionActivityUpdate.ActiveList -> scope
is SessionActivityUpdate.StatusUnavailable -> scope
is SessionActivityUpdate.Terminal -> owner.scope()
is SessionActivityUpdate.LocalSend -> owner.scope()
is SessionActivityUpdate.LiveState -> owner.scope()
is SessionActivityUpdate.RestoreCheckpoint -> owner.scope()
is SessionActivityUpdate.PendingInputOpened -> owner.scope()
is SessionActivityUpdate.PendingInputClosed -> owner.scope()
is SessionActivityUpdate.ProcessState -> owner.scope()
is SessionActivityUpdate.Tick -> error("Tick has no scope")
}
private fun SessionActivityOwner.scope() = SessionActivityScope.of(connectionId, profile)
private fun SessionLiveStatus.phase(): SessionActivityPhase = when (this) {
SessionLiveStatus.Starting -> SessionActivityPhase.Starting
SessionLiveStatus.Working -> SessionActivityPhase.Working
SessionLiveStatus.Waiting -> SessionActivityPhase.NeedsInput
SessionLiveStatus.Idle -> SessionActivityPhase.Idle
}
data class RuntimeAlias(
val scope: SessionActivityScope,
val runtimeId: String,
val generation: Long,
)
}
@@ -2,6 +2,10 @@ package com.hermesandroid.relay.data
/** Live activity surfaced beside a session without conflating it with selection. */
enum class SessionActivityState {
Starting,
Working,
NeedsInput,
BackgroundWork,
Checking,
Unavailable,
}
@@ -1,234 +0,0 @@
package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import com.hermesandroid.relay.ui.theme.AppThemes
/**
* Parent-configured restrictions for the official Android client.
*
* This policy deliberately describes a client presentation mode, not a server
* authorization boundary. The pinned profile is expected to have already been
* configured with the appropriate server-side tool and content restrictions.
*/
@Serializable
data class SupervisedModePolicy(
val enabled: Boolean = false,
val pinnedProfileName: String? = null,
val capabilities: SupervisedCapabilities = SupervisedCapabilities(),
val appearance: SupervisedAppearance = SupervisedAppearance(),
val visibility: SupervisedVisibility = SupervisedVisibility(),
val parentAccess: SupervisedParentAccess = SupervisedParentAccess(),
) {
/** A saved policy is usable only when it names a concrete Hermes profile. */
val isConfigured: Boolean
get() = !pinnedProfileName.isNullOrBlank()
/** Consumers should use this instead of treating [enabled] alone as sufficient. */
val isActive: Boolean
get() = enabled && isConfigured
internal fun normalized(): SupervisedModePolicy = copy(
pinnedProfileName = pinnedProfileName?.trim()?.takeIf { it.isNotEmpty() },
capabilities = capabilities.normalized(),
appearance = appearance.normalized(),
parentAccess = parentAccess.normalized(),
)
}
/** Actions and content types the supervised chat surface may expose. */
@Serializable
data class SupervisedCapabilities(
val attachments: Boolean = false,
val voice: Boolean = false,
val generatedImages: Boolean = true,
val conversationHistory: Boolean = false,
val newChat: Boolean = true,
val cancelResponse: Boolean = true,
val steerResponse: Boolean = true,
val retryResponse: Boolean = true,
val copyResponses: Boolean = true,
val quoteReplies: Boolean = true,
val editAndResend: Boolean = false,
val shareGeneratedImages: Boolean = false,
val sessionActions: SupervisedSessionActions = SupervisedSessionActions(),
val attachmentMaxCount: Int = DEFAULT_ATTACHMENT_MAX_COUNT,
val attachmentMaxFileMb: Int = DEFAULT_ATTACHMENT_MAX_FILE_MB,
val attachmentCategories: Set<SupervisedAttachmentCategory> = setOf(
SupervisedAttachmentCategory.Images,
),
) {
internal fun normalized(): SupervisedCapabilities = copy(
attachmentMaxCount = attachmentMaxCount.coerceIn(1, MAX_ATTACHMENT_COUNT),
attachmentMaxFileMb = attachmentMaxFileMb.coerceIn(1, MAX_ATTACHMENT_FILE_MB),
attachmentCategories = attachmentCategories.ifEmpty {
setOf(SupervisedAttachmentCategory.Images)
},
)
companion object {
const val DEFAULT_ATTACHMENT_MAX_COUNT = 4
const val DEFAULT_ATTACHMENT_MAX_FILE_MB = 10
const val MAX_ATTACHMENT_COUNT = 10
const val MAX_ATTACHMENT_FILE_MB = 100
}
}
/** Appearance applied only while the supervised root is locked. */
@Serializable
data class SupervisedAppearance(
val appThemeId: String = AppThemes.DEFAULT_ID,
val themePreference: String = "auto",
val showPet: Boolean = false,
val allowProfileIconChanges: Boolean = false,
val allowBackgroundChanges: Boolean = false,
) {
internal fun normalized(): SupervisedAppearance = copy(
appThemeId = AppThemes.byId(appThemeId).id,
themePreference = themePreference.takeIf { it in VALID_THEME_PREFERENCES } ?: "auto",
)
private companion object {
val VALID_THEME_PREFERENCES = setOf("auto", "light", "dark")
}
}
/** Mutable operations available from a supervised conversation-history row. */
@Serializable
data class SupervisedSessionActions(
val pin: Boolean = false,
val rename: Boolean = false,
val archive: Boolean = false,
val delete: Boolean = false,
val shareTranscript: Boolean = false,
) {
val enabledCount: Int
get() = listOf(pin, rename, archive, delete, shareTranscript).count { it }
val allEnabled: Boolean
get() = enabledCount == TOTAL
val noneEnabled: Boolean
get() = enabledCount == 0
fun withAll(enabled: Boolean): SupervisedSessionActions = SupervisedSessionActions(
pin = enabled,
rename = enabled,
archive = enabled,
delete = enabled,
shareTranscript = enabled,
)
companion object {
const val TOTAL = 5
}
}
enum class SupervisedSessionAction {
Pin,
Rename,
Archive,
Delete,
ShareTranscript,
}
fun SupervisedModePolicy.allowsSessionAction(action: SupervisedSessionAction): Boolean {
if (!enabled) return true
if (!capabilities.conversationHistory) return false
return when (action) {
SupervisedSessionAction.Pin -> capabilities.sessionActions.pin
SupervisedSessionAction.Rename -> capabilities.sessionActions.rename
SupervisedSessionAction.Archive -> capabilities.sessionActions.archive
SupervisedSessionAction.Delete -> capabilities.sessionActions.delete
SupervisedSessionAction.ShareTranscript -> capabilities.sessionActions.shareTranscript
}
}
@Serializable
enum class SupervisedAttachmentCategory {
@SerialName("images")
Images,
@SerialName("documents")
Documents,
@SerialName("audio")
Audio,
@SerialName("video")
Video,
}
/**
* Controls which metadata and conversation affordances are rendered.
*
* [Simple] is the quiet default. [Transparent] is a useful preset for older or
* technical users, while [Custom] tells the UI to honor every stored toggle.
*/
@Serializable
data class SupervisedVisibility(
val preset: SupervisedVisibilityPreset = SupervisedVisibilityPreset.Simple,
val showAgentIdentity: Boolean = true,
val showModelName: Boolean = false,
val showProfileName: Boolean = false,
val showConnectionStatus: Boolean = true,
val showTechnicalRoute: Boolean = false,
val showTimestamps: Boolean = true,
val showToolNames: Boolean = false,
val showToolDetails: Boolean = false,
val showWorkingStatus: Boolean = true,
val showReasoning: Boolean = false,
val showUsage: Boolean = false,
) {
/** Resolve presets to the concrete flags consumed by chat presentation. */
fun resolved(): SupervisedVisibility = when (preset) {
SupervisedVisibilityPreset.Simple -> SIMPLE
SupervisedVisibilityPreset.Transparent -> TRANSPARENT
SupervisedVisibilityPreset.Custom -> this
}
companion object {
val SIMPLE = SupervisedVisibility(preset = SupervisedVisibilityPreset.Simple)
val TRANSPARENT = SupervisedVisibility(
preset = SupervisedVisibilityPreset.Transparent,
showModelName = true,
showProfileName = true,
showTechnicalRoute = true,
showToolNames = true,
showUsage = true,
)
}
}
@Serializable
enum class SupervisedVisibilityPreset {
@SerialName("simple")
Simple,
@SerialName("transparent")
Transparent,
@SerialName("custom")
Custom,
}
/** Device-authentication and automatic relock behavior for parent access. */
@Serializable
data class SupervisedParentAccess(
/** Reserved for forward-compatible persistence; normalization never permits an auth bypass. */
val requireDeviceAuthentication: Boolean = true,
val relockOnBackground: Boolean = true,
val timeoutMinutes: Int = DEFAULT_TIMEOUT_MINUTES,
) {
internal fun normalized(): SupervisedParentAccess = copy(
requireDeviceAuthentication = true,
timeoutMinutes = timeoutMinutes.coerceIn(MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES),
)
companion object {
const val DEFAULT_TIMEOUT_MINUTES = 5
const val MIN_TIMEOUT_MINUTES = 1
const val MAX_TIMEOUT_MINUTES = 60
}
}
@@ -1,106 +0,0 @@
package com.hermesandroid.relay.data
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
/** Persists one independent [SupervisedModePolicy] per Hermes connection. */
class SupervisedModeStore private constructor(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.relayDataStore)
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
private val serializer = MapSerializer(String.serializer(), SupervisedModePolicy.serializer())
fun policyFlow(connectionId: String): Flow<SupervisedModePolicy> =
dataStore.data.map { preferences ->
val decoded = decode(preferences[KEY_POLICIES])
if (decoded.corrupt) {
// A malformed persisted policy must never silently reopen the
// unrestricted app. Enabled + unconfigured renders the
// supervised recovery surface until an authenticated user
// repairs or clears the policy.
SupervisedModePolicy(enabled = true)
} else {
decoded.policies[connectionId]?.normalized() ?: SupervisedModePolicy()
}
}
suspend fun setPolicy(connectionId: String, policy: SupervisedModePolicy) {
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
dataStore.edit { preferences ->
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
policies[connectionId] = policy.normalized()
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
}
}
suspend fun updatePolicy(
connectionId: String,
transform: (SupervisedModePolicy) -> SupervisedModePolicy,
) {
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
dataStore.edit { preferences ->
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
val current = policies[connectionId]?.normalized() ?: SupervisedModePolicy()
policies[connectionId] = transform(current).normalized()
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
}
}
suspend fun setEnabled(connectionId: String, enabled: Boolean) {
updatePolicy(connectionId) { it.copy(enabled = enabled) }
}
suspend fun clear(connectionId: String) {
dataStore.edit { preferences ->
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
policies.remove(connectionId)
if (policies.isEmpty()) {
preferences.remove(KEY_POLICIES)
} else {
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
}
}
}
/** Clear supervised policies without disturbing unrelated app settings. */
suspend fun clearAll() {
dataStore.edit { preferences -> preferences.remove(KEY_POLICIES) }
}
private fun decode(raw: String?): DecodeResult {
if (raw.isNullOrBlank()) return DecodeResult(emptyMap(), corrupt = false)
return try {
DecodeResult(json.decodeFromString(serializer, raw), corrupt = false)
} catch (error: Exception) {
Log.w(TAG, "Unable to decode supervised-mode policies; failing closed", error)
DecodeResult(emptyMap(), corrupt = true)
}
}
private data class DecodeResult(
val policies: Map<String, SupervisedModePolicy>,
val corrupt: Boolean,
)
internal companion object {
private const val TAG = "SupervisedModeStore"
private val KEY_POLICIES = stringPreferencesKey("supervised_mode_policies_v1")
fun forTesting(dataStore: DataStore<Preferences>): SupervisedModeStore =
SupervisedModeStore(dataStore)
}
}
@@ -153,7 +153,7 @@ class ChannelMultiplexer {
)
send(pong)
}
"auth.ok", "auth.fail", "supervised.updated", "error" -> {
"auth.ok", "auth.fail" -> {
// Delegate to system handler if registered
handlers["system"]?.onMessage(envelope)
}
@@ -442,35 +442,6 @@ class ConnectionManager(
return true
}
/**
* Reopen the current authenticated Relay socket without discarding pair
* state. Used only as a compatibility fallback when an older Relay does
* not acknowledge a post-auth metadata update; the replacement socket's
* normal `system/auth` frame carries the latest metadata.
*/
fun reconnectForAuthenticatedMetadataUpdate(): Boolean {
val targetUrl = serverUrl?.takeIf { it.isNotBlank() } ?: return false
if (isRelayRateLimitBackoffActive(
rateLimitBackoffUntilMs,
SystemClock.elapsedRealtime(),
)
) {
Log.i(TAG, "metadata reconnect: preserving active rate-limit backoff")
return false
}
val previousSocket = webSocket
if (previousSocket == null) {
connect(targetUrl)
} else {
doConnect(
targetUrl,
previousSocketToClose = previousSocket,
replaceReason = "Relay metadata compatibility refresh",
)
}
return true
}
/**
* Same as [connect] but bypasses the resolver — used by the network-
* change callback when we've already picked a winner and just want to
@@ -48,9 +48,6 @@ interface VoiceAudioClient {
val effectiveRoute: VoiceAudioRoute
get() = route
/** Temporary client-policy override; the shared router honors it before user prefs. */
fun setRouteOverride(route: VoiceAudioRoute?) = Unit
suspend fun transcribe(audioFile: File): Result<String>
suspend fun synthesize(text: String): Result<File>
@@ -85,15 +82,8 @@ class AutoVoiceAudioClient(
private val standardReadyProvider: () -> Boolean,
private val relayReadyProvider: () -> Boolean,
) : VoiceAudioClient {
@Volatile
private var routeOverride: VoiceAudioRoute? = null
override fun setRouteOverride(route: VoiceAudioRoute?) {
routeOverride = route
}
override val route: VoiceAudioRoute
get() = routeOverride ?: routeProvider()
get() = routeProvider()
/**
* Resolve the configured preference to the backend a call would land on:
@@ -102,7 +92,7 @@ class AutoVoiceAudioClient(
* decide whether standard-only limitations (global TTS) currently apply.
*/
override val effectiveRoute: VoiceAudioRoute
get() = when (route) {
get() = when (routeProvider()) {
VoiceAudioRoute.Standard -> VoiceAudioRoute.Standard
VoiceAudioRoute.Relay -> VoiceAudioRoute.Relay
VoiceAudioRoute.Auto ->
@@ -124,7 +114,7 @@ class AutoVoiceAudioClient(
private suspend fun <T> runWithSelectedRoute(
block: suspend (VoiceAudioClient) -> Result<T>,
): Result<T> {
return when (route) {
return when (routeProvider()) {
VoiceAudioRoute.Standard -> {
if (!standardReadyProvider()) {
Result.failure(
@@ -2075,7 +2075,7 @@ class ChatHandler {
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
recentlyActive = item.isActive,
updatedAt = activityAtMs,
startedAt = startedAtMs,
lastActivityAt = lastActivityAtMs,
@@ -52,6 +52,7 @@ import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
@@ -278,6 +279,12 @@ class GatewayChatClient(
private val _processCapability = MutableStateFlow(GatewayProcessCapability.Unknown)
val processCapability: StateFlow<GatewayProcessCapability> = _processCapability.asStateFlow()
/** Per-socket capability for upstream's process-wide live-session snapshot. */
private val _activeSessionCapability =
MutableStateFlow(GatewayActiveSessionCapability.Unknown)
val activeSessionCapability: StateFlow<GatewayActiveSessionCapability> =
_activeSessionCapability.asStateFlow()
/**
* Active personality the gateway is applying, as a config value ("none" when
* the overlay is cleared, otherwise the personality name). Tracks the
@@ -806,6 +813,21 @@ class GatewayChatClient(
fun currentLiveSessionId(storedId: String): String? =
liveSessionId?.takeIf { storedSessionId == storedId }
/**
* Exact durable/profile owner already held by this client for [runtimeId].
* Unlike `session.active_list`, this mapping is safe for multiplexed profiles
* because Android recorded it when the runtime was created/resumed/detached.
*/
fun knownSessionOwner(runtimeId: String): GatewayKnownSessionOwner? {
if (runtimeId == liveSessionId) {
val storedId = storedSessionId ?: return null
return GatewayKnownSessionOwner(storedId, liveSessionProfile)
}
return backgroundTurns[runtimeId]?.let { owner ->
GatewayKnownSessionOwner(owner.storedSessionId, owner.profile)
}
}
/**
* Point this client at a new dashboard route (e.g. LAN→Tailscale after a
* sustained network change). If a turn is in flight, the current socket is
@@ -2065,6 +2087,47 @@ class GatewayChatClient(
)
}
/**
* Fetch authoritative in-memory execution states from current upstream
* Hermes. `session.active_list` is process-wide: it accepts only an optional
* current runtime id and does not profile-filter its rows. Accordingly this
* transport returns rows unscoped and never derives activity from REST
* `is_active` or stamps the selected profile onto a row.
*/
suspend fun listActiveSessions(): GatewayActiveSessionsResult {
if (_activeSessionCapability.value == GatewayActiveSessionCapability.Unsupported) {
return GatewayActiveSessionsResult.Unsupported
}
try {
connectMutex.withLock { ensureConnected() }
} catch (error: Exception) {
return GatewayActiveSessionsResult.TransientFailure(error)
}
val result = rpc(
"session.active_list",
buildJsonObject {
liveSessionId?.let { put("current_session_id", it) }
},
)
val error = result.exceptionOrNull()
if (error.isMethodNotFound()) {
_activeSessionCapability.value = GatewayActiveSessionCapability.Unsupported
return GatewayActiveSessionsResult.Unsupported
}
if (error != null) {
return GatewayActiveSessionsResult.TransientFailure(error)
}
_activeSessionCapability.value = GatewayActiveSessionCapability.Supported
return try {
val payload = result.getOrThrow()
val rows = payload["sessions"] as? JsonArray
?: throw GatewayRpcException("session.active_list returned no sessions array")
GatewayActiveSessionsResult.Success(rows.map(::parseGatewayActiveSession))
} catch (parseError: Exception) {
GatewayActiveSessionsResult.TransientFailure(parseError)
}
}
/** Stop one process owned by the current live gateway session. */
suspend fun killProcess(processId: String): Result<Unit> {
if (processId.isBlank()) {
@@ -2503,6 +2566,7 @@ class GatewayChatClient(
private suspend fun connectOnce() {
val connectStart = System.nanoTime()
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.MintingTicket
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
@@ -2749,6 +2813,28 @@ class GatewayChatClient(
)
}
private fun parseGatewayActiveSession(
element: kotlinx.serialization.json.JsonElement,
): GatewayActiveSession {
val row = element as? JsonObject
?: throw GatewayRpcException("session.active_list returned a non-object row")
val runtimeId = row.stringField("id")?.takeIf(String::isNotBlank)
?: throw GatewayRpcException("session.active_list row returned no runtime id")
val storedId = row.stringField("session_key")?.takeIf(String::isNotBlank)
?: throw GatewayRpcException("session.active_list row returned no session key")
val status = GatewayActiveSessionStatus.fromWire(row.stringField("status"))
?: throw GatewayRpcException("session.active_list row returned an unknown status")
val lastActive = (row["last_active"] as? JsonPrimitive)?.doubleOrNull
?: throw GatewayRpcException("session.active_list row returned no last_active")
return GatewayActiveSession(
runtimeSessionId = runtimeId,
storedSessionId = storedId,
status = status,
lastActiveEpochSeconds = lastActive,
profile = row.stringField("profile")?.trim()?.takeIf(String::isNotEmpty),
)
}
private fun markProcessUnsupportedIfNeeded(error: Throwable?) {
if (error.isMethodNotFound()) {
_processCapability.value = GatewayProcessCapability.Unsupported
@@ -3222,6 +3308,7 @@ class GatewayChatClient(
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
pendingRpcs.values.forEach {
@@ -3407,6 +3494,7 @@ class GatewayChatClient(
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
}
@@ -320,6 +320,69 @@ enum class GatewayProcessCapability {
Unsupported,
}
/** Authoritative execution state reported by upstream `session.active_list`. */
enum class GatewayActiveSessionStatus(val wireValue: String) {
Idle("idle"),
Starting("starting"),
Working("working"),
Waiting("waiting");
companion object {
fun fromWire(value: String?): GatewayActiveSessionStatus? = when (value?.trim()?.lowercase()) {
"idle" -> Idle
"starting" -> Starting
"working" -> Working
"waiting" -> Waiting
else -> null
}
}
}
/**
* One in-memory runtime returned by upstream `session.active_list`.
*
* The RPC is process-wide in current upstream Hermes. Its rows do not normally
* identify their profile, so [profile] stays null unless a future gateway
* explicitly sends one. Callers must resolve [storedSessionId] against their
* own profile-scoped session registry and fail closed when ownership is
* ambiguous; the transport never synthesizes profile attribution.
*/
data class GatewayActiveSession(
/** Per-process runtime id used by live Gateway events and session RPCs. */
val runtimeSessionId: String,
/** Durable history id (`session_key`) used by the REST/session database. */
val storedSessionId: String,
val status: GatewayActiveSessionStatus,
/** Unix epoch seconds from upstream's in-memory runtime record. */
val lastActiveEpochSeconds: Double,
/** Future-compatible only; null for the current upstream contract. */
val profile: String? = null,
)
/** Exact owner already known by this client for a foreground or detached runtime. */
data class GatewayKnownSessionOwner(
val storedSessionId: String,
val profile: String?,
)
/** Whether the current Gateway socket exposes `session.active_list`. */
enum class GatewayActiveSessionCapability {
Unknown,
Supported,
Unsupported,
}
/**
* Result of one process-wide live-session snapshot request. Unsupported is
* intentionally distinct from transport/protocol failure so callers can use
* another source only for older gateways, while failures remain Unknown.
*/
sealed interface GatewayActiveSessionsResult {
data class Success(val sessions: List<GatewayActiveSession>) : GatewayActiveSessionsResult
data object Unsupported : GatewayActiveSessionsResult
data class TransientFailure(val error: Throwable) : GatewayActiveSessionsResult
}
/**
* Connection-level background-process events. These are deliberately separate
* from [GatewayTurnCallbacks]: output and completion notifications can arrive
@@ -250,6 +250,7 @@ data class SessionItem(
@SerialName("output_tokens") val outputTokens: Int? = null,
@SerialName("actual_cost_usd") val actualCostUsd: Double? = null,
@SerialName("estimated_cost_usd") val estimatedCostUsd: Double? = null,
/** REST recency heuristic from upstream; not live Gateway execution state. */
@SerialName("is_active") val isActive: Boolean = false,
@SerialName("has_model_config")
@Serializable(with = FlexibleBooleanSerializer::class)
@@ -133,8 +133,6 @@ import com.hermesandroid.relay.data.CandidateBuild
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.SupervisedModeStore
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.data.displayLabel
@@ -146,7 +144,6 @@ import com.hermesandroid.relay.util.HumanError
import kotlinx.coroutines.delay
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
import com.hermesandroid.relay.ui.screens.AboutScreen
import com.hermesandroid.relay.ui.screens.AdvancedSettingsScreen
import com.hermesandroid.relay.ui.screens.AnalyticsScreen
import com.hermesandroid.relay.ui.screens.AppearanceSettingsScreen
import com.hermesandroid.relay.ui.screens.CustomThemeScreen
@@ -173,8 +170,6 @@ import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
import com.hermesandroid.relay.ui.screens.SettingsScreen
import com.hermesandroid.relay.ui.screens.SupervisedControlsScreen
import com.hermesandroid.relay.ui.screens.SupervisedAppearanceSettingsScreen
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
import com.hermesandroid.relay.ui.screens.PluginsScreen
import com.hermesandroid.relay.ui.screens.PluginPageScreen
@@ -536,17 +531,6 @@ sealed class Screen(
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
// above for the surviving route.)
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
data object AdvancedSettings : Screen("settings/advanced", "Advanced", Icons.Filled.Settings)
data object SupervisedAppearanceSettings : Screen(
"settings/supervised/appearance",
"Appearance",
Icons.Filled.Settings,
)
data object SupervisedControls : Screen(
"settings/supervised",
"Supervised mode",
Icons.Filled.Settings,
)
data object ProviderUsage : Screen("settings/usage", "Usage & limits", Icons.Filled.Settings)
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
@@ -605,24 +589,6 @@ sealed class Screen(
}
}
@Composable
private fun SupervisedStartupLoadingScreen() {
HermesRelayTheme(themePreference = "dark") {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
Text(
text = "Loading protected settings…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
fun RelayApp() {
val applicationContext = LocalContext.current.applicationContext
@@ -637,10 +603,7 @@ fun RelayApp() {
LaunchedEffect(processRuntime) {
processRuntime.ensureInitialized()
}
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) {
SupervisedStartupLoadingScreen()
return
}
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) return
val voiceClient: RelayVoiceClient = processRuntime.relayVoiceClient
val voicePreferences = processRuntime.voicePreferences
@@ -737,72 +700,6 @@ fun RelayApp() {
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
val connectionStoreHydrated by
connectionViewModel.connectionStore.isHydrated.collectAsState()
val supervisedModeStore = remember(applicationContext) {
SupervisedModeStore(applicationContext)
}
val supervisedPolicyState = produceState<Pair<String?, SupervisedModePolicy>?>(
initialValue = null,
key1 = activeConnectionId,
key2 = supervisedModeStore,
) {
val connectionId = activeConnectionId
if (connectionId == null) {
value = null to SupervisedModePolicy()
} else {
supervisedModeStore.policyFlow(connectionId).collect { policy ->
value = connectionId to policy
}
}
}
val ownedSupervisedPolicyState = supervisedPolicyState.value
?.takeIf { (ownerConnectionId, _) -> ownerConnectionId == activeConnectionId }
// Fail closed across process restoration. activeConnectionId starts as
// null while ConnectionStore reads DataStore, so null alone cannot prove
// this is a fresh install with no supervised policy to restore.
if (!isRelayNavigationHydrated(
connectionStoreHydrated = connectionStoreHydrated,
activeConnectionId = activeConnectionId,
supervisedPolicyHydrated = ownedSupervisedPolicyState != null,
)
) {
SupervisedStartupLoadingScreen()
return
}
val supervisedPolicy = ownedSupervisedPolicyState?.second ?: SupervisedModePolicy()
val supervisedPinnedProfile = supervisedPolicy.pinnedProfileName?.let { name ->
agentProfiles.firstOrNull { it.name.equals(name, ignoreCase = true) }
}
val supervisedProfileConfirmed = !supervisedPolicy.enabled || (
profileSelectionSettled &&
supervisedPinnedProfile != null &&
selectedProfile?.name.equals(supervisedPinnedProfile.name, ignoreCase = true)
)
val chatSupervisedPolicy = if (supervisedPolicy.enabled && !supervisedProfileConfirmed) {
supervisedPolicy.copy(pinnedProfileName = null)
} else supervisedPolicy
var parentAccessUnlocked by remember(activeConnectionId) { mutableStateOf(false) }
LaunchedEffect(
activeConnectionId,
supervisedPolicy,
agentProfiles,
selectedProfile,
profileSelectionSettled,
) {
chatViewModel.updateSupervisedModePolicy(chatSupervisedPolicy)
connectionViewModel.authManager.updateSupervisedMode(chatSupervisedPolicy)
if (!supervisedPolicy.enabled) {
parentAccessUnlocked = false
return@LaunchedEffect
}
val pinned = supervisedPinnedProfile ?: return@LaunchedEffect
if (!selectedProfile?.name.equals(pinned.name, ignoreCase = true)) {
connectionViewModel.selectProfile(pinned)
chatViewModel.activateGatewayProfile(pinned)
}
}
val connections by connectionViewModel.connections.collectAsState()
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
@@ -887,22 +784,6 @@ fun RelayApp() {
val appearanceAccent by connectionViewModel.appearanceAccent.collectAsState()
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val parentAccessForCurrentRoute = parentAccessUnlocked &&
!shouldRelockParentAccess(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessUnlocked,
route = currentRoute,
)
val resolvedTheme = resolveSupervisedTheme(
policy = supervisedPolicy,
parentAccessUnlocked = parentAccessForCurrentRoute,
globalAppThemeId = appThemeId,
globalThemePreference = themePreference,
)
val supervisedAppearanceLocked = supervisedPolicy.enabled && !parentAccessForCurrentRoute
// Resolve the active sphere skin (built-in / adaptive / user-loaded) and
// publish it + the full available set so every MorphingSphere picks it up
@@ -919,10 +800,10 @@ fun RelayApp() {
value = SphereRegistry.builtIns +
withContext(Dispatchers.IO) { SphereSkinLoader.loadUserSkins(sphereContext) }
}
val activeSphereSkin = remember(sphereSkinId, resolvedTheme.appThemeId, availableSphereSkins) {
val activeSphereSkin = remember(sphereSkinId, appThemeId, availableSphereSkins) {
SphereRegistry.resolve(
selectedId = sphereSkinId,
themeDefaultSkinId = AppThemes.byId(resolvedTheme.appThemeId).defaultSphereSkinId,
themeDefaultSkinId = AppThemes.byId(appThemeId).defaultSphereSkinId,
available = availableSphereSkins,
)
}
@@ -1057,19 +938,37 @@ fun RelayApp() {
),
)
HermesRelayTheme(
appThemeId = resolvedTheme.appThemeId,
themePreference = resolvedTheme.themePreference,
appThemeId = appThemeId,
themePreference = themePreference,
fontScale = fontScale,
appFontId = appFontId,
accentHex = appearanceAccent.takeIf { resolvedTheme.useGlobalCustomTheme },
accentHex = appearanceAccent,
shapeId = appearanceShape,
customTheme = activeCustomTheme.takeIf { resolvedTheme.useGlobalCustomTheme },
customTheme = activeCustomTheme,
) {
// Surface a crash report from a previous session, if any. Renders a
// platform Dialog (own window) so tree position is z-order-agnostic;
// it just needs to be inside the theme for Material colors.
CrashReportGate()
val navController = rememberNavController()
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
// Collect navigation requests posted by external launchers (e.g., the
// BridgeForegroundService notification's "Settings" action). The
// service sets EXTRA_NAV_ROUTE on its launch intent → MainActivity's
// onCreate / onNewIntent reads it and pumps it onto NavRouteRequest →
// we forward each emission to the NavController. Single observer at
// the app root so every screen benefits.
LaunchedEffect(navController) {
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
navController.navigate(route) {
launchSingleTop = true
}
}
}
// === END PHASE3-safety-rails-followup ===
// Wire the proactive "session" surfacing once: a message with
// surfacing="session" is injected into the active chat conversation.
// ChatViewModel isn't available where ConnectionViewModel builds the
@@ -1140,68 +1039,8 @@ fun RelayApp() {
// restart cleanly lands back in setup.
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
// The unlock remains useful while moving between parent-only settings,
// but never follows an enrolled device user back into supervised chat.
// Cross-layer requests (notifications, services, deep links) use the
// route-scoped unlock. As soon as Chat is current, the parent grant is
// ineffective even before the state-clearing effect runs.
LaunchedEffect(
navController,
supervisedPolicy.enabled,
parentAccessForCurrentRoute,
) {
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
if (
supervisedPolicy.enabled &&
!isSupervisedRouteAllowed(route, parentAccessForCurrentRoute)
) return@collect
navController.navigate(route) {
launchSingleTop = true
}
}
}
LaunchedEffect(
supervisedPolicy.enabled,
parentAccessForCurrentRoute,
currentRoute,
) {
if (shouldRedirectSupervisedRoute(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessForCurrentRoute,
currentRoute = currentRoute,
)
) {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(navController.graph.findStartDestination().id) { inclusive = false }
launchSingleTop = true
}
}
}
LaunchedEffect(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute) {
if (shouldRelockParentAccess(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute)) {
parentAccessUnlocked = false
}
}
LaunchedEffect(parentAccessUnlocked, supervisedPolicy.parentAccess.timeoutMinutes) {
if (parentAccessUnlocked) {
delay(supervisedPolicy.parentAccess.timeoutMinutes * 60_000L)
parentAccessUnlocked = false
}
}
DisposableEffect(lifecycleOwner, supervisedPolicy.enabled, parentAccessUnlocked) {
val relockObserver = LifecycleEventObserver { _, event ->
if (
event == Lifecycle.Event.ON_PAUSE &&
supervisedPolicy.enabled &&
parentAccessUnlocked &&
supervisedPolicy.parentAccess.relockOnBackground
) {
parentAccessUnlocked = false
}
}
lifecycleOwner.lifecycle.addObserver(relockObserver)
onDispose { lifecycleOwner.lifecycle.removeObserver(relockObserver) }
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val suppressGlobalChrome = shouldSuppressGlobalChrome(
onboardingCompleted = onboardingCompleted,
isDemoMode = isDemoMode,
@@ -1880,8 +1719,6 @@ fun RelayApp() {
!suppressGlobalChrome &&
!isKeyboardVisible &&
!showStartupSphere &&
(!supervisedPolicy.enabled ||
supervisedPolicy.visibility.resolved().showTechnicalRoute) &&
shouldShowConnectionFooter(voiceUiState.voiceMode, voicePresentationMode)
) {
val footerRoute = resolveFooterRouteCandidate(
@@ -1956,16 +1793,12 @@ fun RelayApp() {
.fillMaxSize()
.padding(innerPadding),
) {
val routeContentAllowed = isSupervisedRouteContentAllowed(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessForCurrentRoute,
currentRoute = currentRoute,
)
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
composable(Screen.Onboarding.route) {
// The wizard inside OnboardingScreen now owns credential
@@ -2052,43 +1885,15 @@ fun RelayApp() {
// sheet.
val openAgentSheetArg = backStackEntry.arguments
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
val rawRequestedSessionId = backStackEntry.arguments
val requestedSessionId = backStackEntry.arguments
?.getString(Screen.Chat.ARG_SESSION_ID)
?.takeIf { it.isNotBlank() }
val rawRequestedProfileRoute = backStackEntry.arguments
val requestedProfileRoute = backStackEntry.arguments
?.getString(Screen.Chat.ARG_PROFILE)
?.takeIf { it.isNotBlank() }
val rawRequestedProactiveChatId = backStackEntry.arguments
val requestedProactiveChatId = backStackEntry.arguments
?.getString(Screen.Chat.ARG_PROACTIVE_CHAT_ID)
?.takeIf { it.isNotBlank() }
// Nav/deep-link arguments are not ownership evidence. The
// supervised drawer uses profile-scoped session rows
// directly; external args stay discarded until an
// owner-aware source can explicitly prove the binding.
val sanitizedRouteArgs = sanitizeSupervisedChatRouteArgs(
policy = supervisedPolicy,
args = SupervisedChatRouteArgs(
sessionId = rawRequestedSessionId,
profile = rawRequestedProfileRoute,
proactiveChatId = rawRequestedProactiveChatId,
),
pinnedProfileOwnershipProven = false,
)
val requestedSessionId = sanitizedRouteArgs.sessionId
val requestedProfileRoute = sanitizedRouteArgs.profile
val requestedProactiveChatId = sanitizedRouteArgs.proactiveChatId
LaunchedEffect(
supervisedPolicy.enabled,
rawRequestedSessionId,
rawRequestedProfileRoute,
rawRequestedProactiveChatId,
) {
if (supervisedPolicy.enabled) {
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROACTIVE_CHAT_ID, null)
}
}
val proactiveInboxEntries by connectionViewModel.inboxMessages.collectAsState()
val phoneThreadChatIds by connectionViewModel.phoneThreadChatIds.collectAsState()
LaunchedEffect(
@@ -2251,7 +2056,6 @@ fun RelayApp() {
launchSingleTop = true
}
},
supervisedPolicy = chatSupervisedPolicy,
onNavigateToBotMode = {
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
},
@@ -2572,22 +2376,6 @@ fun RelayApp() {
SettingsScreen(
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
supervisedPolicy = supervisedPolicy,
parentAccessUnlocked = parentAccessUnlocked,
onRequestParentAccess = { parentAccessUnlocked = true },
onUpdateSupervisedPolicy = { policy ->
activeConnectionId?.let { connectionId ->
connectionSwitchScope.launch {
supervisedModeStore.setPolicy(connectionId, policy)
}
}
},
onNavigateToAdvancedSettings = {
navController.navigate(Screen.AdvancedSettings.route)
},
onNavigateToSupervisedAppearance = {
navController.navigate(Screen.SupervisedAppearanceSettings.route)
},
onBack = { navController.popBackStack() },
// (The `onNavigateToChatWithAgentSheet` callback that
// used to live here was removed 2026-04-21. Tapping
@@ -2662,62 +2450,6 @@ fun RelayApp() {
},
)
}
composable(Screen.AdvancedSettings.route) {
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
AdvancedSettingsScreen(
supervisedPolicy = supervisedPolicy,
onNavigateToSupervisedControls = {
navController.navigate(Screen.SupervisedControls.route)
},
onBack = { navController.popBackStack() },
)
}
}
composable(Screen.SupervisedAppearanceSettings.route) {
if (!supervisedPolicy.enabled && !parentAccessUnlocked) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
SupervisedAppearanceSettingsScreen(
connectionViewModel = connectionViewModel,
policy = supervisedPolicy,
onPolicyChange = { policy ->
activeConnectionId?.let { connectionId ->
connectionSwitchScope.launch {
supervisedModeStore.setPolicy(connectionId, policy)
}
}
},
onBack = { navController.popBackStack() },
)
}
}
composable(Screen.SupervisedControls.route) {
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
SupervisedControlsScreen(
connectionViewModel = connectionViewModel,
policy = supervisedPolicy,
profiles = agentProfiles.filterNot { it.isDefault },
onPolicyChange = { policy ->
activeConnectionId?.let { connectionId ->
connectionSwitchScope.launch {
supervisedModeStore.setPolicy(connectionId, policy)
}
}
},
onBack = { navController.popBackStack() },
onReturnToSupervisedView = {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(Screen.Chat.route) { inclusive = false }
launchSingleTop = true
}
},
)
}
}
composable(Screen.ProviderUsage.route) {
UsageLimitsScreen(
connectionViewModel = connectionViewModel,
@@ -3195,8 +2927,7 @@ fun RelayApp() {
composable(Screen.About.route) {
AboutScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessUnlocked,
onBack = { navController.popBackStack() }
)
}
composable(
@@ -3299,12 +3030,6 @@ fun RelayApp() {
)
}
}
if (!routeContentAllowed) {
// Keep the graph mounted so the redirect can complete, but
// cover restored parent-only content with an opaque fail-closed surface.
SupervisedStartupLoadingScreen()
}
}
} // end bridge-return wrapper column
} // end CompositionLocalProvider
}
@@ -3317,7 +3042,6 @@ fun RelayApp() {
val petSurfaceOwner = petSurfaceOwnerForRoute(currentRoute)
val petActivity = petCompanionCoordinator.activityFor(petSurfaceOwner)
val showFloatingPet = activeFloatingPet != null &&
shouldShowPetInSupervisedMode(supervisedPolicy, parentAccessForCurrentRoute) &&
floatingPetAllowedOnRoute(currentRoute) &&
!petActivity.hidden &&
!suppressGlobalChrome &&
@@ -3348,7 +3072,6 @@ fun RelayApp() {
),
animationEnabled = animationEnabled,
appForeground = appIsForeground,
interactive = !supervisedAppearanceLocked,
route = roamingRoute,
visitRequest = petCompanionCoordinator.pendingVisitRequest,
onVisitRequestConsumed = petCompanionCoordinator::clearVisitRequest,
@@ -1,34 +0,0 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.SupervisedModePolicy
internal data class ResolvedSupervisedTheme(
val appThemeId: String,
val themePreference: String,
val useGlobalCustomTheme: Boolean,
)
/** Keep the supervised palette isolated from the parent's ordinary app theme. */
internal fun resolveSupervisedTheme(
policy: SupervisedModePolicy,
parentAccessUnlocked: Boolean,
globalAppThemeId: String,
globalThemePreference: String,
): ResolvedSupervisedTheme = if (policy.enabled && !parentAccessUnlocked) {
ResolvedSupervisedTheme(
appThemeId = policy.appearance.appThemeId,
themePreference = policy.appearance.themePreference,
useGlobalCustomTheme = false,
)
} else {
ResolvedSupervisedTheme(
appThemeId = globalAppThemeId,
themePreference = globalThemePreference,
useGlobalCustomTheme = true,
)
}
internal fun shouldShowPetInSupervisedMode(
policy: SupervisedModePolicy,
parentAccessUnlocked: Boolean,
): Boolean = !policy.enabled || parentAccessUnlocked || policy.appearance.showPet
@@ -1,107 +0,0 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.SupervisedModePolicy
/** Allowlist applied to external, deep-link, and programmatic navigation. */
internal fun isSupervisedRouteAllowed(route: String?, parentAccessUnlocked: Boolean): Boolean {
if (parentAccessUnlocked) return true
val normalized = route?.substringBefore('?') ?: return false
return normalized == "chat" ||
normalized == Screen.Settings.route ||
normalized == Screen.SupervisedAppearanceSettings.route
}
/** Do not inspect or mutate a NavController until its first destination exists. */
internal fun shouldRedirectSupervisedRoute(
supervisedEnabled: Boolean,
parentAccessUnlocked: Boolean,
currentRoute: String?,
): Boolean = currentRoute != null &&
supervisedEnabled &&
!isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
/** A null route is Navigation's pre-graph bootstrap state, not a forbidden destination. */
internal fun isSupervisedRouteContentAllowed(
supervisedEnabled: Boolean,
parentAccessUnlocked: Boolean,
currentRoute: String?,
): Boolean = currentRoute == null ||
!supervisedEnabled ||
isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
/**
* Cold-start gate for the app navigation graph.
*
* A null active connection is also the seed value used while [ConnectionStore]
* is reading DataStore. Callers must therefore wait for the store's explicit
* hydration signal before treating null as "no connection" and composing the
* unrestricted onboarding/settings graph.
*/
internal fun isRelayNavigationHydrated(
connectionStoreHydrated: Boolean,
activeConnectionId: String?,
supervisedPolicyHydrated: Boolean,
): Boolean = connectionStoreHydrated &&
(activeConnectionId == null || supervisedPolicyHydrated)
/** A parent unlock never follows the user back into the supervised chat root. */
internal fun shouldRelockParentAccess(
supervisedEnabled: Boolean,
parentAccessUnlocked: Boolean,
route: String?,
): Boolean = supervisedEnabled &&
parentAccessUnlocked &&
route?.substringBefore('?') == "chat"
/**
* External chat route arguments are untrusted. A session may be restored only
* after an owner-aware source has proved that it belongs to the pinned profile.
*/
internal fun mayRestoreSupervisedSessionRoute(
policy: SupervisedModePolicy,
requestedSessionId: String?,
requestedProfile: String?,
pinnedProfileOwnershipProven: Boolean,
): Boolean = policy.isActive &&
policy.capabilities.conversationHistory &&
pinnedProfileOwnershipProven &&
!requestedSessionId.isNullOrBlank() &&
!requestedProfile.isNullOrBlank() &&
requestedProfile.equals(policy.pinnedProfileName, ignoreCase = true)
internal data class SupervisedChatRouteArgs(
val sessionId: String? = null,
val profile: String? = null,
val proactiveChatId: String? = null,
)
/** Strip external chat targeting before any destination effect can dispatch it. */
internal fun sanitizeSupervisedChatRouteArgs(
policy: SupervisedModePolicy,
args: SupervisedChatRouteArgs,
pinnedProfileOwnershipProven: Boolean,
): SupervisedChatRouteArgs {
if (!policy.enabled) return args
val allowSession = mayRestoreSupervisedSessionRoute(
policy = policy,
requestedSessionId = args.sessionId,
requestedProfile = args.profile,
pinnedProfileOwnershipProven = pinnedProfileOwnershipProven,
)
return if (allowSession) {
args.copy(proactiveChatId = null)
} else {
SupervisedChatRouteArgs()
}
}
/** A disabled policy may become active only after an enrolled credential succeeds. */
internal fun mayEnableSupervisedMode(
policy: SupervisedModePolicy,
deviceSecure: Boolean,
deviceCredentialConfirmed: Boolean,
): Boolean = !policy.enabled &&
policy.isConfigured &&
deviceSecure &&
deviceCredentialConfirmed
@@ -125,7 +125,6 @@ fun AttachmentGallery(
if (attachments.size < 2) return
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
val scope = rememberCoroutineScope()
val blurMode = LocalMediaBlurMode.current
val revealed = remember { mutableStateMapOf<String, Boolean>() }
@@ -190,7 +189,7 @@ fun AttachmentGallery(
)
}
if (!blurred && exportAllowed) {
if (!blurred) {
SaveOverlayButton(
onClick = {
scope.launch { saveAttachment(context, attachment) }
@@ -202,7 +201,7 @@ fun AttachmentGallery(
}
AttachmentActionsMenu(
expanded = menuExpanded && exportAllowed,
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
context = context,
scope = scope,
@@ -312,8 +312,6 @@ fun AttachmentViewer(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
@@ -407,7 +405,6 @@ fun AttachmentViewer(
title = title,
busy = busy,
actionsEnabled = !blurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
@@ -451,7 +448,6 @@ internal fun AttachmentGalleryViewer(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
@@ -588,7 +584,6 @@ internal fun AttachmentGalleryViewer(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
@@ -618,7 +613,6 @@ private fun MediaViewerToolbar(
title: String,
busy: Boolean,
actionsEnabled: Boolean = true,
exportAllowed: Boolean = true,
onShare: () -> Unit,
onSave: () -> Unit,
onOpenExternal: () -> Unit,
@@ -659,13 +653,11 @@ private fun MediaViewerToolbar(
) {
Icon(Icons.Filled.OpenInNew, contentDescription = stringResource(R.string.attachment_open_externally_a11y))
}
if (exportAllowed) {
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
}
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
}
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
}
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
}
}
}
@@ -32,7 +32,6 @@ fun ChatFailurePanel(
onDetails: () -> Unit,
onRetry: () -> Unit,
onDismiss: () -> Unit,
showDetails: Boolean = true,
modifier: Modifier = Modifier,
) {
Surface(
@@ -73,10 +72,8 @@ fun ChatFailurePanel(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
if (showDetails) {
TextButton(onClick = onDetails) {
Text(stringResource(R.string.chat_failure_details))
}
TextButton(onClick = onDetails) {
Text(stringResource(R.string.chat_failure_details))
}
if (failure.recoverable) {
TextButton(onClick = onRetry) {
@@ -27,7 +27,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -43,9 +42,6 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.util.MediaSaver
import kotlinx.coroutines.launch
/** Whether the current conversation policy permits copying image bytes out of the app. */
val LocalImageExportAllowed = staticCompositionLocalOf { true }
/**
* What the [ChatImageViewer] displays and how it obtains bytes for Save/Share.
*
@@ -108,7 +104,6 @@ fun ChatImageViewer(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
@@ -161,72 +156,60 @@ fun ChatImageViewer(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
val cdClose = stringResource(R.string.cd_close_viewer)
val errorMsg = context.getString(R.string.image_viewer_error)
if (exportAllowed) {
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
busy = false
if (bytes == null) {
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
MediaSaver.share(context, uri, source.mime)
}
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
if (bytes == null) {
busy = false
if (bytes == null) {
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
toast(context, errorMsg)
return@launch
}
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
if (bytes == null) {
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
toast(context, errorMsg)
return@launch
toast(context, savedFmt.format(result.location))
}
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
toast(context, savedFmt.format(result.location))
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, failedFmt.format(result.message))
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, failedFmt.format(result.message))
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = cdSave)
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = cdSave)
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = cdClose)
@@ -483,7 +483,6 @@ fun FloatingPetCompanion(
compact: Boolean,
animationEnabled: Boolean,
appForeground: Boolean,
interactive: Boolean = true,
route: String?,
visitRequest: PetVisitRequest?,
onVisitRequestConsumed: (String) -> Unit,
@@ -2319,14 +2318,13 @@ fun FloatingPetCompanion(
}
.pointerInput(
pet.id,
interactive,
safeBounds,
roamingRails,
settledHabitat,
positioned,
surfaceScrolling,
) {
if (!interactive || !floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
if (!floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
return@pointerInput
}
detectDragGesturesAfterLongPress(
@@ -2401,16 +2399,16 @@ fun FloatingPetCompanion(
)
}
.clickable(
enabled = interactive && floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
enabled = floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
) {
tapReactionNonce += 1
setMenuExpanded(true)
}
.semantics(mergeDescendants = true) {
if (interactive) role = Role.Button
role = Role.Button
contentDescription = companionDescription
stateDescription = stateLabel
customActions = if (interactive) buildList {
customActions = buildList {
add(CustomAccessibilityAction(moveStartLabel) {
onPlacementChanged(placement.copy(edge = PetLogicalEdge.Start)); true
})
@@ -2440,7 +2438,7 @@ fun FloatingPetCompanion(
add(CustomAccessibilityAction(resetLabel) { onResetPlacement(); true })
add(CustomAccessibilityAction(appearanceLabel) { onOpenAppearance(); true })
add(CustomAccessibilityAction(hideLabel) { onHide(); true })
} else emptyList()
}
},
contentAlignment = Alignment.Center,
) {
@@ -2484,7 +2482,7 @@ fun FloatingPetCompanion(
}
DropdownMenu(
expanded = interactive && menuExpanded,
expanded = menuExpanded,
onDismissRequest = { setMenuExpanded(false) },
) {
DropdownMenuItem(
@@ -270,7 +270,6 @@ private fun ImageRender(
maxWidth: Dp
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
val scope = rememberCoroutineScope()
// Decode OFF the main thread — a large inbound image would otherwise block
// composition. Null while decoding (placeholder); decodeFailed → file card.
@@ -357,14 +356,14 @@ private fun ImageRender(
}
// One-tap save overlay — hidden while the blur cover is up so it
// doesn't sit over the "tap to reveal" prompt.
if (!blurred && exportAllowed) {
if (!blurred) {
SaveOverlayButton(
onClick = { scope.launch { saveAttachment(context, attachment) } },
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
)
}
AttachmentActionsMenu(
expanded = menuExpanded && exportAllowed,
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
context = context,
scope = scope,
@@ -381,8 +380,6 @@ private fun FileCardRender(
maxWidth: Dp
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
val scope = rememberCoroutineScope()
val (emoji, typeLabel) = emojiAndLabelFor(attachment.renderMode, attachment.contentType)
var menuExpanded by remember { mutableStateOf(false) }
@@ -466,23 +463,21 @@ private fun FileCardRender(
}
}
// Visible one-tap save affordance (B2).
if (exportAllowed) {
IconButton(
onClick = { scope.launch { saveAttachment(context, attachment) } },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = stringResource(R.string.inbound_attach_cd_save),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
IconButton(
onClick = { scope.launch { saveAttachment(context, attachment) } },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = stringResource(R.string.inbound_attach_cd_save),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
AttachmentActionsMenu(
expanded = menuExpanded && exportAllowed,
expanded = menuExpanded,
onDismiss = { menuExpanded = false },
context = context,
scope = scope,
@@ -94,14 +94,6 @@ import java.util.Date
internal const val CHAT_PET_IDENTITY_OBSTACLE_PREFIX = "chat-message-identity:"
private val MESSAGE_REACTIONS = listOf("❤️", "👍", "👎", "😂", "‼️", "❓")
internal fun assistantImageContent(
content: String,
showImages: Boolean,
): Pair<String, List<ChatInlineImage>> {
val (body, images) = extractChatInlineImages(content)
return body to if (showImages) images else emptyList()
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
@@ -109,13 +101,6 @@ fun MessageBubble(
modifier: Modifier = Modifier,
maxBubbleWidth: Dp = 300.dp,
showThinking: Boolean = true,
showAgentIdentity: Boolean = true,
showTimestamps: Boolean = true,
showWorkingStatus: Boolean = true,
showUsage: Boolean = true,
showTechnicalBadges: Boolean = true,
showAssistantImages: Boolean = true,
allowAssistantImageExport: Boolean = true,
isFirstInGroup: Boolean = true,
isLastInGroup: Boolean = true,
onCopyMessage: (String) -> Unit = {},
@@ -253,24 +238,18 @@ fun MessageBubble(
// content so they render as real images (remote URLs via Coil) or a
// graceful inline notice — not the blank element the markdown renderer
// emits for an image link. User/system bubbles keep their raw content.
val (markdownBody, inlineImages) = remember(
visibleMessageContent,
isUser,
isSystem,
showAssistantImages,
) {
val (markdownBody, inlineImages) = remember(visibleMessageContent, isUser, isSystem) {
if (isUser || isSystem) {
visibleMessageContent to emptyList()
} else {
assistantImageContent(visibleMessageContent, showAssistantImages)
extractChatInlineImages(visibleMessageContent)
}
}
val showImageGeneration = showAssistantImages && showWorkingStatus && shouldShowImageGenerationPlaceholder(
val showImageGeneration = shouldShowImageGenerationPlaceholder(
toolCalls = message.toolCalls,
isStreaming = message.isStreaming,
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
)
val actionContent = if (!isUser && !isSystem) markdownBody else visibleMessageContent
val streamingStatusLabel = if (
!isUser &&
!isSystem &&
@@ -318,10 +297,7 @@ fun MessageBubble(
val blurRepo = remember(context) { MediaSettingsRepository(context.applicationContext) }
val blurMode by blurRepo.blurMode.collectAsState(initial = BlurMode.FLAGGED)
CompositionLocalProvider(
LocalMediaBlurMode provides blurMode,
LocalImageExportAllowed provides allowAssistantImageExport,
) {
CompositionLocalProvider(LocalMediaBlurMode provides blurMode) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = alignment,
@@ -329,7 +305,7 @@ fun MessageBubble(
// Keep sender identity in the first-message label rather than a
// persistent leading column. Long responses and every follow-up in the
// group therefore retain the full bubble-width allowance.
if (showAgentIdentity && !isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
if (!isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -360,7 +336,7 @@ fun MessageBubble(
}
}
if (showTechnicalBadges && !isUser && !isSystem && message.badges.isNotEmpty()) {
if (!isUser && !isSystem && message.badges.isNotEmpty()) {
Row(
modifier = Modifier
.widthIn(max = maxBubbleWidth)
@@ -439,7 +415,7 @@ fun MessageBubble(
// is rendered directly in the conversation
// lane below, without an opaque bubble. Cards and attachments still own
// a normal bubble even when response prose has not arrived yet.
streamingStatusLabel?.takeIf { showWorkingStatus }?.let { streamingStatus ->
streamingStatusLabel?.let { streamingStatus ->
StandaloneStreamingStatus(
status = streamingStatus,
accessibilityDescription = a11yDescription,
@@ -532,7 +508,7 @@ fun MessageBubble(
text = { Text(stringResource(R.string.msg_bubble_copy)) },
onClick = {
showMessageActions = false
onCopyMessage(actionContent)
onCopyMessage(visibleMessageContent)
},
)
if (onQuoteMessage != null) {
@@ -540,7 +516,7 @@ fun MessageBubble(
text = { Text(stringResource(R.string.msg_bubble_quote)) },
onClick = {
showMessageActions = false
onQuoteMessage(message.copy(content = actionContent))
onQuoteMessage(message.copy(content = visibleMessageContent))
},
)
}
@@ -555,7 +531,7 @@ fun MessageBubble(
},
onClick = {
showMessageActions = false
onSpeakMessage?.invoke(actionContent)
onSpeakMessage?.invoke(visibleMessageContent)
},
)
}
@@ -625,7 +601,7 @@ fun MessageBubble(
) {
showMessageActions = true
} else {
onCopyMessage(actionContent)
onCopyMessage(visibleMessageContent)
}
}
)
@@ -822,7 +798,7 @@ fun MessageBubble(
}
}
val hasTokenUsage = showUsage && !isUser &&
val hasTokenUsage = !isUser &&
(message.inputTokens != null || message.outputTokens != null)
// Timestamp — only on the LAST bubble of a same-author run so a
@@ -832,13 +808,13 @@ fun MessageBubble(
// This row is reserved from the first streaming frame. Completion
// can reveal both timestamp and token usage without adding a new
// footer line or changing the bubble's measured height.
if (isLastInGroup && (showTimestamps || hasTokenUsage)) {
if (isLastInGroup) {
Spacer(modifier = Modifier.height(2.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showTimestamps) Text(
Text(
text = timeFormat.format(Date(message.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
@@ -907,15 +883,15 @@ fun MessageBubble(
showEdit = showEditAction,
onCopy = {
showInlineActions = false
onCopyMessage(actionContent)
onCopyMessage(visibleMessageContent)
},
onQuote = {
showInlineActions = false
onQuoteMessage?.invoke(message.copy(content = actionContent))
onQuoteMessage?.invoke(message.copy(content = visibleMessageContent))
},
onSpeak = {
showInlineActions = false
onSpeakMessage?.invoke(actionContent)
onSpeakMessage?.invoke(visibleMessageContent)
},
onStopSpeaking = {
showInlineActions = false
@@ -5,6 +5,7 @@ import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.SweepGradient
import androidx.annotation.StringRes
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
@@ -81,6 +82,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -105,7 +109,6 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SupervisedSessionActions
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
@@ -117,6 +120,7 @@ import com.hermesandroid.relay.ui.theme.resolveProfileAccent
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
internal enum class SessionDrawerFilter {
All,
@@ -204,8 +208,6 @@ fun SessionDrawerContent(
animationEnabled: Boolean = true,
autoTitlesSupported: Boolean = true,
archiveSupported: Boolean = true,
supervisedSessionActions: SupervisedSessionActions? = null,
newChatEnabled: Boolean = true,
onRefresh: (() -> Unit)? = null,
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
onOpenBotMode: (() -> Unit)? = null,
@@ -280,11 +282,14 @@ fun SessionDrawerContent(
}
val scopedRows = (sessions + provisionalSessions).map { ProfileSessionRow(activeProfileName, it) }
val sourceRows = if (showAllProfiles) allProfileSessions else scopedRows
val scopedActivityStates = scopedSessionActivityStates(
rows = sourceRows,
activityStates = activityStates,
allowBareSessionIds = !showAllProfiles,
)
val sourceSessions = sourceRows.map { it.session }
val showThreads = supervisedSessionActions == null &&
(threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) })
val effectiveArchiveSupported = archiveSupported && supervisedSessionActions?.archive != false
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, effectiveArchiveSupported)
val showThreads = threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) }
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, archiveSupported)
// External gateway sources present (discord/telegram/cron/…) for the source
// filter dropdown. Own chats (tui/api_server) + phone Threads aren't listed.
val presentSources = sourceSessions
@@ -324,8 +329,13 @@ fun SessionDrawerContent(
sessionWorkLabels(session).any { it.contains(needle, ignoreCase = true) }
}
.toList()
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, activityStates)
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, activityStates)
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, scopedActivityStates)
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, scopedActivityStates)
val drawerNowMillis = rememberDrawerClock(
isEnabled = isOpen && (
viewOptions.showUpdated || viewOptions.grouping == SessionDrawerGrouping.Project
),
)
val drawerTitle = if (showAllProfiles) {
stringResource(R.string.drawer_all_profiles)
} else {
@@ -395,7 +405,7 @@ fun SessionDrawerContent(
)
// Source filter — show/hide gateway sources (default hides the
// noisy cron+webhook). Only when external sources are present.
if (supervisedSessionActions == null && onToggleSourceHidden != null && presentSources.isNotEmpty()) {
if (onToggleSourceHidden != null && presentSources.isNotEmpty()) {
Box {
IconButton(
onClick = { sourceFilterOpen = true },
@@ -456,7 +466,7 @@ fun SessionDrawerContent(
// Threads affordance — a clean thread-spool that toggles the Threads
// filter. Shown only when the Threads capability is active (or a Thread is
// already present), so an ordinary no-relay drawer is visually unchanged.
if (supervisedSessionActions == null && showThreads) {
if (showThreads) {
IconButton(
onClick = {
filter = if (filter == SessionDrawerFilter.Threads) {
@@ -527,8 +537,7 @@ fun SessionDrawerContent(
onNewChat()
}
},
modifier = Modifier.fillMaxWidth(),
enabled = newChatEnabled,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
@@ -593,10 +602,8 @@ fun SessionDrawerContent(
}
SessionDrawerFilter.entries
.filter { item ->
(item != SessionDrawerFilter.Threads ||
(supervisedSessionActions == null && showThreads)) &&
(item != SessionDrawerFilter.Archive ||
effectiveArchiveSupported)
(item != SessionDrawerFilter.Threads || showThreads) &&
(item != SessionDrawerFilter.Archive || archiveSupported)
}
.forEach { item ->
FilterChip(
@@ -628,19 +635,17 @@ fun SessionDrawerContent(
)
}
}
if (supervisedSessionActions == null) {
TextButton(
onClick = { customizeOpen = true },
modifier = Modifier.align(Alignment.Start),
) {
Icon(
Icons.Filled.FilterList,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.drawer_customize_sessions))
}
TextButton(
onClick = { customizeOpen = true },
modifier = Modifier.align(Alignment.Start),
) {
Icon(
Icons.Filled.FilterList,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.drawer_customize_sessions))
}
// "+ New Thread" — Discord-style user-created thread, shown when the
// Threads filter is active. The first message opens the conversation.
@@ -738,6 +743,7 @@ fun SessionDrawerContent(
ProjectGroupHeader(
label = label,
rows = group.rows,
nowMillis = drawerNowMillis,
expanded = expanded,
onToggle = {
expandedProjectGroups = if (expanded) {
@@ -760,9 +766,7 @@ fun SessionDrawerContent(
if (expanded) items(group.rows, key = ::sessionRowKey) { row ->
val session = row.session
val provisional = session.sessionId.startsWith(PROVISIONAL_THREAD_PREFIX)
val activityState = activityStates[sessionRowKey(row)]
?: activityStates[session.sessionId]
?: if (session.isActive) SessionActivityState.Working else null
val activityState = scopedActivityStates[sessionRowKey(row)]
SessionItem(
modifier = if (isProjectGroup) Modifier.padding(start = 42.dp) else Modifier,
session = session,
@@ -774,20 +778,14 @@ fun SessionDrawerContent(
showUpdated = viewOptions.showUpdated,
showTokens = viewOptions.showTokens,
showCost = viewOptions.showCost,
actionsEnabled = !provisional && (
supervisedSessionActions == null ||
supervisedSessionActions.pin ||
supervisedSessionActions.rename ||
supervisedSessionActions.delete ||
(supervisedSessionActions.archive && archiveSupported)
),
nowMillis = drawerNowMillis,
actionsEnabled = !provisional,
isActive = !showAllProfiles && session.sessionId == currentSessionId,
activityState = activityState,
animationEnabled = animationEnabled && isOpen,
pinned = session.pinned,
archived = session.archived,
archiveSupported = archiveSupported,
supervisedSessionActions = supervisedSessionActions,
onClick = {
if (showAllProfiles) {
onSelectProfileSession?.invoke(row.profile, session.sessionId)
@@ -1234,7 +1232,11 @@ private fun SessionDrawerOrdering.label(): String = when (this) {
private fun SessionDrawerStatus.label(): String = when (this) {
SessionDrawerStatus.NeedsInput -> "Needs input"
SessionDrawerStatus.Starting -> "Starting"
SessionDrawerStatus.Working -> "Working"
SessionDrawerStatus.BackgroundWork -> "Background work"
SessionDrawerStatus.Checking -> "Checking"
SessionDrawerStatus.Unavailable -> "Unavailable"
SessionDrawerStatus.Idle -> "Idle"
}
@@ -1259,6 +1261,7 @@ private fun compactMetric(value: Double): String = when {
private fun ProjectGroupHeader(
label: String,
rows: List<ProfileSessionRow>,
nowMillis: Long,
expanded: Boolean,
onToggle: () -> Unit,
) {
@@ -1307,7 +1310,7 @@ private fun ProjectGroupHeader(
append(context.resources.getQuantityString(R.plurals.drawer_project_session_count, rows.size, rows.size))
if (latestActivity > 0L) {
append(" · ")
append(formatTimestamp(latestActivity, locale, context))
append(formatTimestamp(latestActivity, locale, context, nowMillis))
}
},
style = MaterialTheme.typography.bodySmall,
@@ -1336,6 +1339,7 @@ private fun SessionItem(
showUpdated: Boolean,
showTokens: Boolean,
showCost: Boolean,
nowMillis: Long,
actionsEnabled: Boolean,
isActive: Boolean,
activityState: SessionActivityState?,
@@ -1343,7 +1347,6 @@ private fun SessionItem(
pinned: Boolean,
archived: Boolean,
archiveSupported: Boolean,
supervisedSessionActions: SupervisedSessionActions?,
onClick: () -> Unit,
onTogglePinned: () -> Unit,
onToggleArchived: () -> Unit,
@@ -1355,11 +1358,7 @@ private fun SessionItem(
val locale = LocalLocale.current.platformLocale
val context = LocalContext.current
val untitledLabel = stringResource(R.string.drawer_untitled)
val activityLabel = when (activityState) {
SessionActivityState.Working -> stringResource(R.string.drawer_activity_working)
SessionActivityState.NeedsInput -> stringResource(R.string.drawer_activity_needs_input)
null -> null
}
val activityLabel = activityState?.let { stringResource(sessionActivityLabelResource(it)) }
val motion = rememberAccessibleMotionState()
val backgroundColor = if (isActive) {
MaterialTheme.colorScheme.secondaryContainer
@@ -1459,7 +1458,7 @@ private fun SessionItem(
sourceBadge(session.source)?.let { badge ->
SourceChip(badge)
}
if (showUpdated) sessionTimestampText(session, locale, context)?.let { timestamp ->
if (showUpdated) sessionTimestampText(session, locale, context, nowMillis)?.let { timestamp ->
Text(
text = timestamp,
style = MaterialTheme.typography.bodySmall,
@@ -1511,7 +1510,7 @@ private fun SessionItem(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
) {
if (supervisedSessionActions?.pin != false) DropdownMenuItem(
DropdownMenuItem(
text = {
Text(
if (pinned) {
@@ -1537,7 +1536,7 @@ private fun SessionItem(
onTogglePinned()
},
)
if (supervisedSessionActions == null) DropdownMenuItem(
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_copy_session_id)) },
leadingIcon = {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
@@ -1547,7 +1546,7 @@ private fun SessionItem(
onCopySessionId()
},
)
if (supervisedSessionActions?.rename != false) DropdownMenuItem(
DropdownMenuItem(
text = { Text(stringResource(R.string.drawer_rename)) },
leadingIcon = {
Icon(Icons.Filled.Edit, contentDescription = null)
@@ -1557,7 +1556,7 @@ private fun SessionItem(
onRename()
},
)
if (archiveSupported && supervisedSessionActions?.archive != false) {
if (archiveSupported) {
DropdownMenuItem(
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
leadingIcon = {
@@ -1577,7 +1576,7 @@ private fun SessionItem(
},
)
}
if (supervisedSessionActions?.delete != false) DropdownMenuItem(
DropdownMenuItem(
text = {
Text(
text = stringResource(R.string.drawer_delete),
@@ -1601,6 +1600,16 @@ private fun SessionItem(
}
}
@StringRes
internal fun sessionActivityLabelResource(state: SessionActivityState): Int = when (state) {
SessionActivityState.Starting -> R.string.drawer_activity_starting
SessionActivityState.Working -> R.string.drawer_activity_working
SessionActivityState.NeedsInput -> R.string.drawer_activity_needs_input
SessionActivityState.BackgroundWork -> R.string.drawer_activity_background_work
SessionActivityState.Checking -> R.string.drawer_activity_checking
SessionActivityState.Unavailable -> R.string.drawer_activity_unavailable
}
@Composable
private fun SessionWorkBadgeChip(badge: SessionWorkBadge) {
val icon: ImageVector = when (badge.kind) {
@@ -1705,18 +1714,29 @@ private fun ProfileBadge(
@Composable
private fun SessionActivityIndicator(state: SessionActivityState, label: String) {
val color = when (state) {
SessionActivityState.Starting,
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
SessionActivityState.BackgroundWork,
SessionActivityState.Checking,
SessionActivityState.Unavailable,
-> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(RoundedCornerShape(50))
.background(color),
modifier = if (state == SessionActivityState.BackgroundWork) {
Modifier
.size(7.dp)
.border(1.dp, color, CircleShape)
} else {
Modifier
.size(7.dp)
.clip(CircleShape)
.background(color)
},
)
Text(
text = label,
@@ -1734,10 +1754,17 @@ private fun Modifier.sessionActivityBorder(
): Modifier {
if (state == null) return this
val color = when (state) {
SessionActivityState.Starting,
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
SessionActivityState.BackgroundWork,
SessionActivityState.Checking,
SessionActivityState.Unavailable,
-> MaterialTheme.colorScheme.onSurfaceVariant
}
val shouldRotate = animated && state == SessionActivityState.Working
val shouldRotate = animated && (
state == SessionActivityState.Starting || state == SessionActivityState.Working
)
val phase = if (shouldRotate) {
val transition = rememberInfiniteTransition(label = "session-activity")
transition.animateFloat(
@@ -1843,20 +1870,45 @@ private fun Modifier.sessionActivityBorder(
}
}
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
@Composable
private fun rememberDrawerClock(isEnabled: Boolean): Long {
var nowMillis by remember { mutableStateOf(System.currentTimeMillis()) }
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(isEnabled, lifecycleOwner) {
if (!isEnabled) return@LaunchedEffect
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) {
nowMillis = System.currentTimeMillis()
delay(MINUTE_MILLIS - (nowMillis % MINUTE_MILLIS))
}
}
}
return nowMillis
}
internal fun sessionTimestampText(
session: ChatSession,
locale: Locale,
context: Context,
nowMillis: Long = System.currentTimeMillis(),
): String? {
val timestamp = session.activityTimestamp
if (timestamp <= 0L) return null
val hasDistinctActivity =
session.lastActivityAt > 0L &&
session.startTimestamp > 0L &&
session.lastActivityAt != session.startTimestamp
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_active) else context.getString(R.string.drawer_timestamp_started)
return "$prefix ${formatTimestamp(timestamp, locale, context)}"
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_updated) else context.getString(R.string.drawer_timestamp_started)
return "$prefix ${formatTimestamp(timestamp, locale, context, nowMillis)}"
}
private fun formatTimestamp(millis: Long, locale: Locale, context: Context): String {
val now = System.currentTimeMillis()
val diff = now - millis
private fun formatTimestamp(
millis: Long,
locale: Locale,
context: Context,
nowMillis: Long = System.currentTimeMillis(),
): String {
val diff = nowMillis - millis
return when {
diff < 60_000 -> context.getString(R.string.drawer_just_now)
diff < 3_600_000 -> "${diff / 60_000}m ago"
@@ -1864,3 +1916,5 @@ private fun formatTimestamp(millis: Long, locale: Locale, context: Context): Str
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
}
}
private const val MINUTE_MILLIS = 60_000L
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import java.util.Locale
@@ -23,7 +24,11 @@ internal enum class SessionDrawerOrdering {
internal enum class SessionDrawerStatus {
NeedsInput,
Starting,
Working,
BackgroundWork,
Checking,
Unavailable,
Idle,
}
@@ -55,7 +60,7 @@ internal data class SessionDrawerGroup(
)
internal fun sessionRowKey(row: ProfileSessionRow): String =
"${row.profile.lowercase(Locale.ROOT)}:${row.session.sessionId}"
"${AgentDisplay.profileSessionKey(row.profile).lowercase(Locale.ROOT)}:${row.session.sessionId}"
internal fun sessionProjectLabel(session: ChatSession): String {
val raw = (session.gitRepoRoot ?: session.workingDirectory)
@@ -69,12 +74,34 @@ internal fun sessionProjectLabel(session: ChatSession): String {
internal fun sessionDrawerStatus(
row: ProfileSessionRow,
activityStates: Map<String, SessionActivityState>,
): SessionDrawerStatus = when (
activityStates[sessionRowKey(row)] ?: activityStates[row.session.sessionId]
) {
): SessionDrawerStatus = when (activityStates[sessionRowKey(row)]) {
SessionActivityState.NeedsInput -> SessionDrawerStatus.NeedsInput
SessionActivityState.Starting -> SessionDrawerStatus.Starting
SessionActivityState.Working -> SessionDrawerStatus.Working
null -> if (row.session.isActive) SessionDrawerStatus.Working else SessionDrawerStatus.Idle
SessionActivityState.BackgroundWork -> SessionDrawerStatus.BackgroundWork
SessionActivityState.Checking -> SessionDrawerStatus.Checking
SessionActivityState.Unavailable -> SessionDrawerStatus.Unavailable
null -> SessionDrawerStatus.Idle
}
/**
* Normalizes live activity to the drawer's profile-scoped row identity.
*
* A selected-profile drawer may accept the legacy bare session id because every row belongs
* to that one explicit profile. All Profiles must use composite keys exclusively: session ids
* are only unique inside their owning profile.
*/
internal fun scopedSessionActivityStates(
rows: List<ProfileSessionRow>,
activityStates: Map<String, SessionActivityState>,
allowBareSessionIds: Boolean,
): Map<String, SessionActivityState> = buildMap {
rows.forEach { row ->
val rowKey = sessionRowKey(row)
val state = activityStates[rowKey]
?: activityStates[row.session.sessionId].takeIf { allowBareSessionIds }
state?.let { put(rowKey, it) }
}
}
internal fun sessionDrawerPrState(session: ChatSession): SessionDrawerPrState = when {
@@ -98,8 +125,12 @@ internal fun filterAndSortSessionRows(
.toList()
val statusRank = mapOf(
SessionDrawerStatus.NeedsInput to 0,
SessionDrawerStatus.Working to 1,
SessionDrawerStatus.Idle to 2,
SessionDrawerStatus.Starting to 1,
SessionDrawerStatus.Working to 2,
SessionDrawerStatus.BackgroundWork to 3,
SessionDrawerStatus.Checking to 4,
SessionDrawerStatus.Unavailable to 5,
SessionDrawerStatus.Idle to 6,
)
val comparator = when (options.ordering) {
SessionDrawerOrdering.Updated -> compareByDescending<ProfileSessionRow> { it.session.activityTimestamp }
@@ -149,7 +180,11 @@ internal fun groupSessionRows(
private val SessionDrawerStatus.displayLabel: String
get() = when (this) {
SessionDrawerStatus.NeedsInput -> "Needs input"
SessionDrawerStatus.Starting -> "Starting"
SessionDrawerStatus.Working -> "Working"
SessionDrawerStatus.BackgroundWork -> "Background work"
SessionDrawerStatus.Checking -> "Checking"
SessionDrawerStatus.Unavailable -> "Unavailable"
SessionDrawerStatus.Idle -> "Idle"
}
@@ -87,8 +87,6 @@ fun AboutScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
onUnlockDeveloperOptions: () -> Unit = {},
/** Supervised clients may read About without gaining a settings mutation backdoor. */
allowDeveloperUnlock: Boolean = true,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -220,7 +218,7 @@ fun AboutScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = allowDeveloperUnlock) {
.clickable {
if (devOptionsUnlocked) return@clickable
val now = System.currentTimeMillis()
if (now - lastTapTime > 2000) {
@@ -1,96 +0,0 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.ui.theme.LocalBrand
/** Optional and specialized features kept off the primary Settings surface. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdvancedSettingsScreen(
supervisedPolicy: SupervisedModePolicy,
onNavigateToSupervisedControls: () -> Unit,
onBack: () -> Unit,
) {
val isDarkTheme = LocalBrand.current.isDark
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
},
title = { Text(stringResource(R.string.settings_advanced)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.settings_advanced_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SettingsCategoryRow(
icon = Icons.Filled.Security,
title = stringResource(R.string.settings_supervised_mode),
subtitle = when {
supervisedPolicy.isActive -> stringResource(
R.string.settings_supervised_on_profile,
supervisedPolicy.pinnedProfileName.orEmpty(),
)
supervisedPolicy.isConfigured -> stringResource(
R.string.settings_supervised_ready_profile,
supervisedPolicy.pinnedProfileName.orEmpty(),
)
else -> stringResource(R.string.settings_supervised_desc)
},
badge = supervisedPolicy.takeIf { it.isActive }?.let {
SettingsStatusPillModel(
label = stringResource(R.string.settings_supervised_on),
tone = SettingsStatusTone.Good,
)
},
onClick = onNavigateToSupervisedControls,
isDarkTheme = isDarkTheme,
petPerchKey = null,
)
}
}
}
@@ -1487,27 +1487,26 @@ private fun AppearanceSummaryRow(
/** Representative, theme-live chat sample so presets are judged in context. */
@Composable
internal fun AppearanceLivePreview(
private fun AppearanceLivePreview(
palette: BrandPalette,
shapeScale: AppearanceShapeScale,
restricted: Boolean = false,
) {
CompositionLocalProvider(
LocalBrand provides palette,
LocalAppearanceShapeScale provides shapeScale,
) {
MaterialTheme(colorScheme = palette.toColorScheme(), shapes = shapeScale.asMaterialShapes()) {
AppearanceLivePreviewContent(restricted = restricted)
AppearanceLivePreviewContent()
}
}
}
@Composable
private fun AppearanceLivePreviewContent(restricted: Boolean) {
private fun AppearanceLivePreviewContent() {
val backgroundEnabled = LocalBackgroundVisualizationEnabled.current
val backgroundAvatar = LocalAgentAvatar.current
Card(
modifier = Modifier.fillMaxWidth().height(if (restricted) 258.dp else 294.dp),
modifier = Modifier.fillMaxWidth().height(294.dp),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
@@ -1649,16 +1648,14 @@ private fun AppearanceLivePreviewContent(restricted: Boolean) {
style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!restricted) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
Text(
text = stringResource(R.string.appearance_preview_tool_meta),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = LocalBrand.current.green,
modifier = Modifier.padding(start = 5.dp),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
Text(
text = stringResource(R.string.appearance_preview_tool_meta),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = LocalBrand.current.green,
modifier = Modifier.padding(start = 5.dp),
)
}
}
Box(modifier = Modifier.padding(start = 6.dp).size(38.dp), contentAlignment = Alignment.Center) {
@@ -1681,12 +1678,10 @@ private fun AppearanceLivePreviewContent(restricted: Boolean) {
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Filled.Add, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
if (!restricted) {
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
}
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
Text(
stringResource(R.string.appearance_preview_message_placeholder),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
@@ -1696,7 +1691,7 @@ private fun AppearanceLivePreviewContent(restricted: Boolean) {
Icon(Icons.Filled.GraphicEq, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
}
}
if (!restricted) Surface(
Surface(
modifier = Modifier.align(Alignment.CenterHorizontally),
shape = appearanceRoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -1790,7 +1785,7 @@ private fun FontOptionRow(
* are added.
*/
@Composable
internal fun ThemeSwatchChip(
private fun ThemeSwatchChip(
appTheme: AppTheme,
selected: Boolean,
onClick: () -> Unit,
@@ -50,7 +50,6 @@ import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
@@ -148,7 +147,6 @@ import androidx.compose.animation.fadeOut
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
@@ -181,11 +179,6 @@ import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.PhysicalKeyboardEnterBehavior
import com.hermesandroid.relay.data.ProfilePresentationPolicy
import com.hermesandroid.relay.data.ProactiveInboxEntry
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.SupervisedSessionAction
import com.hermesandroid.relay.data.allowsSessionAction
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
import com.hermesandroid.relay.ui.components.AgentInfoSheet
@@ -303,21 +296,6 @@ private const val CHAT_AUTOCOMPLETE_PET_OBSTACLE = "chat-autocomplete-obstacle"
private const val CHAT_RECENT_PROMPTS_PET_OBSTACLE = "chat-recent-prompts-obstacle"
private val CHAT_PET_ROUTES = setOf("chat")
internal fun resolveSessionActivityStates(
background: Map<String, SessionActivityState>,
currentSessionId: String?,
isStreaming: Boolean,
needsInput: Boolean,
): Map<String, SessionActivityState> = background.toMutableMap().apply {
currentSessionId?.let { sessionId ->
when {
needsInput -> put(sessionId, SessionActivityState.NeedsInput)
isStreaming -> put(sessionId, SessionActivityState.Working)
else -> remove(sessionId)
}
}
}
internal fun resolveChatHeaderSubtitle(
isStreaming: Boolean,
statusText: String,
@@ -732,40 +710,8 @@ fun ChatScreen(
// existing test/preview call sites keep compiling.
onNavigateToVoiceSettings: () -> Unit = {},
onNavigateToProfileInspector: (String) -> Unit = {},
supervisedPolicy: SupervisedModePolicy = SupervisedModePolicy(),
onNavigateToBotMode: () -> Unit = {},
) {
val supervised = supervisedPolicy.enabled
val supervisedVisibility = supervisedPolicy.visibility.resolved()
LaunchedEffect(supervisedPolicy) {
voiceViewModel.updateSupervisedModePolicy(supervisedPolicy)
}
if (supervised && !supervisedPolicy.isActive) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Supervised chat unavailable") },
actions = {
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
},
)
},
) { padding ->
Box(
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
"The supervised profile is unavailable. Parent access is required to update this connection.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
return
}
val voiceUiState by voiceViewModel.uiState.collectAsState()
val responseSpeechActive by voiceViewModel.responseSpeechActive.collectAsState()
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
@@ -788,6 +734,7 @@ fun ChatScreen(
LaunchedEffect(voiceUiState.voiceMode) {
if (!voiceUiState.voiceMode) voicePresentationOverride = null
}
// Route classified chat errors (media cache, streaming failures, …) to
// the app-wide snackbar. Same pattern every VM-bound screen uses.
val snackbarHost = LocalSnackbarHost.current
@@ -849,22 +796,7 @@ fun ChatScreen(
}
val rawMessages by chatViewModel.messages.collectAsState()
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
if (!supervised) rawMessages
else rawMessages.map { message ->
if (message.role == MessageRole.ASSISTANT) {
message.copy(
attachments = if (supervisedPolicy.capabilities.generatedImages) {
message.attachments.filter { it.isImage }
} else {
emptyList()
},
cards = emptyList(),
)
} else message
}
}
val messages by chatViewModel.messages.collectAsState()
val messageReactionsSupported by chatViewModel.messageReactionsSupported.collectAsState()
val newestReactableMessageKeys = remember(messages) {
setOfNotNull(
@@ -891,17 +823,10 @@ fun ChatScreen(
// Stable voice can use the standard Hermes dashboard audio routes or the
// optional Relay voice routes. Gate the mic on either route being usable;
// availability picks the actionable toast when neither is.
val connectionVoiceReady by connectionViewModel.voiceReady.collectAsState()
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val voiceReady = if (supervised) {
supervisedPolicy.capabilities.voice &&
standardVoiceAvailability ==
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Ready
} else {
connectionVoiceReady
}
val voiceReady by connectionViewModel.voiceReady.collectAsState()
val chatSpeakResponseActionsEnabled =
shouldOfferChatSpeakAction(voiceReady, voiceUiState.state)
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val standardVoiceSignInRouteHint by
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
@@ -930,15 +855,9 @@ fun ChatScreen(
mutableStateOf(false)
}
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
val sessionActivityStates = remember(
backgroundSessionActivityStates,
currentSessionId,
isStreaming,
pendingAsk,
) {
resolveSessionActivityStates(
background = backgroundSessionActivityStates,
currentSessionId = currentSessionId,
val sessionActivityStates = backgroundSessionActivityStates
LaunchedEffect(currentSessionId, isStreaming, pendingAsk) {
chatViewModel.updateCurrentSessionActivity(
isStreaming = isStreaming,
needsInput = pendingAsk != null,
)
@@ -966,6 +885,54 @@ fun ChatScreen(
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
var allProfileSessionsLoading by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
suspend fun refreshAllProfileSessions(showError: Boolean) {
if (isProfileLocked || allProfileSessionsLoading) return
allProfileSessionsLoading = true
val result = connectionViewModel.listAllProfileSessions()
result?.fold(
onSuccess = { items ->
allProfileSessions = items.mapNotNull { item ->
val owner = item.profile?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
ProfileSessionRow(
profile = owner,
session = com.hermesandroid.relay.data.ChatSession(
sessionId = item.id,
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
recentlyActive = item.isActive,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
pinned = item.pinned,
archived = item.archived,
workingDirectory = item.cwd,
gitBranch = item.gitBranch,
gitRepoRoot = item.gitRepoRoot,
pullRequestNumber = item.pullRequest?.number,
pullRequestUrl = item.pullRequest?.url,
pullRequestState = item.pullRequest?.state,
pullRequestDraft = item.pullRequest?.draft == true,
),
)
}
chatViewModel.updateSessionActivityDirectory(
rows = allProfileSessions.map { it.profile to it.session.sessionId },
)
},
onFailure = { error ->
if (showError) snackbarHostState.showSnackbar(
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
)
},
)
allProfileSessionsLoading = false
}
val conversationBinding by chatViewModel.conversationBinding.collectAsState()
val explicitBindingProfileName = conversationBinding.profileName
.takeIf { conversationBinding.hasExplicitOwner }
@@ -1023,15 +990,8 @@ fun ChatScreen(
?: sessionModelState.pickerModel?.let { model ->
modelProviders.singleOrNull { model in it.models }?.slug
}
val configuredShowThinking by connectionViewModel.showThinking.collectAsState()
val configuredToolDisplay by connectionViewModel.toolDisplay.collectAsState()
val showThinking = configuredShowThinking &&
(!supervised || supervisedVisibility.showReasoning)
val toolDisplay = if (!supervised) configuredToolDisplay else when {
supervisedVisibility.showToolDetails -> "detailed"
supervisedVisibility.showToolNames -> "compact"
else -> "off"
}
val showThinking by connectionViewModel.showThinking.collectAsState()
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
val keepComposerFocusedOnSend by
@@ -1050,10 +1010,7 @@ fun ChatScreen(
// marker so the user knows approvals are off without opening the agent drawer.
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
val pendingAttachments by chatViewModel.pendingAttachments.collectAsState()
val configuredMaxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
val maxAttachmentMb = if (supervised) {
minOf(configuredMaxAttachmentMb, supervisedPolicy.capabilities.attachmentMaxFileMb)
} else configuredMaxAttachmentMb
val maxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
val charLimit by connectionViewModel.maxMessageLength.collectAsState()
// === Gateway desktop-parity state ===
@@ -1062,9 +1019,6 @@ fun ChatScreen(
val contextWindow by chatViewModel.contextWindow.collectAsState()
// Injected-context audit sheet (opened by tapping the context meter).
var showContextSheet by remember { mutableStateOf(false) }
LaunchedEffect(supervised) {
if (supervised) showContextSheet = false
}
val steerableTurn by chatViewModel.steerableTurn.collectAsState()
val steerNotice by chatViewModel.steerNotice.collectAsState()
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
@@ -1378,6 +1332,13 @@ fun ChatScreen(
val listState = rememberLazyListState()
val userScrolledAwayState = remember(currentSessionId) { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
LaunchedEffect(chatViewModel, drawerState) {
chatViewModel.sessionDirectoryRefreshRequests.collect {
if (drawerState.isOpen || allProfileSessions.isNotEmpty()) {
refreshAllProfileSessions(showError = false)
}
}
}
PetInteractionLayer(
owner = "chat-interaction-layer",
active = shouldHideChatPet(
@@ -1465,7 +1426,6 @@ fun ChatScreen(
}
val clipboard = LocalClipboard.current
val haptic = LocalHapticFeedback.current
val snackbarHostState = remember { SnackbarHostState() }
val handleCardAction: (String, String, HermesCardAction) -> Unit =
remember(chatViewModel, context) {
{ messageId, cardKey, action ->
@@ -2060,9 +2020,9 @@ fun ChatScreen(
}
}
}
val showAutocomplete by remember(filteredCommands, inputText, supervised) {
val showAutocomplete by remember(filteredCommands, inputText) {
derivedStateOf {
!supervised && inputText.startsWith("/") && filteredCommands.isNotEmpty()
inputText.startsWith("/") && filteredCommands.isNotEmpty()
}
}
@@ -2094,6 +2054,7 @@ fun ChatScreen(
// shows up without a manual reload. Cheap dashboard read; the optimistic
// row for the active session is preserved by ChatHandler.updateSessions.
LaunchedEffect(drawerState.isOpen) {
chatViewModel.setSessionActivityDrawerOpen(drawerState.isOpen)
if (drawerState.isOpen && chatReady) {
chatViewModel.refreshSessions()
}
@@ -2334,7 +2295,7 @@ fun ChatScreen(
}
}
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
val profileShelfAvailable = ProfilePresentationPolicy.shouldShowShelf(
profiles = agentProfiles,
presentation = profilePresentation,
selectedKey = selectedProfileKey,
@@ -2361,7 +2322,7 @@ fun ChatScreen(
// Material routes scrim taps through the drawer's gesture handler.
// Keep it enabled so tapping outside always dismisses the drawer; the
// voice overlay already owns input while voice mode is visible.
gesturesEnabled = !supervised || supervisedPolicy.capabilities.conversationHistory,
gesturesEnabled = true,
drawerContent = {
val drawerProfileName = explicitBindingProfileName ?: effectiveProfile?.name
val drawerTitle = if (drawerProfileName != null) {
@@ -2405,9 +2366,7 @@ fun ChatScreen(
}
SessionDrawerContent(
sessions = if (
supervised && !supervisedPolicy.capabilities.conversationHistory
) emptyList() else sessions,
sessions = sessions,
currentSessionId = currentSessionId,
scopeTitle = drawerTitle,
scopeSubtitle = drawerSubtitle,
@@ -2418,19 +2377,14 @@ fun ChatScreen(
animationEnabled = animationEnabled,
autoTitlesSupported = serverAutoTitles,
archiveSupported = sessionArchivingSupported,
supervisedSessionActions = supervisedPolicy.capabilities.sessionActions
.takeIf { supervised },
newChatEnabled = !supervised || supervisedPolicy.capabilities.newChat,
onRefresh = { chatViewModel.refreshSessions() },
onOpenBotMode = {
scope.launch { drawerState.close() }
onNavigateToBotMode()
},
onNewChat = {
if (!supervised || supervisedPolicy.capabilities.newChat) {
chatViewModel.createNewChat()
scope.launch { drawerState.close() }
}
chatViewModel.createNewChat()
scope.launch { drawerState.close() }
},
onNewDefaultChat = {
if (isProfileLocked) return@SessionDrawerContent
@@ -2456,9 +2410,6 @@ fun ChatScreen(
scope.launch { drawerState.close() }
},
onDeleteSession = { sessionId ->
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Delete)) {
return@SessionDrawerContent
}
val connectionId = activeConnection?.id
val profileId = explicitBindingProfileName ?: selectedProfile?.name
chatViewModel.deleteSession(sessionId) {
@@ -2472,21 +2423,10 @@ fun ChatScreen(
}
},
onRenameSession = { sessionId, title ->
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Rename)) {
return@SessionDrawerContent
}
chatViewModel.renameSession(sessionId, title)
},
onSetSessionPinned = { sessionId, pinned ->
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Pin)) {
chatViewModel.setSessionPinned(sessionId, pinned)
}
},
onSetSessionArchived = { sessionId, archived ->
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Archive)) {
chatViewModel.setSessionArchived(sessionId, archived)
}
},
onSetSessionPinned = chatViewModel::setSessionPinned,
onSetSessionArchived = chatViewModel::setSessionArchived,
onCopySessionId = { sessionId ->
scope.launch {
clipboard.setClipEntry(
@@ -2516,7 +2456,7 @@ fun ChatScreen(
onToggleSourceHidden = { source, hidden ->
connectionViewModel.setSourceHidden(source, hidden)
},
allProfilesSupported = !supervised && !isProfileLocked &&
allProfilesSupported = !isProfileLocked &&
!activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
allProfileSessions = allProfileSessions,
allProfileSessionsLoading = allProfileSessionsLoading,
@@ -2524,48 +2464,7 @@ fun ChatScreen(
onProfileColorChange = connectionViewModel::setProfileColor,
onRefreshAllProfiles = {
if (!isProfileLocked && !allProfileSessionsLoading) scope.launch {
allProfileSessionsLoading = true
val result = connectionViewModel.listAllProfileSessions()
result?.fold(
onSuccess = { items ->
allProfileSessions = items.mapNotNull { item ->
val owner = item.profile?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
ProfileSessionRow(
profile = owner,
session = com.hermesandroid.relay.data.ChatSession(
sessionId = item.id,
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
pinned = item.pinned,
archived = item.archived,
workingDirectory = item.cwd,
gitBranch = item.gitBranch,
gitRepoRoot = item.gitRepoRoot,
pullRequestNumber = item.pullRequest?.number,
pullRequestUrl = item.pullRequest?.url,
pullRequestState = item.pullRequest?.state,
pullRequestDraft = item.pullRequest?.draft == true,
),
)
}
},
onFailure = { error ->
snackbarHostState.showSnackbar(
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
)
},
)
allProfileSessionsLoading = false
refreshAllProfileSessions(showError = true)
}
},
onSelectProfileSession = { profileName, sessionId ->
@@ -2678,14 +2577,8 @@ fun ChatScreen(
// Top bar — messaging app style with avatar, name, model subtitle
TopAppBar(
navigationIcon = {
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
}
} else if (supervisedPolicy.capabilities.newChat) {
IconButton(onClick = { chatViewModel.createNewChat() }) {
Icon(Icons.Filled.Edit, contentDescription = "New chat")
}
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
}
},
title = {
@@ -2717,10 +2610,8 @@ fun ChatScreen(
// style subtitle status.
var everConnected by remember { mutableStateOf(false) }
if (headerChatReady) everConnected = true
val showStreamingState = isStreaming &&
(!supervised || supervisedVisibility.showWorkingStatus)
val statusText = when {
headerChatReady -> if (showStreamingState) {
headerChatReady -> if (isStreaming) {
stringResource(R.string.chat_streaming)
} else {
stringResource(R.string.chat_connected_label)
@@ -2770,14 +2661,6 @@ fun ChatScreen(
// personality label.
val subtitleText = if (!headerChatReady) {
statusText
} else if (supervised) {
buildList {
if (supervisedVisibility.showProfileName) {
conversationProfile?.name?.takeIf { it.isNotBlank() }?.let(::add)
}
if (supervisedVisibility.showModelName && !modelName.isNullOrBlank()) add(modelName)
if (isEmpty() && supervisedVisibility.showConnectionStatus) add(statusText)
}.joinToString(" · ")
} else {
resolveChatHeaderSubtitle(
isStreaming = isStreaming,
@@ -2796,7 +2679,7 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.clickable(enabled = !supervised) {
.clickable {
if (profileShelfAvailable) {
showProfileShelf = !showProfileShelf
} else {
@@ -2820,7 +2703,7 @@ fun ChatScreen(
// Avatar — a plain 40dp circle whose letter swaps to the
// active agent (profile or personality). No overlay ring:
// the letter itself is the indicator.
if (!supervised || supervisedVisibility.showAgentIdentity) Box(modifier = Modifier.size(40.dp)) {
Box(modifier = Modifier.size(40.dp)) {
Surface(
modifier = Modifier.size(40.dp),
shape = CircleShape,
@@ -2870,16 +2753,14 @@ fun ChatScreen(
}
}
}
if (!supervised || supervisedVisibility.showConnectionStatus) {
ConnectionStatusBadge(
isConnected = headerChatReady,
isConnecting = isConnecting,
modifier = Modifier
.size(10.dp)
.align(Alignment.BottomEnd),
size = 10.dp,
)
}
ConnectionStatusBadge(
isConnected = headerChatReady,
isConnecting = isConnecting,
modifier = Modifier
.size(10.dp)
.align(Alignment.BottomEnd),
size = 10.dp
)
}
// Name + single-line subtitle.
@@ -2920,13 +2801,7 @@ fun ChatScreen(
} else {
Column {
Text(
text = if (supervised && !supervisedVisibility.showAgentIdentity) {
stringResource(R.string.screen_chat_label)
} else if (agentDisplayName.isNotBlank()) {
agentDisplayName
} else {
stringResource(R.string.chat_agent_default)
},
text = if (agentDisplayName.isNotBlank()) agentDisplayName else stringResource(R.string.chat_agent_default),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
@@ -2963,7 +2838,7 @@ fun ChatScreen(
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
if (showStreamingState && animationEnabled) {
if (isStreaming && animationEnabled) {
StreamingDots(
color = subtitleColor,
modifier = Modifier.clearAndSetSemantics { },
@@ -2984,7 +2859,7 @@ fun ChatScreen(
// full explanation (global mode / --yolo / per-session)
// lives. Keeps the risk visible without eating subtitle
// width on every turn.
if (!supervised && yoloEnabled == true) {
if (yoloEnabled == true) {
RelayChromeIconButton(
icon = Icons.Filled.Bolt,
contentDescription = stringResource(R.string.cd_approvals_off),
@@ -3002,14 +2877,12 @@ fun ChatScreen(
// tappable → Connections, so the affordance moved with the
// info. Dropping it here declutters the actions row and frees
// width for the title subtitle.)
if (!supervised) {
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = stringResource(R.string.cd_terminal),
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
}
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = stringResource(R.string.cd_terminal),
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
RelayChromeIconButton(
icon = Icons.Filled.Tune,
contentDescription = stringResource(R.string.cd_settings),
@@ -3022,11 +2895,7 @@ fun ChatScreen(
// Settings — which is what was squeezing the title subtitle.
// Session identity is useful before the first message; sharing only appears
// once the conversation has content.
if (
(!supervised && (messages.isNotEmpty() || !currentSessionId.isNullOrBlank())) ||
(supervised && messages.isNotEmpty() &&
supervisedPolicy.allowsSessionAction(SupervisedSessionAction.ShareTranscript))
) {
if (messages.isNotEmpty() || !currentSessionId.isNullOrBlank()) {
var showOverflowMenu by remember { mutableStateOf(false) }
Box {
RelayChromeIconButton(
@@ -3039,7 +2908,7 @@ fun ChatScreen(
expanded = showOverflowMenu,
onDismissRequest = { showOverflowMenu = false },
) {
currentSessionId?.takeIf { !supervised && it.isNotBlank() }?.let { sessionId ->
currentSessionId?.takeIf { it.isNotBlank() }?.let { sessionId ->
DropdownMenuItem(
text = { Text(copySessionIdLabel) },
leadingIcon = {
@@ -3064,7 +2933,7 @@ fun ChatScreen(
},
)
}
if (!supervised && messages.isNotEmpty()) {
if (messages.isNotEmpty()) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_search_conversation)) },
leadingIcon = {
@@ -3088,22 +2957,6 @@ fun ChatScreen(
shareConversation(context, messages)
},
)
} else if (
messages.isNotEmpty() &&
supervisedPolicy.allowsSessionAction(
SupervisedSessionAction.ShareTranscript,
)
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_share_conversation)) },
leadingIcon = {
Icon(Icons.Filled.Share, contentDescription = null)
},
onClick = {
showOverflowMenu = false
shareConversation(context, messages)
},
)
}
}
}
@@ -3145,15 +2998,13 @@ fun ChatScreen(
// and the mode strip — slim bar + `NN% · used/max` token readout,
// color-graded by fullness. Composes to nothing until the server
// reports a context_max for the session.
if (!supervised || supervisedVisibility.showUsage) {
ContextMeterBar(
usedFraction = contextUsage,
usedTokens = contextWindow?.usedTokens,
maxTokens = contextWindow?.maxTokens,
onClick = if (supervised) null else ({ showContextSheet = true }),
)
}
if (!supervised && showContextSheet) {
ContextMeterBar(
usedFraction = contextUsage,
usedTokens = contextWindow?.usedTokens,
maxTokens = contextWindow?.maxTokens,
onClick = { showContextSheet = true },
)
if (showContextSheet) {
// Live audit of the exact extra context the agent will be
// injected with on the next turn (transparency / auditability).
InjectedContextSheet(
@@ -3214,31 +3065,7 @@ fun ChatScreen(
},
label = "chatEmptyStatePhaseTransition",
) { targetConnectState ->
if (supervised && targetConnectState != ChatConnectState.Ready) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (supervisedVisibility.showConnectionStatus) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (targetConnectState == ChatConnectState.Connecting) {
CircularProgressIndicator()
}
Text(
text = if (targetConnectState == ChatConnectState.Connecting) {
stringResource(R.string.chat_connecting_dots)
} else {
stringResource(R.string.chat_disconnected_label)
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else if (targetConnectState == ChatConnectState.Connecting) {
if (targetConnectState == ChatConnectState.Connecting) {
ChatColdStartLoadingState(
animationEnabled = animationEnabled,
streamingIntensity = streamingIntensity,
@@ -3278,10 +3105,7 @@ fun ChatScreen(
Spacer(modifier = Modifier.weight(0.15f))
// ASCII sphere (constrained to square aspect)
if (
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
if (LocalBackgroundVisualizationEnabled.current) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -3309,10 +3133,7 @@ fun ChatScreen(
// thread itself (not just the header) -
// the desktop's intro.
ChatConnectState.Ready ->
if (
effectiveProfile != null &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
if (effectiveProfile != null) {
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
} else {
stringResource(R.string.chat_start_conversation)
@@ -3330,11 +3151,7 @@ fun ChatScreen(
val profileBlurb = effectiveProfile?.description
?.trim()
?.takeIf { it.isNotBlank() && !it.equals(agentDisplayName, ignoreCase = true) }
if (
targetConnectState == ChatConnectState.Ready &&
profileBlurb != null &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
if (targetConnectState == ChatConnectState.Ready && profileBlurb != null) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = profileBlurb,
@@ -3451,7 +3268,6 @@ fun ChatScreen(
// Ambient avatar behind messages
if (
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity) &&
animationBehindChat &&
!ambientMode
) {
@@ -3473,13 +3289,8 @@ fun ChatScreen(
// /media/by-path route when a relay session is paired,
// instead of degrading to the "image is on the server"
// notice. Null when no relay (standard no-plugin) → notice.
val relayServerImageResolver = remember(
chatViewModel,
supervised,
supervisedPolicy.capabilities.generatedImages,
) {
if (supervised && !supervisedPolicy.capabilities.generatedImages) null
else RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
val relayServerImageResolver = remember(chatViewModel) {
RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
}
val thinkingIndicatorConfig = remember(
thinkingIndicatorStyle,
@@ -3534,7 +3345,6 @@ fun ChatScreen(
items(messages.size, key = { messages[it].uiKey }) { index ->
val message = messages[index]
val processNotification = message.hermesProcessNotificationOrNull()
?.takeIf { !supervised || supervisedVisibility.showToolNames }
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
// no attachments). Attachments keep the bubble alive for inbound media;
@@ -3559,10 +3369,7 @@ fun ChatScreen(
messages[index + 1].timestamp - message.timestamp > GROUP_GAP_MS
// Date separator
if (
(!supervised || supervisedVisibility.showTimestamps) &&
(index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp))
) {
if (index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp)) {
DateSeparator(timestamp = message.timestamp)
}
@@ -3574,9 +3381,7 @@ fun ChatScreen(
message.attachments.isNotEmpty() ||
message.cards.isNotEmpty()
message.backgroundTask
?.takeIf { !supervised || supervisedVisibility.showWorkingStatus }
?.let { task ->
message.backgroundTask?.let { task ->
val taskModifier = Modifier.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
@@ -3634,14 +3439,6 @@ fun ChatScreen(
},
maxBubbleWidth = maxBubbleWidth,
showThinking = showThinking,
showAgentIdentity = !supervised || supervisedVisibility.showAgentIdentity,
showTimestamps = !supervised || supervisedVisibility.showTimestamps,
showWorkingStatus = !supervised || supervisedVisibility.showWorkingStatus,
showUsage = !supervised || supervisedVisibility.showUsage,
showTechnicalBadges = !supervised || supervisedVisibility.showTechnicalRoute,
showAssistantImages = !supervised || supervisedPolicy.capabilities.generatedImages,
allowAssistantImageExport = !supervised ||
supervisedPolicy.capabilities.shareGeneratedImages,
isFirstInGroup = isFirstInGroup,
isLastInGroup = isLastInGroup,
recoveringAnswer = recoveringAnswer,
@@ -3654,9 +3451,9 @@ fun ChatScreen(
onAttachmentManualFetch = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onCardAction = if (supervised) ({ _, _, _ -> }) else handleCardAction,
onCardInput = if (supervised) ({ _, _, _ -> }) else handleCardInput,
onSessionReference = if (supervised) null else { reference ->
onCardAction = handleCardAction,
onCardInput = handleCardInput,
onSessionReference = { reference ->
val target = agentProfiles.firstOrNull {
it.name.equals(reference.profile, ignoreCase = true)
}
@@ -3674,7 +3471,6 @@ fun ChatScreen(
}
},
onReact = if (
!supervised &&
isGatewayTransport &&
messageReactionsSupported &&
!message.isStreaming &&
@@ -3688,7 +3484,6 @@ fun ChatScreen(
null
},
onEditMessage = if (
(!supervised || supervisedPolicy.capabilities.editAndResend) &&
isGatewayTransport &&
!isStreaming &&
message.role == MessageRole.USER &&
@@ -3707,14 +3502,10 @@ fun ChatScreen(
null
},
animationEnabled = animationEnabled,
onQuoteMessage = if (
!supervised || supervisedPolicy.capabilities.quoteReplies
) {
{ quoted ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
quotedMessage = quoted
}
} else null,
onQuoteMessage = { quoted ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
quotedMessage = quoted
},
onNavigateToMessage = { messageId ->
val targetIndex = messages.indexOfFirst { it.id == messageId }
if (targetIndex >= 0) {
@@ -3725,10 +3516,7 @@ fun ChatScreen(
scope.launch { listState.animateScrollToItem(targetIndex + 1) }
}
},
onSpeakMessage = if (
chatSpeakResponseActionsEnabled &&
(!supervised || supervisedPolicy.capabilities.voice)
) {
onSpeakMessage = if (chatSpeakResponseActionsEnabled) {
{ text -> voiceViewModel.speakResponse(text) }
} else {
null
@@ -3739,9 +3527,6 @@ fun ChatScreen(
null
},
onCopyMessage = { text ->
if (supervised && !supervisedPolicy.capabilities.copyResponses) {
return@MessageBubble
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
// The new Clipboard API is suspend-based, so the
// setClipEntry call has to live inside a coroutine.
@@ -4210,8 +3995,7 @@ fun ChatScreen(
// Gateway redirect is text-only. Attachment-bearing follow-ups must
// retain their files in the session-owned queue instead of showing
// a correction action that cannot carry them.
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty() &&
(!supervised || supervisedPolicy.capabilities.steerResponse)
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty()
val trailing = when {
!isStreaming && hasContent -> ChatInputTrailing.SEND
!isStreaming -> ChatInputTrailing.VOICE
@@ -4344,7 +4128,7 @@ fun ChatScreen(
}
}
}
val modelControl = modelPickerOptions.takeIf { !supervised && it.isNotEmpty() }?.let {
val modelControl = modelPickerOptions.takeIf { it.isNotEmpty() }?.let {
ChatInputPickerControl(
value = compactModelChipLabel(currentModelForInput, modelDefaultLabel),
contentDescription = stringResource(R.string.cd_select_model),
@@ -4395,7 +4179,6 @@ fun ChatScreen(
// is definitively unreachable (SSE-only) — the agent sheet carries the
// disabled-with-reason version there.
val effortControl = if (
!supervised &&
chatGatewayAvailability != GatewayAvailability.Unreachable &&
effortAvailability.supported != false &&
effortPickerOptions.isNotEmpty()
@@ -4412,13 +4195,7 @@ fun ChatScreen(
}
visibleChatFailure?.let { failure ->
val displayFailure = if (!supervised) failure else failure.copy(
model = failure.model.takeIf { supervisedVisibility.showModelName },
provider = failure.provider.takeIf { supervisedVisibility.showTechnicalRoute },
)
val failureRouteLabel = if (
supervised && !supervisedVisibility.showTechnicalRoute
) "" else when (failure.route) {
val failureRouteLabel = when (failure.route) {
ChatFailureRoute.GATEWAY ->
stringResource(R.string.chat_failure_route_gateway)
ChatFailureRoute.API_FALLBACK ->
@@ -4426,28 +4203,23 @@ fun ChatScreen(
null -> ""
}
ChatFailurePanel(
failure = displayFailure,
failure = failure,
routeLabel = failureRouteLabel,
onDetails = { showChatFailureDetails = true },
onRetry = {
if (!supervised || supervisedPolicy.capabilities.retryResponse) {
chatViewModel.retryLastMessage()
}
},
onRetry = { chatViewModel.retryLastMessage() },
onDismiss = chatViewModel::dismissChatFailure,
showDetails = !supervised || supervisedVisibility.showTechnicalRoute,
)
if (showChatFailureDetails) {
ChatFailureDetailsDialog(
failure = displayFailure,
failure = failure,
routeLabel = failureRouteLabel,
onCopy = {
val details = buildString {
append(failureRouteLabel)
displayFailure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
displayFailure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
failure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
failure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
append("\n\n")
append(displayFailure.rawError)
append(failure.rawError)
}
scope.launch {
clipboard.setClipEntry(
@@ -4529,9 +4301,6 @@ fun ChatScreen(
)
},
onStop = {
if (supervised && !supervisedPolicy.capabilities.cancelResponse) {
return@ChatInputBar
}
chatViewModel.cancelStream()
// Firm haptic (LongPress — TextHandleMove was near-
// imperceptible) plus a "Stopped" badge stamped on the turn
@@ -4547,44 +4316,14 @@ fun ChatScreen(
}
},
onAttachPhotos = {
val allowed = !supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Images in
supervisedPolicy.capabilities.attachmentCategories &&
pendingAttachments.size < supervisedPolicy.capabilities.attachmentMaxCount
)
if (allowed) {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
onAttachFiles = {
if (!supervised || supervisedPolicy.capabilities.attachments) {
val mimeTypes = if (!supervised) arrayOf("*/*") else buildList {
val categories = supervisedPolicy.capabilities.attachmentCategories
if (SupervisedAttachmentCategory.Images in categories) add("image/*")
if (SupervisedAttachmentCategory.Audio in categories) add("audio/*")
if (SupervisedAttachmentCategory.Video in categories) add("video/*")
if (SupervisedAttachmentCategory.Documents in categories) {
add("text/*")
add("application/pdf")
}
}.toTypedArray()
if (mimeTypes.isNotEmpty()) filePickerLauncher.launch(mimeTypes)
}
},
onAttachCamera = if (!supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Images in
supervisedPolicy.capabilities.attachmentCategories
)) requestCameraCapture else ({ }),
onPasteImage = if (!supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Images in
supervisedPolicy.capabilities.attachmentCategories
)) pasteImageFromClipboard else ({ }),
onLongPressAttach = { if (!supervised) showCommandPalette = true },
onAttachFiles = { filePickerLauncher.launch(arrayOf("*/*")) },
onAttachCamera = requestCameraCapture,
onPasteImage = pasteImageFromClipboard,
onLongPressAttach = { showCommandPalette = true },
charLimit = charLimit,
caption = turnStatus ?: inputCaption,
voiceReady = voiceReady,
@@ -4596,13 +4335,8 @@ fun ChatScreen(
submitEnabled = pendingAttachments.none {
it.state == com.hermesandroid.relay.data.AttachmentState.LOADING
},
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS.takeIf {
convertLargePastesToAttachments && (!supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Documents in
supervisedPolicy.capabilities.attachmentCategories
))
},
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS
.takeIf { convertLargePastesToAttachments },
onLargePaste = { pastedText ->
val owner = activeComposerDraftKey ?: composerDraftKey
val sizeBytes = pastedText.toByteArray(Charsets.UTF_8).size.toLong()
@@ -4920,7 +4654,7 @@ fun ChatScreen(
}
// Command palette bottom sheet
if (showCommandPalette && !supervised) {
if (showCommandPalette) {
CommandPalette(
commands = allCommands,
onSelect = { cmd ->
@@ -4948,7 +4682,7 @@ fun ChatScreen(
// personality, connection summary). Replaces the old AlertDialog and the
// two top-bar chips (ProfilePicker + PersonalityPicker). Tap target is
// the title Row in the TopAppBar above.
if (showAgentInfo && !supervised) {
if (showAgentInfo) {
AgentInfoSheet(
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
@@ -101,7 +101,6 @@ import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProviderUsageLandingMode
import com.hermesandroid.relay.data.ProviderUsagePreferences
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import com.hermesandroid.relay.network.upstream.GatewayAvailability
@@ -158,13 +157,6 @@ fun SettingsScreen(
connectionViewModel: ConnectionViewModel,
/** Header back affordance — Settings is a pushed destination, not a tab. */
onBack: (() -> Unit)? = null,
supervisedPolicy: SupervisedModePolicy? = null,
parentAccessUnlocked: Boolean = false,
/** Called only after the restricted surface completes device authentication. */
onRequestParentAccess: () -> Unit = {},
onUpdateSupervisedPolicy: (SupervisedModePolicy) -> Unit = {},
onNavigateToAdvancedSettings: () -> Unit = {},
onNavigateToSupervisedAppearance: () -> Unit = {},
// Needed by the Active Agent summary card at the top of the screen — it
// reads the current personality pick so the subtitle can render
// `connection · model · personality` without re-reading ChatViewModel
@@ -214,21 +206,6 @@ fun SettingsScreen(
// discoverable before a pair-and-pick happens.
onNavigateToProfileInspector: (profileName: String) -> Unit,
) {
// Keep the restricted root when an enabled policy becomes temporarily
// unusable (for example, its profile was renamed). Parent authentication,
// not a configuration error, is what unlocks the full settings surface.
if (supervisedPolicy?.enabled == true && !parentAccessUnlocked) {
SupervisedSettingsScreen(
connectionViewModel = connectionViewModel,
policy = supervisedPolicy,
onPolicyChange = onUpdateSupervisedPolicy,
onBack = onBack,
onNavigateToAppearance = onNavigateToSupervisedAppearance,
onParentAccessGranted = onRequestParentAccess,
)
return
}
val context = LocalContext.current
val isDarkTheme = LocalBrand.current.isDark
@@ -520,7 +497,6 @@ fun SettingsScreen(
modifier = Modifier.settingsPetSurface("settings-card:profile-lock"),
)
// ── Quick Controls ─────────────────────────────────────────
// The switches flipped most often, pinned to the top-level Settings
// landing instead of buried in a sub-screen. Persistent connection is
@@ -690,24 +666,6 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Security,
title = stringResource(R.string.settings_advanced),
subtitle = when {
supervisedPolicy?.isActive == true -> "On · ${supervisedPolicy.pinnedProfileName}"
supervisedPolicy?.isConfigured == true -> "Ready · ${supervisedPolicy.pinnedProfileName}"
else -> stringResource(R.string.settings_advanced_desc)
},
badge = supervisedPolicy?.takeIf { it.isActive }?.let {
SettingsStatusPillModel(
label = "On",
tone = SettingsStatusTone.Good,
)
},
onClick = onNavigateToAdvancedSettings,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Analytics,
title = stringResource(R.string.settings_analytics),
@@ -1322,12 +1280,12 @@ private fun ProfileLockOptionRow(
}
}
internal data class SettingsStatusPillModel(
private data class SettingsStatusPillModel(
val label: String,
val tone: SettingsStatusTone = SettingsStatusTone.Neutral,
)
internal enum class SettingsStatusTone {
private enum class SettingsStatusTone {
Neutral,
Good,
Info,
@@ -1525,22 +1483,18 @@ private fun SettingsSectionHeader(
* mega-SettingsScreen.
*/
@Composable
internal fun SettingsCategoryRow(
private fun SettingsCategoryRow(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
isDarkTheme: Boolean,
badge: SettingsStatusPillModel? = null,
petPerchKey: String? = title,
petPerchKey: String = title,
) {
val surfaceModifier = if (petPerchKey != null) {
Modifier.settingsPetSurface("settings-category:$petPerchKey")
} else {
Modifier
}
Card(
modifier = surfaceModifier
modifier = Modifier
.settingsPetSurface("settings-category:$petPerchKey")
.fillMaxWidth()
.gradientBorder(
shape = appearanceRoundedCornerShape(12.dp),
@@ -41,10 +41,14 @@ import com.hermesandroid.relay.data.ProactiveInboxEntry
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import com.hermesandroid.relay.data.RealtimeTurnTrace
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.SupervisedSessionAction
import com.hermesandroid.relay.data.allowsSessionAction
import com.hermesandroid.relay.data.SessionActivityFreshness
import com.hermesandroid.relay.data.SessionActivityOwner
import com.hermesandroid.relay.data.SessionActivityPhase
import com.hermesandroid.relay.data.SessionActivityRegistry
import com.hermesandroid.relay.data.SessionActivityScope
import com.hermesandroid.relay.data.SessionActivityUpdate
import com.hermesandroid.relay.data.SessionLiveRuntime
import com.hermesandroid.relay.data.SessionLiveStatus
import com.hermesandroid.relay.data.ToolCallEvent
import com.hermesandroid.relay.data.VoiceIntentTrace
import com.hermesandroid.relay.data.HermesCard
@@ -61,6 +65,9 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
import com.hermesandroid.relay.network.upstream.GatewayAsk
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionsResult
import com.hermesandroid.relay.network.upstream.GatewayApprovalMode
import com.hermesandroid.relay.network.upstream.GatewayApprovalModeCapability
import com.hermesandroid.relay.network.upstream.GatewayBackgroundInteractionEvent
@@ -261,6 +268,57 @@ internal fun shouldSuppressPassiveSessionError(context: String?, error: Throwabl
"unauthorized" in message || "forbidden" in message
}
internal data class ResolvedGatewayActiveSessions(
val runtimes: List<SessionLiveRuntime>,
val ambiguous: Boolean,
val ambiguousForCurrent: Boolean,
)
/** Resolve process-wide runtime rows without ever inventing a profile owner. */
internal fun resolveGatewayActiveSessions(
sessions: List<GatewayActiveSession>,
directory: Set<SessionActivityOwner>,
currentOwner: SessionActivityOwner?,
currentRuntimeId: String? = null,
knownOwnersByRuntime: Map<String, SessionActivityOwner> = emptyMap(),
): ResolvedGatewayActiveSessions {
var ambiguous = false
var ambiguousForCurrent = false
val runtimes = sessions.map { row ->
val explicitProfile = row.profile?.trim()?.takeIf(String::isNotEmpty)
?.let(AgentDisplay::profileSessionKey)
val candidates = directory.filter { owner ->
owner.storedSessionId == row.storedSessionId &&
(explicitProfile == null || owner.profile.equals(explicitProfile, ignoreCase = true))
}
val owner = when {
knownOwnersByRuntime[row.runtimeSessionId]
?.takeIf { it.storedSessionId == row.storedSessionId } != null ->
knownOwnersByRuntime.getValue(row.runtimeSessionId)
explicitProfile == null && currentOwner != null && currentRuntimeId != null &&
currentRuntimeId == row.runtimeSessionId &&
currentOwner.storedSessionId == row.storedSessionId -> currentOwner
explicitProfile != null && candidates.size == 1 -> candidates.single()
else -> null
}
if (owner == null) {
ambiguous = true
if (currentOwner?.storedSessionId == row.storedSessionId) ambiguousForCurrent = true
}
SessionLiveRuntime(
owner = owner,
runtimeId = row.runtimeSessionId,
status = when (row.status) {
GatewayActiveSessionStatus.Idle -> SessionLiveStatus.Idle
GatewayActiveSessionStatus.Starting -> SessionLiveStatus.Starting
GatewayActiveSessionStatus.Working -> SessionLiveStatus.Working
GatewayActiveSessionStatus.Waiting -> SessionLiveStatus.Waiting
},
)
}
return ResolvedGatewayActiveSessions(runtimes, ambiguous, ambiguousForCurrent)
}
sealed interface VoiceMessageSubmissionResult {
data class Submitted(val userUiKey: String) : VoiceMessageSubmissionResult
data class Rejected(val reason: String) : VoiceMessageSubmissionResult
@@ -278,24 +336,6 @@ internal fun voiceTurnTransportRejection(
}
class ChatViewModel : ViewModel() {
/**
* Active Android-only supervision policy. RelayApp replaces this snapshot
* whenever the active connection changes. Enforcement belongs here as well
* as in Compose so alternate UI entry points cannot bypass the restrictions.
*/
@Volatile
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
supervisedModePolicy = policy
if (policy.enabled) {
_pendingAttachments.update { attachments ->
attachments.filterIndexed { index, attachment ->
isAttachmentAllowedBySupervision(attachment, index)
}.take(policy.capabilities.attachmentMaxCount)
}
}
}
private var apiClient: HermesApiClient? = null
private var chatHandler: ChatHandler? = null
@@ -342,27 +382,126 @@ class ChatViewModel : ViewModel() {
val backgroundSessionActivityStates: StateFlow<Map<String, SessionActivityState>> =
_backgroundSessionActivityStates.asStateFlow()
private fun publishBackgroundSessionActivity() {
val contextKey = activeProfileContextKey
_backgroundSessionActivityStates.value = if (contextKey == null) {
private val sessionActivityRegistry = MutableStateFlow(SessionActivityRegistry())
private val sessionActivityGeneration = AtomicLong(0L)
private val sessionActivityPollMutex = Mutex()
private var sessionActivityPollJob: Job? = null
private var sessionActivityDirectory: Set<SessionActivityOwner> = emptySet()
private var lastProjectedProcessIds: Set<String> = emptySet()
private var lastProjectedProcessOwner: SessionActivityOwner? = null
private var lastLocalActivityOwner: SessionActivityOwner? = null
private var lastLocalStreaming = false
private var lastSessionActivityScope: SessionActivityScope? = null
private val _sessionDirectoryRefreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val sessionDirectoryRefreshRequests: SharedFlow<Unit> =
_sessionDirectoryRefreshRequests.asSharedFlow()
private fun activityScope(contextKey: String? = activeProfileContextKey): SessionActivityScope? {
val raw = contextKey?.trim().orEmpty()
val separator = raw.lastIndexOf("::")
if (separator <= 0 || separator >= raw.lastIndex) return null
return SessionActivityScope.of(raw.substring(0, separator), raw.substring(separator + 2))
}
private fun activityOwner(
sessionId: String?,
contextKey: String? = activeProfileContextKey,
): SessionActivityOwner? {
val scope = activityScope(contextKey) ?: return null
val storedId = sessionId?.trim()?.takeIf(String::isNotEmpty) ?: return null
return SessionActivityOwner.of(scope.connectionId, scope.profile, storedId)
}
private fun reduceSessionActivity(update: SessionActivityUpdate) {
sessionActivityRegistry.update { it.reduce(update) }
publishSessionActivityProjection()
}
private fun reduceSessionActivities(updates: Iterable<SessionActivityUpdate>) {
sessionActivityRegistry.update { current ->
updates.fold(current) { state, update -> state.reduce(update) }
}
publishSessionActivityProjection()
}
private fun activateSessionActivityScope() {
val scope = activityScope() ?: return
if (scope == lastSessionActivityScope) return
clearProjectedBackgroundProcesses()
lastSessionActivityScope = scope
val generation = sessionActivityGeneration.incrementAndGet()
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
lastLocalActivityOwner = null
lastLocalStreaming = false
reduceSessionActivity(
SessionActivityUpdate.BeginGeneration(
scope = scope,
generation = generation,
observedAtMillis = System.currentTimeMillis(),
),
)
requestSessionActivityRefresh()
}
private fun publishSessionActivityProjection() {
val activeConnectionId = activityScope()?.connectionId
_backgroundSessionActivityStates.value = if (activeConnectionId == null) {
emptyMap()
} else {
backgroundTurnCheckpoints.keys
.asSequence()
.filter { it.contextKey == contextKey }
.associate { key ->
key.sessionId to if (
key in backgroundNeedsInputKeys ||
backgroundPendingInteractions.containsKey(key)
) {
SessionActivityState.NeedsInput
} else {
SessionActivityState.Working
}
sessionActivityRegistry.value.presentationStates(System.currentTimeMillis())
.filterKeys { it.connectionId == activeConnectionId }
.mapKeys { (owner, _) ->
val displayProfile = owner.profile.takeUnless {
it == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
} ?: "default"
"$displayProfile:${owner.storedSessionId}"
}
}
}
private fun publishBackgroundSessionActivity() {
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
backgroundTurnCheckpoints.forEach { (key, checkpoint) ->
val owner = activityOwner(key.sessionId, key.contextKey) ?: return@forEach
reduceSessionActivity(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = checkpoint.liveSessionId,
phase = SessionActivityPhase.Working,
generation = generation,
observedAtMillis = now,
),
)
if (key in backgroundNeedsInputKeys || backgroundPendingInteractions.containsKey(key)) {
reduceSessionActivity(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
confirmed = backgroundPendingInteractions.containsKey(key),
generation = generation,
observedAtMillis = now,
),
)
} else if (sessionActivityRegistry.value.record(owner)
?.pendingInputs
?.containsKey("checkpoint:${key.contextKey}:${key.sessionId}") == true
) {
reduceSessionActivity(
SessionActivityUpdate.PendingInputClosed(
owner = owner,
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
confirmed = false,
generation = generation,
observedAtMillis = now,
),
)
}
}
publishSessionActivityProjection()
}
private fun TurnCheckpointKey.keepAliveKey(): String = "$contextKey::$sessionId"
private fun activeTurnCheckpointKey(): TurnCheckpointKey? =
@@ -689,10 +828,7 @@ class ChatViewModel : ViewModel() {
}
fun addAttachment(attachment: Attachment) {
_pendingAttachments.update { current ->
if (!isAttachmentAllowedBySupervision(attachment, current.size)) current
else current + attachment
}
_pendingAttachments.update { it + attachment }
}
fun removeAttachment(index: Int) {
@@ -702,20 +838,11 @@ class ChatViewModel : ViewModel() {
}
fun replacePendingAttachments(attachments: List<Attachment>) {
val policy = supervisedModePolicy
_pendingAttachments.value = if (!policy.enabled) {
attachments.toList()
} else {
attachments.filter { isAttachmentAllowedBySupervision(it, 0) }
.take(policy.capabilities.attachmentMaxCount)
}
_pendingAttachments.value = attachments.toList()
}
fun replaceAttachment(composerId: String, attachment: Attachment) {
_pendingAttachments.update { attachments ->
if (!isAttachmentAllowedBySupervision(attachment, (attachments.size - 1).coerceAtLeast(0))) {
return@update attachments.filterNot { it.composerId == composerId }
}
var replaced = false
val updated = attachments.map { current ->
if (current.composerId == composerId) {
@@ -745,23 +872,6 @@ class ChatViewModel : ViewModel() {
_pendingAttachments.value = emptyList()
}
private fun isAttachmentAllowedBySupervision(attachment: Attachment, existingCount: Int): Boolean {
val policy = supervisedModePolicy
if (!policy.enabled) return true
val capabilities = policy.capabilities
if (!policy.isActive || !capabilities.attachments) return false
if (existingCount >= capabilities.attachmentMaxCount) return false
val maxBytes = capabilities.attachmentMaxFileMb.toLong() * 1024L * 1024L
if ((attachment.fileSize ?: 0L) > maxBytes) return false
val category = when {
attachment.contentType.startsWith("image/") -> SupervisedAttachmentCategory.Images
attachment.contentType.startsWith("audio/") -> SupervisedAttachmentCategory.Audio
attachment.contentType.startsWith("video/") -> SupervisedAttachmentCategory.Video
else -> SupervisedAttachmentCategory.Documents
}
return category in capabilities.attachmentCategories
}
// Server-side personality selection
private val _selectedPersonality = MutableStateFlow("default")
val selectedPersonality: StateFlow<String> = _selectedPersonality.asStateFlow()
@@ -1945,10 +2055,329 @@ class ChatViewModel : ViewModel() {
gatewayProcessController.dismiss(processId)
}
/**
* Replaces the known profile/session directory used to attribute process-wide
* `session.active_list` rows. A row is projected only when ownership is exact.
*/
fun updateSessionActivityDirectory(
rows: Collection<Pair<String, String>>,
) {
val scope = activityScope() ?: return
sessionActivityDirectory = rows.mapNotNullTo(mutableSetOf()) { (profile, sessionId) ->
runCatching {
SessionActivityOwner.of(
scope.connectionId,
AgentDisplay.profileSessionKey(profile),
sessionId,
)
}.getOrNull()
}
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
reduceSessionActivities(
sessionActivityDirectory.map { owner ->
SessionActivityUpdate.ObserveOwner(owner, generation, now)
},
)
requestSessionActivityRefresh()
}
private fun updateCurrentProfileActivityDirectory(sessions: Collection<ChatSession>) {
val scope = activityScope() ?: return
sessionActivityDirectory = sessionActivityDirectory
.filterNotTo(mutableSetOf()) {
it.connectionId == scope.connectionId && it.profile == scope.profile
}
.apply {
sessions.forEach { row ->
add(SessionActivityOwner.of(scope.connectionId, scope.profile, row.sessionId))
}
}
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
reduceSessionActivities(
sessionActivityDirectory
.filter { it.connectionId == scope.connectionId && it.profile == scope.profile }
.map { owner -> SessionActivityUpdate.ObserveOwner(owner, generation, now) },
)
}
fun setSessionActivityDrawerOpen(open: Boolean) {
if (open) requestSessionActivityRefresh()
}
/** Local UI edges are immediate evidence, then the active-list poll confirms them. */
fun updateCurrentSessionActivity(isStreaming: Boolean, needsInput: Boolean) {
val owner = activityOwner(chatHandler?.currentSessionId?.value) ?: return
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
val previousAskId = "current-pending-input"
lastLocalActivityOwner?.takeIf { it != owner }?.let { previousOwner ->
if (sessionActivityRegistry.value.record(previousOwner)
?.pendingInputs
?.containsKey(previousAskId) == true
) {
reduceSessionActivity(
SessionActivityUpdate.PendingInputClosed(
previousOwner,
previousAskId,
generation = generation,
observedAtMillis = now,
),
)
}
}
val pendingWasOpen = sessionActivityRegistry.value.record(owner)
?.pendingInputs
?.containsKey(previousAskId) == true
if (needsInput || pendingWasOpen) {
reduceSessionActivity(
if (needsInput) {
SessionActivityUpdate.PendingInputOpened(
owner, previousAskId, generation = generation, observedAtMillis = now,
)
} else {
SessionActivityUpdate.PendingInputClosed(
owner, previousAskId, generation = generation, observedAtMillis = now,
)
},
)
}
val streamingEdge = lastLocalActivityOwner == owner && lastLocalStreaming != isStreaming
val alreadyStarting = sessionActivityRegistry.value.record(owner)
?.phase(now) == SessionActivityPhase.Starting
if ((isStreaming && !alreadyStarting) ||
(lastLocalActivityOwner == owner && lastLocalStreaming)
) {
reduceSessionActivity(
SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = null,
status = if (isStreaming) SessionLiveStatus.Working else SessionLiveStatus.Idle,
generation = generation,
observedAtMillis = now,
),
)
}
lastLocalActivityOwner = owner
lastLocalStreaming = isStreaming
if (streamingEdge) _sessionDirectoryRefreshRequests.tryEmit(Unit)
requestSessionActivityRefresh()
}
private fun markSessionActivityStarting(sessionId: String?) {
val owner = activityOwner(sessionId) ?: return
reduceSessionActivity(
SessionActivityUpdate.LocalSend(
owner = owner,
generation = sessionActivityGeneration.get(),
observedAtMillis = System.currentTimeMillis(),
),
)
requestSessionActivityRefresh()
}
private fun settleSessionActivity(sessionId: String?, runtimeId: String? = null) {
val owner = activityOwner(sessionId) ?: return
reduceSessionActivity(
SessionActivityUpdate.Terminal(
owner = owner,
runtimeId = runtimeId,
generation = sessionActivityGeneration.get(),
observedAtMillis = System.currentTimeMillis(),
),
)
_sessionDirectoryRefreshRequests.tryEmit(Unit)
requestSessionActivityRefresh()
}
fun requestSessionActivityRefresh() {
val client = gatewayClient ?: return
if (streamingEndpoint != "gateway" || !chatVisible) return
sessionActivityPollJob?.cancel()
sessionActivityPollJob = viewModelScope.launch {
pollSessionActivity(client)
}
}
private suspend fun pollSessionActivity(client: GatewayChatClient) {
sessionActivityPollMutex.withLock {
if (gatewayClient !== client || !chatVisible || streamingEndpoint != "gateway") return
val generation = sessionActivityGeneration.get()
val currentScope = activityScope() ?: return
val currentOwner = activityOwner(chatHandler?.currentSessionId?.value)
val directory = buildSet {
addAll(sessionActivityDirectory.filter { it.connectionId == currentScope.connectionId })
currentOwner?.let { add(it) }
chatHandler?.sessions?.value.orEmpty().forEach { row ->
add(SessionActivityOwner.of(
currentScope.connectionId,
currentScope.profile,
row.sessionId,
))
}
}
val now = System.currentTimeMillis()
when (val result = client.listActiveSessions()) {
is GatewayActiveSessionsResult.Success -> {
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
val resolved = resolveGatewayActiveSessions(
sessions = result.sessions,
directory = directory,
currentOwner = currentOwner,
currentRuntimeId = currentOwner?.let {
client.currentLiveSessionId(it.storedSessionId)
},
knownOwnersByRuntime = result.sessions.mapNotNull { row ->
client.knownSessionOwner(row.runtimeSessionId)?.let { known ->
val knownProfile = when {
!known.profile.isNullOrBlank() ->
AgentDisplay.profileSessionKey(known.profile)
currentOwner != null &&
row.runtimeSessionId == client.currentLiveSessionId(
currentOwner.storedSessionId,
) &&
known.storedSessionId == currentOwner.storedSessionId ->
currentOwner.profile
else -> directory.singleOrNull { owner ->
owner.storedSessionId == known.storedSessionId &&
owner.profile in setOf(
"default",
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
)
}?.profile ?: return@let null
}
row.runtimeSessionId to SessionActivityOwner.of(
currentScope.connectionId,
knownProfile,
known.storedSessionId,
)
}
}.toMap(),
)
val scopes = directory.mapTo(mutableSetOf()) {
SessionActivityScope.of(it.connectionId, it.profile)
}
sessionActivityRegistry.value.records.keys
.filterTo(mutableSetOf()) { it.connectionId == currentScope.connectionId }
.mapTo(scopes) { SessionActivityScope.of(it.connectionId, it.profile) }
resolved.runtimes.mapNotNullTo(scopes) { runtime ->
runtime.owner?.let { SessionActivityScope.of(it.connectionId, it.profile) }
}
if (scopes.isEmpty()) scopes += currentScope
reduceSessionActivities(
scopes.map { scope ->
SessionActivityUpdate.ActiveList(
scope = scope,
runtimes = resolved.runtimes.filter { it.owner?.let { owner ->
owner.connectionId == scope.connectionId && owner.profile == scope.profile
} == true },
isCompleteForScope = !resolved.ambiguous,
generation = generation,
observedAtMillis = now,
)
},
)
}
GatewayActiveSessionsResult.Unsupported,
is GatewayActiveSessionsResult.TransientFailure -> {
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
val scopes = directory.mapTo(mutableSetOf()) {
SessionActivityScope.of(it.connectionId, it.profile)
}.apply { add(currentScope) }
reduceSessionActivities(
scopes.map { scope ->
SessionActivityUpdate.StatusUnavailable(scope, generation, now)
},
)
}
}
projectCurrentBackgroundProcesses(generation, now)
}
if (gatewayClient !== client || !chatVisible) return
val hasConfirmedLiveWork = sessionActivityRegistry.value.records.values.any { record ->
record.freshness == SessionActivityFreshness.Confirmed &&
record.phase(System.currentTimeMillis()) != SessionActivityPhase.Idle
}
val delayMs = if (hasConfirmedLiveWork) 1_500L else 30_000L
sessionActivityPollJob = viewModelScope.launch {
delay(delayMs)
if (gatewayClient === client && chatVisible) pollSessionActivity(client)
}
}
private fun projectCurrentBackgroundProcesses(generation: Long, now: Long) {
val sessionId = chatHandler?.currentSessionId?.value ?: return
val owner = activityOwner(sessionId) ?: return
val previousOwner = lastProjectedProcessOwner
if (previousOwner != null && previousOwner != owner) {
reduceSessionActivities(
lastProjectedProcessIds.map { processId ->
SessionActivityUpdate.ProcessState(
owner = previousOwner,
processId = processId,
running = false,
generation = generation,
observedAtMillis = now,
)
},
)
lastProjectedProcessIds = emptySet()
}
lastProjectedProcessOwner = owner
if (!gatewayProcessController.ownsSnapshot(sessionId, activeProfileContextKey)) {
lastProjectedProcessIds = emptySet()
return
}
val activeIds = backgroundProcesses.value.filter { it.isRunning }.mapTo(mutableSetOf()) { it.id }
reduceSessionActivities(
(lastProjectedProcessIds + activeIds).map { processId ->
SessionActivityUpdate.ProcessState(
owner = owner,
processId = processId,
running = processId in activeIds,
generation = generation,
observedAtMillis = now,
)
},
)
lastProjectedProcessIds = activeIds
}
private fun clearProjectedBackgroundProcesses() {
val owner = lastProjectedProcessOwner
if (owner != null && lastProjectedProcessIds.isNotEmpty()) {
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
reduceSessionActivities(
lastProjectedProcessIds.map { processId ->
SessionActivityUpdate.ProcessState(
owner = owner,
processId = processId,
running = false,
generation = generation,
observedAtMillis = now,
)
},
)
}
lastProjectedProcessIds = emptySet()
lastProjectedProcessOwner = null
}
fun updateGatewayClient(client: GatewayChatClient?) {
val previousClient = gatewayClient
val changed = previousClient !== client
if (changed) {
clearProjectedBackgroundProcesses()
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
sessionActivityGeneration.incrementAndGet()
sessionActivityDirectory = emptySet()
lastLocalActivityOwner = null
lastLocalStreaming = false
lastSessionActivityScope = null
gatewayVisibleReattachJob?.cancel()
gatewayVisibleReattachJob = null
previousClient?.setUnsolicitedTurnProvider(null)
@@ -1967,6 +2396,7 @@ class ChatViewModel : ViewModel() {
sessionId = chatHandler?.currentSessionId?.value,
scopeKey = activeProfileContextKey,
)
activateSessionActivityScope()
}
// Bind each gateway session.create/resume to the currently-selected
// profile (pulled live) — the upstream gateway builds the agent from it.
@@ -2114,6 +2544,7 @@ class ChatViewModel : ViewModel() {
) {
prewarmGateway()
}
if (changed && client != null) requestSessionActivityRefresh()
}
/** Remove the detached sibling's recovery snapshot after server completion. */
@@ -2134,7 +2565,20 @@ class ChatViewModel : ViewModel() {
backgroundPendingInteractions.remove(key)
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
}
reduceSessionActivities(
matching.mapNotNull { key ->
activityOwner(key.sessionId, key.contextKey)?.let { owner ->
SessionActivityUpdate.Terminal(
owner = owner,
runtimeId = completion.liveSessionId,
generation = sessionActivityGeneration.get(),
observedAtMillis = System.currentTimeMillis(),
)
}
},
)
publishBackgroundSessionActivity()
requestSessionActivityRefresh()
chatTurnCheckpointStore?.let { store ->
viewModelScope.launch {
checkpointMutex.withLock {
@@ -2580,7 +3024,13 @@ class ChatViewModel : ViewModel() {
fun setChatVisible(visible: Boolean) {
val changed = chatVisible != visible
chatVisible = visible
if (visible && changed) prewarmGateway()
if (visible && changed) {
prewarmGateway()
requestSessionActivityRefresh()
} else if (!visible) {
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
}
}
// === Gateway desktop-parity state ===
@@ -3128,6 +3578,16 @@ class ChatViewModel : ViewModel() {
gatewayStateSyncJob?.cancel()
lastSurfacedCredentialWarning = null
gatewayStateSyncJob = viewModelScope.launch {
launch {
backgroundProcesses.collect {
if (gatewayClient !== client) return@collect
projectCurrentBackgroundProcesses(
generation = sessionActivityGeneration.get(),
now = System.currentTimeMillis(),
)
requestSessionActivityRefresh()
}
}
launch {
client.serverPersonality.collect { value ->
if (gatewayClient !== client || value == null) return@collect
@@ -3835,6 +4295,7 @@ class ChatViewModel : ViewModel() {
sessionId = sessionId,
)
}
activateSessionActivityScope()
handler.activeAgentName = currentAgentDisplayName()
if (
previousBinding.contextKey == contextKey &&
@@ -4102,6 +4563,8 @@ class ChatViewModel : ViewModel() {
currentSessionProfileName() == profileName
) {
handler.updateSessions(sessions)
updateCurrentProfileActivityDirectory(handler.sessions.value)
requestSessionActivityRefresh()
}
},
onFailure = { error ->
@@ -4161,7 +4624,6 @@ class ChatViewModel : ViewModel() {
onReady: ((String?) -> Unit)? = null,
onFailure: (() -> Unit)? = null,
) {
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return
val handler = chatHandler ?: return
recordPreResetEvidence(handler, "new_chat")
clearOpenedSessionOwner()
@@ -4494,7 +4956,6 @@ class ChatViewModel : ViewModel() {
}
fun deleteSession(sessionId: String, onDeleted: () -> Unit = {}) {
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Delete)) return
val handler = chatHandler ?: return
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
@@ -4559,7 +5020,6 @@ class ChatViewModel : ViewModel() {
}
fun renameSession(sessionId: String, newTitle: String) {
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Rename)) return
val handler = chatHandler ?: return
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
@@ -4604,7 +5064,6 @@ class ChatViewModel : ViewModel() {
}
fun setSessionPinned(sessionId: String, pinned: Boolean) {
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Pin)) return
val expectedContextKey = activeProfileContextKey
val profileName = currentSessionProfileName()
mutateSessionFlag(
@@ -4623,7 +5082,6 @@ class ChatViewModel : ViewModel() {
}
fun setSessionArchived(sessionId: String, archived: Boolean) {
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Archive)) return
if (!_sessionArchivingSupported.value) {
emitError(
UnsupportedOperationException("Archive and restore require Dashboard sessions"),
@@ -4692,22 +5150,6 @@ class ChatViewModel : ViewModel() {
fun sendMessage(text: String) {
if (text.isBlank()) return
supervisedMessageBlockReason(supervisedModePolicy, text)?.let { reason ->
chatHandler?.addSystemNotice(reason)
return
}
if (supervisedModePolicy.enabled) {
val attachments = _pendingAttachments.value
if (attachments.any { attachment ->
!isAttachmentAllowedBySupervision(attachment, attachments.indexOf(attachment))
}
) {
chatHandler?.addSystemNotice(
"One or more attachments are unavailable under the supervised policy.",
)
return
}
}
recordRecentPrompt(text)
// Demo / Explore mode: there is no server, but a silently dead Send
@@ -4976,10 +5418,6 @@ class ChatViewModel : ViewModel() {
action: com.hermesandroid.relay.data.HermesCardAction,
) {
val handler = chatHandler ?: return
if (supervisedModePolicy.enabled) {
handler.addSystemNotice("This action is unavailable in supervised mode.")
return
}
// Ask answers route straight to the gateway respond RPCs —
// answerAsk records its own (sanitized) dispatch stamp, so don't
// double-stamp here.
@@ -5018,10 +5456,6 @@ class ChatViewModel : ViewModel() {
ask: GatewayAsk,
restored: ChatTurnAskCheckpoint? = null,
) {
if (supervisedModePolicy.enabled) {
denySupervisedInteraction(handler, ask)
return
}
val sessionId = handler.currentSessionId.value
val contextKey = activeProfileContextKey
val existing = _pendingAsk.value
@@ -5150,33 +5584,6 @@ class ChatViewModel : ViewModel() {
sessionId?.let { maybeNotifyInteraction(it, ask) }
}
/**
* Supervised Chat never exposes approval, clarification, sudo, or secret
* inputs. Settle the upstream interaction immediately with its safest
* negative/empty response; if that cannot be confirmed, interrupt the turn
* so a hidden card cannot leave the session waiting indefinitely.
*/
private fun denySupervisedInteraction(handler: ChatHandler, ask: GatewayAsk) {
val gateway = gatewayClient
if (gateway == null) {
handler.addSystemNotice("An interactive request was blocked by supervised mode.")
cancelStream()
return
}
viewModelScope.launch {
val response: Result<GatewayAskResponse>? = when (ask.kind) {
GatewayAsk.Kind.APPROVAL -> gateway.respondApproval(choice = "deny")
GatewayAsk.Kind.CLARIFY -> ask.requestId?.let {
gateway.respondClarify(it, "This supervised client cannot answer interactive requests.")
}
GatewayAsk.Kind.SUDO -> ask.requestId?.let { gateway.respondSudo(it, "") }
GatewayAsk.Kind.SECRET -> ask.requestId?.let { gateway.respondSecret(it, "") }
}
handler.addSystemNotice("An interactive request was denied by supervised mode.")
if (response == null || response.isFailure) cancelStream()
}
}
/** Render only upstream-supported approval values; old servers retain Approve/Deny. */
private fun approvalActions(ask: GatewayAsk): List<HermesCardAction> {
val advertised = ask.choices.orEmpty()
@@ -6533,6 +6940,7 @@ class ChatViewModel : ViewModel() {
private fun finalizeTurnSideEffects(handler: ChatHandler, messageId: String) {
val completedOwner = activeQueueOwnerRunId
handler.onStreamComplete(messageId)
settleSessionActivity(handler.currentSessionId.value)
if (completedOwner != null && queuedMessageItems.any { it.ownerRunId == completedOwner }) {
completedQueueOwnerRuns += completedOwner
}
@@ -6561,6 +6969,7 @@ class ChatViewModel : ViewModel() {
private fun finalizeFailedTurnSideEffects(handler: ChatHandler, messageId: String) {
handler.onStreamComplete(messageId)
handler.markError(messageId)
settleSessionActivity(handler.currentSessionId.value)
clearTurnCheckpoint()
activeStream = null
_steerableTurn.value = false
@@ -6603,6 +7012,7 @@ class ChatViewModel : ViewModel() {
} else {
handler.clearStreamingStatus()
}
settleSessionActivity(handler.currentSessionId.value)
}
}
@@ -7106,6 +7516,9 @@ class ChatViewModel : ViewModel() {
badges = listOf("Realtime Agent"),
)
)
if (streamingEndpoint == "gateway") {
markSessionActivityStarting(handler.currentSessionId.value)
}
return assistantMessageId
}
@@ -7996,6 +8409,9 @@ class ChatViewModel : ViewModel() {
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
)
)
if (streamingEndpoint == "gateway") {
markSessionActivityStarting(handler.currentSessionId.value)
}
val streamDeltas = StreamDeltaCoalescer(
scope = viewModelScope,
@@ -8313,6 +8729,7 @@ class ChatViewModel : ViewModel() {
_steerableTurn.value = false
_steerNotice.value = null
clearTurnCheckpoint()
settleSessionActivity(errorSessionId)
} else if (
dispatchedSseEndpoint == "sessions" &&
errorSessionId != null &&
@@ -8362,6 +8779,7 @@ class ChatViewModel : ViewModel() {
),
)
clearTurnCheckpoint()
settleSessionActivity(errorSessionId)
}
} else {
AppAnalytics.onStreamError()
@@ -8402,6 +8820,7 @@ class ChatViewModel : ViewModel() {
_steerableTurn.value = false
_steerNotice.value = null
clearTurnCheckpoint()
settleSessionActivity(errorSessionId)
}
}
val onPreflightErrorCb = { error: Throwable ->
@@ -8433,6 +8852,7 @@ class ChatViewModel : ViewModel() {
_steerableTurn.value = false
_steerNotice.value = null
clearTurnCheckpoint()
settleSessionActivity(handler.currentSessionId.value)
}
// === v0.4.1 voice-intent + v0.7.x card-dispatch session sync ===
@@ -8711,6 +9131,7 @@ class ChatViewModel : ViewModel() {
),
)
handler.setSessionId(sid)
markSessionActivityStarting(sid)
updateTurnCheckpointSession(sid)
selectBackgroundProcessSession(sid)
gatewayProcessController.sessionReady(sid)
@@ -8829,6 +9250,7 @@ class ChatViewModel : ViewModel() {
// don't resurrect the turn on SSE.
intentionallyCancelled = false
activeStream = null
settleSessionActivity(handler.currentSessionId.value)
} else {
// Nothing started server-side — rerun this turn on
// the SSE fallback. Callbacks land on the main
@@ -8982,9 +9404,6 @@ class ChatViewModel : ViewModel() {
* so we shouldn't see duplicate calls here.
*/
fun onMediaAttachmentRequested(messageId: String, token: String) {
if (supervisedModePolicy.enabled &&
!supervisedModePolicy.capabilities.generatedImages
) return
val handler = chatHandler ?: return
val relay = relayHttpClient
val repo = mediaSettingsRepo
@@ -9042,9 +9461,6 @@ class ChatViewModel : ViewModel() {
* token and uses [RelayHttpClient.fetchMedia].
*/
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
if (supervisedModePolicy.enabled &&
!supervisedModePolicy.capabilities.generatedImages
) return
val handler = chatHandler ?: return
val relay = relayHttpClient ?: return
val repo = mediaSettingsRepo ?: return
@@ -9118,9 +9534,6 @@ class ChatViewModel : ViewModel() {
* it into the markdown-image renderer, which previously ignored the relay.
*/
suspend fun resolveServerImage(serverPath: String): ServerImageResult {
if (supervisedModePolicy.enabled &&
!supervisedModePolicy.capabilities.generatedImages
) return ServerImageResult.Failure("Generated images are disabled in supervised mode")
val relay = relayHttpClient
?: return ServerImageResult.Failure("Relay not configured on this connection")
// fetchMediaByPath returns Result<MediaBytes>; fold it ONCE, right here,
@@ -9163,11 +9576,6 @@ class ChatViewModel : ViewModel() {
expectedRole: MessageRole,
unavailableMessage: String,
) {
if (
expectedRole == MessageRole.ASSISTANT &&
supervisedModePolicy.enabled &&
!supervisedModePolicy.capabilities.generatedImages
) return
val handler = chatHandler ?: return
val relay = relayHttpClient
val repo = mediaSettingsRepo
@@ -2749,9 +2749,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private fun installAuthManager(am: AuthManager) {
am.setActiveEndpointProvider { connectionManager.activeRelayEndpoint.value }
am.setSupervisedMetadataReconnectFallback {
connectionManager.reconnectForAuthenticatedMetadataUpdate()
}
authManager = am
// Push into the flow so the flatMapLatest chains on authState /
// pairingCode / currentPairedSession repoint to the new manager.
@@ -3594,8 +3591,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// ConnectionStore's EncryptedSharedPrefs.
profileController.profileSelectionStore.clear(connectionId)
profileController.profileLockStore.clear(connectionId)
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
.clear(connectionId)
profileController.profilePresentationStore.clear(connectionId)
profileController.profileSessionStore.clearConnection(connectionId)
profileController.profileDisplayAliasStore.clearConnection(connectionId)
@@ -3635,9 +3630,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
init {
authManager.setSupervisedMetadataReconnectFallback {
connectionManager.reconnectForAuthenticatedMetadataUpdate()
}
// Wire multiplexer to connection manager (for relay/bridge/terminal)
multiplexer.setSendCallback { envelope ->
connectionManager.send(envelope)
@@ -4104,8 +4096,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
connectionStore.removeConnection(duplicate.id)
profileController.profileSelectionStore.clear(duplicate.id)
profileController.profileLockStore.clear(duplicate.id)
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
.clear(duplicate.id)
profileController.profilePresentationStore.clear(duplicate.id)
profileController.profileSessionStore.clearConnection(duplicate.id)
}
@@ -7200,8 +7190,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
check(dataManager.resetAppData()) { "App data store reset failed" }
profileController.profileSelectionStore.clearAll()
profileController.profileLockStore.clearAll()
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
.clearAll()
profileController.profilePresentationStore.clearAll()
profileController.profileSessionStore.clearAll()
_apiServerUrl.value = ""
@@ -110,6 +110,10 @@ internal class GatewayProcessController(
resetForSession(sessionId, scopeKey)
}
/** True only when the published process snapshot belongs to this exact owner. */
fun ownsSnapshot(sessionId: String, scopeKey: String?): Boolean =
selectedSessionId == sessionId && selectedScopeKey == scopeKey && readySessionId == sessionId
/**
* Admit process RPCs for [sessionId] after the gateway has created/resumed
* that chat's live session. Stale ready callbacks are ignored.
@@ -1,25 +0,0 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.SupervisedModePolicy
/**
* Fail-closed dispatch policy for Android Supervised Mode.
*
* This intentionally runs before demo handling, route selection, slash.exec,
* command.dispatch, steering, and queueing. Kotlin's default trim recognizes
* Unicode whitespace, preventing an indented slash command from bypassing the
* client restriction.
*/
internal fun supervisedMessageBlockReason(
policy: SupervisedModePolicy,
text: String,
): String? {
if (!policy.enabled) return null
if (!policy.isConfigured) {
return "Supervised mode is unavailable until the parent selects a profile."
}
if (text.trimStart().startsWith('/')) {
return "Slash commands are unavailable in supervised mode."
}
return null
}
@@ -21,8 +21,6 @@ import com.hermesandroid.relay.data.DEFAULT_VOICE_STOP_PHRASES
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoiceEngineMode
import com.hermesandroid.relay.data.VoiceIntentTrace
@@ -281,23 +279,6 @@ internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
*/
enum class InteractionMode { TapToTalk, HoldToTalk, Continuous }
internal fun isVoiceCommandAllowed(
action: VoiceCommandAction,
policy: SupervisedModePolicy,
): Boolean {
if (!policy.enabled) return true
val capabilities: SupervisedCapabilities = policy.capabilities
return when (action) {
VoiceCommandAction.StartNewChat -> capabilities.newChat
VoiceCommandAction.StopResponse,
VoiceCommandAction.CancelBackgroundTask -> capabilities.cancelResponse
VoiceCommandAction.EndVoiceChat,
VoiceCommandAction.PauseContinuousListening,
VoiceCommandAction.ResumeContinuousListening,
VoiceCommandAction.RepeatBackgroundAnswer -> capabilities.voice
}
}
internal fun InteractionMode.storageValue(): String = when (this) {
InteractionMode.TapToTalk -> "tap"
InteractionMode.HoldToTalk -> "hold"
@@ -742,7 +723,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private var voicePreferences: VoicePreferencesRepository? = null
private var voicePreferencesJob: Job? = null
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
private var voiceStopPhrases: List<String> = DEFAULT_VOICE_STOP_PHRASES
private var finalAnswerOnly: Boolean = false
private var realtimeTraceDetails: Boolean = false
@@ -1400,28 +1380,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
}
/** Apply the active Android client policy at the voice coordinator boundary. */
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
supervisedModePolicy = policy
val supervised = policy.enabled
voiceAudioClient?.setRouteOverride(if (supervised) VoiceAudioRoute.Standard else null)
if (supervised) {
if (voiceEngineMode == VoiceEngineMode.RealtimeAgent) closeRealtimeSession()
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput
_voiceStats.update {
it.copy(
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput.storageValue,
)
}
if (!policy.capabilities.voice && _uiState.value.voiceMode) exitVoiceMode()
} else {
val prefs = voicePreferences ?: return
viewModelScope.launch {
prefs.settings.firstOrNull()?.let { applyVoiceSettingsSnapshot(it) }
}
}
}
private fun persistInteractionMode(mode: InteractionMode) {
val prefs = voicePreferences ?: return
viewModelScope.launch {
@@ -1527,11 +1485,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
) {
closeRealtimeSession()
}
voiceEngineMode = if (supervisedModePolicy.enabled) {
VoiceEngineMode.HermesVoiceOutput
} else {
nextEngineMode
}
voiceEngineMode = nextEngineMode
voiceStopPhrases = settings.stopPhrases
finalAnswerOnly = settings.finalAnswerOnly
realtimeTraceDetails = settings.realtimeTraceDetails
@@ -1552,7 +1506,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
it.copy(
vadThresholdMs = settings.silenceThresholdMs,
interactionMode = settings.interactionMode,
voiceEngineMode = voiceEngineMode.storageValue,
voiceEngineMode = settings.engineMode,
realtimeModel = settings.realtimeModel,
realtimeVoice = settings.realtimeVoice,
)
@@ -1586,7 +1540,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
activationId: String? = null,
expectScreenContext: Boolean = false,
) {
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.voice) return
val freshEntry = !_uiState.value.voiceMode
val orphanedRun = _uiState.value
.takeIf { freshEntry }
@@ -2477,10 +2430,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
if (!canSpeakSettledResponse(state, providerRealtimeAgentTurnActive.get())) {
return false
}
if (
supervisedModePolicy.enabled &&
voiceAudioClient?.effectiveRoute != VoiceAudioRoute.Standard
) return false
val spoken = sanitizeForTts(text)
if (spoken.isBlank()) return false
@@ -3058,17 +3007,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
return null
}
if (!isVoiceCommandAllowed(action, supervisedModePolicy)) {
_uiState.update {
it.copy(
state = VoiceState.Idle,
outputAudioActive = false,
responseText = "That voice action is disabled by Parent controls.",
)
}
return action
}
Log.i(TAG, "Hands-free voice command action=$action source=${if (fromRealtime) "realtime" else "stt"}")
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
@@ -3138,23 +3076,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
setError("Voice pipeline not initialized")
return
}
if (
supervisedModePolicy.enabled &&
audioClient.effectiveRoute != VoiceAudioRoute.Standard
) {
setError("Supervised voice requires the Standard Hermes voice route")
return
}
currentTurnPcm = inputPcm
currentTurnPcmSampleRate = inputSampleRate
resetBrokeredToolSpeechState()
resetRealtimeSpeechCoalescer()
resetTtsTurnStats()
val engineModeForTurn = if (supervisedModePolicy.enabled) {
VoiceEngineMode.HermesVoiceOutput
} else {
voiceEngineMode
}
val engineModeForTurn = voiceEngineMode
Log.i(
TAG,
"Processing voice input engine=${engineModeForTurn.storageValue} " +
@@ -3303,7 +3230,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// ever mis-edited. If/when a `BuildFlavor.bridgeTier3` compile-
// time constant exists we should still short-circuit here for
// clarity, but today the factory already does the right thing.
val bridgeHandler = voiceBridgeIntentHandler.takeUnless { supervisedModePolicy.enabled }
val bridgeHandler = voiceBridgeIntentHandler
// === PHASE3-voice-cancel-midcountdown ===
// Voice-in-voice cancel: if a destructive action is currently
@@ -4979,8 +4906,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
private fun shouldPreferRealtimeVoice(): Boolean =
!supervisedModePolicy.enabled &&
voiceOutputAvailable != false &&
voiceOutputAvailable != false &&
realtimePcmPlayer != null &&
voiceClient != null &&
// Use the RESOLVED route: AutoVoiceAudioClient.effectiveRoute maps
+5 -9
View File
@@ -657,14 +657,6 @@
<string name="settings_analytics_desc">Estatísticas de uso, TTFT, tokens e integridade</string>
<string name="settings_diagnostics">Diagnóstico</string>
<string name="settings_diagnostics_desc">Verificações de status e atividade recente da API, do relay, da sessão e da voz</string>
<string name="settings_advanced">Avançado</string>
<string name="settings_advanced_desc">Modo supervisionado e outros recursos opcionais</string>
<string name="settings_advanced_intro">Recursos opcionais e especializados ficam aqui para manter a tela principal de Configurações organizada.</string>
<string name="settings_supervised_mode">Modo supervisionado</string>
<string name="settings_supervised_desc">Escolha um perfil e os recursos de chat permitidos</string>
<string name="settings_supervised_on">Ativado</string>
<string name="settings_supervised_on_profile">Ativado · %1$s</string>
<string name="settings_supervised_ready_profile">Pronto · %1$s</string>
<string name="settings_developer_options">Opções do desenvolvedor</string>
<string name="settings_developer_options_desc">Flags de recursos, gerenciamento de dados e opções experimentais</string>
<string name="settings_whats_new">Novidades</string>
@@ -884,6 +876,10 @@
<string name="drawer_search_sessions">Pesquisar sessões</string>
<string name="drawer_activity_working">Em andamento</string>
<string name="drawer_activity_needs_input">Precisa de resposta</string>
<string name="drawer_activity_starting">Iniciando</string>
<string name="drawer_activity_background_work">Trabalho em segundo plano</string>
<string name="drawer_activity_checking">Verificando</string>
<string name="drawer_activity_unavailable">Indisponível</string>
<string name="drawer_new_thread">Nova Thread</string>
<string name="drawer_chats_not_named">Os chats não recebem nomes automáticos nesta conexão — use ⋮ → Renomear.</string>
<string name="drawer_loading_sessions">Carregando sessões…</string>
@@ -913,7 +909,7 @@
<string name="drawer_filter_pinned">Fixadas</string>
<string name="drawer_filter_archive">Arquivo</string>
<string name="drawer_filter_sessions">Sessões</string>
<string name="drawer_timestamp_active">Ativa</string>
<string name="drawer_timestamp_updated">Atualizada</string>
<string name="drawer_timestamp_started">Iniciada</string>
<string name="drawer_just_now">Agora mesmo</string>
<!-- P1: BridgeScreen -->
@@ -698,14 +698,6 @@
<string name="settings_analytics_desc">使用统计、TTFT、token、健康状态</string>
<string name="settings_diagnostics">诊断</string>
<string name="settings_diagnostics_desc">状态检查,以及最近的 API、Relay、会话和语音活动</string>
<string name="settings_advanced">高级</string>
<string name="settings_advanced_desc">受监督模式和其他可选功能</string>
<string name="settings_advanced_intro">可选和专用功能集中在此,以保持主设置界面简洁。</string>
<string name="settings_supervised_mode">受监督模式</string>
<string name="settings_supervised_desc">选择配置文件和允许的聊天功能</string>
<string name="settings_supervised_on">已开启</string>
<string name="settings_supervised_on_profile">已开启 · %1$s</string>
<string name="settings_supervised_ready_profile">已就绪 · %1$s</string>
<string name="settings_developer_options">开发者选项</string>
<string name="settings_developer_options_desc">功能标志、数据管理、实验性</string>
<string name="settings_whats_new">新功能</string>
@@ -930,6 +922,10 @@
<string name="drawer_search_sessions">搜索会话</string>
<string name="drawer_activity_working">正在处理</string>
<string name="drawer_activity_needs_input">需要输入</string>
<string name="drawer_activity_starting">正在启动</string>
<string name="drawer_activity_background_work">后台工作</string>
<string name="drawer_activity_checking">正在检查</string>
<string name="drawer_activity_unavailable">不可用</string>
<string name="drawer_new_thread">新话题</string>
<string name="drawer_chats_not_named">此连接上的对话不会自动命名——使用 ⋮ → 重命名。</string>
<string name="drawer_loading_sessions">正在加载会话…</string>
@@ -959,7 +955,7 @@
<string name="drawer_filter_pinned">已固定</string>
<string name="drawer_filter_archive">归档</string>
<string name="drawer_filter_sessions">会话</string>
<string name="drawer_timestamp_active">活跃</string>
<string name="drawer_timestamp_updated">更新</string>
<string name="drawer_timestamp_started">已开始</string>
<string name="drawer_just_now">刚刚</string>
+5 -9
View File
@@ -698,14 +698,6 @@
<string name="settings_analytics_desc">Nutzungsstatistiken, TTFT, Token, Status</string>
<string name="settings_diagnostics">Diagnose</string>
<string name="settings_diagnostics_desc">Statusprüfungen sowie letzte API-, Relay-, Sitzungs- und Sprachaktivitäten</string>
<string name="settings_advanced">Erweitert</string>
<string name="settings_advanced_desc">Beaufsichtigter Modus und weitere optionale Funktionen</string>
<string name="settings_advanced_intro">Optionale und spezielle Funktionen befinden sich hier, damit die Haupteinstellungen übersichtlich bleiben.</string>
<string name="settings_supervised_mode">Beaufsichtigter Modus</string>
<string name="settings_supervised_desc">Profil und erlaubte Chatfunktionen auswählen</string>
<string name="settings_supervised_on">Ein</string>
<string name="settings_supervised_on_profile">Ein · %1$s</string>
<string name="settings_supervised_ready_profile">Bereit · %1$s</string>
<string name="settings_developer_options">Entwickleroptionen</string>
<string name="settings_developer_options_desc">Funktionsschalter, Datenverwaltung, Experimente</string>
<string name="settings_whats_new">Neuigkeiten</string>
@@ -933,6 +925,10 @@
<string name="drawer_search_sessions">Sitzungen durchsuchen</string>
<string name="drawer_activity_working">Wird bearbeitet</string>
<string name="drawer_activity_needs_input">Eingabe erforderlich</string>
<string name="drawer_activity_starting">Wird gestartet</string>
<string name="drawer_activity_background_work">Hintergrundarbeit</string>
<string name="drawer_activity_checking">Wird geprüft</string>
<string name="drawer_activity_unavailable">Nicht verfügbar</string>
<string name="drawer_new_thread">Neuer Thread</string>
<string name="drawer_chats_not_named">Chats werden bei dieser Verbindung nicht automatisch benannt — verwende ⋮ → Umbenennen.</string>
<string name="drawer_loading_sessions">Sitzungen werden geladen…</string>
@@ -962,7 +958,7 @@
<string name="drawer_filter_pinned">Angeheftet</string>
<string name="drawer_filter_archive">Archiv</string>
<string name="drawer_filter_sessions">Sitzungen</string>
<string name="drawer_timestamp_active">Aktiv</string>
<string name="drawer_timestamp_updated">Aktualisiert</string>
<string name="drawer_timestamp_started">Gestartet</string>
<string name="drawer_just_now">Gerade eben</string>
+5 -9
View File
@@ -625,14 +625,6 @@
<string name="settings_analytics_desc">Estadísticas de uso, TTFT, tokens, salud</string>
<string name="settings_diagnostics">Diagnóstico</string>
<string name="settings_diagnostics_desc">Verificaciones de estado, además de actividad reciente de API, relay, sesión y voz</string>
<string name="settings_advanced">Avanzado</string>
<string name="settings_advanced_desc">Modo supervisado y otras funciones opcionales</string>
<string name="settings_advanced_intro">Las funciones opcionales y especializadas están aquí para mantener despejada la pantalla principal de Ajustes.</string>
<string name="settings_supervised_mode">Modo supervisado</string>
<string name="settings_supervised_desc">Elige un perfil y las funciones de chat permitidas</string>
<string name="settings_supervised_on">Activado</string>
<string name="settings_supervised_on_profile">Activado · %1$s</string>
<string name="settings_supervised_ready_profile">Listo · %1$s</string>
<string name="settings_developer_options">Opciones de desarrollador</string>
<string name="settings_developer_options_desc">Indicadores de funciones, gestión de datos, experimental.</string>
<string name="settings_whats_new">Novedades</string>
@@ -848,6 +840,10 @@
<string name="drawer_search_sessions">Buscar sesiones</string>
<string name="drawer_activity_working">En curso</string>
<string name="drawer_activity_needs_input">Requiere intervención</string>
<string name="drawer_activity_starting">Iniciando</string>
<string name="drawer_activity_background_work">Trabajo en segundo plano</string>
<string name="drawer_activity_checking">Comprobando</string>
<string name="drawer_activity_unavailable">No disponible</string>
<string name="drawer_new_thread">Nuevo hilo</string>
<string name="drawer_chats_not_named">Los chats no tienen nombre automático en esta conexión. Utiliza «→Renombrar».</string>
<string name="drawer_loading_sessions">Cargando sesiones…</string>
@@ -877,7 +873,7 @@
<string name="drawer_filter_pinned">Fijado</string>
<string name="drawer_filter_archive">Archivo</string>
<string name="drawer_filter_sessions">Sesiones</string>
<string name="drawer_timestamp_active">Activo</string>
<string name="drawer_timestamp_updated">Actualizado</string>
<string name="drawer_timestamp_started">Comenzó</string>
<string name="drawer_just_now">En este momento</string>
<string name="bridge_back">Atrás</string>
+5 -9
View File
@@ -698,14 +698,6 @@
<string name="settings_analytics_desc">使用状況統計、TTFT、トークン、ヘルス</string>
<string name="settings_diagnostics">診断</string>
<string name="settings_diagnostics_desc">ステータス チェック、および最近の API、Relay、セッション、および音声アクティビティ</string>
<string name="settings_advanced">詳細設定</string>
<string name="settings_advanced_desc">監督モードとその他のオプション機能</string>
<string name="settings_advanced_intro">メインの設定画面をシンプルに保つため、オプション機能と専門機能はここにまとめられています。</string>
<string name="settings_supervised_mode">監督モード</string>
<string name="settings_supervised_desc">プロファイルと許可するチャット機能を選択</string>
<string name="settings_supervised_on">オン</string>
<string name="settings_supervised_on_profile">オン · %1$s</string>
<string name="settings_supervised_ready_profile">準備完了 · %1$s</string>
<string name="settings_developer_options">開発者向けオプション</string>
<string name="settings_developer_options_desc">機能フラグ、データ管理、実験的</string>
<string name="settings_whats_new">新着情報</string>
@@ -946,6 +938,10 @@
<string name="drawer_search_sessions">セッションを検索</string>
<string name="drawer_activity_working">処理中</string>
<string name="drawer_activity_needs_input">入力が必要</string>
<string name="drawer_activity_starting">開始中</string>
<string name="drawer_activity_background_work">バックグラウンド処理</string>
<string name="drawer_activity_checking">確認中</string>
<string name="drawer_activity_unavailable">利用不可</string>
<string name="drawer_new_thread">新しいスレッド</string>
<string name="drawer_chats_not_named">この接続ではチャットの名前は自動的に付けられません。「⋮」→「名前の変更」を使用してください。</string>
<string name="drawer_loading_sessions">セッションを読み込み中…</string>
@@ -975,7 +971,7 @@
<string name="drawer_filter_pinned">固定された</string>
<string name="drawer_filter_archive">アーカイブ</string>
<string name="drawer_filter_sessions">セッション</string>
<string name="drawer_timestamp_active">アクティブ</string>
<string name="drawer_timestamp_updated">更新</string>
<string name="drawer_timestamp_started">開始しました</string>
<string name="drawer_just_now">ちょうど今</string>
+5 -9
View File
@@ -668,14 +668,6 @@
<string name="settings_analytics_desc">Статистика использования, TTFT, токены, состояние</string>
<string name="settings_diagnostics">Диагностика</string>
<string name="settings_diagnostics_desc">Проверка состояния, а также недавняя активность API, Relay, сессий и голосовых данных</string>
<string name="settings_advanced">Дополнительно</string>
<string name="settings_advanced_desc">Режим с контролем и другие дополнительные функции</string>
<string name="settings_advanced_intro">Дополнительные и специальные функции собраны здесь, чтобы не перегружать главный экран настроек.</string>
<string name="settings_supervised_mode">Режим с контролем</string>
<string name="settings_supervised_desc">Выберите профиль и разрешённые функции чата</string>
<string name="settings_supervised_on">Вкл.</string>
<string name="settings_supervised_on_profile">Вкл. · %1$s</string>
<string name="settings_supervised_ready_profile">Готово · %1$s</string>
<string name="settings_developer_options">Настройки разработчика</string>
<string name="settings_developer_options_desc">Флаги функций, управление данными, экспериментальные</string>
<string name="settings_whats_new">Что нового</string>
@@ -956,6 +948,10 @@
<string name="drawer_search_sessions">Поиск сессий</string>
<string name="drawer_activity_working">Выполняется</string>
<string name="drawer_activity_needs_input">Требуется ввод</string>
<string name="drawer_activity_starting">Запуск</string>
<string name="drawer_activity_background_work">Фоновая работа</string>
<string name="drawer_activity_checking">Проверка</string>
<string name="drawer_activity_unavailable">Недоступно</string>
<string name="drawer_new_thread">Новая ветка</string>
<string name="drawer_chats_not_named">Чаты не автоматически именуются на этом соединении — используйте ⋮ → Переименовать.</string>
<string name="drawer_loading_sessions">Загрузка сессий…</string>
@@ -985,7 +981,7 @@
<string name="drawer_filter_pinned">Закрепленные</string>
<string name="drawer_filter_archive">Архив</string>
<string name="drawer_filter_sessions">Сессии</string>
<string name="drawer_timestamp_active">Активен</string>
<string name="drawer_timestamp_updated">Обновлено</string>
<string name="drawer_timestamp_started">Начат</string>
<string name="drawer_just_now">Только что</string>
<string name="bridge_back">Назад</string>
+5 -9
View File
@@ -736,14 +736,6 @@
<string name="settings_analytics_desc">Usage stats, TTFT, tokens, health</string>
<string name="settings_diagnostics">Diagnostics</string>
<string name="settings_diagnostics_desc">Status checks, plus recent API, relay, session, and voice activity</string>
<string name="settings_advanced">Advanced</string>
<string name="settings_advanced_desc">Supervised mode and other optional features</string>
<string name="settings_advanced_intro">Optional and specialized features live here to keep the main Settings screen focused.</string>
<string name="settings_supervised_mode">Supervised mode</string>
<string name="settings_supervised_desc">Choose a profile and approved chat features</string>
<string name="settings_supervised_on">On</string>
<string name="settings_supervised_on_profile">On · %1$s</string>
<string name="settings_supervised_ready_profile">Ready · %1$s</string>
<string name="settings_developer_options">Developer options</string>
<string name="settings_developer_options_desc">Feature flags, data management, experimental</string>
<string name="settings_whats_new">What\'s New</string>
@@ -1048,6 +1040,10 @@
<string name="drawer_search_sessions">Search sessions</string>
<string name="drawer_activity_working">Working</string>
<string name="drawer_activity_needs_input">Needs input</string>
<string name="drawer_activity_starting">Starting</string>
<string name="drawer_activity_background_work">Background work</string>
<string name="drawer_activity_checking">Checking</string>
<string name="drawer_activity_unavailable">Unavailable</string>
<string name="drawer_new_thread">New Thread</string>
<string name="drawer_chats_not_named">Chats aren\'t auto-named on this connection — use ⋮ → Rename.</string>
<string name="drawer_loading_sessions">Loading sessions…</string>
@@ -1077,7 +1073,7 @@
<string name="drawer_filter_pinned">Pinned</string>
<string name="drawer_filter_archive">Archive</string>
<string name="drawer_filter_sessions">Sessions</string>
<string name="drawer_timestamp_active">Active</string>
<string name="drawer_timestamp_updated">Updated</string>
<string name="drawer_timestamp_started">Started</string>
<string name="drawer_just_now">Just now</string>
@@ -1,55 +0,0 @@
package com.hermesandroid.relay.auth
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedModeAuthPayloadTest {
@Test fun `active policy reports only public capability ids`() {
val payload = relaySupervisedModePayload(
SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(attachments = true, voice = true),
),
)
assertTrue(payload.getValue("active").jsonPrimitive.boolean)
assertEquals("willow", payload.getValue("profile_label").jsonPrimitive.content)
val capabilities = payload.getValue("capabilities").jsonArray.map { it.jsonPrimitive.content }
assertTrue("text_chat" in capabilities)
assertTrue("attachments" in capabilities)
assertTrue("voice" in capabilities)
assertFalse(capabilities.any { it.contains("model") || it.contains("tool") })
}
@Test fun `inactive update explicitly clears Relay tag`() {
val payload = relaySupervisedModePayload(SupervisedModePolicy())
assertFalse(payload.getValue("active").jsonPrimitive.boolean)
assertEquals(setOf("active"), payload.keys)
}
@Test fun `live update uses typed correlated system envelope`() {
val envelope = relaySupervisedModeUpdateEnvelope(
SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(voice = true),
),
)
assertEquals("system", envelope.channel)
assertEquals("supervised.update", envelope.type)
assertTrue(envelope.id.isNotBlank())
val mode = envelope.payload.getValue("supervised_mode")
.jsonObject
assertTrue(mode.getValue("active").jsonPrimitive.boolean)
assertEquals("willow", mode.getValue("profile_label").jsonPrimitive.content)
}
}
@@ -0,0 +1,453 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionActivityRegistryTest {
private val owner = owner("connection-a", "Default", "session-a")
private val scope = SessionActivityScope.of("connection-a", "default")
@Test
fun `directory owner is checking until status is unavailable or confirms idle`() {
val checking = SessionActivityRegistry().reduce(
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
)
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase())
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
val unavailable = checking.reduce(
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
)
assertEquals(SessionActivityState.Unavailable, unavailable.record(owner)?.presentationState())
val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, confirmedIdle.record(owner)?.freshness)
assertNull(confirmedIdle.record(owner)?.presentationState())
}
@Test
fun `directory observation cannot downgrade confirmed live evidence`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 20))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
}
@Test
fun `owner normalizes profile without collapsing connection ownership`() {
assertEquals(owner, owner("connection-a", " DEFAULT ", "session-a"))
val otherConnection = owner("connection-b", "default", "session-a")
assertEquals(2, setOf(owner, otherConnection).size)
}
@Test
fun `exact pending input outranks live working and answer restores it`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase(nowMillis = 10))
state = state.reduce(closeInput(owner, "request-a", generation = 1))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 10))
}
@Test
fun `expired pending input no longer overrides live state`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1, expiresAt = 50))
.reduce(SessionActivityUpdate.Tick(nowMillis = 50))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 50))
}
@Test
fun `checkpoint is revalidating until successful snapshot settles it`() {
var state = SessionActivityRegistry().reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 1,
),
)
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
state = state.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
}
@Test
fun `successful snapshot absence settles only the same profile`() {
val otherProfile = owner("connection-a", "work", "session-a")
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(live(otherProfile, "runtime-a", SessionLiveStatus.Working, generation = 1))
state = state.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(otherProfile)?.phase())
}
@Test
fun `same ids cannot alias across profiles or connections`() {
val profileB = owner("connection-a", "work", "session-a")
val connectionB = owner("connection-b", "default", "session-a")
var state = SessionActivityRegistry()
listOf(owner, profileB, connectionB).forEach {
state = state.reduce(live(it, "runtime-shared", SessionLiveStatus.Working, generation = 2))
}
assertEquals(owner, state.ownerForRuntime(scope, "runtime-shared"))
assertEquals(
profileB,
state.ownerForRuntime(SessionActivityScope.of("connection-a", "work"), "runtime-shared"),
)
assertEquals(
connectionB,
state.ownerForRuntime(SessionActivityScope.of("connection-b", "default"), "runtime-shared"),
)
}
@Test
fun `unscoped active row cannot mark duplicate stored ids as working`() {
val profileB = owner("connection-a", "work", "session-a")
var state = SessionActivityRegistry()
.reduce(live(owner, "old-a", SessionLiveStatus.Working, generation = 1))
.reduce(live(profileB, "old-b", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
activeList(
scope,
1,
false,
SessionLiveRuntime(
owner = null,
runtimeId = "ambiguous-runtime",
status = SessionLiveStatus.Working,
),
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(profileB)?.phase())
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
}
@Test
fun `partial snapshot applies resolved row without settling absent owner`() {
val absentOwner = owner("connection-a", "default", "session-b")
var state = SessionActivityRegistry()
.reduce(live(absentOwner, "runtime-b", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
activeList(
scope,
1,
false,
SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Working),
SessionLiveRuntime(null, "ambiguous-runtime", SessionLiveStatus.Working),
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(absentOwner)?.phase())
assertEquals(owner, state.ownerForRuntime(scope, "runtime-a"))
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
}
@Test
fun `new generation rejects late terminal event and invalidates old alias`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-old", SessionLiveStatus.Working, generation = 3))
.reduce(SessionActivityUpdate.BeginGeneration(scope, generation = 4, observedAtMillis = 20))
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
assertNull(state.ownerForRuntime(scope, "runtime-old"))
state = state.reduce(live(owner, "runtime-old", SessionLiveStatus.Idle, generation = 3))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
}
@Test
fun `failed or unsupported status refresh is unavailable rather than idle`() {
val state = SessionActivityRegistry()
.reduce(
SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = "runtime-a",
status = SessionLiveStatus.Working,
source = SessionActivityEvidenceSource.ActiveList,
generation = 1,
observedAtMillis = 10,
),
)
.reduce(
SessionActivityUpdate.StatusUnavailable(
scope = scope,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
assertEquals(SessionActivityState.Unavailable, state.record(owner)?.presentationState())
}
@Test
fun `active-list failure does not override exact live session event`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(
SessionActivityUpdate.StatusUnavailable(
scope = scope,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
assertEquals(SessionActivityState.Working, state.record(owner)?.presentationState())
}
@Test
fun `presentation keeps starting background and revalidation distinct from working`() {
val starting = SessionActivityRegistry().reduce(
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
)
assertEquals(SessionActivityState.Starting, starting.record(owner)?.presentationState())
val background = starting
.reduce(process(owner, "process-a", running = true, generation = 1))
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
assertEquals(SessionActivityState.BackgroundWork, background.record(owner)?.presentationState())
val checking = starting.reduce(
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
)
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
}
@Test
fun `terminal turn with running process projects background work separately`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(process(owner, "process-a", running = true, generation = 1))
.reduce(terminal(owner, "runtime-a", generation = 1))
assertEquals(SessionActivityPhase.BackgroundWork, state.record(owner)?.phase())
state = state.reduce(process(owner, "process-a", running = false, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
}
@Test
fun `terminal settles pending input and removes its runtime alias`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
state = state.reduce(terminal(owner, "runtime-a", generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertNull(state.ownerForRuntime(scope, "runtime-a"))
}
@Test
fun `authoritative live state is not overwritten by restored checkpoint`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
.reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 30,
),
)
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
}
@Test
fun `restored needs-input checkpoint stays checking until live confirmation`() {
val state = SessionActivityRegistry().reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.NeedsInput,
generation = 1,
observedAtMillis = 10,
),
)
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
}
@Test
fun `synthetic checkpoint input does not confirm restored working state`() {
var state = SessionActivityRegistry()
.reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 10,
),
)
.reduce(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:session-a",
confirmed = false,
generation = 1,
observedAtMillis = 11,
),
)
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
state = state.reduce(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:session-a",
confirmed = true,
generation = 1,
observedAtMillis = 12,
),
)
assertEquals(SessionActivityState.NeedsInput, state.record(owner)?.presentationState())
}
@Test
fun `authoritative idle active-list row clears stale pending input`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
.reduce(
activeList(
scope,
generation = 1,
runtimes = arrayOf(SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Idle)),
),
)
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertTrue(state.record(owner)?.pendingInputs.orEmpty().isEmpty())
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `runtime-only update requires alias in current scoped generation`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
SessionActivityUpdate.RuntimeState(
scope = scope,
runtimeId = "runtime-a",
status = SessionLiveStatus.Waiting,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
state = state.reduce(SessionActivityUpdate.BeginGeneration(scope, 2, 30))
.reduce(
SessionActivityUpdate.RuntimeState(
scope = scope,
runtimeId = "runtime-a",
status = SessionLiveStatus.Idle,
generation = 2,
observedAtMillis = 40,
),
)
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
}
private fun owner(connection: String, profile: String, session: String) =
SessionActivityOwner.of(connection, profile, session)
private fun live(
owner: SessionActivityOwner,
runtime: String,
status: SessionLiveStatus,
generation: Long,
) = SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = runtime,
status = status,
generation = generation,
observedAtMillis = 10,
)
private fun activeList(
scope: SessionActivityScope,
generation: Long,
complete: Boolean = true,
vararg runtimes: SessionLiveRuntime,
) = SessionActivityUpdate.ActiveList(
scope = scope,
runtimes = runtimes.toList(),
isCompleteForScope = complete,
generation = generation,
observedAtMillis = 20,
)
private fun openInput(
owner: SessionActivityOwner,
request: String,
generation: Long,
expiresAt: Long? = null,
) = SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = request,
expiresAtMillis = expiresAt,
generation = generation,
observedAtMillis = 10,
)
private fun closeInput(owner: SessionActivityOwner, request: String, generation: Long) =
SessionActivityUpdate.PendingInputClosed(
owner = owner,
requestId = request,
generation = generation,
observedAtMillis = 20,
)
private fun process(owner: SessionActivityOwner, id: String, running: Boolean, generation: Long) =
SessionActivityUpdate.ProcessState(
owner = owner,
processId = id,
running = running,
generation = generation,
observedAtMillis = 10,
)
private fun terminal(owner: SessionActivityOwner, runtime: String, generation: Long) =
SessionActivityUpdate.Terminal(
owner = owner,
runtimeId = runtime,
generation = generation,
observedAtMillis = 20,
)
}
@@ -1,221 +0,0 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedModeStoreTest {
@Test
fun freshConnectionUsesRestrictiveDefaults() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
val policy = store.policyFlow("connection-a").first()
assertFalse(policy.enabled)
assertFalse(policy.isConfigured)
assertFalse(policy.isActive)
assertFalse(policy.capabilities.attachments)
assertFalse(policy.capabilities.voice)
assertFalse(policy.visibility.resolved().showModelName)
assertFalse(policy.visibility.resolved().showTechnicalRoute)
assertTrue(policy.parentAccess.requireDeviceAuthentication)
assertEquals(5, policy.parentAccess.timeoutMinutes)
}
@Test
fun policyRoundTripsWithCapabilitiesLimitsAndVisibility() = runTest {
val dataStore = InMemorySupervisedPreferencesDataStore()
val store = SupervisedModeStore.forTesting(dataStore)
val saved = SupervisedModePolicy(
enabled = true,
pinnedProfileName = " willow ",
capabilities = SupervisedCapabilities(
attachments = true,
voice = true,
attachmentMaxCount = 6,
attachmentMaxFileMb = 20,
attachmentCategories = setOf(
SupervisedAttachmentCategory.Images,
SupervisedAttachmentCategory.Documents,
),
sessionActions = SupervisedSessionActions(
pin = true,
rename = true,
shareTranscript = true,
),
),
appearance = SupervisedAppearance(
appThemeId = "rose",
themePreference = "dark",
showPet = true,
allowProfileIconChanges = true,
allowBackgroundChanges = true,
),
visibility = SupervisedVisibility(
preset = SupervisedVisibilityPreset.Custom,
showAgentIdentity = true,
showModelName = true,
showToolNames = true,
),
)
store.setPolicy("connection-a", saved)
val restored = SupervisedModeStore.forTesting(dataStore).policyFlow("connection-a").first()
assertTrue(restored.isActive)
assertEquals("willow", restored.pinnedProfileName)
assertEquals(6, restored.capabilities.attachmentMaxCount)
assertEquals(20, restored.capabilities.attachmentMaxFileMb)
assertEquals(saved.capabilities.attachmentCategories, restored.capabilities.attachmentCategories)
assertEquals(saved.capabilities.sessionActions, restored.capabilities.sessionActions)
assertEquals("rose", restored.appearance.appThemeId)
assertEquals("dark", restored.appearance.themePreference)
assertTrue(restored.appearance.showPet)
assertTrue(restored.appearance.allowProfileIconChanges)
assertTrue(restored.appearance.allowBackgroundChanges)
assertEquals(SupervisedVisibilityPreset.Custom, restored.visibility.preset)
assertTrue(restored.visibility.showModelName)
assertTrue(restored.visibility.showToolNames)
}
@Test
fun connectionsAreIsolatedAndClearRemovesOnlyTarget() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
store.setPolicy("connection-b", SupervisedModePolicy(true, "juniper"))
store.clear("connection-a")
assertFalse(store.policyFlow("connection-a").first().enabled)
assertEquals("juniper", store.policyFlow("connection-b").first().pinnedProfileName)
}
@Test
fun updateAndSetEnabledPreserveOtherPolicyFields() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
store.setPolicy(
"connection-a",
SupervisedModePolicy(
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(voice = true),
),
)
store.setEnabled("connection-a", true)
store.updatePolicy("connection-a") {
it.copy(visibility = it.visibility.copy(preset = SupervisedVisibilityPreset.Transparent))
}
val policy = store.policyFlow("connection-a").first()
assertTrue(policy.isActive)
assertTrue(policy.capabilities.voice)
assertEquals(SupervisedVisibilityPreset.Transparent, policy.visibility.preset)
}
@Test
fun invalidLimitsAreNormalizedAndEmptyCategoriesFallBackToImages() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
store.setPolicy(
"connection-a",
SupervisedModePolicy(
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(
attachmentMaxCount = Int.MAX_VALUE,
attachmentMaxFileMb = -1,
attachmentCategories = emptySet(),
),
parentAccess = SupervisedParentAccess(
requireDeviceAuthentication = false,
timeoutMinutes = 0,
),
appearance = SupervisedAppearance(
appThemeId = "missing-theme",
themePreference = "sepia",
),
),
)
val policy = store.policyFlow("connection-a").first()
assertEquals(SupervisedCapabilities.MAX_ATTACHMENT_COUNT, policy.capabilities.attachmentMaxCount)
assertEquals(1, policy.capabilities.attachmentMaxFileMb)
assertEquals(setOf(SupervisedAttachmentCategory.Images), policy.capabilities.attachmentCategories)
assertTrue(policy.parentAccess.requireDeviceAuthentication)
assertEquals(SupervisedParentAccess.MIN_TIMEOUT_MINUTES, policy.parentAccess.timeoutMinutes)
assertEquals("hermes-relay", policy.appearance.appThemeId)
assertEquals("auto", policy.appearance.themePreference)
}
@Test
fun simplePresetResolvesToSafeValuesEvenIfStoredFlagsDiffer() {
val visibility = SupervisedVisibility(
preset = SupervisedVisibilityPreset.Simple,
showModelName = true,
showTechnicalRoute = true,
showReasoning = true,
).resolved()
assertFalse(visibility.showModelName)
assertFalse(visibility.showTechnicalRoute)
assertFalse(visibility.showReasoning)
assertTrue(visibility.showAgentIdentity)
assertTrue(visibility.showConnectionStatus)
}
@Test
fun malformedPersistedPolicyFailsClosed() = runTest {
val policyKey = androidx.datastore.preferences.core.stringPreferencesKey(
"supervised_mode_policies_v1",
)
val dataStore = InMemorySupervisedPreferencesDataStore(
mutablePreferencesOf(policyKey to "{not-valid-json"),
)
val policy = SupervisedModeStore.forTesting(dataStore)
.policyFlow("connection-a")
.first()
assertTrue(policy.enabled)
assertFalse(policy.isConfigured)
assertFalse(policy.isActive)
}
@Test
fun clearAllDoesNotClearUnrelatedPreferences() = runTest {
val unrelatedKey = androidx.datastore.preferences.core.stringPreferencesKey("unrelated")
val dataStore = InMemorySupervisedPreferencesDataStore(
mutablePreferencesOf(unrelatedKey to "kept"),
)
val store = SupervisedModeStore.forTesting(dataStore)
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
store.clearAll()
assertFalse(store.policyFlow("connection-a").first().enabled)
assertEquals("kept", dataStore.data.first()[unrelatedKey])
}
}
private class InMemorySupervisedPreferencesDataStore(
initial: Preferences = emptyPreferences(),
) : DataStore<Preferences> {
private val state = MutableStateFlow(initial)
override val data: Flow<Preferences> = state
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences {
val updated = transform(state.value)
state.value = updated
return updated
}
}
@@ -1,41 +0,0 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedSessionPolicyTest {
@Test fun `session action summary derives none mixed and all`() {
val none = SupervisedSessionActions()
val mixed = none.copy(rename = true, delete = true)
val all = none.withAll(true)
assertTrue(none.noneEnabled)
assertEquals(2, mixed.enabledCount)
assertFalse(mixed.noneEnabled)
assertFalse(mixed.allEnabled)
assertTrue(all.allEnabled)
assertEquals(SupervisedSessionActions.TOTAL, all.enabledCount)
}
@Test fun `supervised history and granular flag are both required`() {
val base = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(
conversationHistory = true,
sessionActions = SupervisedSessionActions(rename = true),
),
)
assertTrue(base.allowsSessionAction(SupervisedSessionAction.Rename))
assertFalse(base.allowsSessionAction(SupervisedSessionAction.Delete))
assertFalse(
base.copy(
capabilities = base.capabilities.copy(conversationHistory = false),
).allowsSessionAction(SupervisedSessionAction.Rename),
)
assertTrue(SupervisedModePolicy().allowsSessionAction(SupervisedSessionAction.Delete))
}
}
@@ -1,33 +0,0 @@
package com.hermesandroid.relay.network.relay
import com.hermesandroid.relay.network.relay.models.Envelope
import org.junit.Assert.assertEquals
import org.junit.Test
class ChannelMultiplexerSupervisedUpdateTest {
@Test fun `supervised update acknowledgement reaches system auth handler`() {
val multiplexer = ChannelMultiplexer()
val received = mutableListOf<Envelope>()
multiplexer.registerHandler("system") { received += it }
val acknowledgement = Envelope(
channel = "system",
type = "supervised.updated",
id = "update-1",
)
multiplexer.route(acknowledgement)
assertEquals(listOf(acknowledgement), received)
}
@Test fun `correlated system error reaches system auth handler`() {
val multiplexer = ChannelMultiplexer()
val received = mutableListOf<Envelope>()
multiplexer.registerHandler("system") { received += it }
val error = Envelope(channel = "system", type = "error", id = "update-2")
multiplexer.route(error)
assertEquals(listOf(error), received)
}
}
@@ -1,37 +0,0 @@
package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.VoiceAudioRoute
import java.io.File
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class AutoVoiceAudioClientSupervisionTest {
@Test
fun `route override forces standard even when auto prefers ready relay`() = runTest {
val standard = FakeVoiceClient(VoiceAudioRoute.Standard, "standard")
val relay = FakeVoiceClient(VoiceAudioRoute.Relay, "relay")
val router = AutoVoiceAudioClient(
standardClient = standard,
relayClient = relay,
routeProvider = { VoiceAudioRoute.Auto },
standardReadyProvider = { true },
relayReadyProvider = { true },
)
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
router.setRouteOverride(VoiceAudioRoute.Standard)
assertEquals(VoiceAudioRoute.Standard, router.effectiveRoute)
assertEquals("standard", router.transcribe(File("voice.wav")).getOrThrow())
router.setRouteOverride(null)
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
}
private class FakeVoiceClient(
override val route: VoiceAudioRoute,
private val transcript: String,
) : VoiceAudioClient {
override suspend fun transcribe(audioFile: File): Result<String> = Result.success(transcript)
override suspend fun synthesize(text: String): Result<File> = Result.success(File("voice.mp3"))
}
}
@@ -195,6 +195,11 @@ class GatewayClientHarness(
put("sessions", JsonArray(emptyList()))
}
@Volatile
var activeSessionListPayload: JsonObject = buildJsonObject {
put("sessions", JsonArray(emptyList()))
}
@Volatile
var profileCreatePayload: JsonObject = buildJsonObject {
put("ok", true)
@@ -316,6 +321,7 @@ class GatewayClientHarness(
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
)
"session.list" -> sessionListPayload
"session.active_list" -> activeSessionListPayload
"session.title" -> buildJsonObject { put("ok", true) }
"prompt.submit" -> promptSubmitPayload
"session.interrupt" -> buildJsonObject { put("ok", true) }
@@ -1514,6 +1520,112 @@ class GatewayChatClientTest {
assertTrue((refreshParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
}
@Test
fun `active session list parses every authoritative upstream state`() = runBlocking {
harness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray {
listOf("idle", "starting", "working", "waiting").forEachIndexed { index, status ->
add(buildJsonObject {
put("id", "runtime-$index")
put("session_key", "stored-$index")
put("status", status)
put("last_active", 1_774_000_000.25 + index)
})
}
})
}
val result = client.listActiveSessions()
val rows = (result as GatewayActiveSessionsResult.Success).sessions
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
assertEquals(
listOf(
GatewayActiveSessionStatus.Idle,
GatewayActiveSessionStatus.Starting,
GatewayActiveSessionStatus.Working,
GatewayActiveSessionStatus.Waiting,
),
rows.map(GatewayActiveSession::status),
)
assertEquals("runtime-2", rows[2].runtimeSessionId)
assertEquals("stored-2", rows[2].storedSessionId)
assertEquals(1_774_000_002.25, rows[2].lastActiveEpochSeconds, 0.0)
assertTrue(rows.all { it.profile == null })
}
@Test
fun `active session list treats empty successful snapshot as authoritative`() = runBlocking {
val result = client.listActiveSessions()
assertEquals(emptyList<GatewayActiveSession>(), (result as GatewayActiveSessionsResult.Success).sessions)
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
}
@Test
fun `active session list stays process wide and never synthesizes fixed profile`() = runBlocking {
val routeHarness = GatewayClientHarness()
routeHarness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray {
add(buildJsonObject {
put("id", "runtime-operator")
put("session_key", "stored-shared")
put("status", "working")
put("last_active", 1_774_000_000.0)
})
})
}
val routeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val routeClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = routeHarness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
fixedSessionProfile = "operator",
okHttpClient = OkHttpClient(),
callbackDispatcher = { it() },
scope = routeScope,
)
try {
val result = routeClient.listActiveSessions() as GatewayActiveSessionsResult.Success
val params = routeHarness.awaitRpc("session.active_list")
val requests = List(2) { routeHarness.server.takeRequest(5, TimeUnit.SECONDS) }
assertFalse(params.containsKey("profile"))
assertNull(result.sessions.single().profile)
assertTrue(requests.filterNotNull().any { it.path?.contains("profile=operator") == true })
} finally {
routeClient.shutdown()
routeScope.cancel()
routeHarness.shutdown()
}
}
@Test
fun `active session method not found is explicit and sticky for current socket`() = runBlocking {
harness.methodNotFound += "session.active_list"
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
assertEquals(GatewayActiveSessionCapability.Unsupported, client.activeSessionCapability.value)
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
}
@Test
fun `active session transient error stays distinct from unsupported`() = runBlocking {
harness.rpcErrors["session.active_list"] = 5036 to "could not enumerate active sessions"
val failed = client.listActiveSessions()
assertTrue(failed is GatewayActiveSessionsResult.TransientFailure)
assertEquals(GatewayActiveSessionCapability.Unknown, client.activeSessionCapability.value)
harness.rpcErrors.remove("session.active_list")
assertTrue(client.listActiveSessions() is GatewayActiveSessionsResult.Success)
assertEquals(2, harness.rpcLog.count { it.first == "session.active_list" })
}
@Test
fun `process list uses live session id and parses typed snapshot`() = runBlocking {
assertTrue(client.prewarmAwait("stored-session"))
@@ -4027,6 +4139,10 @@ class GatewayChatClientTest {
harness.awaitRpc("prompt.submit")
assertTrue(client.backgroundActiveTurn())
assertEquals(
GatewayKnownSessionOwner("20260612_120000_abc123", "coder"),
client.knownSessionOwner("live-1"),
)
client.clearSession()
client.sessionProfileProvider = { "writer" }
@@ -61,6 +61,7 @@ import com.hermesandroid.relay.data.AppearancePreferences
import com.hermesandroid.relay.data.DashboardConnectionStatus
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.PetBehaviorPreferences
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.ui.components.ChatInputBar
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
@@ -138,9 +139,14 @@ class StoreScreenshotTest {
@get:Rule
val compose = createComposeRule()
private fun capture(name: String, themeId: String = "hermes-relay", body: @Composable () -> Unit) {
private fun capture(
name: String,
themeId: String = "hermes-relay",
themePreference: String = "dark",
body: @Composable () -> Unit,
) {
compose.setContent {
HermesRelayTheme(appThemeId = themeId, themePreference = "dark") {
HermesRelayTheme(appThemeId = themeId, themePreference = themePreference) {
// Adaptive is the app's real default skin (resolve("auto") -> Adaptive);
// it recolors to the active theme. The preview/test fallback is Classic,
// which mismatches the app and reads poorly on light themes.
@@ -251,6 +257,14 @@ class StoreScreenshotTest {
}
@Test fun s06_manage() = capture("06_manage", "hermes-relay") { ManageScene() }
@Test fun s04_sessions() = capture("04_sessions", "hermes-relay") { SessionsScene() }
@Test fun s12_session_activity_states() = capture("12_session_activity_states", "hermes-relay") {
SessionActivityStatesScene()
}
@Test fun s12_session_activity_states_light() = capture(
"12_session_activity_states_light",
"nous-blue",
themePreference = "light",
) { SessionActivityStatesScene() }
@Test fun s07_connections() = capture("07_connections", "hermes-relay") { ConnectionsScene() }
// Real Appearance screen scrolled to the new Font picker — proves the
// bundled Inter/Nunito faces load as visibly distinct previews (vs System).
@@ -805,6 +819,41 @@ private fun SessionsScene() {
}
}
@Composable
private fun SessionActivityStatesScene() {
val states = SessionActivityState.entries
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim)) {
SessionDrawerContent(
sessions = states.mapIndexed { index, state ->
ChatSession(
sessionId = "activity-$index",
title = when (state) {
SessionActivityState.Starting -> "Launching the agent"
SessionActivityState.Working -> "Reviewing the release"
SessionActivityState.NeedsInput -> "Approval required"
SessionActivityState.BackgroundWork -> "Build still running"
SessionActivityState.Checking -> "Reconnecting to Hermes"
SessionActivityState.Unavailable -> "Offline session"
},
model = "gpt-5.6-sol",
)
},
currentSessionId = null,
activeProfileName = "default",
scopeTitle = "Hermes",
scopeSubtitle = "Live session status",
activityStates = states.mapIndexed { index, state ->
"default:activity-$index" to state
}.toMap(),
animationEnabled = false,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
@Composable
private fun ConnectionsScene() = ConnectionsSettingsScreen(
connections = marketingConnections,
@@ -1,57 +0,0 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.SupervisedAppearance
import com.hermesandroid.relay.data.SupervisedModePolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedAppearancePolicyTest {
private val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
appearance = SupervisedAppearance(
appThemeId = "rose",
themePreference = "dark",
showPet = false,
),
)
@Test fun `locked supervised root uses only its own theme`() {
val resolved = resolveSupervisedTheme(policy, false, "midnight", "light")
assertEquals("rose", resolved.appThemeId)
assertEquals("dark", resolved.themePreference)
assertFalse(resolved.useGlobalCustomTheme)
}
@Test fun `parent access restores ordinary app theme`() {
val resolved = resolveSupervisedTheme(policy, true, "midnight", "light")
assertEquals("midnight", resolved.appThemeId)
assertEquals("light", resolved.themePreference)
assertTrue(resolved.useGlobalCustomTheme)
}
@Test fun `pet visibility follows supervised policy only while locked`() {
assertFalse(shouldShowPetInSupervisedMode(policy, false))
assertTrue(shouldShowPetInSupervisedMode(policy, true))
assertTrue(
shouldShowPetInSupervisedMode(
policy.copy(appearance = policy.appearance.copy(showPet = true)),
false,
),
)
}
@Test fun `enabled recovery policy stays on restricted appearance defaults`() {
val recovery = SupervisedModePolicy(enabled = true)
val resolved = resolveSupervisedTheme(recovery, false, "rose", "dark")
assertEquals("hermes-relay", resolved.appThemeId)
assertEquals("auto", resolved.themePreference)
assertFalse(resolved.useGlobalCustomTheme)
assertFalse(shouldShowPetInSupervisedMode(recovery, false))
}
}
@@ -1,121 +0,0 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedNavigationPolicyTest {
@Test fun `locked surface permits only approved destinations`() {
assertTrue(isSupervisedRouteAllowed("chat?sessionId=owned", false))
assertTrue(isSupervisedRouteAllowed("settings", false))
assertTrue(isSupervisedRouteAllowed(Screen.SupervisedAppearanceSettings.route, false))
// The full Appearance destination includes profile/avatar/pet controls.
// The supervised Settings root owns its own allowlisted theme controls.
assertFalse(isSupervisedRouteAllowed("settings/appearance", false))
assertFalse(isSupervisedRouteAllowed("settings/about", false))
assertFalse(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, false))
assertFalse(isSupervisedRouteAllowed("manage", false))
assertFalse(isSupervisedRouteAllowed("settings/developer", false))
assertFalse(isSupervisedRouteAllowed("settings/supervised", false))
assertFalse(isSupervisedRouteAllowed(null, false))
}
@Test fun `parent unlock permits full navigation`() {
assertTrue(isSupervisedRouteAllowed("manage", true))
assertTrue(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, true))
}
@Test fun `supervised redirect waits until the navigation graph has a route`() {
assertFalse(shouldRedirectSupervisedRoute(true, false, null))
assertTrue(isSupervisedRouteContentAllowed(true, false, null))
assertFalse(shouldRedirectSupervisedRoute(true, false, Screen.Chat.route))
assertTrue(isSupervisedRouteContentAllowed(true, false, Screen.Chat.route))
assertTrue(shouldRedirectSupervisedRoute(true, false, Screen.AdvancedSettings.route))
assertFalse(isSupervisedRouteContentAllowed(true, false, Screen.AdvancedSettings.route))
assertFalse(shouldRedirectSupervisedRoute(true, true, Screen.AdvancedSettings.route))
assertTrue(isSupervisedRouteContentAllowed(true, true, Screen.AdvancedSettings.route))
}
@Test fun `navigation waits for connection store before trusting null active id`() {
assertFalse(isRelayNavigationHydrated(false, null, false))
assertTrue(isRelayNavigationHydrated(true, null, false))
assertFalse(isRelayNavigationHydrated(true, "home", false))
assertTrue(isRelayNavigationHydrated(true, "home", true))
}
@Test fun `parent access relocks as soon as chat becomes current`() {
assertTrue(shouldRelockParentAccess(true, true, "chat?sessionId=ignored"))
assertFalse(shouldRelockParentAccess(true, true, "settings/supervised"))
assertFalse(shouldRelockParentAccess(false, true, "chat"))
assertFalse(shouldRelockParentAccess(true, false, "chat"))
}
@Test fun `supervised route session requires history pinned profile and trusted ownership proof`() {
val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(conversationHistory = true),
)
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "willow", false))
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "parent", true))
assertTrue(mayRestoreSupervisedSessionRoute(policy, "session-1", "WILLOW", true))
assertFalse(
mayRestoreSupervisedSessionRoute(
policy.copy(capabilities = policy.capabilities.copy(conversationHistory = false)),
"session-1",
"willow",
true,
),
)
}
@Test fun `supervised external route discards session profile and proactive targets`() {
val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(conversationHistory = true),
)
val external = SupervisedChatRouteArgs(
sessionId = "parent-session",
profile = "willow",
proactiveChatId = "phone",
)
assertTrue(
sanitizeSupervisedChatRouteArgs(policy, external, false) ==
SupervisedChatRouteArgs(),
)
assertTrue(
sanitizeSupervisedChatRouteArgs(policy, external, true) ==
external.copy(proactiveChatId = null),
)
assertTrue(
sanitizeSupervisedChatRouteArgs(SupervisedModePolicy(), external, false) == external,
)
}
@Test fun `first enable requires configured policy secure screen and successful device credential`() {
val configured = SupervisedModePolicy(pinnedProfileName = "willow")
assertFalse(
mayEnableSupervisedMode(
configured,
deviceSecure = false,
deviceCredentialConfirmed = true,
),
)
assertFalse(
mayEnableSupervisedMode(
configured,
deviceSecure = true,
deviceCredentialConfirmed = false,
),
)
assertFalse(mayEnableSupervisedMode(SupervisedModePolicy(), true, true))
assertTrue(mayEnableSupervisedMode(configured, true, true))
assertFalse(mayEnableSupervisedMode(configured.copy(enabled = true), true, true))
}
}
@@ -1,8 +1,10 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class SessionDrawerPolicyTest {
@@ -19,6 +21,127 @@ class SessionDrawerPolicyTest {
assertEquals("alpha:same", sessionRowKey(alpha))
assertEquals("beta:same", sessionRowKey(beta))
assertEquals("default:same", sessionRowKey(row("default", "same")))
}
@Test
fun `rest recent activity alone does not mark a session working`() {
val recentlyActive = row("default", "recent", recentlyActive = true)
assertEquals(
SessionDrawerStatus.Idle,
sessionDrawerStatus(recentlyActive, activityStates = emptyMap()),
)
}
@Test
fun `duplicate session ids cannot leak activity across profiles`() {
val alpha = row("alpha", "same")
val beta = row("beta", "same")
val scoped = scopedSessionActivityStates(
rows = listOf(alpha, beta),
activityStates = mapOf(sessionRowKey(alpha) to SessionActivityState.Working),
allowBareSessionIds = false,
)
assertEquals(SessionDrawerStatus.Working, sessionDrawerStatus(alpha, scoped))
assertEquals(SessionDrawerStatus.Idle, sessionDrawerStatus(beta, scoped))
assertFalse(sessionRowKey(beta) in scoped)
}
@Test
fun `all profiles ignores ambiguous bare session activity`() {
val alpha = row("alpha", "same")
val beta = row("beta", "same")
val scoped = scopedSessionActivityStates(
rows = listOf(alpha, beta),
activityStates = mapOf("same" to SessionActivityState.Working),
allowBareSessionIds = false,
)
assertEquals(emptyMap<String, SessionActivityState>(), scoped)
}
@Test
fun `selected profile may scope legacy bare session activity`() {
val row = row("work", "session")
val scoped = scopedSessionActivityStates(
rows = listOf(row),
activityStates = mapOf("session" to SessionActivityState.NeedsInput),
allowBareSessionIds = true,
)
assertEquals(
mapOf(sessionRowKey(row) to SessionActivityState.NeedsInput),
scoped,
)
}
@Test
fun `status filter and grouping use the same authoritative state`() {
val restOnly = row("default", "rest-only", recentlyActive = true)
val working = row("default", "working")
val states = mapOf(sessionRowKey(working) to SessionActivityState.Working)
val filtered = filterAndSortSessionRows(
rows = listOf(restOnly, working),
options = SessionDrawerViewOptions(statuses = setOf(SessionDrawerStatus.Working)),
activityStates = states,
)
val grouped = groupSessionRows(
rows = listOf(restOnly, working),
grouping = SessionDrawerGrouping.Status,
activityStates = states,
)
assertEquals(listOf("working"), filtered.map { it.session.sessionId })
assertEquals(listOf("Idle", "Working"), grouped.mapNotNull { it.label })
}
@Test
fun `expanded live phases retain distinct drawer statuses and labels`() {
val phases = listOf(
SessionActivityState.NeedsInput to (SessionDrawerStatus.NeedsInput to "Needs input"),
SessionActivityState.Starting to (SessionDrawerStatus.Starting to "Starting"),
SessionActivityState.Working to (SessionDrawerStatus.Working to "Working"),
SessionActivityState.BackgroundWork to (SessionDrawerStatus.BackgroundWork to "Background work"),
SessionActivityState.Checking to (SessionDrawerStatus.Checking to "Checking"),
SessionActivityState.Unavailable to (SessionDrawerStatus.Unavailable to "Unavailable"),
)
val rows = phases.mapIndexed { index, _ -> row("default", "session-$index") }
val states = rows.zip(phases).associate { (row, phase) -> sessionRowKey(row) to phase.first }
assertEquals(
phases.map { it.second.first },
rows.map { sessionDrawerStatus(it, states) },
)
assertEquals(
phases.map { it.second.second },
groupSessionRows(rows, SessionDrawerGrouping.Status, states).mapNotNull { it.label },
)
assertEquals(
listOf(
R.string.drawer_activity_needs_input,
R.string.drawer_activity_starting,
R.string.drawer_activity_working,
R.string.drawer_activity_background_work,
R.string.drawer_activity_checking,
R.string.drawer_activity_unavailable,
),
phases.map { sessionActivityLabelResource(it.first) },
)
phases.forEachIndexed { index, phase ->
assertEquals(
listOf("session-$index"),
filterAndSortSessionRows(
rows = rows,
options = SessionDrawerViewOptions(statuses = setOf(phase.second.first)),
activityStates = states,
).map { it.session.sessionId },
)
}
}
@Test
@@ -113,6 +236,7 @@ class SessionDrawerPolicyTest {
outputTokens: Int = 0,
cost: Double? = null,
updatedAt: Long = 0L,
recentlyActive: Boolean = false,
) = ProfileSessionRow(
profile = profile,
session = ChatSession(
@@ -126,6 +250,7 @@ class SessionDrawerPolicyTest {
outputTokens = outputTokens,
actualCostUsd = cost,
lastActivityAt = updatedAt,
recentlyActive = recentlyActive,
),
)
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
import org.junit.Rule
import org.junit.Test
@@ -359,6 +360,66 @@ class SessionDrawerTest {
compose.onNodeWithText("Filters").assertIsDisplayed()
}
@Test
fun `drawer renders every authoritative activity phase distinctly`() {
val states = SessionActivityState.entries
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = states.mapIndexed { index, _ ->
ChatSession("session-$index", "Session $index", null)
},
currentSessionId = null,
activeProfileName = "default",
activityStates = states.mapIndexed { index, state ->
"default:session-$index" to state
}.toMap(),
animationEnabled = false,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
listOf(
"Starting",
"Working",
"Needs input",
"Background work",
"Checking",
"Unavailable",
).forEach { label ->
compose.onNodeWithText(label).performScrollTo().assertIsDisplayed()
}
}
@Test
fun `rest recency alone renders no working badge`() {
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = listOf(
ChatSession(
"recent",
"Recently updated",
null,
recentlyActive = true,
),
),
currentSessionId = null,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("Working").assertDoesNotExist()
}
@Test
fun `all profiles customization can override a profile identity color`() {
var changed: Pair<String, String?>? = null
@@ -0,0 +1,57 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.data.ChatSession
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class SessionDrawerTimestampTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun `distinct activity is labeled updated`() {
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
assertEquals(
"Updated Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
}
@Test
fun `relative timestamp changes when the drawer clock advances`() {
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
assertEquals(
"Updated Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
assertEquals(
"Updated 2m ago",
sessionTimestampText(session, Locale.US, context, nowMillis = 240_000L),
)
}
@Test
fun `session without later activity keeps started label`() {
val session = session(startedAt = 120_000L, lastActivityAt = 120_000L)
assertEquals(
"Started Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
}
private fun session(startedAt: Long, lastActivityAt: Long) = ChatSession(
sessionId = "session",
title = "Session",
model = null,
startedAt = startedAt,
lastActivityAt = lastActivityAt,
)
}
@@ -1,26 +0,0 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedImagePresentationTest {
@Test
fun `disabled assistant images strip markdown without exposing a fetchable source`() {
val content = "Here it is ![result](https://example.com/private.png) and ![local](/tmp/result.png)"
val (body, images) = assistantImageContent(content, showImages = false)
assertEquals("Here it is and", body)
assertTrue(images.isEmpty())
}
@Test
fun `enabled assistant images preserve all supported sources`() {
val content = "![remote](https://example.com/a.png) ![local](/tmp/b.png)"
val (_, images) = assistantImageContent(content, showImages = true)
assertEquals(listOf("https://example.com/a.png", "/tmp/b.png"), images.map { it.src })
}
}
@@ -1,49 +0,0 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class SessionActivityStateTest {
@Test
fun `multiple background turns remain visible beside current working turn`() {
val resolved = resolveSessionActivityStates(
background = mapOf(
"session-a" to SessionActivityState.Working,
"session-b" to SessionActivityState.NeedsInput,
),
currentSessionId = "session-c",
isStreaming = true,
needsInput = false,
)
assertEquals(SessionActivityState.Working, resolved["session-a"])
assertEquals(SessionActivityState.NeedsInput, resolved["session-b"])
assertEquals(SessionActivityState.Working, resolved["session-c"])
}
@Test
fun `needs input takes precedence over current working state`() {
val resolved = resolveSessionActivityStates(
background = emptyMap(),
currentSessionId = "session-a",
isStreaming = true,
needsInput = true,
)
assertEquals(SessionActivityState.NeedsInput, resolved["session-a"])
}
@Test
fun `selected idle session does not retain a stale background state`() {
val resolved = resolveSessionActivityStates(
background = mapOf("session-a" to SessionActivityState.Working),
currentSessionId = "session-a",
isStreaming = false,
needsInput = false,
)
assertFalse(resolved.containsKey("session-a"))
}
}
@@ -14,6 +14,7 @@ import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.upstream.ChatHandler
@@ -23,6 +24,7 @@ import com.hermesandroid.relay.network.upstream.GatewayClientHarness
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.SessionItem
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -291,6 +293,78 @@ class ChatViewModelGatewayInboundTurnTest {
)
}
@Test
fun activeListWorkingThenDisappearanceSettlesDespiteRestRecency() {
bindActivityTestDirectory()
handler.updateSessions(
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
)
gatewayHarness.activeSessionListPayload = activeSessionPayload("working")
viewModel.setChatVisible(true)
gatewayHarness.awaitRpc("session.active_list")
awaitCondition {
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
SessionActivityState.Working
}
gatewayHarness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray { })
}
viewModel.requestSessionActivityRefresh()
gatewayHarness.awaitRpcCount("session.active_list", 2)
awaitCondition {
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
}
}
@Test
fun activeListWaitingProjectsNeedsInput() {
bindActivityTestDirectory()
gatewayHarness.activeSessionListPayload = activeSessionPayload("waiting")
viewModel.setChatVisible(true)
gatewayHarness.awaitRpc("session.active_list")
awaitCondition {
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
SessionActivityState.NeedsInput
}
}
@Test
fun unsupportedActiveListProjectsUnavailableInsteadOfRestWorking() {
bindActivityTestDirectory()
handler.updateSessions(
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
)
gatewayHarness.methodNotFound += "session.active_list"
viewModel.setChatVisible(true)
gatewayHarness.awaitRpc("session.active_list")
awaitCondition {
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
SessionActivityState.Unavailable
}
}
@Test
fun rejectedAdmissionCannotLeaveStartingStatusStale() {
bindActivityTestDirectory()
gatewayClient.clearSession()
gatewayHarness.rpcErrors["session.resume"] = 4090 to "stored session is unavailable"
viewModel.sendMessage("This admission should fail")
gatewayHarness.awaitRpc("session.resume")
awaitCondition { !handler.isStreaming.value }
assertTrue(
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] !=
SessionActivityState.Starting,
)
}
@After
fun tearDown() {
DiagnosticsLog.clear()
@@ -2376,6 +2450,27 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(handler.messages.value.any { it.content == BACKGROUND_ANSWER })
}
private fun bindActivityTestDirectory() {
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", "default"),
STORED_SESSION_ID,
)
viewModel.updateSessionActivityDirectory(
rows = listOf("default" to STORED_SESSION_ID),
)
}
private fun activeSessionPayload(status: String) = buildJsonObject {
put("sessions", buildJsonArray {
add(buildJsonObject {
put("id", "live-resumed")
put("session_key", STORED_SESSION_ID)
put("status", status)
put("last_active", 1.0)
})
})
}
private fun persistedAnswerHistory(
answer: String = BACKGROUND_ANSWER,
id: String = "persisted-background-answer",
@@ -318,10 +318,14 @@ class GatewayProcessControllerTest {
controller.bind(source, "same-id", scopeKey = "profile-a")
controller.sessionReady("same-id")
runCurrent()
assertTrue(controller.ownsSnapshot("same-id", "profile-a"))
controller.selectSession("same-id", scopeKey = "profile-b")
assertFalse(controller.ownsSnapshot("same-id", "profile-a"))
assertFalse(controller.ownsSnapshot("same-id", "profile-b"))
controller.sessionReady("same-id")
runCurrent()
assertTrue(controller.ownsSnapshot("same-id", "profile-b"))
oldResult.complete(Result.success(listOf(process(id = "old-profile"))))
runCurrent()
@@ -0,0 +1,90 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.SessionActivityOwner
import com.hermesandroid.relay.data.SessionLiveStatus
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionActivityResolutionTest {
private val alpha = SessionActivityOwner.of("connection", "alpha", "same")
private val beta = SessionActivityOwner.of("connection", "beta", "same")
@Test
fun `duplicate stored ids stay unresolved without exact runtime binding`() {
val result = resolveGatewayActiveSessions(
sessions = listOf(active("runtime-beta", GatewayActiveSessionStatus.Working)),
directory = setOf(alpha, beta),
currentOwner = alpha,
)
assertNull(result.runtimes.single().owner)
assertTrue(result.ambiguous)
assertTrue(result.ambiguousForCurrent)
}
@Test
fun `exact foreground runtime binding resolves duplicate stored id`() {
val result = resolveGatewayActiveSessions(
sessions = listOf(active("runtime-alpha", GatewayActiveSessionStatus.Waiting)),
directory = setOf(alpha, beta),
currentOwner = alpha,
currentRuntimeId = "runtime-alpha",
)
assertEquals(alpha, result.runtimes.single().owner)
assertEquals(SessionLiveStatus.Waiting, result.runtimes.single().status)
}
@Test
fun `unscoped stored id stays unresolved even when bounded directory looks unique`() {
val unique = SessionActivityOwner.of("connection", "beta", "unique")
val result = resolveGatewayActiveSessions(
sessions = listOf(
GatewayActiveSession(
runtimeSessionId = "runtime",
storedSessionId = "unique",
status = GatewayActiveSessionStatus.Starting,
lastActiveEpochSeconds = 1.0,
),
),
directory = setOf(alpha, unique),
currentOwner = alpha,
)
assertNull(result.runtimes.single().owner)
assertTrue(result.ambiguous)
}
@Test
fun `client known detached runtime resolves exact profile owner`() {
val unique = SessionActivityOwner.of("connection", "beta", "unique")
val result = resolveGatewayActiveSessions(
sessions = listOf(
GatewayActiveSession(
runtimeSessionId = "runtime",
storedSessionId = "unique",
status = GatewayActiveSessionStatus.Starting,
lastActiveEpochSeconds = 1.0,
),
),
directory = setOf(alpha, unique),
currentOwner = alpha,
knownOwnersByRuntime = mapOf("runtime" to unique),
)
assertEquals(unique, result.runtimes.single().owner)
assertEquals(SessionLiveStatus.Starting, result.runtimes.single().status)
}
private fun active(runtimeId: String, status: GatewayActiveSessionStatus) =
GatewayActiveSession(
runtimeSessionId = runtimeId,
storedSessionId = "same",
status = status,
lastActiveEpochSeconds = 1.0,
)
}
@@ -1,31 +0,0 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.SupervisedModePolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SupervisedChatPolicyTest {
@Test
fun `normal mode preserves slash commands`() {
assertNull(supervisedMessageBlockReason(SupervisedModePolicy(), " /model"))
}
@Test
fun `enabled policy fails closed without pinned profile`() {
assertEquals(
"Supervised mode is unavailable until the parent selects a profile.",
supervisedMessageBlockReason(SupervisedModePolicy(enabled = true), "hello"),
)
}
@Test
fun `active policy blocks slash commands after unicode whitespace`() {
val policy = SupervisedModePolicy(enabled = true, pinnedProfileName = "willow")
assertEquals(
"Slash commands are unavailable in supervised mode.",
supervisedMessageBlockReason(policy, "\u2003\t /model hidden"),
)
assertNull(supervisedMessageBlockReason(policy, "please explain /model"))
}
}
@@ -1,35 +0,0 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.voice.VoiceCommandAction
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedVoiceCommandPolicyTest {
@Test
fun `normal mode preserves every voice command`() {
VoiceCommandAction.entries.forEach { action ->
assertTrue(isVoiceCommandAllowed(action, SupervisedModePolicy()))
}
}
@Test
fun `supervised mode gates new chat and cancellation independently`() {
val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(
voice = true,
newChat = false,
cancelResponse = false,
),
)
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StartNewChat, policy))
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StopResponse, policy))
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.CancelBackgroundTask, policy))
assertTrue(isVoiceCommandAllowed(VoiceCommandAction.EndVoiceChat, policy))
}
}
+69 -119
View File
@@ -1,6 +1,6 @@
# Hermes-Relay — Decisions & Implementation Guide
> Updated: 2026-08-24
> Updated: 2026-04-06
>
> Read this before SPEC.md — it tells you what to build, what was deferred, and why.
@@ -3692,124 +3692,7 @@ be considered later without being silently introduced now.
---
## ADR 66 — Android Supervised Mode is a parent-controlled client policy
**Status:** Implemented in code; physical managed-device certification pending (2026-08-24).
**Context.** Some operators prepare a deliberately restricted Hermes profile
for use through a parent-supervised Android client. The profile remains the
authority for its model, prompt, tools, provider credentials, content behavior,
and server-side data. Hermes-Relay should help a parent present a smaller,
proctored phone interface without representing that interface as end-to-end
child security or as a server-enforced account type.
**Decision.** Android will treat Supervised Mode as an opt-in, locally enforced
policy pinned to one existing Connection and one existing Hermes profile. The
parent is responsible for preparing and reviewing that profile before enabling
the mode. Entering, changing, or leaving the parent policy requires Android
device authentication. That prompt authenticates an enrolled device user, not
a distinct server-side parent identity. While the policy is active, the app restores directly
into a restricted root and never renders the ordinary app behind an
authentication prompt. A missing Connection, missing profile, malformed policy,
failed authentication, process restart, or restored route that cannot prove its
owner fails closed to the restricted surface.
The ordinary Chat screen stays visually quiet. It does not carry a persistent
"supervised" banner. Its existing Settings action opens only approved
preferences; a separate **Parent access** row authenticates before showing the
policy editor or full application settings. Backgrounding, inactivity, process
recreation, and leaving parent settings relock parent access according to the
policy. Deep links, notification actions, restored navigation, shortcuts, and
programmatic routes pass the same gate.
The parent policy controls capabilities rather than imposing a special
attachment count. Initial capabilities are text chat, new chat, cancel, steer,
attachments, standard voice, generated-media viewing, save/share media, copy,
retry, quote/reply, and edit/resend. Attachments and voice are independently
enabled. When attachments are enabled, Android retains the normal supported
attachment flow and its existing size/type limits unless the parent selects a
stricter limit; disabling attachments removes every picker, paste-to-file,
camera/share-to-chat, and restored-draft entry point. Disabling voice removes
capture, voice intents, and voice settings from the restricted surface. Provider
credentials remain on the configured Hermes host under the existing standard
voice contract.
The restricted composer does not expose the command palette, slash
autocomplete, server command catalog, or command-generated action cards. A
leading slash is rejected locally rather than dispatched; approved outcomes
such as New chat and Cancel remain explicit typed UI actions. Approval,
clarification, secret, and elevated-access requests are denied or skipped
immediately with a bounded notice. The supervised user cannot authorize them;
a parent may retry from the authenticated full client.
Restricted Settings contains only parent-approved, non-authoritative choices,
such as a supervised-only theme, text size, language, haptics, accessibility,
message presentation, sensitive-media blur, and permitted voice playback
preferences. Connections, Manage, profiles, models, personalities, reasoning,
approvals, tools, plugins, Terminal, TUI, Bridge, Device Control, notification
companion, diagnostics, logs, files, credentials, developer controls, Relay
management, and other sessions are absent rather than shown disabled.
The parent may allow the configured floating pet and may independently let the
supervised user change the phone-local profile icon or an already-installed chat
background. The parent retains those appearance controls when supervised-user
changes are disabled. Conversation history and its mutations are separate
permissions: pin, rename, archive/restore, transcript sharing, and delete are
individually allowlisted, while technical session identifiers and cross-profile
administration remain hidden. Delete retains its confirmation step.
The parent also chooses what Chat discloses. **Simple** is the default: agent
name/avatar plus generic Connected, Working, and Reconnecting states; it hides
model, profile, provider/route, context, token/usage, reasoning, and tool detail.
**Transparent** may add timestamps, bounded usage/context information, and
approved activity labels without exposing arguments, results, paths, or
credentials. **Custom** exposes the individual visibility switches. Model name
and profile name default off in every new policy. Required errors, safety
notices, parent-action states, and connection failures cannot be hidden by a
cosmetic visibility choice.
Session selection is limited to the pinned profile. New chat creates a new
conversation for that profile; history visibility, transcript retention, and
conversation actions follow the parent policy. Ending Supervised Mode may clear
local drafts, pending media, and restricted caches, but does not imply deletion
of server-owned session history. Server history remains available through the
parent's ordinary authenticated Hermes surfaces.
When the optional Relay plugin is paired, Android reports a bounded
client-declared `supervised` tag and a non-sensitive policy summary with its
ordinary device identity. Relay and its UI may display that tag and allow the
operator to revoke the paired Relay session through the existing revocation
model. The tag is informational: Relay does not interpret or enforce the Android
policy, pin a profile, filter Gateway traffic, or certify the client. Revoking
the Relay session removes Relay-backed capabilities but cannot revoke a direct
Dashboard/Gateway session or remotely disable an Android-only policy. Without
Relay pairing, Supervised Mode remains usable and locally enforced.
**Security and product boundary.** This mode restricts the official Android UI,
not the Hermes agent or server. It cannot secure another client, a modified APK,
direct server access, server-side tools, provider output, or a parent account
whose credentials are available elsewhere. It is not a substitute for profile
hardening, provider safety controls, parental review, operating-system controls,
or applicable legal obligations. Public language uses **Supervised Mode** or
**parent-controlled client**, not "child account," "safe for children," or
"server enforced."
**Verification gate.** Implementation requires policy, authentication,
navigation, process-death, deep-link, notification, capability, attachment,
voice, session-ownership, Relay-tag, and revocation tests. Physical testing must
cover the exact Android build on a managed/restricted device, including relock,
restart, offline recovery, and attempts to escape the restricted root. Until
that evidence exists, documentation and release notes must call the feature
planned or experimental and must not call it child-ready.
**Consequences.** The project gains a generalized, low-noise supervised client
without creating a new Hermes account type or making Relay a chat authorization
proxy. Parents receive clear local controls and optional paired-device
visibility, while server ownership and the limits of client-side enforcement
remain explicit.
---
## ADR 67 — Android Bot Mode is a separate upstream-owned messaging workspace
## ADR 66 — Android Bot Mode is a separate upstream-owned messaging workspace
**Status:** Accepted (2026-08-24).
@@ -3910,3 +3793,70 @@ an upstream Hermes change, while vanilla/current Hermes retains a bounded
single-account fallback. Merely configuring a provider credential does not
expose tokens to paired devices. The Android UI can add providers without
adding provider-specific screens or silently treating missing data as zero usage.
---
## ADR 68 — Android session activity has one profile-scoped authority
**Status:** Accepted (2026-08-25).
**Context.** Dashboard and API-server session lists expose `is_active`, but
upstream defines it as an unended persisted row updated within the last five
minutes. It is useful recency metadata, not proof that a model turn is running.
Android nevertheless used it as a fallback for **Working**, while local
composer state, detached-turn checkpoints, pending requests, and drawer rows
each derived activity independently. A completed turn could therefore remain
Working, a restart could restore an unverified busy state, and an All Profiles
row could inherit another profile's live status through a bare session id.
Current upstream exposes live authority through the process-wide Gateway
`session.active_list`. It reports attachable in-memory runtimes as `starting`,
`working`, `waiting`, or `idle`, with both the live id and durable session key.
It accepts only an optional `current_session_id`; rows normally have no profile
metadata or filter. Exact pending-request events carry more specific
Needs-input ownership. Exact turn terminals and `session.info {running:false}`
can settle a matching generation. `process.list` is a different contract: a
background process may remain after its parent model turn is idle.
**Decision.** Android owns one composite activity registry keyed by stable
connection identity, normalized profile, and durable session id. Runtime ids
are aliases only within that owner. The same reducer drives drawer badges and
filters, visible composer state, animation, and accessibility.
The precedence is:
1. An exact pending approval, clarify, sudo, secret, or MCP request is **Needs input**.
2. A successfully resolved process-wide `session.active_list` row supplies
**Starting**, **Working**, or **Idle** to its exact client-owned profile
record. Waiting without an exact pending payload remains a conservative
needs-input state until the request detail arrives or clears.
3. An exact terminal event, `session.info {running:false}`, or
`session.activate {running:false}` settles only the matching runtime
generation.
4. A matching `process.list` row may add **Background work** independently; it
never keeps the conversation Working.
5. A checkpoint restored after process recreation is **Checking** until
revalidated. A failed or unsupported live refresh is **Unavailable**.
Android resolves each active-list row through exact foreground or detached
ownership already held by that client, or explicit profile metadata if a
future upstream sends it. A bounded REST directory never proves that a durable
`session_key` is globally unique. Ambiguous or unresolved rows apply no status.
Resolved rows from a partial snapshot may update their exact owners, but they
cannot infer absence. A missing row clears stale live state for a scope only
when the successful process-wide snapshot was complete and every relevant row
was unambiguously resolved. A failed refresh does not settle anything. REST
`is_active`, `last_active`, and relative timestamps never influence execution
state. Old socket generations, late refreshes, and unscoped session ids cannot
revive a newer settled entry.
**Consequences.** Working and Needs input describe current upstream-owned
runtime state instead of recent persistence. All Profiles remains isolated,
restart recovery is honest about uncertainty, and background processes stay
visible without mislabeling their parent turn. Older Gateways remain usable
but show Unavailable when no exact local terminal truth exists. Declarative
Gateway scenarios cover all four upstream states, complete-snapshot
disappearance, client-side profile isolation, and method-not-found; physical
and current-host certification remains tracked in `TODO.md`. An upstream
profile field/filter or explicitly owned aggregate activity route would remove
the remaining ambiguity for multi-profile clients.
+13
View File
@@ -38,6 +38,9 @@ the upstream contract identifiers it depends on.
| `scope_rejection_inputs` | Exact, foreign, and unscoped event inputs |
| `terminal_gap_activate` | Socket closes after live output; replacement `session.activate` reports `running=false`; history is authoritative |
| `terminal_gap_session_info` | Scoped `session.info {running:false}` settles a turn without `message.complete` |
| `active_status_lifecycle` | `session.active_list` reports starting, working, waiting, and idle, then a complete empty process-wide snapshot permits removal of unambiguously owned prior rows |
| `active_status_profile_scope` | A row has no profile metadata and a caller profile hint has no effect; the client must use exact client-held ownership and reject invented attribution |
| `active_status_unsupported` | An older Gateway returns JSON-RPC method-not-found; the client retains Unknown rather than inventing Idle or Working |
Fixture evidence is a bounded metadata-only ring. It records sequence,
connection number, RPC method, event type, scope classification, and outcome.
@@ -125,6 +128,16 @@ The check is source-only and non-mutating. It starts no runtime, creates no
sessions, and uses no provider or authentication credentials. It fails closed
for dirty, fork-marked, or non-vanilla checkouts.
For activity scenarios, the adapter confirms that current upstream owns
`session.active_list`, emits `starting`, `working`, `waiting`, and `idle`, lets
pending input outrank running work, accepts only `current_session_id` as its
optional selector, and returns both the live runtime id and durable session key
from the process-local registry. The runtime fixture then
tests client reconciliation, including successful disappearance and explicit
method-not-found behavior. Because rows normally carry no profile, partial
ownership resolution may update exact matches but cannot infer absence for an
unresolved scope. Source inspection alone does not claim a client pass.
## Planned extensions
The scenario format is intentionally usable by future official Desktop and TUI
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+2 -2
View File
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.13.0 - Bots, usage, and reliable chat
v1.13.1 - Accurate session activity
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
```
## Category
+2 -5
View File
@@ -7,7 +7,7 @@ Android's declarative plugin surface is specified in
**Status:** v1.0.0 stable. The default path supports chat, Manage, and voice on vanilla upstream Hermes without installing the Relay plugin. Relay is additive: terminal, bridge/device control, notification companion, remote access, extra/provider-native voice, desktop tooling, and dashboard Relay management. Historical phase notes remain in this file for context; the current route ownership source of truth is [`docs/upstream-surface-matrix.md`](upstream-surface-matrix.md).
**Repo:** [Codename-11/hermes-relay](https://github.com/Codename-11/hermes-relay)
**Updated:** 2026-08-24
**Updated:** 2026-08-22
---
@@ -46,10 +46,6 @@ token, terminal/bridge grants, and optional network candidates.
4. **Clean UX** — Material 3, minimal setup, and clear route identity for Vanilla Hermes vs Relay.
5. **Offline-aware** — graceful degradation when connection drops. Auto-reconnect with exponential backoff.
6. **Server-side state** — the app is a thin client. Sessions, history, memory, profiles, and dashboard state live on the Hermes server.
7. **Supervision is a client policy** — Android may offer a parent-controlled,
profile-pinned restricted interface, but it does not claim to make the
selected Hermes profile, server, or agent child-safe. See ADR 66 and the
[Supervised Mode guide](../user-docs/guide/supervised-mode.md).
---
@@ -480,6 +476,7 @@ Bottom navigation bar with 4 tabs:
- **Canonical Bot Chat** — each individual row resolves the exact hidden session titled `Bot Chat` on its owning Gateway. Lookup failure is not absence, so Android creates and materializes the lazy row with `session.title` only after an authoritative empty exact-title result. The dedicated Bot Chat destination retains that route's pooled Gateway client, loads history through the same connection/profile Dashboard, sends only through Gateway, and returns directly to Bot Mode without rebinding Standard Chat or the global connection. `/new` or `/reset` compacts the canonical conversation instead of forking it. The route pool mints a fresh WebSocket ticket per dial, includes the immutable profile in the WebSocket URL, isolates credentials by exact trusted connection origin, and tears down only the removed connection's clients.
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. The process-owned conversation binding is the single connection/profile/session identity for Chat; selecting an All Profiles row atomically makes its owner the selected agent and persists that profile/session, while merely browsing All Profiles changes no agent state. Lifecycle or locale-driven Activity recreation cannot replace an explicit binding with stale persisted state, and asynchronous list/history/mutation work is accepted only for the binding's exact namespace. A profile lock hides All Profiles and rejects stale/deep-linked cross-profile opens. The All Profiles browser mode otherwise survives Activity state restoration and refetches its rows after recreation. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
- **Authoritative session activity** — one composite registry keyed by connection, normalized profile, and durable session id drives the drawer, filters, grouping, animation, accessibility, and the visible composer. Exact pending approval/clarify/sudo/secret/MCP requests produce **Needs input**; the Gateway's process-wide `session.active_list` supplies **Starting**, **Working**, and **Idle**; exact terminal or `session.info {running:false}` can settle the matching generation. Because active-list rows normally have no profile metadata, Android assigns a row only through exact foreground/detached ownership already held by that client, or explicit profile metadata if a future upstream sends it. A bounded REST directory never proves global uniqueness. Unresolved rows create no status. Resolved rows from a partial snapshot may update their exact owners, but disappearance settles a scope only when the successful process-wide snapshot was completely and unambiguously resolved for it. Restart/checkpoint recovery is **Checking**; a failed or unsupported live refresh is **Unavailable**, never inferred Idle. REST `is_active` remains recency metadata only. `process.list` may add a separate **Background work** indicator and never keeps the parent conversation Working. Old socket generations, bare session ids from another profile, and delayed snapshots cannot revive newer settled state.
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible turn without sending `session.interrupt`; each running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Explicit Stop still interrupts. SSE fallback stays single-stream and cancels on navigation.
- **Queued Gateway follow-ups** — every local queued item is immutably scoped to its originating connection, profile, stored session, transport, and run generation; only that run's completion can make it eligible, and switching sessions shows only that session's queue. Restored text queues retain the same scope, while unavailable/deleted destinations and non-restorable attachment queues fail visibly instead of following the current composer. Drained messages add `queued: true` to `prompt.submit`; ordinary sends omit the field. Authoritative submit rejections (`4004`, `4018`, `4028`, `4029`, `4030`, `4090`, `5008`, `5070`, and `5071`) preserve the server message and never fall through to API-server SSE.
- **Durable composer drafts** — each connection/profile/session owns one app-private draft containing text, quote/edit context, and pending attachment bytes. Metadata and content-addressed blobs live under Android's no-backup directory, are capped at 64 drafts and 128 MB of retained blobs outside the active draft, flush when Chat backgrounds, and are removed after a successful send. Session/profile/connection navigation saves the previous owner before restoring the destination; an opened cross-profile session uses its actual owning profile rather than the global picker.
+16 -1
View File
@@ -1,6 +1,6 @@
# Hermes-Relay Surface Matrix
Updated: 2026-08-20
Updated: 2026-08-25
This matrix records the v1.0.0 route ownership contract. It is meant to keep
future app, plugin, and agent work honest about what is vanilla upstream
@@ -23,6 +23,9 @@ Verified upstream source snapshot:
`hermes_cli/plugins_cmd.py`
- Additive Manage contracts were rechecked at upstream MCP hosted-OAuth commits
through `4dc2b7be0` and custom-endpoint commit `3d9789357`.
- Session activity contracts were rechecked against upstream `main` at
`d736f5d53f1d33fabad5a17cb070eb138b618fb8` in `tui_gateway/server.py`,
`tui_gateway/methods_session.py`, and `hermes_cli/web_routers/sessions.py`.
## Ownership
@@ -35,6 +38,7 @@ Verified upstream source snapshot:
| `/v1/skills`, `/v1/toolsets` | Upstream API server | No | Discovery | Authenticated read-only API-server skill/toolset inventory; Android Diagnostics summarizes enabled toolsets and Relay tool visibility. |
| Dashboard `/api/status`, `/api/auth/me` | Upstream dashboard | No | Manage auth | Dashboard cookie/session path; separate from API bearer. Optional status diagnostics include Nous bootstrap validity and profile/gateway topology; these do not gate transport selection. |
| Dashboard `/api/auth/ws-ticket`, `/api/ws` | Upstream dashboard/tui_gateway | No | Preferred chat transport | Vanilla Hermes gateway chat path with live reasoning/thinking events. `message.complete` is the ordinary terminal event; `session.info {running:false}` is the authoritative settle backstop when a replacement socket missed that terminal frame. A reconnect reactivates the exact live runtime with `session.activate`; durable `session.resume` remains the cold-open path and an explicit rejection never creates a replacement context. |
| Gateway `session.active_list` | Upstream tui_gateway | No | Authoritative process-wide live activity | Returns attachable runtimes across the Gateway process, with live `id`, durable `session_key`, and `starting`, `working`, `waiting`, or `idle`. The only optional selector is `current_session_id`; rows normally carry no profile metadata. Android attributes a row only from exact foreground/detached ownership already held by that client, or from explicit profile metadata if a future upstream sends it. A bounded REST directory never proves global uniqueness. Unresolved rows remain unattributed, and absence settles a scope only after a complete, unambiguously resolved successful snapshot. Method-not-found or refresh failure is Unavailable, not Idle. Pending input outranks running work. |
| Dashboard `model.options` / `/api/model/*` | Upstream dashboard/tui_gateway | No | Provider/model inventory and selection | Source of truth for coherent provider/model identities. A reasoning boolean or exact effort list is consumed when present; clients do not infer provider identity from a model string alone. |
| Gateway `pet.info`, `pet.gallery`, `pet.select`, `pet.disable` | Upstream tui_gateway | No | Profile-scoped animated companion | `pet.info` supplies bounded PNG/WebP sheet bytes, revision, geometry, real frame counts, loop timing, scale, and row taxonomy. Android passes `knownRevision` to avoid duplicate sheet transfer, renders the active pet through its native activity-aware companion, and keeps phone-local pet packs separate. All four RPCs carry the effective profile. |
| Dashboard `/api/audio/transcribe`, `/api/audio/speak-stream`, `/api/audio/speak` | Upstream dashboard | No | Vanilla Hermes voice | Manage sign-in unlocks Vanilla Hermes voice. Assistant text streams into upstream speech when available; older hosts fall back to whole-request speech before audio starts. API server has no `/v1/audio/*` route today. |
@@ -191,6 +195,17 @@ URL for compatibility.
## API Fallback Compatibility Details
- Dashboard/API session-list `is_active` is a persistence-recency hint: an
unended row whose `last_active` is less than five minutes old. It is not a
running-turn signal and must never produce Working, Waiting, or Starting.
- Gateway `process.list` describes separately running background processes. A
process may outlive its parent model turn, so clients present that as
Background work without keeping the conversation in Working.
- Upstream can make multi-profile clients safer and simpler by adding profile
metadata or a profile filter to `session.active_list`, or by publishing an
aggregate activity route with explicit profile ownership. Until then,
clients must fail closed on duplicate or unresolved durable keys.
- Android accepts the API server's final-response image data URLs for PNG,
JPEG, GIF, WebP, and BMP. Decoding is strict: MIME and file signatures must
agree, encoded and decoded bytes are capped at the upstream 5 MiB limit, and
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.13.0"
appVersionCode = "49"
appVersionName = "1.13.1"
appVersionCode = "50"
agp = "9.3.2"
kotlin = "2.4.10"
compose-bom = "2026.08.00"
+7 -7
View File
File diff suppressed because one or more lines are too long
@@ -1,73 +0,0 @@
const MAX_PROFILE_LABEL_LENGTH = 80;
const MAX_CAPABILITY_LENGTH = 32;
const MAX_CAPABILITIES = 12;
const MAX_VISIBLE_CAPABILITIES = 4;
const CAPABILITY_LABELS = {
text_chat: "Text chat",
attachments: "Attachments",
voice: "Voice",
generated_images: "Generated images",
new_chat: "New chat",
cancel: "Cancel",
steer: "Steer",
share_images: "Share images",
copy: "Copy",
retry: "Retry",
quote_reply: "Quote & reply",
timestamps: "Timestamps",
};
function boundedText(value, maxLength) {
if (typeof value !== "string") return null;
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
if (!normalized) return null;
return normalized.slice(0, maxLength);
}
function capabilityLabel(value) {
const normalized = boundedText(value, MAX_CAPABILITY_LENGTH);
if (!normalized) return null;
const key = normalized.toLowerCase();
return CAPABILITY_LABELS[key] || null;
}
/**
* Normalize optional, client-reported supervised-mode metadata for display.
* This deliberately requires active === true and never treats the report as a
* Relay authorization policy.
*/
export function supervisedSessionDisplay(session) {
const raw = session && session.supervised_mode;
if (
!raw ||
typeof raw !== "object" ||
Array.isArray(raw) ||
raw.active !== true ||
raw.enforcement_owner !== "android_client"
) {
return null;
}
const profileLabel = boundedText(raw.profile_label, MAX_PROFILE_LABEL_LENGTH);
const source = Array.isArray(raw.capabilities) ? raw.capabilities : [];
const capabilities = [];
const seen = new Set();
for (const entry of source.slice(0, MAX_CAPABILITIES)) {
const label = capabilityLabel(entry);
if (!label) continue;
const key = label.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
capabilities.push(label);
}
const visibleCapabilities = capabilities.slice(0, MAX_VISIBLE_CAPABILITIES);
const remainingCapabilityCount = Math.max(0, capabilities.length - visibleCapabilities.length);
return {
profileLabel,
visibleCapabilities,
remainingCapabilityCount,
};
}
+1 -34
View File
@@ -13,7 +13,6 @@ import {
} from "../lib/api.js";
import { relativeTime, ttlCountdown, uptime, shortToken } from "../lib/formatters.js";
import { formatSessionExpiry } from "../lib/session-expiry.mjs";
import { supervisedSessionDisplay } from "../lib/supervised-session.mjs";
import PairDialog from "../components/PairDialog.jsx";
import {
Alert,
@@ -597,7 +596,6 @@ export default function RelayManagement({ autoRefresh }) {
const ov = overview || {};
const list = sessions || [];
const hasSupervisedSession = list.some((session) => supervisedSessionDisplay(session));
return (
<div className="space-y-4">
@@ -645,13 +643,6 @@ export default function RelayManagement({ autoRefresh }) {
</Button>
</CardHeader>
<CardContent>
{hasSupervisedSession ? (
<div className="mb-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
Supervised mode is reported and enforced by the Android client, not by Relay. Relay
shows the client&apos;s reported settings here so a paired device can be identified and
revoked.
</div>
) : null}
{!autoRefresh ? (
<div className="mb-3">
<Button size="sm" variant="outline" onClick={load}>
@@ -694,21 +685,13 @@ export default function RelayManagement({ autoRefresh }) {
const grants = extractGrants(s);
const type = classifySession(s, grants);
const transport = sessionTransport(s);
const supervised = supervisedSessionDisplay(s);
const deviceDetail = [s.device_model, s.device_platform]
.filter((value) => value && value !== "unknown")
.join(" · ");
return (
<TableRow key={tokenPrefix || idx}>
<TableCell className="font-medium">
<div className="flex flex-wrap items-center gap-2">
<span>{label}</span>
{supervised ? (
<Badge variant="secondary" className="w-fit text-xs">
Supervised
</Badge>
) : null}
</div>
<div>{label}</div>
<div className="font-mono text-xs font-normal text-muted-foreground">
{tokenPrefix ? shortToken(tokenPrefix, 12) : "no token prefix"}
</div>
@@ -717,22 +700,6 @@ export default function RelayManagement({ autoRefresh }) {
{deviceDetail}
</div>
) : null}
{supervised && supervised.profileLabel ? (
<div className="text-xs font-normal text-muted-foreground">
Pinned profile: {supervised.profileLabel}
</div>
) : null}
{supervised && supervised.visibleCapabilities.length > 0 ? (
<div
className="max-w-xs text-xs font-normal text-muted-foreground"
title="Capabilities reported by the Android client"
>
Client allows: {supervised.visibleCapabilities.join(" · ")}
{supervised.remainingCapabilityCount > 0
? ` · +${supervised.remainingCapabilityCount} more`
: ""}
</div>
) : null}
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
@@ -1,81 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { supervisedSessionDisplay } from "../src/lib/supervised-session.mjs";
test("leaves legacy and inactive sessions unchanged", () => {
assert.equal(supervisedSessionDisplay({ device_name: "Pixel" }), null);
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: false } }), null);
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: "true" } }), null);
assert.equal(
supervisedSessionDisplay({ supervised_mode: { active: true, enforcement_owner: "relay" } }),
null,
);
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: true } }), null);
assert.equal(supervisedSessionDisplay({ supervised_mode: [] }), null);
});
test("formats active client-reported metadata", () => {
assert.deepEqual(
supervisedSessionDisplay({
supervised_mode: {
active: true,
profile_label: " Willow ",
capabilities: ["attachments", "voice", "generated_images", "new_chat"],
enforcement_owner: "android_client",
},
}),
{
profileLabel: "Willow",
visibleCapabilities: ["Attachments", "Voice", "Generated images", "New chat"],
remainingCapabilityCount: 0,
},
);
});
test("bounds, sanitizes, and deduplicates untrusted display values", () => {
const result = supervisedSessionDisplay({
supervised_mode: {
active: true,
enforcement_owner: "android_client",
profile_label: `Willow\u0000 ${"x".repeat(100)}`,
capabilities: [
"voice",
"VOICE",
"unknown_capability",
"text_chat",
"generated_images",
"cancel",
"steer",
"attachments",
"new_chat",
"share_images",
"copy",
"retry",
"quote_reply",
"timestamps",
],
},
});
assert.equal(result.profileLabel.length, 80);
assert.deepEqual(
result.visibleCapabilities,
["Voice", "Text chat", "Generated images", "Cancel"],
);
assert.equal(result.remainingCapabilityCount, 6);
});
test("tolerates malformed optional members", () => {
assert.deepEqual(
supervisedSessionDisplay({
supervised_mode: {
active: true,
enforcement_owner: "android_client",
profile_label: 42,
capabilities: "voice",
},
}),
{ profileLabel: null, visibleCapabilities: [], remainingCapabilityCount: 0 },
);
});
+1 -107
View File
@@ -97,29 +97,6 @@ _PAIRING_CODE_TTL = 600.0
DEFAULT_REFRESH_TTL_SECONDS: float = 180 * 24 * 3600 # 180 days
_REFRESH_TOKEN_BYTES = 32
# Client-reported supervised-mode metadata is intentionally small and
# non-authoritative. Relay stores it only so paired-device surfaces can show
# the operator which Android client is presenting a restricted UI. The
# Android client remains the enforcement owner.
SUPERVISED_PROFILE_LABEL_MAX_LENGTH = 80
SUPERVISED_CAPABILITY_MAX_COUNT = 12
SUPERVISED_CAPABILITIES: frozenset[str] = frozenset(
{
"attachments",
"cancel",
"copy",
"generated_images",
"new_chat",
"quote_reply",
"retry",
"share_images",
"steer",
"text_chat",
"timestamps",
"voice",
}
)
# ── Data models ──────────────────────────────────────────────────────────────
@@ -137,58 +114,6 @@ def _refresh_token_hash(token: str) -> str:
return sha256(token.encode("utf-8")).hexdigest()
@dataclass(frozen=True)
class SupervisedMode:
"""Bounded, client-reported metadata for paired-device display only."""
active: bool = False
profile_label: str = ""
capabilities: tuple[str, ...] = ()
def to_public_dict(self) -> dict[str, Any]:
"""Return the stable public wire shape for an active report."""
return {
"active": True,
"profile_label": self.profile_label,
"capabilities": list(self.capabilities),
"enforcement_owner": "android_client",
}
def parse_supervised_mode(value: Any) -> SupervisedMode:
"""Validate untrusted supervised-mode metadata.
Missing, inactive, malformed, oversized, or unknown values all normalize
to ordinary mode. Capability values are allowlisted so this public summary
cannot become a side channel for model, tool, path, or arbitrary client
data.
"""
ordinary = SupervisedMode()
if not isinstance(value, dict) or value.get("active") is not True:
return ordinary
raw_label = value.get("profile_label")
raw_capabilities = value.get("capabilities", [])
if not isinstance(raw_label, str) or not isinstance(raw_capabilities, list):
return ordinary
if not raw_label.isprintable():
return ordinary
label = raw_label.strip()
if not label or len(label) > SUPERVISED_PROFILE_LABEL_MAX_LENGTH:
return ordinary
if len(raw_capabilities) > SUPERVISED_CAPABILITY_MAX_COUNT:
return ordinary
capabilities: list[str] = []
for candidate in raw_capabilities:
if not isinstance(candidate, str) or candidate not in SUPERVISED_CAPABILITIES:
return ordinary
if candidate not in capabilities:
capabilities.append(candidate)
return SupervisedMode(True, label, tuple(capabilities))
def _default_grants(ttl_seconds: float, now: float) -> dict[str, float]:
"""Compute default per-channel grants given an overall session TTL.
@@ -304,7 +229,6 @@ class Session:
refresh_token: str | None = field(default=None, repr=False, compare=False)
device_model: str = "unknown"
device_platform: str = "unknown"
supervised_mode: SupervisedMode = field(default_factory=SupervisedMode)
def __post_init__(self) -> None:
if self.expires_at == 0.0:
@@ -366,7 +290,6 @@ class TrustedDevice:
device_form_factor: str = "unknown"
device_model: str = "unknown"
device_platform: str = "unknown"
supervised_mode: SupervisedMode = field(default_factory=SupervisedMode)
def __post_init__(self) -> None:
if self.expires_at == 0.0:
@@ -535,7 +458,7 @@ def _session_to_json(session: Session) -> dict[str, Any]:
return "never"
return v
payload = {
return {
"token": session.token,
"device_name": session.device_name,
"device_id": session.device_id,
@@ -550,9 +473,6 @@ def _session_to_json(session: Session) -> dict[str, Any]:
"device_platform": session.device_platform,
"first_seen": session.first_seen,
}
if session.supervised_mode.active:
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
return payload
def _session_from_json(payload: dict[str, Any]) -> Session | None:
@@ -588,7 +508,6 @@ def _session_from_json(payload: dict[str, Any]) -> Session | None:
device_model = str(payload.get("device_model", "unknown"))
device_platform = str(payload.get("device_platform", "unknown"))
first_seen = float(payload.get("first_seen", created_at))
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
except (KeyError, TypeError, ValueError):
return None
@@ -606,7 +525,6 @@ def _session_from_json(payload: dict[str, Any]) -> Session | None:
device_model=device_model,
device_platform=device_platform,
first_seen=first_seen,
supervised_mode=supervised_mode,
)
@@ -632,8 +550,6 @@ def _trusted_device_to_json(device: TrustedDevice) -> dict[str, Any]:
}
if device.grants is not None:
payload["grants"] = dict(device.grants)
if device.supervised_mode.active:
payload["supervised_mode"] = device.supervised_mode.to_public_dict()
return payload
@@ -670,7 +586,6 @@ def _trusted_device_from_json(payload: dict[str, Any]) -> TrustedDevice | None:
device_form_factor = str(payload.get("device_form_factor", "unknown"))
device_model = str(payload.get("device_model", "unknown"))
device_platform = str(payload.get("device_platform", "unknown"))
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
except (KeyError, TypeError, ValueError):
return None
@@ -691,7 +606,6 @@ def _trusted_device_from_json(payload: dict[str, Any]) -> TrustedDevice | None:
device_form_factor=device_form_factor,
device_model=device_model,
device_platform=device_platform,
supervised_mode=supervised_mode,
)
@@ -969,7 +883,6 @@ class SessionManager:
device_form_factor: str = "unknown",
device_model: str = "unknown",
device_platform: str = "unknown",
supervised_mode: SupervisedMode | None = None,
issue_refresh_token: bool = False,
) -> Session:
"""Create a new session for an authenticated device.
@@ -1000,9 +913,6 @@ class SessionManager:
device_platform:
Optional operating-system/platform metadata retained for device
details. Neither field participates in authorization.
supervised_mode:
Optional bounded report of Android's supervised client state.
Informational only; Relay does not enforce the reported policy.
issue_refresh_token:
When True, also create a persisted trusted-device credential and
attach the raw one-time refresh token to the returned
@@ -1012,7 +922,6 @@ class SessionManager:
"""
if ttl_seconds is None:
ttl_seconds = DEFAULT_TTL_SECONDS
supervised_mode = supervised_mode or SupervisedMode()
now = time.time()
if ttl_seconds == 0:
@@ -1072,7 +981,6 @@ class SessionManager:
device_form_factor=device_form_factor,
device_model=device_model,
device_platform=device_platform,
supervised_mode=supervised_mode,
)
token = str(uuid.uuid4())
@@ -1089,7 +997,6 @@ class SessionManager:
device_form_factor=device_form_factor,
device_model=device_model,
device_platform=device_platform,
supervised_mode=supervised_mode,
first_seen=now,
refresh_token=refresh_token,
)
@@ -1144,7 +1051,6 @@ class SessionManager:
device_form_factor=session.device_form_factor,
device_model=session.device_model,
device_platform=session.device_platform,
supervised_mode=session.supervised_mode,
)
session.refresh_token = refresh_token
self._save_to_disk()
@@ -1161,7 +1067,6 @@ class SessionManager:
device_form_factor: str = "unknown",
device_model: str = "unknown",
device_platform: str = "unknown",
supervised_mode: SupervisedMode | None = None,
) -> Session | None:
"""Mint a replacement session from a trusted-device refresh token.
@@ -1216,8 +1121,6 @@ class SessionManager:
trusted.device_model = device_model
if device_platform and device_platform != "unknown":
trusted.device_platform = device_platform
if supervised_mode is not None:
trusted.supervised_mode = supervised_mode
self._trusted_devices[new_refresh_hash] = trusted
session = self.create_session(
@@ -1230,7 +1133,6 @@ class SessionManager:
device_form_factor=trusted.device_form_factor,
device_model=trusted.device_model,
device_platform=trusted.device_platform,
supervised_mode=trusted.supervised_mode,
issue_refresh_token=False,
)
session.refresh_token = new_refresh_token
@@ -1251,7 +1153,6 @@ class SessionManager:
device_platform: str | None = None,
client_surface: str | None = None,
device_form_factor: str | None = None,
supervised_mode: SupervisedMode | None = None,
) -> None:
"""Adopt identity metadata from a valid reconnecting client.
@@ -1279,10 +1180,6 @@ class SessionManager:
setattr(session, field_name, value)
changed = True
if supervised_mode is not None and session.supervised_mode != supervised_mode:
session.supervised_mode = supervised_mode
changed = True
for trusted in self._trusted_devices.values():
if trusted.device_id != session.device_id:
continue
@@ -1295,9 +1192,6 @@ class SessionManager:
if getattr(trusted, field_name) != value:
setattr(trusted, field_name, value)
changed = True
if supervised_mode is not None and trusted.supervised_mode != supervised_mode:
trusted.supervised_mode = supervised_mode
changed = True
if changed:
self._save_to_disk()
+1 -61
View File
@@ -53,7 +53,6 @@ from .auth import (
RateLimiter,
Session,
SessionManager,
parse_supervised_mode,
)
from .channels.bridge import BridgeError, BridgeHandler
from .channels.chat import ChatHandler
@@ -888,7 +887,7 @@ def _session_to_dict(session: Session, current_token: str | None) -> dict[str, A
return None if math.isinf(ts) else ts
grants_out = {k: _norm(v) for k, v in session.grants.items()}
payload = {
return {
"token_prefix": session.token[:8],
"device_name": session.device_name,
"device_id": session.device_id,
@@ -904,9 +903,6 @@ def _session_to_dict(session: Session, current_token: str | None) -> dict[str, A
"device_platform": session.device_platform,
"is_current": current_token is not None and session.token == current_token,
}
if session.supervised_mode.active:
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
return payload
def _require_bearer_session(
@@ -1016,18 +1012,6 @@ async def handle_sessions_revoke(request: web.Request) -> web.Response:
target = matches[0]
revoked_self = current_token is not None and target.token == current_token
server.sessions.revoke_session(target.token)
# Revocation ends already-connected Relay sockets as well as preventing
# future authentication. This does not enforce the Android supervised
# policy; it revokes the ordinary paired Relay session that reported it.
revoked_sockets = [
ws for ws, token in server._clients.items() if token == target.token
]
for ws in revoked_sockets:
if not ws.closed:
await ws.close(
code=aiohttp.WSCloseCode.POLICY_VIOLATION,
message=b"Relay session revoked",
)
route_credential_id = credential_id_for(target.token)
server.secure_link_route_credentials.pop(route_credential_id, None)
if server.secure_link_connector is not None:
@@ -4225,8 +4209,6 @@ def _build_auth_ok_payload(
}
if session.refresh_token:
payload["refresh_token"] = session.refresh_token
if session.supervised_mode.active:
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
if route_credential is not None:
payload["route_credential"] = route_credential
return payload
@@ -4328,11 +4310,6 @@ async def _authenticate(
device_platform = str(
payload.get("device_platform", "unknown") or "unknown"
).strip()
# Every authentication is a fresh client report. Missing or invalid
# metadata explicitly returns the paired session to ordinary mode rather
# than leaving a stale supervised badge behind after the client disables
# the mode or downgrades.
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
# Pairing policy is attached by a loopback-only operator flow. Clients
# may still send ttl_seconds / grants for wire compatibility, but those
@@ -4361,7 +4338,6 @@ async def _authenticate(
device_form_factor=(
device_form_factor if "device_form_factor" in payload else None
),
supervised_mode=supervised_mode,
)
if (
not refresh_token_attempt
@@ -4394,7 +4370,6 @@ async def _authenticate(
device_form_factor=device_form_factor,
device_model=device_model,
device_platform=device_platform,
supervised_mode=supervised_mode,
)
if session is not None:
server.rate_limiter.record_success(remote_ip)
@@ -4428,7 +4403,6 @@ async def _authenticate(
device_form_factor=device_form_factor,
device_model=device_model,
device_platform=device_platform,
supervised_mode=supervised_mode,
issue_refresh_token=True,
)
server.rate_limiter.record_success(remote_ip)
@@ -4610,40 +4584,6 @@ async def _handle_system(
elif msg_type == "pong":
# Client responding to our ping — nothing to do
pass
elif msg_type == "supervised.update":
# Informational update from an already-authenticated Android client.
# The socket's paired session is the only ownership input: payload
# fields cannot select or modify another session. Relay deliberately
# does not enforce the reported client policy.
token = server._clients.get(ws)
session = server.sessions.get_session(token) if token else None
if session is None:
await _send_system(
ws,
"error",
{"message": "Authenticated Relay session is no longer valid"},
msg_id,
)
return
if not isinstance(payload, dict):
payload = {}
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
server.sessions.update_session_device_metadata(
session,
supervised_mode=supervised_mode,
)
applied: dict[str, Any] = {
"active": False,
"enforcement_owner": "android_client",
}
if supervised_mode.active:
applied = supervised_mode.to_public_dict()
await _send_system(
ws,
"supervised.updated",
{"supervised_mode": applied},
msg_id,
)
else:
logger.debug("Unhandled system message type: %s", msg_type)
@@ -1,367 +0,0 @@
"""Client-reported supervised-mode metadata is bounded and informational."""
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
from plugin.relay.auth import (
SUPERVISED_CAPABILITY_MAX_COUNT,
SUPERVISED_PROFILE_LABEL_MAX_LENGTH,
SessionManager,
SupervisedMode,
parse_supervised_mode,
)
from plugin.relay.config import RelayConfig
from plugin.relay.server import RelayServer, _build_auth_ok_payload, create_app
class SupervisedModeParsingTests(unittest.TestCase):
def test_valid_report_is_normalized_and_deduplicated(self) -> None:
parsed = parse_supervised_mode(
{
"active": True,
"profile_label": " Learning ",
"capabilities": ["text_chat", "voice", "voice"],
"enforcement_owner": "server", # client cannot override it
}
)
self.assertEqual(
parsed,
SupervisedMode(True, "Learning", ("text_chat", "voice")),
)
self.assertEqual(parsed.to_public_dict()["enforcement_owner"], "android_client")
def test_missing_inactive_and_malformed_reports_are_ordinary(self) -> None:
invalid = (
None,
[],
{"active": False, "profile_label": "Learning"},
{"active": "true", "profile_label": "Learning"},
{"active": True, "profile_label": 7},
{"active": True, "profile_label": "Learning", "capabilities": {}},
{"active": True, "profile_label": "Learning\n", "capabilities": []},
{
"active": True,
"profile_label": "Learning",
"capabilities": ["model:gpt-private"],
},
)
for value in invalid:
with self.subTest(value=value):
self.assertEqual(parse_supervised_mode(value), SupervisedMode())
def test_oversized_report_is_ordinary(self) -> None:
self.assertEqual(
parse_supervised_mode(
{
"active": True,
"profile_label": "x" * (SUPERVISED_PROFILE_LABEL_MAX_LENGTH + 1),
"capabilities": [],
}
),
SupervisedMode(),
)
self.assertEqual(
parse_supervised_mode(
{
"active": True,
"profile_label": "Learning",
"capabilities": ["voice"] * (SUPERVISED_CAPABILITY_MAX_COUNT + 1),
}
),
SupervisedMode(),
)
class SupervisedModePersistenceTests(unittest.TestCase):
def test_active_report_and_trusted_device_survive_restart_and_refresh(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "sessions.json"
mode = SupervisedMode(
True,
"Learning",
("text_chat", "attachments", "voice"),
)
manager = SessionManager(persistence_path=path)
session = manager.create_session(
"Managed phone",
"managed-phone-id",
supervised_mode=mode,
issue_refresh_token=True,
)
refresh_token = session.refresh_token
assert refresh_token is not None
reloaded = SessionManager(persistence_path=path)
restored = reloaded.get_session(session.token)
self.assertIsNotNone(restored)
assert restored is not None
self.assertEqual(restored.supervised_mode, mode)
reloaded._sessions.clear()
replacement = reloaded.refresh_session(
refresh_token,
device_name="Managed phone",
device_id="managed-phone-id",
)
self.assertIsNotNone(replacement)
assert replacement is not None
self.assertEqual(replacement.supervised_mode, mode)
def test_legacy_and_invalid_disk_rows_load_as_ordinary(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "sessions.json"
manager = SessionManager(persistence_path=path)
session = manager.create_session("Legacy phone", "legacy-id")
payload = json.loads(path.read_text(encoding="utf-8"))
payload["sessions"][0]["supervised_mode"] = {
"active": True,
"profile_label": "Learning",
"capabilities": ["unknown_future_value"],
}
path.write_text(json.dumps(payload), encoding="utf-8")
reloaded = SessionManager(persistence_path=path)
restored = reloaded.get_session(session.token)
self.assertIsNotNone(restored)
assert restored is not None
self.assertEqual(restored.supervised_mode, SupervisedMode())
payload["sessions"][0].pop("supervised_mode")
path.write_text(json.dumps(payload), encoding="utf-8")
legacy = SessionManager(persistence_path=path).get_session(session.token)
self.assertIsNotNone(legacy)
assert legacy is not None
self.assertEqual(legacy.supervised_mode, SupervisedMode())
def test_auth_ok_only_emits_active_client_report(self) -> None:
server = RelayServer(RelayConfig())
ordinary = server.sessions.create_session("Phone", "ordinary-id")
self.assertNotIn("supervised_mode", _build_auth_ok_payload(ordinary, server))
managed = server.sessions.create_session(
"Managed phone",
"managed-id",
supervised_mode=SupervisedMode(True, "Learning", ("voice",)),
)
report = _build_auth_ok_payload(managed, server)["supervised_mode"]
self.assertEqual(report["profile_label"], "Learning")
self.assertEqual(report["capabilities"], ["voice"])
self.assertEqual(report["enforcement_owner"], "android_client")
class SupervisedModeSessionRoutesTests(AioHTTPTestCase):
async def get_application(self) -> web.Application:
return create_app(RelayConfig())
async def test_list_exposes_active_report_and_omits_ordinary_report(self) -> None:
ordinary = self.app["server"].sessions.create_session("Phone", "ordinary-id")
self.app["server"].sessions.create_session(
"Managed phone",
"managed-id",
supervised_mode=SupervisedMode(
True, "Learning", ("text_chat", "attachments")
),
)
response = await self.client.get(
"/sessions", headers={"Authorization": f"Bearer {ordinary.token}"}
)
self.assertEqual(response.status, 200)
rows = {row["device_name"]: row for row in (await response.json())["sessions"]}
self.assertNotIn("supervised_mode", rows["Phone"])
self.assertEqual(
rows["Managed phone"]["supervised_mode"],
{
"active": True,
"profile_label": "Learning",
"capabilities": ["text_chat", "attachments"],
"enforcement_owner": "android_client",
},
)
async def test_pairing_auth_records_and_returns_client_report(self) -> None:
response = await self.client.post(
"/pairing/register", json={"code": "MODE01"}
)
self.assertEqual(response.status, 200, await response.text())
socket = await self.client.ws_connect("/ws")
await socket.send_json(
{
"channel": "system",
"type": "auth",
"payload": {
"pairing_code": "MODE01",
"device_name": "Managed phone",
"device_id": "managed-auth-id",
"client_surface": "android",
"supervised_mode": {
"active": True,
"profile_label": "Learning",
"capabilities": ["text_chat", "voice"],
},
},
}
)
envelope = await socket.receive_json()
await socket.close()
self.assertEqual(envelope["type"], "auth.ok")
report = envelope["payload"]["supervised_mode"]
self.assertEqual(report["profile_label"], "Learning")
self.assertEqual(report["capabilities"], ["text_chat", "voice"])
self.assertEqual(report["enforcement_owner"], "android_client")
stored = self.app["server"].sessions.get_session(
envelope["payload"]["session_token"]
)
self.assertIsNotNone(stored)
assert stored is not None
self.assertTrue(stored.supervised_mode.active)
reconnect = await self.client.ws_connect("/ws")
await reconnect.send_json(
{
"channel": "system",
"type": "auth",
"payload": {
"session_token": stored.token,
"device_id": "managed-auth-id",
},
}
)
ordinary_envelope = await reconnect.receive_json()
await reconnect.close()
self.assertEqual(ordinary_envelope["type"], "auth.ok")
self.assertNotIn("supervised_mode", ordinary_envelope["payload"])
refreshed = self.app["server"].sessions.get_session(stored.token)
self.assertIsNotNone(refreshed)
assert refreshed is not None
self.assertFalse(refreshed.supervised_mode.active)
async def test_authenticated_live_update_is_owned_acked_and_persisted(self) -> None:
manager = self.app["server"].sessions
original = SupervisedMode(True, "Learning", ("text_chat", "voice"))
session = manager.create_session(
"Managed phone",
"managed-live-id",
supervised_mode=original,
issue_refresh_token=True,
)
other = manager.create_session(
"Other phone",
"other-id",
supervised_mode=SupervisedMode(True, "Other", ("text_chat",)),
)
socket = await self.client.ws_connect("/ws")
await socket.send_json(
{
"channel": "system",
"type": "auth",
"payload": {
"session_token": session.token,
"device_id": session.device_id,
"supervised_mode": original.to_public_dict(),
},
}
)
self.assertEqual((await socket.receive_json())["type"], "auth.ok")
await socket.send_json(
{
"channel": "system",
"type": "supervised.update",
"id": "update-active",
"payload": {
# Must be ignored: ownership comes from the authenticated
# socket, not any selector supplied in the update body.
"session_token": other.token,
"supervised_mode": {
"active": True,
"profile_label": "School",
"capabilities": ["text_chat", "attachments"],
},
},
}
)
ack = await socket.receive_json()
self.assertEqual(ack["type"], "supervised.updated")
self.assertEqual(ack["id"], "update-active")
self.assertEqual(
ack["payload"]["supervised_mode"],
{
"active": True,
"profile_label": "School",
"capabilities": ["text_chat", "attachments"],
"enforcement_owner": "android_client",
},
)
self.assertEqual(
manager.get_session(session.token).supervised_mode,
SupervisedMode(True, "School", ("text_chat", "attachments")),
)
self.assertEqual(manager.get_session(other.token).supervised_mode.profile_label, "Other")
self.assertTrue(
any(
device.device_id == session.device_id
and device.supervised_mode.profile_label == "School"
for device in manager._trusted_devices.values()
)
)
await socket.send_json(
{
"channel": "system",
"type": "supervised.update",
"id": "update-inactive",
"payload": {"supervised_mode": {"active": False}},
}
)
cleared = await socket.receive_json()
await socket.close()
self.assertEqual(cleared["type"], "supervised.updated")
self.assertEqual(cleared["id"], "update-inactive")
self.assertEqual(
cleared["payload"]["supervised_mode"],
{"active": False, "enforcement_owner": "android_client"},
)
self.assertFalse(manager.get_session(session.token).supervised_mode.active)
self.assertTrue(
all(
not device.supervised_mode.active
for device in manager._trusted_devices.values()
if device.device_id == session.device_id
)
)
async def test_revoke_closes_connected_relay_socket(self) -> None:
caller = self.app["server"].sessions.create_session("Caller", "caller-id")
target = self.app["server"].sessions.create_session(
"Managed phone",
"managed-id",
supervised_mode=SupervisedMode(True, "Learning", ("text_chat",)),
)
socket = AsyncMock()
socket.closed = False
self.app["server"]._clients[socket] = target.token
response = await self.client.delete(
f"/sessions/{target.token[:8]}",
headers={"Authorization": f"Bearer {caller.token}"},
)
self.assertEqual(response.status, 200)
self.assertIsNone(self.app["server"].sessions.get_session(target.token))
socket.close.assert_awaited_once()
self.assertEqual(
socket.close.await_args.kwargs["message"], b"Relay session revoked"
)
@@ -29,12 +29,14 @@ GATEWAY_TERMINAL = "gateway.message_complete"
GATEWAY_SETTLED_INFO = "gateway.settled_session_info"
SESSION_ACTIVATE = "gateway.session_activate_live"
SESSION_RESUME = "gateway.session_resume_durable"
SESSION_ACTIVE_LIST = "gateway.session_active_list"
API_BOUNDARY = "api.fallback_boundary"
ALL_CONTRACTS = (
GATEWAY_TERMINAL,
GATEWAY_SETTLED_INFO,
SESSION_ACTIVATE,
SESSION_RESUME,
SESSION_ACTIVE_LIST,
API_BOUNDARY,
)
@@ -286,6 +288,54 @@ def _check_resume(methods: SourceFile) -> CheckResult:
return CheckResult(contract, False, (), str(exc))
def _check_active_list(server: SourceFile, methods: SourceFile) -> CheckResult:
contract = SESSION_ACTIVE_LIST
try:
status = server.function("_session_live_status")
item = server.function("_session_live_item")
handler = methods.method_handler("session.active_list")
status_text = server.segment(status)
item_strings = _string_constants(item)
handler_text = methods.segment(handler)
missing_statuses = sorted(
{"starting", "working", "waiting", "idle"} - _string_constants(status)
)
if missing_statuses:
raise ValueError("live status missing state(s): " + ", ".join(missing_statuses))
pending_at = status_text.find("_session_pending_kind(")
running_at = status_text.find('.get("running")')
if pending_at < 0 or running_at < 0 or pending_at > running_at:
raise ValueError("waiting state no longer takes precedence over running")
missing_fields = sorted({"id", "session_key", "status"} - item_strings)
if missing_fields:
raise ValueError("active-list row missing field(s): " + ", ".join(missing_fields))
required_markers = ("_sessions_lock", "_sessions.items()", "_session_live_item(")
missing_markers = [marker for marker in required_markers if marker not in handler_text]
if missing_markers or "sessions" not in _string_constants(handler):
raise ValueError(
"session.active_list no longer snapshots the live registry: "
+ ", ".join(missing_markers or ["sessions result"])
)
handler_strings = _string_constants(handler)
if "current_session_id" not in handler_strings:
raise ValueError("session.active_list no longer accepts current_session_id")
if "profile" in handler_strings:
raise ValueError("session.active_list unexpectedly claims a profile filter")
return CheckResult(
contract,
True,
(
server.evidence(status, "starting, working, waiting, and idle derivation"),
server.evidence(item, "live row carries runtime and durable identities"),
methods.evidence(
handler, "active list snapshots the process-wide in-memory registry"
),
),
)
except ValueError as exc:
return CheckResult(contract, False, (), str(exc))
def _check_api_boundary(api: SourceFile) -> CheckResult:
contract = API_BOUNDARY
try:
@@ -365,6 +415,7 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
GATEWAY_SETTLED_INFO: lambda: _check_settled_info(server),
SESSION_ACTIVATE: lambda: _check_activate(server, methods),
SESSION_RESUME: lambda: _check_resume(methods),
SESSION_ACTIVE_LIST: lambda: _check_active_list(server, methods),
API_BOUNDARY: lambda: _check_api_boundary(api),
}
return [checks[requirement]() for requirement in requirements]
@@ -36,6 +36,26 @@ def _run_prompt_submit(sid, session, agent):
finally:
session["running"] = False
_emit_settled_session_info(sid, session, agent)
def _session_pending_kind(sid):
return "approval" if sid in _pending else ""
def _session_live_status(sid, session):
if _session_pending_kind(sid):
return "waiting"
ready = session.get("agent_ready")
if ready is not None and not ready.is_set() and session.get("agent_build_started"):
return "starting"
if session.get("running"):
return "working"
return "idle"
def _session_live_item(sid, session, current_sid=""):
return {
"id": sid,
"session_key": session.get("session_key", sid),
"status": _session_live_status(sid, session),
}
'''
METHODS_SOURCE = '''
@@ -67,6 +87,14 @@ def _(rid, params):
def _(rid, params):
session, error = _sess_nowait(params, rid)
return _live_session_payload(params["session_id"], session)
@method("session.active_list")
def _(rid, params):
current = str(params.get("current_session_id") or "")
with _sessions_lock:
snapshot = list(_sessions.items())
rows = [_session_live_item(sid, session, current) for sid, session in snapshot]
return _ok(rid, {"sessions": rows})
'''
API_SOURCE = '''
@@ -159,6 +187,25 @@ class GatewayScenarioConformanceTest(unittest.TestCase):
self.assertEqual((module.GATEWAY_SETTLED_INFO, module.SESSION_ACTIVATE), requirements)
def test_active_list_requires_waiting_to_outrank_working(self):
path = self.root / module.SERVER
reordered = SERVER_SOURCE.replace(
' if _session_pending_kind(sid):\n'
' return "waiting"\n'
' ready = session.get("agent_ready")',
' if session.get("running"):\n'
' return "working"\n'
' if _session_pending_kind(sid):\n'
' return "waiting"\n'
' ready = session.get("agent_ready")',
)
path.write_text(reordered, encoding="utf-8")
result = module.audit_sources(self.root, (module.SESSION_ACTIVE_LIST,))[0]
self.assertFalse(result.passed)
self.assertIn("precedence", result.problem)
def test_manifest_rejects_unknown_contract(self):
manifest = self.root / "scenario.json"
manifest.write_text(json.dumps({"requires": ["relay.private_route"]}), encoding="utf-8")
+21 -1
View File
@@ -47,7 +47,8 @@ distribution, and trust installation are deliberately outside this fixture.
- `POST /api/auth/ws-ticket` mints a fresh, single-use 30-second ticket.
- `GET /api/ws?ticket=...` upgrades to WebSocket and sends `gateway.ready`.
- JSON-RPC methods: `session.create`, `session.resume`, `session.activate`,
`prompt.submit`, and `session.interrupt`.
`session.active_list`, `prompt.submit`, and `session.interrupt` when the
selected scenario enables them.
- `GET /api/sessions/{stored-id}/messages` returns persisted, paginated history
and accepts the upstream `profile`, `limit`, `offset`, and `order` query shape.
- Unknown RPC methods return JSON-RPC `-32601`; wrong live/durable identities
@@ -70,6 +71,13 @@ ordered `steps` list using these operations:
| `set_running` | Change the authoritative session running state. |
| `close` | Create a fixture-controlled socket gap without replaying later frames. |
An optional `active_list` object scripts process-wide live-runtime snapshots.
`supported: false` returns JSON-RPC `-32601`, matching an older Gateway.
`supported: true` returns each declared `snapshots` entry in order and retains
the final successful snapshot for later polls. Rows use upstream's
`starting`/`working`/`waiting`/`idle` vocabulary. A successful empty snapshot
is therefore distinct from a failed or unsupported refresh.
Every bundled manifest also declares a top-level `contract_requirements` string
array. Its values use the contract names accepted by the on-demand upstream
conformance adapter (for example, `gateway.settled_session_info` and
@@ -80,6 +88,18 @@ The initial catalog covers ordinary streaming, rapid chunks/reasoning/tool
events, queued turns, scoped and foreign/unscoped inputs, persisted history,
and both issue #365 terminal-gap forms:
- `active_status_lifecycle`: one successful live snapshot contains starting,
working, waiting, and idle rows; the next successful snapshot is empty so a
client can prove a complete, unambiguously resolved snapshot clears prior
live state.
- `active_status_profile_scope`: a process-wide row has no profile metadata and
ignores a caller-supplied profile hint. Client adapters must resolve it from
exact foreground/detached ownership already held by that client (or future
explicit upstream profile metadata); a bounded directory must not invent an
owner from apparent uniqueness.
- `active_status_unsupported`: `session.active_list` returns method-not-found so
older-host fallback remains explicit rather than being mistaken for Idle.
- `terminal_gap_activate`: live deltas arrive, history persists, the socket
closes before `message.complete`, and replacement `session.activate` reports
the exact live session with `running=false`. A bounded two-second fixture delivery
@@ -200,6 +200,47 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
self.assertTrue(any(event.get("session_id") == "fixture-foreign-session" for event in events))
self.assertEqual(fixture.scenario.live_session_id, events[-1]["session_id"])
async def test_active_list_exposes_all_live_states_then_authoritative_absence(self) -> None:
_, base_url = await self.start("active_status_lifecycle")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "session.active_list", {"current_session_id": "fixture-live-working"})
first = (await ws.receive_json())["result"]["sessions"]
self.assertEqual(
["starting", "working", "waiting", "idle"],
[row["status"] for row in first],
)
await self.rpc(ws, 2, "session.active_list", {"current_session_id": "fixture-live-working"})
second = (await ws.receive_json())["result"]["sessions"]
self.assertEqual([], second)
# An exhausted script remains on its last successful snapshot so polling
# cannot accidentally resurrect an earlier live row.
await self.rpc(ws, 3, "session.active_list")
third = (await ws.receive_json())["result"]["sessions"]
self.assertEqual([], third)
async def test_active_list_rows_require_client_owned_profile_resolution(self) -> None:
fixture, base_url = await self.start("active_status_profile_scope")
ws, _ = await self.connect(base_url)
# Upstream accepts current_session_id, not a profile filter. The fixture
# deliberately ignores this extra hint and returns an unscoped row.
await self.rpc(ws, 1, "session.active_list", {"profile": "default"})
rows = (await ws.receive_json())["result"]["sessions"]
self.assertEqual("research", fixture.scenario.profile)
self.assertEqual("fixture-shared-stored-session", rows[0]["session_key"])
self.assertNotIn("profile", rows[0])
async def test_active_list_unsupported_is_explicit_method_not_found(self) -> None:
_, base_url = await self.start("active_status_unsupported")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "session.active_list")
frame = await ws.receive_json()
self.assertEqual(-32601, frame["error"]["code"])
async def test_evidence_is_bounded_and_contains_no_rpc_payloads(self) -> None:
_, base_url = await self.start("ordinary_turn")
ws, _ = await self.connect(base_url)
@@ -221,6 +262,9 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
class ScenarioTestCase(unittest.TestCase):
def test_all_bundled_scenarios_validate(self) -> None:
for name in (
"active_status_lifecycle",
"active_status_profile_scope",
"active_status_unsupported",
"ordinary_turn",
"rapid_tools_interims",
"terminal_gap_activate",
@@ -232,6 +276,21 @@ class ScenarioTestCase(unittest.TestCase):
self.assertEqual(name, scenario.name)
self.assertTrue(scenario.contract_requirements)
def test_active_list_scenario_rejects_unknown_status(self) -> None:
scenario = {
"name": "invalid_activity",
"live_session_id": "live",
"stored_session_id": "stored",
"turns": [],
"active_list": {
"supported": True,
"snapshots": [[{"id": "live", "session_key": "stored", "status": "recent"}]],
},
}
with self.assertRaisesRegex(ScenarioError, "invalid status"):
from vanilla_gateway.scenario import Scenario
Scenario.from_dict(scenario)
def test_terminal_gap_manifests_select_upstream_contracts(self) -> None:
self.assertEqual(
(
@@ -15,6 +15,7 @@ class ScenarioError(ValueError):
_STEP_OPS = {"event", "persist", "sleep", "close", "set_running"}
_LIVE_STATUSES = {"starting", "working", "waiting", "idle"}
_SAFE_NAME = re.compile(r"[A-Za-z0-9_.-]{1,120}")
@@ -27,6 +28,8 @@ class Scenario:
contract_requirements: tuple[str, ...]
initial_history: tuple[dict[str, Any], ...]
turns: tuple[dict[str, Any], ...]
active_list_supported: bool
active_list_snapshots: tuple[tuple[dict[str, Any], ...], ...]
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Scenario":
@@ -34,8 +37,8 @@ class Scenario:
missing = [key for key in required if key not in raw]
if missing:
raise ScenarioError(f"missing scenario fields: {', '.join(missing)}")
if not isinstance(raw["turns"], list) or not raw["turns"]:
raise ScenarioError("turns must be a non-empty list")
if not isinstance(raw["turns"], list):
raise ScenarioError("turns must be a list")
if not isinstance(raw["name"], str) or not _SAFE_NAME.fullmatch(raw["name"]):
raise ScenarioError("name must be a short metadata-safe scenario identifier")
for turn_index, turn in enumerate(raw["turns"]):
@@ -76,6 +79,41 @@ class Scenario:
raise ScenarioError("contract_requirements must be a list of non-empty strings")
if len(set(requirements)) != len(requirements):
raise ScenarioError("contract_requirements must not contain duplicates")
active_list = raw.get("active_list", {})
if not isinstance(active_list, dict):
raise ScenarioError("active_list must be an object")
active_list_supported = active_list.get("supported", False)
if not isinstance(active_list_supported, bool):
raise ScenarioError("active_list supported must be a boolean")
snapshots = active_list.get("snapshots", [])
if not isinstance(snapshots, list):
raise ScenarioError("active_list snapshots must be a list")
if not active_list_supported and snapshots:
raise ScenarioError("unsupported active_list cannot declare snapshots")
validated_snapshots: list[tuple[dict[str, Any], ...]] = []
for snapshot_index, snapshot in enumerate(snapshots):
if not isinstance(snapshot, list):
raise ScenarioError(f"active_list snapshot {snapshot_index} must be a list")
validated_rows: list[dict[str, Any]] = []
for row_index, row in enumerate(snapshot):
if not isinstance(row, dict):
raise ScenarioError(
f"active_list snapshot {snapshot_index} row {row_index} must be an object"
)
if not isinstance(row.get("id"), str) or not row["id"]:
raise ScenarioError(
f"active_list snapshot {snapshot_index} row {row_index} requires an id"
)
if not isinstance(row.get("session_key"), str) or not row["session_key"]:
raise ScenarioError(
f"active_list snapshot {snapshot_index} row {row_index} requires a session_key"
)
if row.get("status") not in _LIVE_STATUSES:
raise ScenarioError(
f"active_list snapshot {snapshot_index} row {row_index} has invalid status"
)
validated_rows.append(dict(row))
validated_snapshots.append(tuple(validated_rows))
return cls(
name=str(raw["name"]),
live_session_id=str(raw["live_session_id"]),
@@ -84,6 +122,8 @@ class Scenario:
contract_requirements=tuple(requirements),
initial_history=tuple(dict(row) for row in history),
turns=tuple(dict(turn) for turn in raw["turns"]),
active_list_supported=active_list_supported,
active_list_snapshots=tuple(validated_snapshots),
)
@@ -0,0 +1,23 @@
{
"name": "active_status_lifecycle",
"live_session_id": "fixture-live-working",
"stored_session_id": "fixture-shared-stored-session",
"profile": "default",
"contract_requirements": [
"gateway.session_active_list"
],
"initial_history": [],
"turns": [],
"active_list": {
"supported": true,
"snapshots": [
[
{"id": "fixture-live-starting", "session_key": "fixture-starting", "status": "starting", "current": false},
{"id": "fixture-live-working", "session_key": "fixture-shared-stored-session", "status": "working", "current": true},
{"id": "fixture-live-waiting", "session_key": "fixture-waiting", "status": "waiting", "current": false},
{"id": "fixture-live-idle", "session_key": "fixture-idle", "status": "idle", "current": false}
],
[]
]
}
}
@@ -0,0 +1,19 @@
{
"name": "active_status_profile_scope",
"live_session_id": "fixture-live-research",
"stored_session_id": "fixture-shared-stored-session",
"profile": "research",
"contract_requirements": [
"gateway.session_active_list"
],
"initial_history": [],
"turns": [],
"active_list": {
"supported": true,
"snapshots": [
[
{"id": "fixture-live-research", "session_key": "fixture-shared-stored-session", "status": "waiting", "current": true}
]
]
}
}
@@ -0,0 +1,15 @@
{
"name": "active_status_unsupported",
"live_session_id": "fixture-live-unsupported",
"stored_session_id": "fixture-stored-unsupported",
"profile": "default",
"contract_requirements": [
"gateway.session_active_list"
],
"initial_history": [],
"turns": [],
"active_list": {
"supported": false,
"snapshots": []
}
}
@@ -42,6 +42,11 @@ class GatewayFixture:
self._tickets: set[str] = set()
self._history_rows = [dict(row) for row in scenario.initial_history]
self._turns = deque(dict(turn) for turn in scenario.turns)
self._active_list_snapshots = deque(
[dict(row) for row in snapshot]
for snapshot in scenario.active_list_snapshots
)
self._last_active_list_snapshot: list[dict[str, Any]] = []
self._queued: deque[_QueuedTurn] = deque()
self._running = False
self._turn_active = False
@@ -137,6 +142,13 @@ class GatewayFixture:
elif method == "session.interrupt":
self._running = False
result = {"ok": True}
elif method == "session.active_list" and self.scenario.active_list_supported:
if self._active_list_snapshots:
self._last_active_list_snapshot = self._active_list_snapshots.popleft()
result = {"sessions": [dict(row) for row in self._last_active_list_snapshot]}
self.evidence.add(
"activity", connection=connection, method=method, outcome="snapshot",
)
else:
await self._rpc_error(socket, request_id, -32601, f"Method not found: {method}")
return
@@ -290,6 +302,8 @@ class GatewayFixture:
"remaining_turns": len(self._turns),
"queued_turns": len(self._queued),
"history_rows": len(self._history_rows),
"profile": self.scenario.profile,
"remaining_active_list_snapshots": len(self._active_list_snapshots),
},
)
-1
View File
@@ -220,7 +220,6 @@ export default defineConfig({
{ text: 'Remote access', link: '/guide/remote-access' },
{ text: 'Release tracks', link: '/guide/release-tracks' },
{ text: 'Chat', link: '/guide/chat' },
{ text: 'Supervised Mode', link: '/guide/supervised-mode' },
{ text: 'Sessions', link: '/guide/sessions' },
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
],
-6
View File
@@ -98,12 +98,6 @@ Google Play builds do not include AccessibilityService-backed screen reading or
| Message history | Loads from server on session switch |
| Persistence | Last session resumes on app restart |
## Supervised access
| Feature | Status | Description |
|---------|--------|-------------|
| Android Supervised Mode | Planned | Parent-controlled, profile-pinned restricted client interface; not a server-enforced child account. See the [Supervised Mode guide](/guide/supervised-mode). |
## Analytics
| Feature | Description |
-1
View File
@@ -53,7 +53,6 @@ voice routes. Sideload builds additionally expose Android Device Control routes.
- [Quick Start](/guide/quick-start) — Recommended Android + Relay setup
- [Installation & Setup](/guide/getting-started) — Builds, manual setup, and fallbacks
- [Chat Guide](/guide/chat) — Using the chat interface
- [Supervised Mode](/guide/supervised-mode) — Planned parent-controlled, profile-pinned Android interface
- [Sessions](/guide/sessions) — Managing conversations
- [Features](/features/) — All features at a glance
- [Architecture](/architecture/) — How it works under the hood
-226
View File
@@ -1,226 +0,0 @@
---
title: Supervised Mode
description: Configure a parent-controlled, profile-pinned Hermes-Relay Android experience
---
# Supervised Mode
::: warning Physical certification pending
Supervised Mode is implemented in the Android client, but it has not completed
physical managed-device certification. Treat it as experimental and do not call
an installation child-ready until the managed-device checks below pass.
:::
Supervised Mode is a parent-controlled, restricted view of Hermes-Relay for
Android. It is intended for a parent or guardian who has already created and
reviewed a suitably restricted Hermes profile and wants the phone app to expose
only an approved set of chat features.
It is a client-interface control, not a child account or a server security
boundary. The selected Hermes profile still controls the agent's prompt, model,
tools, provider credentials, content behavior, and server-side data.
## Before enabling it
Prepare the Hermes profile first. At minimum, review its:
- identity and system instructions;
- model and provider safety settings;
- enabled skills, tools, and external services;
- memory, files, schedules, and existing sessions;
- voice and image-generation providers;
- retention and parental-review expectations.
Supervised Mode cannot make an unrestricted profile safe by hiding controls on
the phone. Ordinary prose can still cause the configured agent to use whatever
server-side capabilities that profile has.
## Set it up
From full Android Settings, the parent:
1. Open **Settings → Advanced → Supervised Mode** for the active Hermes
Connection.
2. Choose one existing named profile. Android requires a secure device screen
lock before the mode can be enabled.
3. Select the allowed features and any stricter attachment or history limits.
4. Choose a visibility preset or customize what appears in Chat.
5. Review the summary, then enable the mode.
The app returns to the pinned profile's Chat screen. If the Connection or
profile is unavailable, the restricted client shows a recovery state
without falling back to another profile or exposing full Settings.
## The everyday experience
Chat should look like ordinary Hermes-Relay Chat. There is no persistent
Supervised Mode banner consuming conversation space. The agent name and avatar
remain the primary identity, with a small connection state when permitted.
The existing Settings button opens **Restricted Settings**, which contains only
approved preferences. A clearly labelled **Parent access** row starts device
authentication before any parent controls or full application settings appear.
Restricted Settings may include:
- a supervised-only theme, text size, language, and haptics;
- parent-approved pet display and, when allowed, a phone-local profile icon and
chat background;
- accessibility preferences;
- message presentation and sensitive-media blur;
- harmless playback or interaction preferences when voice is allowed;
- Help and About;
- the locked Parent access row.
Connections, profiles, Manage, model controls, personalities, reasoning,
approvals, tools, plugins, Terminal, TUI, Bridge, Device Control, notification
companion, diagnostics, logs, files, credentials, developer options, Relay
management, and other sessions are not shown.
The command palette, slash autocomplete, server command catalog, and command
action cards are also absent. Messages whose first non-whitespace character is
`/` are rejected by the restricted client. Approved outcomes such as New chat
and Cancel remain normal, explicit buttons. If the agent requests approval, a
secret, clarification, or elevated access, the restricted client denies or
skips that request and shows a short notice. A parent can retry the task later
from the full client after authentication.
## Allowed features
The parent chooses capabilities independently. The proposed controls are:
| Capability | Suggested default | Effect when disabled |
|---|---:|---|
| Text chat | On | Required for the restricted chat experience |
| New chat | On | Removes the new-conversation action |
| Cancel reply | On | Removes Stop while a reply is running |
| Steer reply | On | Queues or disables mid-reply input instead |
| Attachments | Parent choice | Removes pickers, camera/share intake, paste-to-file, and restored attachment drafts |
| Standard voice | Parent choice | Removes recording, voice intents, and voice preferences |
| Generated media | On | Hides generated-image viewing and related actions |
| Save/share media | Off | Keeps permitted media view-only inside the app |
| Copy replies | On | Removes copy actions |
| Retry | On | Removes retry/regenerate actions |
| Quote/reply | On | Removes quote/reply actions |
| Edit and resend | Off | Prevents rewriting earlier prompts from the client |
| Session history | Parent choice | Limits the pinned profile to the current or approved conversations |
| Session actions | Off | Individually allows pin, rename, archive, share, and delete for visible history |
Attachments are a general capability, not a one-image rule. When enabled, the
normal supported attachment flow and app limits apply unless the parent chooses
a stricter maximum size or permitted-type policy. When disabled, every Android
entry point must be removed or rejected consistently, including share intents
and a draft restored after process death.
Standard voice uses the existing host-side voice configuration. Provider
credentials stay on the Hermes host and are not exposed in Restricted Settings.
The supervised theme is stored separately from the parent app theme and applies
only while the restricted root is locked. Pet display is parent-controlled.
Profile-icon and background changes can be enabled independently for the
supervised user; the authenticated parent retains those controls either way.
Session actions use a separate allowlist. The parent can allow all, allow none,
or choose individual actions. Copying technical session identifiers, browsing
other profiles, Relay Threads, and drawer customization remain unavailable.
Delete continues to require confirmation.
Generated media is limited by display policy, not by a claim that Android can
prove how the server created it. Parents may allow viewing while disabling save
and share. Ordinary remote links, files, and unsupported media retain the app's
normal safety behavior.
## What appears in Chat
Visibility controls affect presentation only. They never suppress an error,
safety notice, parent-action state, or connection failure that requires
attention.
### Simple (recommended)
- Shows the agent name and avatar.
- Shows generic **Connected**, **Working**, and **Reconnecting** states.
- Hides model, profile, provider, route, context, token usage, reasoning, and
tool details.
- Keeps the header and composer visually quiet.
### Transparent
Adds parent-approved timestamps, bounded usage or context information, and
safe activity labels. It still does not reveal tool arguments, tool results,
host paths, credentials, or administration surfaces.
### Custom
Lets the parent control individual surfaces, including:
- model name and profile name;
- connection state and route identity;
- timestamps, context, and token usage;
- generic work status, tool names, and tool detail;
- reasoning visibility;
- message and media actions.
Model and profile names default off for a new policy. The agent's friendly name
and avatar provide the normal identity in the Simple preset.
## Parent access and relocking
Enabling, changing, or ending Supervised Mode requires Android device
authentication. Parent access should relock when its authenticated task closes,
after the configured inactivity period, when the app backgrounds, or after
process recreation.
Android's device-credential prompt authenticates any user enrolled for that
device; it does not establish a separate parent identity. Use a device lock the
supervised user does not know, or keep the device under direct supervision.
The restricted root is restored before the first interactive screen. Deep
links, notification actions, shortcuts, saved back stacks, and share intents
must not provide a route around it. A missing or unreadable policy fails closed
to restricted recovery instead of opening full Settings.
Ending the mode may clear local drafts, pending attachments, and supervised
media caches according to the parent's choice. It does not automatically delete
Hermes sessions or history stored on the server. Parents review or delete that
history through their normal authenticated Hermes interface.
## Optional Relay visibility
If the Android client is paired with the optional Relay plugin, it may identify
itself with a client-reported **Supervised** tag and a short, non-sensitive
capability summary. The Relay UI can then make the device easy to recognize and
can revoke its paired Relay session through the normal paired-device controls.
The tag is informational. Relay does not enforce the Android policy, pin the
Hermes profile, filter direct Dashboard/Gateway chat, or certify that the client
is unmodified. Revoking the Relay session disables Relay-backed access for that
pairing; it does not remotely end an Android-only mode or revoke an independent
Dashboard sign-in. Supervised Mode does not require Relay.
## Limits of protection
Supervised Mode cannot control:
- another Hermes client or a modified Android build;
- someone with direct access to the Hermes server or parent credentials;
- tools, files, services, and provider behavior enabled in the selected profile;
- server-side session retention or provider data handling;
- the developmental suitability or factual accuracy of model output;
- Android behavior outside the Hermes-Relay app.
Use it alongside a restrictive Hermes profile, parental supervision, Android
parental or enterprise controls where appropriate, and regular review of the
profile and its conversations.
## Certification requirement
The feature should not be described as child-ready until the exact Android
build passes automated policy and navigation tests plus physical testing on a
managed/restricted Android device. Certification must cover authentication,
relocking, restart and offline recovery, process death, deep links,
notifications, share intents, attachments, voice, session ownership, Relay
tagging/revocation, and attempts to escape the restricted interface.
See [Profiles](/features/profiles) for the server-owned identity model and
[Chat](/guide/chat) for the full, unrestricted interface.