Compare commits

...
Author SHA1 Message Date
Bailey Dixon 6b32c7aeef Merge remote-tracking branch 'origin/dev' into codex/pr-423-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 20:25:44 -04:00
Bailey Dixon 29706e1548 Merge pull request #422 from Codename-11/feature/android-bot-mode
feat(android): add multi-gateway bot mode
2026-08-24 20:24:58 -04:00
Bailey Dixon 6579b621ff Merge pull request #420 from Codename-11/fix/android-power-audit-377
fix(android): animate visible idle Sphere efficiently
2026-08-24 20:23:29 -04:00
Bailey Dixon befe8399ab Merge remote-tracking branch 'origin/dev' into codex/pr-420-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 20:13:41 -04:00
Bailey Dixon c10b87b94c Merge pull request #398 from JackHunzicker/fix/gateway-history-attachments
fix: retry Windows media paths and honor HERMES_HOME
2026-08-24 20:12:20 -04:00
Bailey Dixon 5e9d8840ae fix(android): clear stale chat busy state 2026-08-24 19:17:22 -04:00
Bailey Dixon c7c24b2874 merge: refresh Android idle sphere fix with dev 2026-08-24 16:28:31 -04:00
Bailey Dixon 3e8e0728db merge: refresh PR #398 with current dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
2026-08-24 15:58:11 -04:00
Bailey Dixon 1658439d05 fix(android): animate visible idle sphere efficiently 2026-08-24 11:57:26 -04:00
Jack 90ab705a88 docs: record attachment and relay config fixes 2026-08-23 18:47:51 -05:00
Jack 054aab1c09 fix(server): honor HERMES_HOME for relay config 2026-08-23 18:47:51 -05:00
Jack bae1762951 fix(android): retry Windows media paths by path 2026-08-23 18:47:50 -05:00
11 changed files with 321 additions and 20 deletions
+4
View File
@@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **Android fresh chats no longer inherit a stale busy composer.** New-chat navigation settles visible streaming ownership even when a Gateway turn has already lost its live handle, and Stop remains an immediate escape hatch after the terminal bubble has settled. (#416, #418)
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
- **Android retries Windows-hosted `MEDIA:` attachments through Relay's by-path route.** A document deferred on cellular no longer treats `C:\...` as an opaque media token and reports it as expired.
- **Relay profile discovery follows `HERMES_HOME` by default.** Custom Hermes installations surface their real default profile and persist Relay sessions beside the active config while retaining the explicit `RELAY_HERMES_CONFIG` override.
- **Desktop daemon connections recover instead of exiting after an interrupted Relay socket.** Healthy daemons retry through Relay restarts and repeated failed reconnect attempts, oversized desktop-tool results fail within a bounded response instead of closing the shared WebSocket, and terminal failures leave an accurate stopped status for the tray.
- **Desktop computer control follows Hermes' current CUA Driver contract.** CUA Driver 0.20 and newer are accepted when their manifest, daemon/MCP arguments, required tools, and canonical path remain compatible, and Windows sessions use the manifest-declared direct standard-mode runtime instead of a potentially stale machine-wide daemon. Current 0.21 installations no longer fall back solely because of an obsolete upper version pin or daemon contract.
+12
View File
@@ -62,6 +62,18 @@ automotive device verified foreground preservation, AssistStructure and screensh
delivery, immediate listening, contextual response, and one-shot consumption;
broader firmware certification remains tracked in `TODO.md`.
## 2026-08-23 — Windows attachment retry and Hermes-home resolution
Android now recognizes Windows absolute paths during manual inbound-media retry.
Cellular-deferred `MEDIA:C:\...` documents use Relay's authenticated
`/media/by-path` route instead of being sent to the opaque-token route and
misreported as expired. A Robolectric/MockWebServer regression covers a spaced
Markdown filename and asserts the exact route and decoded path query.
Relay configuration now derives its default `config.yaml` and session-persistence
paths from `HERMES_HOME` when present. `RELAY_HERMES_CONFIG` remains the explicit
override. Focused Python tests cover both resolution paths.
## 2026-08-23 — GitHub Discussions community surface
GitHub Discussions is enabled as the repository's lightweight community surface.
@@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
@@ -25,6 +26,8 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.LocalBrand
import kotlinx.coroutines.delay
import kotlin.math.sin
/**
* ASCII morphing sphere — the visual embodiment of the AI agent.
@@ -53,6 +56,32 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
private const val SPHERE_TIME_UNITS_PER_SEC = 1f
private const val SPHERE_TWO_PI = 6.2832f
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
private const val SPHERE_IDLE_BREATH_RADIANS_PER_SEC = 0.72f
private const val SPHERE_IDLE_BREATH_SCALE = 0.012f
private const val SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS = 184L
internal enum class SphereMotionMode {
Still,
AmbientLayer,
Procedural,
}
internal fun sphereMotionMode(
state: SphereState,
voiceMode: Boolean,
motionVisible: Boolean,
fixedTime: Float?,
fixedColorPhase: Float?,
): SphereMotionMode {
if (!motionVisible || fixedTime != null || fixedColorPhase != null) {
return SphereMotionMode.Still
}
return if (state == SphereState.Idle && !voiceMode) {
SphereMotionMode.AmbientLayer
} else {
SphereMotionMode.Procedural
}
}
@Composable
fun MorphingSphere(
@@ -64,7 +93,8 @@ fun MorphingSphere(
voiceMode: Boolean = false,
skin: SphereSkin = LocalSphereSkin.current,
fixedTime: Float? = null,
fixedColorPhase: Float? = null
fixedColorPhase: Float? = null,
motionVisible: Boolean = true,
) {
val brand = LocalBrand.current
// Gate reactive inputs on what the skin declares it honors — this is the
@@ -104,16 +134,21 @@ fun MorphingSphere(
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
// Continuous motion runs only for active agent/voice states. Idle is a
// stable frame: the 58x34 text grid is expensive enough that even a
// throttled cosmetic drift dominated measured screen-on CPU. Active states
// retain full display-rate motion and dt-based timing.
// Active states retain the full procedural animation. Visible Idle uses a
// lightweight graphics-layer breath: redrawing the 58x34 glyph grid just
// for ambient drift was the measured screen-on hotspot, while transforming
// its cached layer preserves the intended living Sphere at far lower cost.
val animatedTime = remember { mutableFloatStateOf(0f) }
val animatedColorPhase = remember { mutableFloatStateOf(0f) }
val fullFrameRate = state != SphereState.Idle || effVoiceMode
val driveAnimation = (fixedTime == null || fixedColorPhase == null) && fullFrameRate
if (driveAnimation) {
LaunchedEffect(fullFrameRate) {
val motionMode = sphereMotionMode(
state = state,
voiceMode = effVoiceMode,
motionVisible = motionVisible,
fixedTime = fixedTime,
fixedColorPhase = fixedColorPhase,
)
if (motionMode == SphereMotionMode.Procedural) {
LaunchedEffect(motionMode) {
var lastNanos = withFrameNanos { it }
while (true) {
val now = withFrameNanos { it }
@@ -127,6 +162,25 @@ fun MorphingSphere(
}
}
}
val idleBreathPhase = remember { mutableFloatStateOf(0f) }
LaunchedEffect(motionMode) {
if (motionMode != SphereMotionMode.AmbientLayer) {
idleBreathPhase.floatValue = 0f
return@LaunchedEffect
}
var lastNanos = withFrameNanos { it }
while (true) {
val now = withFrameNanos { it }
val dtSec = (now - lastNanos).coerceAtLeast(0L) / 1_000_000_000f
lastNanos = now
idleBreathPhase.floatValue =
(idleBreathPhase.floatValue + dtSec * SPHERE_IDLE_BREATH_RADIANS_PER_SEC) %
SPHERE_TWO_PI
// The frame wait plus this delay caps the gentle layer-only pulse
// near 5fps while active procedural states retain display-rate motion.
delay(SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS)
}
}
val time = fixedTime ?: animatedTime.floatValue
val colorPhase = fixedColorPhase ?: animatedColorPhase.floatValue
@@ -138,7 +192,18 @@ fun MorphingSphere(
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
val glyphStrings = remember { HashMap<Char, String>(32) }
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
Canvas(
modifier = modifier
.fillMaxSize()
.graphicsLayer {
if (motionMode == SphereMotionMode.AmbientLayer) {
val scale = 1f + sin(idleBreathPhase.floatValue) * SPHERE_IDLE_BREATH_SCALE
scaleX = scale
scaleY = scale
}
}
.clipToBounds(),
) {
val canvasW = size.width
val canvasH = size.height
val cellW = canvasW / cols
@@ -1,9 +1,12 @@
package com.hermesandroid.relay.ui.components.avatar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.SphereReactivity
import com.hermesandroid.relay.util.AppForegroundTracker
/**
* Default ambient visualization — the ASCII [MorphingSphere].
@@ -34,6 +37,7 @@ object SphereAvatar : AgentAvatar {
@Composable
override fun Render(state: AvatarRenderState, modifier: Modifier) {
val appForeground by AppForegroundTracker.isForeground.collectAsState()
MorphingSphere(
modifier = modifier,
state = state.state,
@@ -46,6 +50,7 @@ object SphereAvatar : AgentAvatar {
// call did with fixedTime/fixedColorPhase = 0f.
fixedTime = if (state.paused) 0f else null,
fixedColorPhase = if (state.paused) 0f else null,
motionVisible = appForeground,
)
}
}
@@ -444,6 +444,7 @@ class ChatViewModel : ViewModel() {
// === END PHASE3-status ===
const val MEDIA_TAP_TO_DOWNLOAD = "Tap to download"
private const val MEDIA_FETCH_TIMEOUT_MS = 120_000L
private val WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX = Regex("""^[A-Za-z]:[\\/].+""")
/** Upper bound on the rolling tool-call history flow. */
const val TOOL_CALL_HISTORY_LIMIT = 10
@@ -5958,6 +5959,13 @@ class ChatViewModel : ViewModel() {
activeStream?.cancel()
activeStream = null
activeStreamIsGateway = false
// Navigation owns the visible composer even when the live handle has
// already ended or could not be detached. Do not wait for a late
// cancel callback to clear a handler-wide busy bit after the new
// transcript has replaced its streaming bubble.
handler.clearStreamingStatus()
_steerableTurn.value = false
_steerNotice.value = null
}
/** Last-chance synchronous flush before the ViewModel scope is cancelled. */
@@ -8804,6 +8812,11 @@ class ChatViewModel : ViewModel() {
if (streamingMsg != null) {
handler.markStopped(streamingMsg.id)
handler.onStreamComplete(streamingMsg.id)
} else {
// The terminal bubble can settle before the handler-wide busy
// flag (or navigation can already have cleared the transcript).
// Stop must still be an unconditional escape hatch.
handler.clearStreamingStatus()
}
}
}
@@ -8890,11 +8903,10 @@ class ChatViewModel : ViewModel() {
* Re-run the fetch for an attachment that's in the "Tap to download"
* deferred state. Used by the inbound-media card's CTA on cellular.
*
* Works for both flavors of inbound attachment: if the stored key starts
* with `/` it's an absolute path (bare-media form, use
* [RelayHttpClient.fetchMediaByPath]); otherwise it's a relay token
* (use [RelayHttpClient.fetchMedia]). `secrets.token_urlsafe` never
* produces `/` so the prefix check is unambiguous.
* Works for both flavors of inbound attachment: POSIX paths start with `/`
* and Windows paths match `C:\...`; both use
* [RelayHttpClient.fetchMediaByPath]. Everything else is an opaque relay
* token and uses [RelayHttpClient.fetchMedia].
*/
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
val handler = chatHandler ?: return
@@ -8927,7 +8939,10 @@ class ChatViewModel : ViewModel() {
settings,
expectedRole = expectedRole,
) {
if (fetchKey.startsWith("/")) {
if (
fetchKey.startsWith("/") ||
WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX.matches(fetchKey)
) {
relay.fetchMediaByPath(fetchKey)
} else {
relay.fetchMedia(fetchKey)
@@ -9022,9 +9037,9 @@ class ChatViewModel : ViewModel() {
},
content = "",
state = AttachmentState.LOADING,
// Reuse relayToken as a generic inbound-fetch key. Paths always
// start with `/`, real tokens never do — downstream helpers
// that need to distinguish can check the prefix.
// Reuse relayToken as a generic inbound-fetch key. Downstream
// helpers distinguish POSIX or Windows absolute paths from opaque
// relay tokens.
relayToken = originalPath,
fileName = originalPath.substringAfterLast('/').substringAfterLast('\\').ifBlank { null }
)
@@ -0,0 +1,44 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Test
class MorphingSphereMotionPolicyTest {
@Test
fun `visible idle sphere uses lightweight ambient motion`() {
assertEquals(
SphereMotionMode.AmbientLayer,
sphereMotionMode(
state = SphereState.Idle,
voiceMode = false,
motionVisible = true,
fixedTime = null,
fixedColorPhase = null,
),
)
}
@Test
fun `hidden or paused idle sphere is still`() {
assertEquals(
SphereMotionMode.Still,
sphereMotionMode(SphereState.Idle, false, false, null, null),
)
assertEquals(
SphereMotionMode.Still,
sphereMotionMode(SphereState.Idle, false, true, 0f, 0f),
)
}
@Test
fun `visible active and voice states keep procedural motion`() {
assertEquals(
SphereMotionMode.Procedural,
sphereMotionMode(SphereState.Thinking, false, true, null, null),
)
assertEquals(
SphereMotionMode.Procedural,
sphereMotionMode(SphereState.Idle, true, true, null, null),
)
}
}
@@ -1003,6 +1003,33 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(handler.messages.value.any { "Stopped" in it.badges })
}
@Test
fun stopClearsStaleBusyStateAfterTerminalBubbleAlreadySettled() {
handler.onTextDelta("stale-answer", "Finished answer")
handler.onTurnComplete("stale-answer")
assertTrue(handler.isStreaming.value)
assertFalse(handler.messages.value.single().isStreaming)
viewModel.cancelStream()
assertFalse(handler.isStreaming.value)
assertNull(handler.turnStatus.value)
}
@Test
fun newChatClearsStaleBusyStateWhenNoLiveGatewayTurnRemains() {
handler.onTextDelta("stale-answer", "Finished answer")
handler.onTurnComplete("stale-answer")
assertTrue(handler.isStreaming.value)
assertFalse(gatewayClient.hasActiveTurn())
viewModel.createNewChat()
assertFalse(handler.isStreaming.value)
assertTrue(handler.messages.value.isEmpty())
assertNull(handler.currentSessionId.value)
}
@Test
fun reopenedChatRestoresRichStateAndReattachesLiveGatewayTurn() {
val now = System.currentTimeMillis()
@@ -103,6 +103,54 @@ class ChatViewModelMediaStateTest {
assertEquals("content://com.axiomlabs.hermesrelay.fileprovider/hermes-media/photo.jpg", loaded.cachedUri)
}
@Test
fun assistantWindowsPathCellularRetryUsesByPathEndpoint() {
val windowsPath =
"C:\\Users\\Example\\AppData\\Local\\Temp\\Sovereign Intelligence copy.md"
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-file-1",
role = "assistant",
content = JsonPrimitive("MEDIA:$windowsPath"),
)
)
)
val deferred = awaitMessage {
it.attachments.singleOrNull()?.errorMessage == "Tap to download"
}
assertEquals(AttachmentState.FAILED, deferred.attachments.single().state)
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "text/markdown")
.setHeader(
"Content-Disposition",
"inline; filename=\"Sovereign Intelligence copy.md\"",
)
.setBody("# copy")
)
viewModel.cellularNetworkOverride = false
viewModel.manualFetchAttachment("assistant-file-1", 0)
val loaded = awaitMessage {
it.attachments.singleOrNull()?.state == AttachmentState.LOADED
}.attachments.single()
assertEquals("text/markdown", loaded.contentType)
assertEquals("Sovereign Intelligence copy.md", loaded.fileName)
val request = server.takeRequest()
assertEquals("/media/by-path", request.requestUrl?.encodedPath)
assertEquals(
"path=C%3A%5CUsers%5CExample%5CAppData%5CLocal%5CTemp%5C" +
"Sovereign%20Intelligence%20copy.md",
request.requestUrl?.encodedQuery,
)
assertEquals(windowsPath, request.requestUrl?.queryParameter("path"))
}
private fun awaitMessage(predicate: (ChatMessage) -> Boolean): ChatMessage {
val deadline = System.nanoTime() + 5_000_000_000L
while (System.nanoTime() < deadline) {
+19
View File
@@ -68,6 +68,25 @@ Rule of thumb: where a surface is CI-gateable, write the **failing test first**
is the deliberate exception — CI only covers lint + unit there, so on-device
verification stays a manual maintainer step and a fix is never "done" from CI alone.
### Emulator UI evidence
Hermes Android is dark-mode-first. Before emulator screenshots, animation
review, or renderer performance measurements, explicitly enable Android dark
mode and restart the app so evidence is not captured in the emulator's light
default:
```bash
adb -s <emulator-serial> shell cmd uimode night yes
adb -s <emulator-serial> shell am force-stop com.axiomlabs.hermesrelay.sideload
adb -s <emulator-serial> shell am start -n \
com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity
adb -s <emulator-serial> shell cmd uimode night
```
Confirm the final command reports `Night mode: yes` before capturing evidence.
Use host GPU acceleration where available; software rendering is useful for
compatibility but is not representative performance evidence.
## Local bridge: `scripts/start-issue.sh`
```bash
+7 -1
View File
@@ -160,6 +160,12 @@ class RelayConfig:
@classmethod
def from_env(cls) -> RelayConfig:
"""Build config from environment variables, falling back to defaults."""
hermes_home = (os.getenv("HERMES_HOME") or "").strip()
default_hermes_config = (
str(Path(hermes_home).expanduser() / "config.yaml")
if hermes_home
else cls.hermes_config_path
)
config = cls(
host=os.getenv("RELAY_HOST", cls.host),
port=int(os.getenv("RELAY_PORT", str(cls.port))),
@@ -167,7 +173,7 @@ class RelayConfig:
ssl_key=os.getenv("RELAY_SSL_KEY"),
webapi_url=os.getenv("RELAY_WEBAPI_URL", cls.webapi_url),
hermes_config_path=os.getenv(
"RELAY_HERMES_CONFIG", cls.hermes_config_path
"RELAY_HERMES_CONFIG", default_hermes_config
),
log_level=os.getenv("RELAY_LOG_LEVEL", cls.log_level),
terminal_shell=os.getenv("RELAY_TERMINAL_SHELL") or None,
+56
View File
@@ -0,0 +1,56 @@
"""Relay environment-resolution regression tests."""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from plugin.relay.config import RelayConfig
class RelayConfigEnvironmentTests(unittest.TestCase):
def test_hermes_home_supplies_default_config_and_session_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
with mock.patch.dict(os.environ, {"HERMES_HOME": str(home)}, clear=False):
os.environ.pop("RELAY_HERMES_CONFIG", None)
os.environ.pop("RELAY_SESSIONS_FILE", None)
config = RelayConfig.from_env()
self.assertEqual(str(home / "config.yaml"), config.hermes_config_path)
self.assertEqual(
str(home / "hermes-relay-sessions.json"),
config.session_persistence_path,
)
def test_explicit_relay_config_override_wins_over_hermes_home(self) -> None:
with tempfile.TemporaryDirectory() as home_tmp, tempfile.TemporaryDirectory() as override_tmp:
home = Path(home_tmp)
override = Path(override_tmp) / "relay-config.yaml"
override.write_text("{}\n", encoding="utf-8")
with mock.patch.dict(
os.environ,
{
"HERMES_HOME": str(home),
"RELAY_HERMES_CONFIG": str(override),
},
clear=False,
):
os.environ.pop("RELAY_SESSIONS_FILE", None)
config = RelayConfig.from_env()
self.assertEqual(str(override), config.hermes_config_path)
self.assertEqual(
str(override.parent / "hermes-relay-sessions.json"),
config.session_persistence_path,
)
if __name__ == "__main__":
unittest.main()