Compare commits

...
Author SHA1 Message Date
Bailey DixonandClaude Opus 4.8 99b9cf1704 fix(android): currentSession() must not re-throw network errors (crash)
An on-device crash (FATAL EXCEPTION: main, SocketTimeoutException,
Caused by SocketException "Software caused connection abort") over a
Tailscale connection. Full trace recovered from a background logcat
capture pinned it to DashboardApiClient.currentSession().

Root cause: currentSession() returns Result<DashboardAuthSession> but did
a raw okHttpClient.newCall(req).execute() with NO try/catch — the lone
outlier among the client's methods (executeJson/executeJsonElement/
audioRoutesPresent all catch). The execute() ran on Dispatchers.IO
(correct), but a transient stale-pooled-connection abort re-threw out of
withContext(IO). The caller chain — ConnectionViewModel.probeStandardVoice()
-> viewModelScope.launch (Dispatchers.Main.immediate, the Suppressed frame
in the trace) — used try/finally with no catch, so the exception was
uncaught on the main thread and killed the app. (execute() being off-main
is why StrictMode never fired; the uncaught propagation was the bug.)

Fix:
- currentSession() wraps its request in try/catch -> Result.failure on any
  exception, honoring the Result contract callers rely on (mirrors
  executeJson()).
- Defense-in-depth: probeStandardVoice() gains a catch (rethrowing
  CancellationException) that degrades availability state instead of
  letting any probe sub-call crash the Main coroutine.

Test: DashboardApiClientTest.currentSession_onConnectionAbort_returnsFailure_doesNotThrow
(MockWebServer DISCONNECT_AT_START) asserts a connection abort yields
Result.failure, not a throw. :app:testSideloadDebugUnitTest green (25/25).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:51:51 -04:00
Bailey Dixon ee0591457b Merge pull request #126 from Codename-11/dev
release(android): android-v1.2.3
2026-06-23 22:04:36 -04:00
Bailey DixonandClaude Opus 4.8 26811f0eb8 release(android): android-v1.2.3
Connection-stability hotfix. Promotes the TLS/Tailscale connect-crash fix
(#118, #124; likely #70) from [Unreleased] to [1.2.3]. appVersionName
1.2.3 / appVersionCode 17. Desktop CLI entries stay under [Unreleased] for
their own cli-v* cut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:35:16 -04:00
Bailey Dixon eafdb4efe2 Merge pull request #125 from Codename-11/fix/evictall-network-on-main-thread
fix(android): close TLS sockets off the main thread on client shutdown
2026-06-23 21:31:04 -04:00
Bailey DixonandClaude Opus 4.8 802385c65c fix(android): close TLS sockets off the main thread on client shutdown
Connecting over an encrypted link (Tailscale Serve / public HTTPS) could
hard-close the app with NetworkOnMainThreadException. HermesApiClient,
DashboardApiClient and ConnectionManager all call ConnectionPool.evictAll()
inline in shutdown(); evictAll() closes pooled sockets synchronously, and a
live https/wss keep-alive close drains a TLS close-notify through
SSLOutputStream -- a real network write StrictMode forbids on the main
thread. Several call sites reach shutdown() from a viewModelScope
(Dispatchers.Main.immediate) coroutine -- probeStandardVoice()'s finally
block on every connect, and onCleared()'s connectionManager.shutdown() --
so the process was killed on connect over TLS. (Plaintext closes write
nothing, which is why every report is on Tailscale/public TLS.)

Push the guard into the leaf: a shared shutdownOffMainThread() runs the
executor-shutdown + evictAll() on a short-lived daemon thread when called
from the main thread, and inline otherwise (preserving the blocking
awaitTermination semantics for callers already on IO). Every shutdown()
call site is now safe regardless of dispatcher; the redundant
withContext(IO)/Thread wrappers in onCleared() are removed.

Adds a Robolectric NetworkShutdownTest asserting the teardown never runs on
the main thread when invoked from the main looper, and runs inline off it.

Fixes #118, #124. Likely resolves the v1.1.0/Tailscale crash in #70.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:58:17 -04:00
Bailey DixonandClaude Opus 4.8 ec05643b6b docs(devlog): record android-v1.2.2 release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:58:55 -04:00
Bailey Dixon 984d9a2e63 release(android): android-v1.2.2 (#122)
release(android): android-v1.2.2
2026-06-22 22:38:07 -04:00
Bailey DixonandClaude Opus 4.8 65f22e21d9 Merge origin/dev into dev (adopt compileSdk 37, integrate typed stream events)
Catch up the local 1.2.2 work with origin/dev, which moved to compileSdk 37
(206d182) and added typed stream.event passthrough (PR #120). Dropped the
local markdown-renderer 0.41.0 / lifecycle 2.10.0 pins (a compileSdk-36
workaround) for compileSdk 37 + the 0.42.0 / 2.11.0 deps origin adopted.
Kept the 1.2.2 version bump (code 16) and all feature/fix work; both
2026-06-22 DEVLOG entries retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:22:38 -04:00
Bailey DixonandClaude Opus 4.8 36b05b637e fix(chat): refine clean-chat layout, scrolling, and history
Iterate the clean text-flow mode (refines 1dca285) to its final shape:

- Vertically-centered sphere + text group that rises toward the top third
  as the reply grows — no reserved empty "void", no gap above the composer
  (replaces the earlier fixed weight split).
- Top fade-edge applies only when the flow is actually scrolled, so a reply
  that fits shows its first line crisply instead of looking cut off.
- The flow now renders the recent CONVERSATION as one faded, scrollable
  transcript (user turns marked "›"), so scrolling up brings history into
  view; the line buffer accumulates across turns (keyed on a
  conversation-stable id) and the update loop keeps watching for new turns.
- Clean mode consumes stray pointer events in its empty areas (mirrors the
  voice overlay scrim) so taps/swipes don't fall through to the chat and
  session drawer behind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:16:33 -04:00
Bailey Dixon 0dfc581117 Merge pull request #120 from Codename-11/feat/typed-stream-events
feat(relay): typed stream event passthrough
2026-06-22 20:55:05 -04:00
Bailey DixonandClaude Opus 4.8 08a4efdceb release(android): android-v1.2.2
Bump appVersionName 1.2.1 -> 1.2.2, appVersionCode 15 -> 16.

Headline: multi-profile reliability — deleting a session on a non-default
profile now sticks, and a cold start opens the session drawer on the right
profile instead of flashing the default one — plus a full-screen Diagnostics
status timeline, simpler "Hermes"/"Relay" connection wording, and a roomier
clean-chat text area.

Build fix folded in: the 2026-06-22 Dependabot wave raised the compileSdk
floor to 37 on two deps, breaking the dev build on our compileSdk 36. Pinned
markdown-renderer 0.42.0 -> 0.41.0 and lifecycle 2.11.0 -> 2.10.0 (both the
last versions that build on 36, and the 1.2.1-shipped values); guard comments
added. Do not bump past these without a compileSdk bump.

Docs: CHANGELOG [1.2.2] (Desktop-CLI entries stay under [Unreleased] for their
own cli-v* cut), RELEASE_NOTES, whats_new.txt, Play default.txt, and
changelog.json (also backfilled the missing 1.2.1 entry). Verified buildable:
:app:assembleSideloadDebug green (versionCode 16 APK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:49:23 -04:00
Bailey Dixon 92adfafc81 fix(android): preserve typed stream event badges 2026-06-22 20:45:25 -04:00
Bailey DixonandClaude Opus 4.8 45326b377e docs: record cold-start profile-isolation fix
Note the session-drawer cold-start race fix (889273a) in TODO (batch
follow-ups + broader profile-isolation sweep), DEVLOG, and CHANGELOG
[Unreleased] Fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:19:50 -04:00
Bailey DixonandClaude Opus 4.8 889273aa85 fix(profiles): don't load the server-default session list before the profile resolves
On cold start the session drawer (and the restored session context) could
hydrate with the SERVER-DEFAULT profile's sessions and then visibly snap to
the persisted profile a beat later. The chat client became ready — and the
first refreshSessions() fired — before the per-connection agent-profile
list arrived to resolve the persisted selection, so the first
profile-scoped read ran with a null (server-default) profile; the list
landed a tick later, re-resolved the profile, and re-fetched correctly.

Add ProfileController.selectionSettled (true once the selection has
resolved, OR no non-default profile is pending, OR the profile list has
arrived so resolution was attempted) and gate the cold-start LaunchedEffect
on it. While a non-default profile is still resolving the first load waits
on a 2.5s backstop instead of fetching; the effect re-fires the instant the
profile resolves, cancelling the wait so only the correct, profile-scoped
load lands. The backstop keeps the drawer from ever stranding empty if the
profile list never arrives. Also defers the per-profile session-context /
transcript restore in the same effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:18:17 -04:00
Bailey Dixon 206d182704 chore(android): compile against api 37 2026-06-22 20:16:12 -04:00
Bailey DixonandClaude Opus 4.8 440f34080e docs: record 2026-06-22 outstanding-TODO orchestration batch
Check off the four resolved User-Added items (clean-chat viewport,
connections reframe, diagnostics/analytics, session-delete fix), add the
batch's deferred follow-ups (build+lint+device verify, diagnostics
re-probe trigger, pass-check timing), a DEVLOG entry, and CHANGELOG
[Unreleased] entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:07:53 -04:00
Bailey DixonandClaude Opus 4.8 6552566159 fix(sessions): persist session delete on non-default profiles
A non-default Hermes profile keeps its sessions in that profile's own
state.db, but the delete went through the unscoped api_server
DELETE /api/sessions/{id} — which hits the shared DB, leaves the row
intact, and lets the next profile-scoped list resurrect it. Route gateway
deletes through the dashboard profile-scoped surface (the write twin of
the existing list path): add DashboardApiClient.deleteSession(id, profile),
ConnectionViewModel.deleteProfileScopedSession(), a
ChatViewModel.profileSessionDeleter hook wired in RelayApp, and a
refreshSessions() after a successful delete so a still-present row can't
linger in the drawer. Off-gateway (one shared DB, no profiles) the plain
api_server delete is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:03:46 -04:00
Bailey DixonandClaude Opus 4.8 c3098a951e feat(diagnostics): full-screen status-check timeline + analytics polish
Replace the Diagnostics modal bottom sheet with a dedicated
DiagnosticsScreen behind a new Screen.Diagnostics nav route. The screen
leads with a vertical status-check timeline (Network, API server, server
capabilities, chat transport, pairing/auth, relay, voice), each with a
green/amber/red/gray dot on a connecting rail and an inline failure
reason; checks backed by a logged error are tappable into the existing
DiagnosticDetailDialog. Checks derive read-only from existing
ConnectionViewModel flows plus the recent DiagnosticsLog (no new probing)
via a pure, testable buildStatusChecks(); the recent-activity log panel
stays below. Adds StatusCheck/CheckStatus models + a reusable
StatusCheckTimeline composable, and tidies AnalyticsScreen + StatsForNerds
visual hierarchy (no data/behavior change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:02:40 -04:00
Bailey Dixon 85c70338dc feat(relay): add typed stream event passthrough 2026-06-22 19:50:30 -04:00
Bailey DixonandClaude Opus 4.8 c9fa8f722b refactor(ui): reframe "Vanilla/Standard Hermes" as "Hermes" in connections UI
Relabel the default connection path from "Vanilla Hermes" / "Standard
Hermes" to simply "Hermes", and "Hermes-Relay plugin" to "Relay plugin",
across the connections wizard, connection info/switcher sheets, voice
settings, permissions, QR scanner, and power-feature gate (28 display
strings, 10 files). Display text only — no enum names, sealed types,
when-branches, or stored route/storage values were changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:48:16 -04:00
Bailey DixonandClaude Opus 4.8 1dca285cd6 feat(chat): give clean-chat mode a taller scrollable text viewport
Replace the fragile screenHeightDp*0.34f cap on the clean-mode text flow
with a weight split: the centered sphere keeps weight(1f) while the flow
takes weight(1.1f), so the readable/scrollable text area grows from ~34%
to ~52% of the vertical slack. Keeps the min=96.dp floor, internal
scroll + top-fade + a11y mirror paths, and composer/exit spacing intact;
drops the now-dead LocalConfiguration import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:47:09 -04:00
Bailey DixonandClaude Opus 4.8 894b70ef62 chore: scrub private-infra identifiers from public tree
The repo is public and distributed; several files leaked real server
identifiers. Replace them with generic placeholders across docs, scripts,
source, and test fixtures:

- real LAN IP 172.16.24.250            -> 192.168.1.100 (blessed example)
- real Tailscale IP 100.71.8.56        -> 100.64.0.1
- real hostname docker-server / tail6f460 tailnet -> hermes-host(.tailnet.ts.net)
- ssh user@host targets                -> you@hermes-host
- server home path /home/bailey/       -> $HOME/
- custom voice id                      -> <your-voice-id>

Test fixtures changed on both input and assertion sides so suites stay
green (plugin.tests.test_pairing_mint_schema + test_voice_routes pass;
Kotlin URL-deriver/normalization fixtures consistent). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:11:30 -04:00
Bailey DixonandClaude Opus 4.8 80ea95db1c docs(devlog): record plugin-v1.2.1 release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:53:43 -04:00
Bailey DixonandClaude Opus 4.8 ed0b32e246 docs(devlog): record plugin-v1.2.1 release + live-server deploy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:52:02 -04:00
Bailey Dixon 41037a3897 Merge pull request #119 from Codename-11/dev
Release plugin-v1.2.1 (dev → main)
2026-06-22 18:49:19 -04:00
Bailey Dixon 50c5fd8373 Merge branch 'main' into dev 2026-06-22 18:46:59 -04:00
Bailey DixonandClaude Opus 4.8 788d2abcb5 release(plugin): plugin-v1.2.1
Patch release for the Realtime Agent voice path:
- brokered Hermes turns no longer fail with session_not_found (broker
  mints/reuses a valid API Server session, retries once, reads the
  nested create-session response)
- realtime voice session survives long Hermes runs via heartbeat

Both fixes already merged to dev (f6b965a, d1820fb); this bumps the six
plugin version sources to 1.2.1, folds the relay fix into the [1.2.1]
CHANGELOG line, and rewrites PLUGIN_RELEASE_NOTES.md as the release body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:41:48 -04:00
dependabot[bot] 3ec432cd8b chore(deps): bump kotlin from 2.3.21 to 2.4.0 (#114)
Bumps `kotlin` from 2.3.21 to 2.4.0.

Updates `org.jetbrains.kotlin.plugin.compose` from 2.3.21 to 2.4.0
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.21...v2.4.0)

Updates `org.jetbrains.kotlin.plugin.serialization` from 2.3.21 to 2.4.0
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.21...v2.4.0)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.plugin.compose
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 12:19:50 +00:00
dependabot[bot] ef5bae7ca5 chore(deps): bump gradle-wrapper from 9.5.1 to 9.6.0 (#113)
Bumps [gradle-wrapper](https://github.com/gradle/gradle) from 9.5.1 to 9.6.0.
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.5.1...v9.6.0)

---
updated-dependencies:
- dependency-name: gradle-wrapper
  dependency-version: 9.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 12:16:04 +00:00
dependabot[bot] 9be6422941 chore(deps): bump the networking group across 1 directory with 3 updates (#106)
Bumps the networking group with 3 updates in the / directory: [com.squareup.okhttp3:okhttp](https://github.com/square/okhttp), [com.squareup.okhttp3:okhttp-sse](https://github.com/square/okhttp) and [com.squareup.okhttp3:mockwebserver](https://github.com/square/okhttp).


Updates `com.squareup.okhttp3:okhttp` from 5.3.2 to 5.4.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.2...parent-5.4.0)

Updates `com.squareup.okhttp3:okhttp-sse` from 5.3.2 to 5.4.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.2...parent-5.4.0)

Updates `com.squareup.okhttp3:mockwebserver` from 5.3.2 to 5.4.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.2...parent-5.4.0)

Updates `com.squareup.okhttp3:okhttp-sse` from 5.3.2 to 5.4.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.2...parent-5.4.0)

Updates `com.squareup.okhttp3:mockwebserver` from 5.3.2 to 5.4.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.3.2...parent-5.4.0)

---
updated-dependencies:
- dependency-name: com.squareup.okhttp3:mockwebserver
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: networking
- dependency-name: com.squareup.okhttp3:mockwebserver
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: networking
- dependency-name: com.squareup.okhttp3:okhttp
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: networking
- dependency-name: com.squareup.okhttp3:okhttp-sse
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: networking
- dependency-name: com.squareup.okhttp3:okhttp-sse
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: networking
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 12:15:01 +00:00
dependabot[bot] 3d0b090a64 chore(deps): bump androidx.test.ext:junit from 1.2.1 to 1.3.0 (#111)
Bumps androidx.test.ext:junit from 1.2.1 to 1.3.0.

---
updated-dependencies:
- dependency-name: androidx.test.ext:junit
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 12:13:52 +00:00
dependabot[bot] f972284dee chore(deps): bump spatialsdk from 0.12.0 to 0.13.1 (#109)
Bumps `spatialsdk` from 0.12.0 to 0.13.1.

Updates `com.meta.spatial:meta-spatial-sdk` from 0.12.0 to 0.13.1

Updates `com.meta.spatial:meta-spatial-sdk-compose` from 0.12.0 to 0.13.1

Updates `com.meta.spatial:meta-spatial-sdk-ovrmetrics` from 0.12.0 to 0.13.1

Updates `com.meta.spatial:meta-spatial-sdk-toolkit` from 0.12.0 to 0.13.1

Updates `com.meta.spatial:meta-spatial-sdk-vr` from 0.12.0 to 0.13.1

Updates `com.meta.spatial:meta-spatial-sdk-isdk` from 0.12.0 to 0.13.1

Updates `com.meta.spatial:meta-spatial-sdk-castinputforward` from 0.12.0 to 0.13.1

Updates `com.meta.spatial:meta-spatial-sdk-hotreload` from 0.12.0 to 0.13.1

Updates `com.meta.spatial:meta-spatial-sdk-datamodelinspector` from 0.12.0 to 0.13.1

Updates `com.meta.spatial:meta-spatial-sdk-uiset` from 0.12.0 to 0.13.1

Updates `com.meta.spatial:meta-spatial-sdk-mruk` from 0.12.0 to 0.13.1

---
updated-dependencies:
- dependency-name: com.meta.spatial:meta-spatial-sdk
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.meta.spatial:meta-spatial-sdk-castinputforward
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.meta.spatial:meta-spatial-sdk-compose
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.meta.spatial:meta-spatial-sdk-datamodelinspector
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.meta.spatial:meta-spatial-sdk-hotreload
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.meta.spatial:meta-spatial-sdk-isdk
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.meta.spatial:meta-spatial-sdk-mruk
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.meta.spatial:meta-spatial-sdk-ovrmetrics
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.meta.spatial:meta-spatial-sdk-toolkit
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.meta.spatial:meta-spatial-sdk-uiset
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.meta.spatial:meta-spatial-sdk-vr
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 12:12:45 +00:00
dependabot[bot] a0bb195d4d chore(deps): bump coil from 3.4.0 to 3.5.0 (#116)
Bumps `coil` from 3.4.0 to 3.5.0.

Updates `io.coil-kt.coil3:coil-compose` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.4.0...3.5.0)

Updates `io.coil-kt.coil3:coil-network-okhttp` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.4.0...3.5.0)

---
updated-dependencies:
- dependency-name: io.coil-kt.coil3:coil-compose
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.coil-kt.coil3:coil-network-okhttp
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 12:10:57 +00:00
dependabot[bot] 038a2a472b chore(deps): bump org.jetbrains.compose from 1.10.3 to 1.11.1 (#110)
Bumps [org.jetbrains.compose](https://github.com/JetBrains/compose-multiplatform) from 1.10.3 to 1.11.1.
- [Release notes](https://github.com/JetBrains/compose-multiplatform/releases)
- [Changelog](https://github.com/JetBrains/compose-multiplatform/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/compose-multiplatform/compare/v1.10.3...v1.11.1)

---
updated-dependencies:
- dependency-name: org.jetbrains.compose
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 12:09:30 +00:00
dependabot[bot] f8141a6a91 chore(deps): bump markdown-renderer from 0.41.0 to 0.42.0 (#117)
Bumps `markdown-renderer` from 0.41.0 to 0.42.0.

Updates `com.mikepenz:multiplatform-markdown-renderer-m3` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/mikepenz/multiplatform-markdown-renderer/releases)
- [Changelog](https://github.com/mikepenz/multiplatform-markdown-renderer/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mikepenz/multiplatform-markdown-renderer/compare/v0.41.0...v0.42.0)

Updates `com.mikepenz:multiplatform-markdown-renderer-code` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/mikepenz/multiplatform-markdown-renderer/releases)
- [Changelog](https://github.com/mikepenz/multiplatform-markdown-renderer/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mikepenz/multiplatform-markdown-renderer/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: com.mikepenz:multiplatform-markdown-renderer-code
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.mikepenz:multiplatform-markdown-renderer-m3
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 12:07:22 +00:00
dependabot[bot] c83f85745d chore(deps): bump org.robolectric:robolectric from 4.14.1 to 4.16.1 (#115)
Bumps [org.robolectric:robolectric](https://github.com/robolectric/robolectric) from 4.14.1 to 4.16.1.
- [Release notes](https://github.com/robolectric/robolectric/releases)
- [Commits](https://github.com/robolectric/robolectric/compare/robolectric-4.14.1...robolectric-4.16.1)

---
updated-dependencies:
- dependency-name: org.robolectric:robolectric
  dependency-version: 4.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 12:05:46 +00:00
dependabot[bot] 674d2e34a2 chore(deps): bump camera from 1.6.0 to 1.6.1 (#112)
Bumps `camera` from 1.6.0 to 1.6.1.

Updates `androidx.camera:camera-core` from 1.6.0 to 1.6.1

Updates `androidx.camera:camera-camera2` from 1.6.0 to 1.6.1

Updates `androidx.camera:camera-lifecycle` from 1.6.0 to 1.6.1

Updates `androidx.camera:camera-view` from 1.6.0 to 1.6.1

---
updated-dependencies:
- dependency-name: androidx.camera:camera-camera2
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.camera:camera-core
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.camera:camera-lifecycle
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.camera:camera-view
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 12:03:50 +00:00
dependabot[bot] 7531065bdf chore(deps): bump the lifecycle group across 1 directory with 5 updates (#104)
Bumps the lifecycle group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| androidx.lifecycle:lifecycle-runtime-ktx | `2.10.0` | `2.11.0` |
| androidx.lifecycle:lifecycle-runtime-compose | `2.10.0` | `2.11.0` |
| androidx.lifecycle:lifecycle-viewmodel-compose | `2.10.0` | `2.11.0` |
| androidx.lifecycle:lifecycle-process | `2.10.0` | `2.11.0` |
| androidx.lifecycle:lifecycle-viewmodel-ktx | `2.10.0` | `2.11.0` |



Updates `androidx.lifecycle:lifecycle-runtime-ktx` from 2.10.0 to 2.11.0

Updates `androidx.lifecycle:lifecycle-runtime-compose` from 2.10.0 to 2.11.0

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.10.0 to 2.11.0

Updates `androidx.lifecycle:lifecycle-process` from 2.10.0 to 2.11.0

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.10.0 to 2.11.0

Updates `androidx.lifecycle:lifecycle-runtime-compose` from 2.10.0 to 2.11.0

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.10.0 to 2.11.0

Updates `androidx.lifecycle:lifecycle-process` from 2.10.0 to 2.11.0

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.10.0 to 2.11.0

---
updated-dependencies:
- dependency-name: androidx.lifecycle:lifecycle-process
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lifecycle
- dependency-name: androidx.lifecycle:lifecycle-process
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lifecycle
- dependency-name: androidx.lifecycle:lifecycle-runtime-compose
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lifecycle
- dependency-name: androidx.lifecycle:lifecycle-runtime-compose
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lifecycle
- dependency-name: androidx.lifecycle:lifecycle-runtime-ktx
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lifecycle
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lifecycle
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lifecycle
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lifecycle
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lifecycle
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 11:58:48 +00:00
dependabot[bot] 0b922538f0 chore(deps): bump androidx.compose:compose-bom in the compose group (#103)
Bumps the compose group with 1 update: androidx.compose:compose-bom.


Updates `androidx.compose:compose-bom` from 2026.05.01 to 2026.06.00

---
updated-dependencies:
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2026.06.00
  dependency-type: direct:production
  dependency-group: compose
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 11:54:36 +00:00
Bailey DixonandClaude Opus 4.8 3166139f9e docs(devlog): record android-v1.2.1 release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:31:46 -04:00
Bailey Dixon 39cafc20c1 Merge pull request #102 from Codename-11/dev
release: android-v1.2.1
2026-06-21 22:28:41 -04:00
Bailey DixonandClaude Opus 4.8 8b15c6d357 release(android): android-v1.2.1
Promote CHANGELOG [Unreleased] -> [1.2.1] (Android-only; CLI + the relay
session_not_found fix stay under [Unreleased] for their own cli-v*/plugin-v*
cuts), rewrite RELEASE_NOTES.md, in-app whats_new.txt, Play release notes, and
the Play listing copy for 1.2.1. Also clarifies the per-surface CHANGELOG split
in RELEASE.md. Version source (1.2.1 / versionCode 15) was already committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:27:09 -04:00
Bailey DixonandClaude Opus 4.8 c869733069 docs(desktop): document tray cockpit + computer-use grant approval
The tray is a visual cockpit over the CLI: it auto-starts the daemon on launch
(auto_start_daemon default), embeds Voice Mode + the TUI, and adds GUI surfaces
the headless CLI can't — a Grant Requests tab and pause / emergency-stop.

- index.md: "not a chat app" -> "not a full chat app" (it has a CLI-backed
  lightweight chat); document auto-start-daemon-on-launch (distinct from
  boot-persistence), Grant Requests + Voice Mode tabs, pause/emergency-stop.
- tools.md: new "Computer-use (experimental)" section covering the
  enable->observe->grant flow AND how grants are approved — interactive prompt,
  tray Grant Requests tab, and the headless HERMES_RELAY_GRANT_BRIDGE_DIR
  file-bridge (previously undocumented).
- subcommands.md: daemon tip notes the tray auto-runs the daemon (GUI
  equivalent of `daemon start`), same while-running lifetime, not boot-persist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:13:54 -04:00
Bailey DixonandClaude Opus 4.8 7deb3efa88 chore(android): add Developer-options test harness for hard-to-trigger surfaces
Debug-only (FeatureFlags.isDevBuild) triggers in Developer options for the
on-device-only flows unit tests can't reach and that don't occur on demand:

- Emit sample Info/Warning/Error entries into DiagnosticsLog (exercises the
  list -> detail -> Copy/Share/Create-issue flow).
- Preview the in-app update banner via UpdateDebugOverride (Available ->
  Downloaded -> off), honoured by rememberUpdateAvailability ONLY in debug
  builds; cleared when the previewed banner is actioned/dismissed.
- Show What's New now (ConnectionViewModel.showWhatsNewNow()).
- Force a test crash to exercise the crash-report capture + dialog.

No release-build behaviour change: the section is gated by isDevBuild and the
update override is gated by BuildConfig.DEBUG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:00:13 -04:00
Bailey Dixon f0e135c153 Merge: realtime-agent API Server session handoff (#101) into dev
Brokered Hermes turns from the Realtime Agent no longer fail with
session_not_found when the client session id came from another namespace,
and API Server session creation now parses the nested session.id shape.
2026-06-21 21:48:35 -04:00
Bailey DixonandClaude Opus 4.8 f6b965a97c fix(realtime): resolve API Server session handoff for brokered Hermes turns
The Realtime Agent's brokered Hermes path (hermes_run_task) could fail
two ways when reaching back to the API Server:

- a caller-supplied chat_session_id from another session namespace (the
  gateway/client session store) was passed straight to
  /api/sessions/{id}/chat/stream and rejected with 404 session_not_found
- _create_session() only read a flat id/session_id, but the current API
  Server returns the session nested under {"session": {"id": ...}}, so
  creation raised "Hermes API created a session without an id"

stream_task() now tracks whether it owns the API Server session and, on a
404 session_not_found for a caller-supplied id, mints a fresh API Server
session (emitting a session.bound handoff event) and retries the turn
once — a session it created itself, or a second failure, is not retried,
so there is no loop. Valid existing API sessions are reused untouched.
_create_session() parses both the nested and legacy flat response shapes.

Adds plugin/tests/test_hermes_tool_broker.py (13) covering both parsers
and the namespace-mismatch handoff/retry against a local aiohttp fake
API Server.

Closes #101

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:47:57 -04:00
Bailey DixonandClaude Opus 4.8 0aa1b38a18 feat(android): profile lock, voice fixes, diagnostics detail, in-app changelog, Play update nudge
Bumps appVersionName to 1.2.1 (versionCode 15).

Added:
- Profile lock (per-connection): pin to one profile and hide the rest; ProfileLockStore + ProfileController enforcement + Settings lock dialog with a not-found banner.
- In-app What's New / changelog from a bundled changelog.json; revisitable Settings entry sharing one renderer with the auto post-update dialog.
- Diagnostics detail view with Copy / Share / Create-GitHub-issue via a shared IssueReport helper (also adopted by the crash dialog); RelayErrorClassifier now records every classified error to DiagnosticsLog with a clean title + redacted stacktrace.
- Update-available banner: googlePlay uses Play In-App Update (FLEXIBLE; new app-update dep, flavor-scoped), sideload uses the GitHub checker; per-version dismissal + 6h throttle, never nags.

Fixed:
- Voice override now applies in Auto mode (effectiveRoute gate) and voice prefs are namespaced by connectionId.
- Realtime Stop halts playback immediately (suppress in-flight deltas); spoken-status throttle; client idle-watchdog relaxed on promoted/long runs.
- Hold-to-talk releases only on a real finger-up; voice overlay panel + bubbles opaque with non-wrapping labels; invalid engine/route combos gated.
- Connection status overlay terminal states auto-dismiss within ~5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:38:00 -04:00
Bailey DixonandClaude Opus 4.8 a22bdd9488 docs: add SECURITY.md + Code of Conduct; route issue reports to a private channel
- SECURITY.md: GitHub Private Vulnerability Reporting (preferred) + security@codename-11.dev fallback; scope, response expectations, safe harbor.
- CODE_OF_CONDUCT.md: Contributor Covenant 2.1 (conduct@codename-11.dev), adopted by reference.
- Issue config: replace the public "security guidance" link with a private "Report a vulnerability" link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:37:53 -04:00
Bailey Dixon 26e4a054d2 Merge pull request #100 from Codename-11/dev
fix(ci): unblock cli-v release (tray smoke $home bug)
2026-06-21 21:18:57 -04:00
Bailey DixonandClaude Opus 4.8 9f568e12cb fix(ci): tray smoke uses $smokeHome, not read-only $home (unblocks cli-v release)
The tray smoke step in release-cli.yml assigned `$home = ...`, but $HOME is a
read-only automatic variable in PowerShell (names are case-insensitive), so it
threw "Cannot overwrite variable HOME because it is read-only or constant",
failing the tray job and skipping Publish. First cli-v* tag surfaced it — the
CLI binaries themselves built fine. Use a distinct scratch variable; the
$env:HOME / $env:USERPROFILE environment vars stay writable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:17:39 -04:00
Bailey Dixon a0b4d3715c Merge pull request #99 from Codename-11/dev
release(cli): cli-v0.4.0-alpha.1
2026-06-21 21:03:24 -04:00
Bailey DixonandClaude Opus 4.8 e0a2a59957 release(cli): cli-v0.4.0-alpha.1
Bumps desktop/package.json 0.3.0-alpha.18 -> 0.4.0-alpha.1 (a new minor for the
command-surface uplift; stays in the experimental alpha track) and fills
CLI_RELEASE_NOTES.md for the GitHub Release body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:59:47 -04:00
Bailey DixonandClaude Opus 4.8 738256238f feat(desktop): CLI first-class pass — audit/relay/logo, background daemon, visual layer
Brings the CLI up to the relay's v1.2.0 capabilities and gives it a consistent,
discoverable interface. New commands: `audit` (what the agent ran on this
machine, from a local log), `relay info/security/context` (inspect the relay
server and audit the system-prompt context it injects into the agent), `logo`,
and `daemon start/stop/status` for running the tool router in the background
(no console window, survives closing the terminal).

Every subcommand now answers `--help`; list output (devices/sessions) renders
as aligned tables with status dots; slow operations show a spinner; errors
suggest the fix; and pairing reports per-endpoint probe progress and warns
before a stored session expires. `voice` surfaces the enhanced-voice
(Gemini/xAI) block, and the desktop-tool consent prompt points at `audit`.

Adds a shared zero-dep lib/ (theme/table/spinner/hints/usage/logo/auditLog/
daemonStatus), an `npm run dev:install` local-binary helper, and refreshed
desktop user-docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:59:42 -04:00
Bailey DixonandClaude Opus 4.8 d1820fb606 fix(relay): keep realtime voice heartbeat alive during long Hermes runs
The realtime voice agent killed a turn after ~90s of websocket silence
(client idle watchdog). The relay heartbeat stopped the moment
hermes_run_status left {running, waiting_for_confirmation}, so a long or
background Hermes run could starve it and trip the stall. The heartbeat
now continues while session.hermes_task is unfinished, and the spoken
progress repeat is raised 30s->90s and gated on a coarse status change so
tool-message churn no longer re-narrates.

Adds plugin/tests/test_realtime_heartbeat.py (11 cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:23:46 -04:00
Bailey DixonandClaude Opus 4.8 11274ce51b ci(android): add release-build smoke to catch tag-time breakage early
The android-v* release builds the release variant (bundleRelease
assembleRelease, both flavors); PR CI only built debug, so release-only
failures (R8/minify, resource shrinking, bundletool OOM) surfaced at the tag
— e.g. the v1.2.0 OOM at -Xmx2048m. Adds a debug-signed release-build smoke
(no secrets) on dev/main pushes and the dev->main release PR, so the same
build that the tag runs is exercised before tagging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:22:55 -04:00
Bailey Dixon 6fb15ddc9c Merge: main (v1.2.0 release + CI fixes) back into dev 2026-06-21 18:08:20 -04:00
Bailey Dixon 15dcd6d637 fix(docs): pin search-insights for deterministic npm ci (#98)
Unblocks Deploy Docs.
2026-06-21 18:07:18 -04:00
Bailey DixonandClaude Opus 4.8 42d262bc79 fix(docs): pin search-insights so npm ci is deterministic across npm versions
The bundled docsearch declares search-insights as an OPTIONAL peer dep with
no resolved lock entry. npm 11.9 (local) treats it as satisfiable and passes;
CI's npm rejects it ("Missing: search-insights@2.17.3 from lock file").
Pinning it as a direct devDependency gives it a resolved node_modules entry,
so `npm ci` agrees on every npm version. Validated with a clean local npm ci.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:06:20 -04:00
Bailey Dixon b977b6b02a fix(ci): docs build on Node 24 to match lockfile (#97)
Unblocks Deploy Docs.
2026-06-21 18:02:02 -04:00
Bailey DixonandClaude Opus 4.8 d411764935 fix(ci): build docs on Node 24 (npm 11) to match the lockfile
Deploy Docs failed `npm ci` with "Missing: search-insights@2.17.3 from lock
file". user-docs/package-lock.json is generated by npm 11, which omits the
resolved entry for the optional `search-insights` peer dep of bundled
docsearch; CI's Node 20 / npm 10 demands it. Align CI to npm 11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:01:26 -04:00
Bailey Dixon 73c31803e9 fix(ci): raise Gradle heap to 4g for release bundling (#96)
Unblocks the android-v1.2.0 re-cut.
2026-06-21 17:51:25 -04:00
Bailey DixonandClaude Opus 4.8 d7a15d08fe fix(ci): raise Gradle heap to 4g so release bundle packaging doesn't OOM
The android-v* release workflow builds both flavors' AABs+APKs
(bundleRelease assembleRelease); at -Xmx2048m, packageSideloadReleaseBundle
OOMed ("Java heap space") in bundletool after the googlePlay bundle. PR CI
only builds debug, so it never hit this. 4g clears it with margin and also
helps local release builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:50:30 -04:00
Bailey Dixon da36172af3 Merge: main (v1.2.0 release) back into dev 2026-06-21 17:34:24 -04:00
Bailey Dixon 3a99842011 Release v1.2.0 (android + plugin) (#95)
Merge dev -> main for android-v1.2.0 and plugin-v1.2.0.
2026-06-21 17:31:30 -04:00
Bailey Dixon d261a1c374 feat: support static pet packs 2026-06-21 17:18:24 -04:00
Bailey DixonandClaude Opus 4.8 cf30b0dbc2 ci(android): auto-publish Play Store listing on main pushes
The Play Store Listing workflow now publishes the listing (screenshots,
graphics, and text) automatically when its path-scoped assets change on main,
in addition to manual workflow_dispatch. PRs and dev pushes still validate
only, and it skips gracefully (a notice, not a failure) when the
PLAY_SERVICE_ACCOUNT_JSON secret is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:41:25 -04:00
Bailey DixonandClaude Opus 4.8 8ea813d8d8 docs(android): document the deterministic screenshot harness
Add a "Deterministic rendering" section to docs/screenshot-automation.md (run
command, how to add a view, real-screen vs curated-frame for config/data
screens, the JDK-21 and no-plugin gotchas, and the Play-listing publish flow),
plus a CLAUDE.md Key Files pointer so the harness is discoverable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:41:05 -04:00
Bailey DixonandClaude Opus 4.8 a806726cb2 docs(android): add App Themes gallery to the user docs
New Themes feature page showing the eight-theme gallery (the same chat reskinned
by every theme), wired into the docs sidebar and the features index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:40:45 -04:00
Bailey DixonandClaude Opus 4.8 45519e9fc8 chore(android): refresh 1.2.0 store screenshots (deterministic 1:1 renders)
Regenerate all eight phone screenshots host-side at exact 2:1; replace the
command-palette and settings scenes with App Themes and Appearance (the latter
the real AppearanceSettingsScreen, rendered 1:1). Re-export the Play graphics
and README grid; screenshots.py validate is clean (no 2:1 crop warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:40:24 -04:00
Bailey DixonandClaude Opus 4.8 7746d7de98 test(android): add Roborazzi host-side screenshot harness
Deterministic, device-free store/docs screenshot renderer: renders real
screens/components with mock data at exactly 1080x2160 (no Play 2:1 clipping).
Drops the AGP-9-incompatible Roborazzi Gradle plugin (keeps the runtime) and
runs unit tests on JDK 21 for the markdown code-highlighter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:40:01 -04:00
Bailey DixonandClaude Opus 4.8 3bec0d22b8 release(plugin): plugin-v1.2.0
Bump plugin/dashboard metadata to 1.2.0 (in sync). Release notes cover the
relay enhancement layer + agent-context injection (sensitive-media block,
/context/injected audit, dashboard toggles, default-on), provider-aware
enhanced voice (Gemini + xAI), isolated TUI-tuned tmux, and voice cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 23:24:48 -04:00
Bailey DixonandClaude Opus 4.8 222fab4fb9 release(android): android-v1.2.0
Promote [Unreleased] -> [1.2.0]; backfill the agent-pet system, in-app
crash reporting, per-profile icons, clean mode, permissions screen, the
"Standard"->"Vanilla Hermes" rename, and PDF/image crash fixes that
shipped to dev without changelog bullets. appVersionCode 13 -> 14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 23:24:33 -04:00
Bailey DixonandClaude Opus 4.8 b27f3a0a7d docs(android): pet kit — frames must visibly animate, not just register
A generation-method change over-corrected: the registration/safe-box prompt
produced 16 near-identical frames (measured interframe diff ~0.02/255), so pets
rendered static even though frameCount is 16 and the renderer cycles all of
them. Clarify across the prompt template, gotchas, and pet-spec that the cells
are an animation, NOT copies — lock only the identity/anchor (position+scale),
but the moving parts (eyes, mouth, hands, hair, accent) must visibly progress
through the full motion arc across all 16 frames; over-locking is its own
distinct failure. Also carries the chroma-key + safe-box authoring guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 23:05:51 -04:00
Bailey DixonandClaude Opus 4.8 3cbf0333ae fix(android): drop the customized ring when a profile icon image is shown
The 2dp "customized" ring is meant to mark the letter avatar; on an actual
profile photo it just looks like a bad outline. Suppress it whenever
LocalAgentIconPath is set (sheet header + Settings); the ring still shows for
the letter fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:53:54 -04:00
Bailey DixonandClaude Opus 4.8 954d2522ed feat(android): use the per-profile icon for header/navbar avatars too
The profile icon only reached the per-message label; the circular header avatars
(agent sheet, chat top bar, Settings) still showed the generated letter. Add a
shared AgentAvatarFace that renders the LocalAgentIconPath image when set, else
the name's initial, and use it in all three. The chat header keeps its letter
cross-fade for the no-icon case (image short-circuits before AnimatedContent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:41:00 -04:00
Bailey DixonandClaude Opus 4.8 fa973dd2df docs(todo): mark per-profile icon + static-image avatar shipped; ignore build logs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:31:09 -04:00
Bailey DixonandClaude Opus 4.8 d827e460e0 feat(android): static-image avatars + per-profile agent icon (client-side)
Two custom-identity features (they share ConnectionViewModel, so one commit):

Static-image avatar: "Add a pet" now accepts a single image (PNG/JPG/GIF/WebP),
detected by magic bytes, and auto-wraps it as a one-frame static pet (idle.png +
a synthesized minimal pet.json) — a custom avatar with no manifest authoring.
importZip -> importUri; importPetFromZip -> importPet.

Per-profile agent icon: a client-side twin of ProfileDisplayAliasStore. New
ProfileIconStore (own DataStore, keyed per (connection, profile), never sent to
Hermes) holds a path to an image copied into files/profile-icons/ (not a SAF
URI, so it survives without persistable permission). Wired through
ProfileController next to profileDisplayAlias, exposed on ConnectionViewModel,
provided at the app root as LocalAgentIconPath, and rendered as a small circular
Coil image beside the agent name in MessageBubble. Picker (AgentIconRow) sits
under the local-name row in ConnectionInfoSheet. Scope: small name-adjacent icon
only; the big avatar stays global. Tests for both; PetImporter image-wrap +
ProfileIconStore scoping/clear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:29:51 -04:00
Bailey DixonandClaude Opus 4.8 3cd8791ce4 feat(android): in-app pet state preview in Appearance
Testing a pet meant inducing each state by driving the agent (run a tool for
working, fail a turn for error, start voice for speaking). Add a preview under
the speed/stabilize controls (pet selected only): a ~140dp canvas rendering the
active pet, a FilterChip row for the seven sustained states, and Greet/Done
buttons that replay the one-shots. Pure UI on the existing AgentAvatar seam —
no new ViewModel/pref/renderer; it calls activeAvatar.Render(AvatarRenderState(
state=...)) with a user-picked state, so it also reflects the live speed and
stabilize settings. Working = Thinking + toolCallBurst; Greet remounts via key;
Done drives a momentary Speaking->Idle transition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:50:31 -04:00
Bailey DixonandClaude Opus 4.8 d1bf6245fd feat(android): auto-stabilize pet frames (re-center on content)
AI-generated sprite sheets keep a character's appearance consistent but not its
position/scale across cells, so the pet floats/jumps as it plays (audited: 34px
vertical drift over 16 cells, 8/16 frames touching the cell edge). Add decode-
time stabilization: scan each frame's opaque pixels (alpha bbox) and shift the
draw so the content's center sits at the cell center. Works for sheets (per
cell) and sequences (per bitmap); one-time scan on IO with a reused buffer.

Exposed as a global LocalPetStabilize (pet_stabilize pref) with a "Stabilize
frames" Switch in Appearance, default on. Keys the decode produceState so
toggling re-decodes. Fixes an installed pet at render time with no re-import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:04:29 -04:00
Bailey DixonandClaude Opus 4.8 f083ceacf0 docs(android): stress frame registration in the pet prompt kit
On-device audit of a 4x4 pet showed the character's vertical center drifting
34px across the 16 cells with 8/16 frames touching the cell edge — the image
model kept appearance consistent but not position/scale, so the pet floats and
the next frame's edge bleeds in. The renderer slices/centers exact cells
faithfully, so this is an authoring (registration) gap, not an engine bug. Add
registration instructions to the prompt template (lock head/shoulders, same
position + scale, only small secondary motion) and the consistency caveat
(registration degrades with cell count; drop to 3x3/2x2 if a 4x4 drifts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:48:41 -04:00
Bailey Dixon a43d395108 Merge: transport-tier stepper + dashboard default-on into dev 2026-06-20 20:40:23 -04:00
Bailey DixonandClaude Opus 4.8 47d4d4f532 feat(android): transport-tier stepper in session details + dashboard default-on display
Android: SessionPathDetails (agent sheet → Connection) gains a vertical basic→best transport ladder (Completions → Runs → Sessions → Gateway) via a new TransportTierStepper, using the same resolveChatTransportStatus as the status badge — active tier filled+highlighted, server-unsupported tiers muted, with the resolver's reason beneath. Dashboard: the Agent-context toggles now read as ON when the env is unset (matching the new config default) via a strict-bool coercion, and the label says 'On by default for relay installs'; dist rebuilt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:40:00 -04:00
Bailey DixonandClaude Opus 4.8 242665348d docs(android): pet cell-resolution guidance (256px cells, size for biggest surface)
Pixelation is a resolution axis (cell px), separate from smoothness (frame
count): one frame set is contain-fit into every surface, so author for the
largest (the full-screen chat background) and small placements (voice overlay)
downscale and stay sharp. Bump the kit default to 256px cells (a 1024x1024
sheet for 4x4), note 512px is fine for a sprite sheet (one bitmap), and that
the old "<=256px" note was for frame-sequences. Updates custom-avatars.md,
pet-prompt-kit.txt, pet-spec.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:20:41 -04:00
Bailey DixonandClaude Opus 4.8 5544c23f05 feat(android): pet playback-speed control in Appearance
A pet that feels too fast/slow needed re-authoring + re-importing to tune. Add a
global playback-speed multiplier (pet_speed pref, 0.5x-1.5x, default 1.0) as a
Slider in Appearance, shown when a pet is selected. It's provided at the app
root via a new LocalPetPlaybackSpeed composition local and read live in
PetAvatar.Render (rememberUpdatedState), so dragging it re-times the pet
instantly with no restart. Applies to every clip including one-shots and
composes with intensity (baseFps * speed * intensityFactor, clamped 1-60). The
sphere avatar ignores it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:20:08 -04:00
Bailey DixonandClaude Opus 4.8 3d5a94d818 docs: correct default-on for relay agent-context injection (CHANGELOG/DEVLOG)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:18:56 -04:00
Bailey DixonandClaude Opus 4.8 aeaf7282f3 feat(relay): enable agent-context injection by default for relay installs
The relay plugin install is itself the opt-in, and the wrap is fail-open, auditable (chat 'Relay context (server-side)'), and reversible from the dashboard toggle — so default the master + media-sensitivity gates ON. Vanilla upstream (no plugin) is unaffected; set RELAY_AGENT_CONTEXT_ENABLED=0 to opt out. Tests updated for the new default + explicit-off coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:10:20 -04:00
Bailey DixonandClaude Opus 4.8 fc8aaff749 docs(android): default pet kit to 4x4 (16-frame) sheets for smooth motion
A 2x2 (4-frame) sheet reads steppy at any fps. The renderer already slices any
N×M grid (decodeClip derives cols/rows from sheet size / cell size; drawPetFrame
indexes col=i%cols, row=i/cols), so "support 4x4" is an authoring default, not a
renderer change. Default the kit to a 4x4 grid (16 frames): prompt template,
manifest example, and pet-prompt-kit.txt now use frameCount 16 with fps matched
to the count (idle ~8 -> ~2s loop); 2x2/4 stays documented as the
easier-consistency fallback. pet-spec notes any rectangular grid works. Adds a
PetLoaderTest case for a 16-frame sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:06:27 -04:00
Bailey DixonandClaude Opus 4.8 27e62ff768 fix(android): smooth pet frame loop (remove double-wait frame skip)
PetAvatar.Render's frame loop awaited withFrameNanos (one vsync) AND
delay(1000/fps) each iteration, so every frame waited ~16ms longer than its
duration; the surplus accumulated until the loop skipped a frame to catch up —
a periodic hitch, worst at low fps. Drop the delay: withFrameNanos already
paces the loop at vsync, and the accumulator advances the sprite only when a
frame's worth of real time has elapsed, so playback is smooth and intensity's
variable rate no longer causes skips.

Also document that smoothness comes from frame count (8-16), not fps, and to
match fps to count (calm states 3-4); lowered the example/kit idle+listening
fps to 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:48:00 -04:00
Bailey DixonandClaude Opus 4.8 c4b1a02ba5 docs(todo): relay enhancement-layer follow-ups + retirement notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:22:22 -04:00
Bailey Dixon f3fc8e557c Merge: relay enhancement layer + agent-context injection into dev
# Conflicts:
#	DEVLOG.md
2026-06-20 15:09:25 -04:00
Bailey DixonandClaude Opus 4.8 b581756ffd docs(relay): enhancement-layer design + structured-media plan + DEVLOG/CHANGELOG
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:06:26 -04:00
Bailey Dixon 3ae2495188 feat: audit relay context and chat transport 2026-06-20 15:01:15 -04:00
Bailey DixonandClaude Opus 4.8 092d6a0c8f feat(android): in-app add/remove/refresh for custom pet avatars
Appearance could select avatars but not add or remove a pet — the only path
was adb push into app-scoped external storage, which scoped storage stalls on
(confirmed hanging on a Samsung device). And the avatar list loaded once at
startup, so even a pushed pet never appeared without a restart; users saw only
the Sphere.

- PetImporter (new): "Add a pet" launches a SAF .zip picker and unpacks into
  pets/. Hardened with a zip-slip guard, per-file/total/count ceilings, and
  post-extract validation through the same PetSpec.toAvatar the loader uses.
- PetLoader.deletePet: remove a pack by resolved manifest id, behind a confirm
  dialog; falls back to the Sphere if the deleted pet was selected.
- Live refresh: an avatarsRefreshTick keys the avatar produceState in RelayApp,
  so import/delete and opening Appearance re-scan pets/ without an app restart
  (resolves the process-scoped-load TODO). Results surface as snackbars.
- AppearanceSettingsScreen: "Add a pet" + "Rescan" buttons and an
  "Installed pets" management list with per-pet remove.
- Tests: PetImporterTest (root/nested import, no-manifest, missing-idle,
  zip-slip refused) and PetLoaderTest delete cases.

Built and installed to the sideload debug build; new unit tests pass (the 12
build failures are the pre-existing DataStore/FileStorage JVM cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:56:49 -04:00
Bailey DixonandClaude Opus 4.8 3c0f6f6cca docs(android): AI pet authoring kit + JSON schema for custom avatars
Pets are pure data, so the only barrier to making one is sourcing the art.
Document an AI-generation workflow plus a machine-readable contract:

- A reference-image-first, character-agnostic prompt template
  ({character}/{style}/{accent}) and a per-state motion table mapping image
  generation onto the agent-state vocabulary, a full 9-state manifest, and a
  one-download pet-prompt-kit.txt.
- A draft-07 JSON Schema (user-docs/public/pet.schema.json) mirroring the
  loader structural rules (required idle, frames-XOR-sheet, positive sheet
  dims) so editors and AI agents can validate a pet.json before installing it.
- A vendor-neutral "let an AI agent build the pack" callout (Codex/Claude Code
  as examples) stating the acceptance criteria and image-gen prerequisite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:56:25 -04:00
Bailey Dixon 41b341ed09 feat(plugin): add relay agent context injection 2026-06-20 14:50:49 -04:00
Bailey DixonandClaude Opus 4.8 b5fd63bb93 fix(android): stop PDF viewer crash when document closes mid-measure
PdfDoc.pageCount was a lazy getter delegating to PdfRenderer.pageCount, so a LazyColumn measure pass racing DisposableEffect's onDispose { doc.close() } could call getPageCount() on an already-closed renderer -> IllegalStateException 'Document already closed' (caught in the wild by the crash reporter). Capture pageCount once at open time (a PDF's count is immutable) so it never reads the renderer after close, and skip page render when the doc is already closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:34:30 -04:00
Bailey DixonandClaude Opus 4.8 5077ddd244 fix(android): render server-local chat images whose path has a space
An agent referenced /mnt/.../Coralee Adshade/undressher.jpg three ways and none rendered — all because of the space in the path:

- MEDIA:/path bare marker used /\S+, which stops at the space, so the marker never matched and showed as raw text. Now /.+? (allows spaces; OkHttp re-encodes for /media/by-path).

- ![](<path with spaces>): the markdown angle-bracket URL form wasn't accepted — the regex kept the leading '<' and stopped at the space, failing the startsWith("/") server-local check. Regex now accepts <...> and normalizeImageSrc strips the brackets.

- ![](/path%20encoded): the percent-encoded space wasn't decoded, so the relay looked up a literal '%20' directory and 404'd. normalizeImageSrc now percent-decodes absolute paths (protecting a literal '+').

Verified on-device: the previously-raw MEDIA: line now renders the image. File and relay were fine; this was entirely client-side path handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:11:49 -04:00
Bailey DixonandClaude Opus 4.8 52990aaf37 feat(android): keep crash report until acknowledged, not just first view
CrashReportGate consumed (read+deleted) the report on first read, so it vanished after one glance even if the user never acted on it. Switch to peek-on-read + clear-on-acknowledge: the report now survives relaunches until the user Dismisses or Reports it (Copy keeps it available), so a crash you saw but didn't report isn't lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:01:33 -04:00
Bailey DixonandClaude Opus 4.8 133a785839 fix(android): stop crash on server-local chat images (kotlin.Result in suspend)
RelayServerImage crashed on app open with 'kotlin.Result cannot be cast to byte[]': the resolver returned Result<ByteArray> from a suspend fun, and runCatching { fetch() } nested Result-in-Result, which Kotlin's value-class Result collapses incorrectly at runtime. Replace the suspend fetch path's kotlin.Result with a purpose-built ServerImageResult sealed type (Success/Failure) so the resolver boundary never returns kotlin.Result from a suspend function.

Caught in the wild by the new in-app crash reporter (Galaxy S25 Ultra, SDK 36).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:55:56 -04:00
Bailey DixonandClaude Opus 4.8 b1a0a7b21d fix(android): use GitHub's stable title+body params for crash-report prefill
Issue-form field-id prefill (template=bug_report.yml&<id>=...) is a GitHub public-preview feature and silently did not apply — only the title carried. Switch to the stable classic ?title=&body=&labels=bug route (blank_issues_enabled is true), with a markdown body that mirrors the form's sections (Affected area / What happened / Environment / Crash) plus a sanitization reminder, so the auto-captured report reliably prefills.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:23:50 -04:00
Bailey DixonandClaude Opus 4.8 a455e4688f feat(android): in-app crash reporting + QR camera hardening for foldables
Add privacy-respecting crash capture (no Firebase): an uncaught handler persists a structured report then re-raises so the system dialog and Play Android vitals still collect it. On next launch a show-once dialog offers Copy + a pre-filled GitHub bug_report.yml issue with device/version/trace.

Harden QrPairingScanner camera init — try/catch around ProcessCameraProvider.get() (main thread) and InputImage.fromMediaImage() (analyzer thread), with a graceful CameraUnavailableCard -> manual pairing fallback instead of a force-close. Addresses a Galaxy Z Fold7 'keeps crashing during setup' report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:04:34 -04:00
Bailey DixonandClaude Opus 4.8 60093e383d feat(android): pet intensity modulation — clip speeds up under load
Complete the pet reactivity story (voice · tools · activity). The activity
ramp (intensity, ~0.7 while streaming) was already fed to every avatar but
pets ignored it; now an opt-in pet quickens its clip as the agent works.

- Live playback-rate modulation in PetAvatar.Render, opt-in via
  reactive.intensity: the base/working loop's fps scales by
  1 + intensity*PET_INTENSITY_RATE (0.6 -> ~1.4x typical, 1.6x peak, capped at
  PET_MAX_FPS). Read live via rememberUpdatedState so speed tracks the agent
  mid-clip without restarting the long-lived frame loop (re-keying on a
  continuously-animated float would thrash). One-shots excluded (!playOnce) so
  greet/done keep their authored rate.
- Flipped PET_RENDERER_CAPABILITIES.intensity to true; the loader's existing
  reactive.intensity && capability formula now lets a declared intensity:true
  through, so the pet honestly advertises Activity. No loader change.
- Tests: declared intensity is honored (Voice · Activity); split the prior
  clamp test so tools-without-a-working-clip still stays off the badge.
- docs/pet-spec.md: intensity row rewritten from Reserved to the speedup
  behavior; removed from Forthcoming (only attention remains there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:16:38 -04:00
Bailey DixonandClaude Opus 4.8 d5a1ef54f0 feat(android): pet one-shot reaction layer (greet + celebrate)
The event tier of pet behavior: a reaction clip that plays ONCE over the base
loop, then returns — the touch that turns a status display into a character
(cf. the Peon Pet's celebrate-on-finish).

- Pet-local triggers, no host plumbing: reactions ride the activity-state
  transitions the avatar already sees. PetOneShot.Greet fires on first
  composition (the pet appears); PetOneShot.Done fires when a productive turn
  ends (Streaming/Speaking -> Idle; Thinking/Error -> Idle don't celebrate).
  Both opt-in (only if the pet ships the clip) and require >= 2 frames.
- Play-once-then-revert in PetAvatar.Render: a `playOnce` frame mode runs the
  clip 0->end (no modulo wrap), parks on the last frame, clears the active
  reaction, and recomposition hands back to the base loop. A reaction overlays
  everything (incl. working). Suppressed under reduced motion; an
  ONE_SHOT_MAX_MS (4s) backstop guarantees it never lingers on decode failure.
- PetLoader resolves friendly aliases (greet/wake, done/celebrate) from explicit
  `states` keys only (no fallback). One-shots are reactions, not a reactivity
  signal, so they don't touch the picker badge.
- Test: a pack with greet/done keys loads and the badge stays Voice (no
  accidental Tools/Activity coupling). Render-time playback is on-device/
  Compose-test territory (flagged in TODO).
- docs/pet-spec.md: new "One-shot reactions" section (Greet/Done table, opt-in,
  play-once, reduced-motion), an Expressive authoring tier. `attention`-on-
  notification stays Forthcoming (needs a host event the avatar lacks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:03:03 -04:00
Bailey DixonandClaude Opus 4.8 217daeddf1 feat(android): pet working/tool-use overlay reacting to tool calls
Give pets a distinct "agent is running a tool" behavior, separate from
thinking — the strongest cross-system convention (MS Agent Think vs Process;
pi-animations Thinking·Working·Tool) is that acting should look different
from thinking.

- Pet-local overlay derived from the already-plumbed toolCallBurst, NOT a 7th
  SphereState — zero blast radius on the Sphere or call sites. PetAvatar.Render
  swaps to an optional workingClip when toolCallBurst >= 0.5 during a
  thinking/writing turn, releasing ~600ms after the last tool as the burst
  decays. Error keeps its own clip; burst is ~0 outside tool activity.
- Opt-in + clip-driven: workingClip resolves only from an explicit `working`
  key (no fallback). Shipping one IS the tool-reactivity capability — it drives
  both the swap and the Tools badge (reactivity.tools = workingClip != null &&
  PET_RENDERER_CAPABILITIES.tools), so the declared reactive.tools flag is no
  longer needed and can't over-promise. Flipped PET_RENDERER_CAPABILITIES.tools
  to true.
- Tests: a working clip lights the Tools badge; a working clip with missing
  files does not; declared-but-no-clip still clamps to Voice.
- docs/pet-spec.md: `working` moved from Forthcoming into the implemented model
  (state-table row, "working overlay" subsection, Rich tier = 7 clips,
  reactivity table tools row). Forthcoming trimmed to one-shots + intensity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:52:43 -04:00
Bailey DixonandClaude Opus 4.8 f6b0afec9f feat(android): honest pet reactivity badge + behavior-model spec
The pet picker badge read reactivity straight from pet.json, so a manifest
could advertise tools/intensity the renderer never delivered. Clamp the
effective reactivity to what the renderer actually honors, and document a
real agent-state -> behavior model so pets can show thinking/writing/etc.

- PetAvatar.PET_RENDERER_CAPABILITIES: single source of truth for the live
  signals Render consumes today (voice only). PetLoader.toAvatar clamps a
  pet's reactivity to declared-AND-supported, so the badge can't over-promise.
- Friendly `writing` clip alias for the Streaming (output) state; tidied the
  Speaking/Error fallback chains. Backward compatible.
- docs/pet-spec.md: new "Agent states & pet behavior" section — state meanings,
  friendly clip-key vocabulary + fallback chains, a Minimal->Rich authoring
  ladder, and a "Forthcoming behavior" tier (working/tool clip, one-shot
  reactions, intensity modulation) grounded in prior art (MS Agent .acs set,
  pi-animations, Peon Pet). Reactivity table notes the clamp.
- PetLoaderTest: declared tools/intensity are dropped from the badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:45:48 -04:00
Bailey DixonandClaude Opus 4.8 11b0bb391d fix(chat): paint a reopened session's real model from the session.resume result
On reopen/prewarm the gateway already returns the session's model in the
session.resume RPC result's `info`, but the client read only `session_id` and
discarded it — so the header/picker showed the global DEFAULT until the first
turn's async session.info arrived (~15-30s later), though the send itself
correctly used the session's stored model. Read info.model/provider/effort/yolo/
fast/usage from the resume result (resumeForPrewarm + ensureSession) into the same
_server* flows the session.info event feeds, via a shared applySessionInfo helper,
so a reopened session shows its actual model immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:01:52 -04:00
Bailey DixonandClaude Opus 4.8 60eb993b15 fix(android): make side-loaded avatars/skins reachable + unify storage
The only documented way to install a pet avatar or sphere skin was an
`adb push` to external app-scoped storage, but both loaders read from
internal `filesDir` (`/data/data/<pkg>/files/`), which is not
`adb push`-able on a non-rooted device. The documented side-load path
could never work on either flavor.

- UserContentDir (new): shared resolver preferring external app-scoped
  storage (getExternalFilesDir, the /sdcard/Android/data/<pkg>/files/
  path adb push reaches, no runtime permission on API 19+) with internal
  filesDir fallback. Single source of truth for where pets AND sphere
  skins live — fixes the bug once for both.
- PetLoader / SphereSkinLoader: resolve through UserContentDir; add pure
  load(dir: File) overloads so the validation/skip-invalid logic is
  unit-testable without an Android Context.
- PetLoaderTest (17) + SphereSkinLoaderTest (6): parse, id/label
  fallbacks, schema + missing-idle + missing-file rejection, the
  safeChild path-traversal guard, fps clamping, one-bad-pack isolation,
  sort order, empty/absent dirs.
- AppearanceSettingsScreen: "Add your own pet" pointer so the feature is
  discoverable with no pets installed (mirrors the sphere-skin pointer).
- docs/pet-spec.md + docs/sphere-spec.md: correct the storage prose, both
  flavor paths, cross-link the two specs, fix an "Agent sphere" naming
  drift, add undecodable-image + per-frame-memory authoring caveats.
- user-docs/features/custom-avatars.md (new) + nav: user-facing page on
  the avatar→skin model, reactivity badges, adding skins/pets, reduced
  motion, troubleshooting.

Follow-ups (TODO.md): per-frame memory cap/downsample, decoded-clip cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:50:30 -04:00
Bailey DixonandClaude Opus 4.8 6abb28e7ce fix(chat): model picker "Server default" caption shows the real default, not the override
The in-chat picker's "Server default" row captioned itself from fallbackModelDetail
(gatewayCurrentModel ?? profile ?? serverModelName). selectModel() force-sets
gatewayCurrentModel to the active override, so once you picked a model the row read
"Current: <your override>" — presenting the override AS the server default. Caption
it from serverModelName (/api/config, never touched by overrides) instead — the same
source the agent drawer already uses correctly. The selected-row highlight was already
right; only the caption was wrong.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:55:30 -04:00
Bailey DixonandClaude Opus 4.8 bb1beed488 fix(media): surface server-image fetch failure reason; gate media badge on pairing
Server-local agent images (markdown ![](/abs/path) via /media/by-path) rendered a
generic "this image is on the server" placeholder on ANY failure, hiding why. The
resolver now returns Result<ByteArray>, the failed phase carries the reason, and
the inline notice shows it (sandbox 403 / not-found 404 / unauthorized / decode /
unsupported path) for debugging. Also gate mediaUrlConfigured() (the media-
capability badge + SSE media hint) on a current paired token, not just a relay
URL, so the badge agrees with what the fetch can actually do.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:49:25 -04:00
Bailey DixonandClaude Opus 4.8 8537f75ab1 feat(chat): clean-mode text persists and slides up; persistent new-chat hint
AgentTextFlow no longer fades lines away — they slide in and PERSIST, scrolling
up within a bounded ~1/3-screen viewport with a soft top-edge fade so the avatar
above stays unobstructed (a calmer, minimal accumulate-and-scroll feel rather
than ephemeral disappearing text). The clean-mode discoverability hint is now a
persistent pill shown ONLY on the empty/new-chat view, replacing the timed popup
that re-fired too often.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:59:55 -04:00
Bailey DixonandClaude Opus 4.8 68a6ff6c00 fix(chat): bind the picked model on a new gateway chat
createNewChat pre-created an api_server session for both transports; on the
gateway that handed the next turn a concrete id, forcing ensureSession down the
session.resume branch (the api_ id resumes against the shared launch state.db on
the default profile), which bypasses the model/provider/effort/fast binding that
only runs on session.create. New chats therefore ran the DEFAULT model while the
picker still showed the last pick. On the gateway transport, drop the gateway
session + null the id so the next send hits session.create and binds the
carried-over model. SSE keeps pre-creating (it needs a concrete id). Also fixes
the same latent effort/fast gap on new gateway chats.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:59:54 -04:00
Bailey DixonandClaude Opus 4.8 c8e8d67560 feat(android): allow image/attachment viewers to rotate to landscape
The full-screen ChatImageViewer and AttachmentViewer call AllowDeviceRotation()
(SENSOR) while open, overriding the app-wide portrait lock so wide images and
video can be viewed in landscape; portrait is restored on dismiss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:27:11 -04:00
Bailey DixonandClaude Opus 4.8 349bee04ae feat(android): lock app to portrait orientation
Single-activity app, so screenOrientation=portrait on MainActivity locks the
whole app. tools:ignore for the deliberate LockedOrientationActivity lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:21:24 -04:00
Bailey Dixon 3f51c23969 Merge: chat clean-mode + swappable avatar/pets into dev 2026-06-19 18:06:29 -04:00
Bailey DixonandClaude Opus 4.8 024515678e feat(chat): clean text-flow mode + swappable avatar with pet plugin system
Clean mode: long-press the chat background enters a full-screen ambient mode
(evolved from ambientMode) — a centered agent avatar with the assistant reply
flowing in as themed monospace text that materializes, dwells, and fades (bounded
6-line buffer), a thin composer, explicit exit, and full reduced-motion/TalkBack
fallbacks to static readable text.

AgentAvatar seam: a swappable AgentAvatar { Render(AvatarRenderState, modifier) }
with SphereAvatar as the default (the morphing sphere + its skin system nested
unchanged). Every sphere call site (chat, clean mode, voice overlay, onboarding,
splash) routes through LocalAgentAvatar; the Appearance picker is now "Agent avatar".

Pets: users can drop animated avatars in files/pets/<id>/pet.json (frame-sequence
or sprite-sheet, no new deps - off-thread BitmapFactory + rate-capped Canvas loop),
selected via an agent_avatar pref (mirrors sphere_skin) and persisted/switched in
Appearance. Fresh install with no pets behaves exactly as today. See docs/pet-spec.md.

Spec: docs/plans/2026-06-18-chat-clean-mode-and-pets.md
Follow-ups in TODO.md: process-scoped pack load, clip re-decode flash, tools/intensity pet reactivity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:03:56 -04:00
Bailey Dixon 378a50eaf0 Merge: voice overhaul (overlay fixes, per-profile voice, settings IA, waveform output sync) into dev 2026-06-19 17:00:49 -04:00
Bailey DixonandClaude Opus 4.8 43135fe4b9 feat(voice): overlay fixes, per-profile voice, settings IA, and waveform output sync
Overlay: kill click-through (focus-mode pointer-consuming scrim + gesturesEnabled=
!voiceMode on the drawer), de-wrap the topbar (trimmed collapsed header + FlowRow
pills), and add a gear link to Voice Settings that exits voice mode before navigating.

Per-profile voice: VoicePreferencesRepository is now scope-aware — engine mode,
audio route, and the enhanced overrides namespace per (connection, profile) and
layer over global defaults; ergonomic prefs stay global. VoiceViewModel re-seeds
on profile change. The relay path already carried per-profile voice end-to-end.

Settings IA: single Voice scope banner, a "Voice for this profile" section, merged
Enhanced + Voice Output into one Text-to-Speech card (Advanced expander), dead
controls behind a "Coming soon" expander, SectionCards extracted, and the
relay-config fetch lifted into VoiceSettingsViewModel. Standard reads "Global voice".

Waveform: the output/Speaking waveform now unfolds only on the first real
playback-amplitude frame (VoicePlayer attaches the Visualizer on audio-session-id
to fix a deep-buffer cold-start race) instead of leading audio off the state flip.

Spec: docs/plans/2026-06-18-voice-overhaul.md
Follow-ups in TODO.md: connectionId namespacing wiring; realtime-PCM waveform gating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:50:38 -04:00
Bailey DixonandClaude Opus 4.8 265ebf7df8 Merge: reconcile optimistic message ids to server ids into dev
Reloader follow-up (off the same worktree): loadMessageHistory now reconciles
live client-UUID message ids to their server ids (position+role+content,
consume-once) before the delta-merge, and the merge adopts the server id in place
— so gateway assistant rows and user rows carry tokens/badges/attachments by id,
no drop-and-reinsert. Content-fallback drops to a pure safety net. 5 new
ChatHandlerTest cases; build + lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:05:07 -04:00
Bailey DixonandClaude Opus 4.8 2b74f4552c docs: route follow-ups to TODO.md; codify in CLAUDE.md + AGENTS.md
DEVLOG records what happened; TODO.md is the single home for follow-ups /
deferred work / known gaps. Adds attachment (B3/A6/C5/thumbnails/D5), voice,
and chat follow-ups to TODO.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:00:51 -04:00
Bailey DixonandClaude Opus 4.8 911926cddb docs(plans): voice overhaul + clean-mode/pets roadmap specs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:57:20 -04:00
Bailey Dixon 80c7337563 Merge: attachment experience (in-app previews, sensitive-media blur, richer capture) into dev 2026-06-18 21:56:33 -04:00
Bailey DixonandClaude Opus 4.8 ad6b7468cd refactor(chat): reconcile optimistic message ids to server ids before the delta-merge
The delta-merge + priorById carry are keyed by message id, but live
(optimistic) ids only sometimes match the reloaded server transcript: SSE
assistant rows are swapped to the server id mid-turn (replaceMessageId), but
gateway assistant rows keep a local UUID (the gateway exposes no per-message
server id during the turn) and USER rows of every transport keep a local
UUID. So the id-keyed carry silently missed those rows — a gateway turn's
tokens/badges survived only if a content match happened to cover them, and
user rows were drop-and-reinserted with attachments rescued only by the
content fallback.

Reconcile live ids to server ids inside loadMessageHistory before building
the carry map: match each still-unreconciled, non-clientOnly live row to an
unclaimed server row by (role, marker-stripped content), consume-once in
document order, and adopt the server id (prior.copy now sets id = messageId).
SSE assistant rows already carry a server id and are skipped (no double-swap);
clientOnly orphans have no server row and are never mapped; a row that matches
no slot is left alone (graceful fallback on truncation/compaction/divergence).
The content-keyed outbound-attachment fallback stays as the safety net, but is
now fed only by rows that did NOT reconcile, so a reconciled row and the queue
can't double-supply the same attachment. Net: gateway assistant AND user rows
now carry tokens/badges/attachments BY ID, in place, and every subsequent
reload matches by id.

run.started (SSE/runs) was considered for an earlier user-id swap but omitted:
the gateway (primary transport) exposes no such id, the first-reload
reconciliation already covers SSE/runs user rows, and a new callback through
three SSE methods + GatewayTurnCallbacks + the ViewModel would be redundant
surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:56:17 -04:00
Bailey DixonandClaude Opus 4.8 aa1b239e64 feat(attachments): in-app previews, sensitive-media blur, and richer capture
Inbound: new AttachmentViewer renders image/video/audio/pdf/text in-app
(Media3 + PdfRenderer) with a shared Share/Save/Open-externally toolbar; tapping
an attachment now previews in-app instead of firing ACTION_VIEW. Off-thread card
thumbnails, inline-image save menus, and configurable sensitive-media blur
(OFF/FLAGGED/ALL_IMAGES) applied in card, inline image, and viewer.

Sensitivity is model-emitted metadata only (no classifier): the relay carries a
`sensitive` bit via register_media -> X-Media-Sensitive header ->
FetchedMedia.sensitive -> Attachment.sensitive; the standard path uses a markdown
spoiler/sentinel convention. Adds D6 content re-sniff via _IMAGE_MAGIC.

Outbound: permissionless Photo Picker + camera capture + clipboard paste behind a
Photos/Files/Camera/Paste menu, unified through ingestAttachmentFromUri.

Design spec: docs/plans/2026-06-18-attachment-experience.md
Deferred: download progress/cancel (B3), multi-image gallery (A6), agent-side
sensitivity config gate (C5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:50:55 -04:00
Bailey DixonandClaude Opus 4.8 b76565f6f6 Merge: chat history reloader hardening into dev
Brings in the reloader-gaps worktree (off dev): preserve user-sent attachments
across reload, replace the id-prefix orphan whitelist with a clientOnly flag, and
delta-merge the history reload instead of wholesale-replacing the transcript.
14 new ChatHandlerTest cases; build + lint green. User-message-id reconciliation
(deeper run.started fix) follows as a separate change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:35:27 -04:00
Bailey DixonandClaude Opus 4.8 50e638f282 refactor(chat): delta-merge the history reload instead of wholesale replace
loadMessageHistory rebuilt every ChatMessage from server data on each
post-turn reload and reassigned the whole list, carrying client-only state
forward only through a hand-picked field list — the root of the
drop-on-reload class and needless row churn.

Make the per-row reconcile a delta-merge keyed by id: a server message that
matches a local row now copies that row and refreshes only the
server-authoritative fields (content, tool calls, cards, reasoning, role,
timestamp), so EVERY client-only field survives automatically instead of a
curated subset — and an unchanged row produces an equal object, so Compose
doesn't re-render it. A server message with no local row is inserted; a
client-only orphan is kept; a row that was server-backed but is no longer in
the transcript is dropped (genuine server-side delete/fork/truncate). Server
reasoning stays authoritative when present, but live-streamed thinking is no
longer blanked when the transcript omits it. Ordering, media-marker
re-dispatch, card extraction, and the MAX_MESSAGES cap are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:16:53 -04:00
Bailey DixonandClaude Opus 4.8 70e94a1aa8 refactor(chat): mark client-only bubbles with a flag instead of id-prefix sniffing
Client-only bubbles (no server-side row) survived the post-turn reload
only if their id matched a known prefix (voice-intent-/steer-/ask-/
system-notice-) or they carried an "Error" badge. Any new client-only
bubble type silently dropped, and the badge check could mis-handle a turn
that errored after persisting.

Add ChatMessage.clientOnly (default false) and set it at every creator:
addSystemNotice, appendAskCardMessage, appendLocalVoiceIntentTrace (both
bubbles), appendLocalVoiceIntentResult, the steer echo, and — where
provenance is only known after the fact — markError (gateway terminal
error on a non-persisted turn) and attachRealtimeTurnTrace (a trace is
attached only for provider-only, non-Hermes-backed realtime turns).

loadMessageHistory now preserves any prior message with clientOnly == true
whose id is absent from the reloaded transcript, replacing the id-prefix
whitelist and the Error-badge sniff. A turn that errored after persisting
keeps its Error badge but IS in the transcript, so it reconciles normally;
only clientOnly + absent-from-transcript marks a preservable orphan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:05:19 -04:00
Bailey DixonandClaude Opus 4.8 373939ce95 fix(chat): preserve user-sent attachments across the history reload
loadMessageHistory rebuilt each ChatMessage with no attachments, so a
user-sent image/file (outbound Attachment, state LOADED, relayToken null)
vanished from its bubble after the post-turn reload. Inbound media
(MEDIA: markers) is re-fetched via the marker re-dispatch, but outbound
attachments are neither in server content nor re-dispatched, so they were
dropped.

Carry outbound-only attachments (relayToken == null) forward across the
reload. priorById matches by id, but user-message ids are never reconciled
to the server id (only the assistant placeholder is swapped via
replaceMessageId), so an id-only carry never fires for user bubbles. Add a
content-keyed, consume-once fallback so outbound attachments survive even
when the reloaded user row carries a fresh server id. Inbound
(relayToken != null) attachments are intentionally excluded to avoid
double-adding what the marker re-dispatch re-fetches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:53:22 -04:00
Bailey DixonandClaude Opus 4.8 f76203c227 docs(diagram): add diagrams/README — file roles + keep-in-sync note
Records the three representations of the architecture model (path-architecture.html,
CombineModel.vue, this SVG) that must be updated together, the canonical gating
sources, and how to regenerate the SVG/PNG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:40:50 -04:00
Bailey DixonandClaude Opus 4.8 a918bdb5fe docs(diagram): add "how Hermes-Relay connects" architecture diagram
Hand-authored SVG (+ PNG raster + editable .excalidraw source) showing the
two-axis model at a glance: Vanilla Hermes (Chat/Manage/Voice, no plugin) as the
always-on backbone, the optional Relay plugin fanning out to the app + CLI
(Terminal/Bridge/relay voice/desktop tools), and the sideload gate sitting on
Device Control.

- Embed the SVG at the top of the user-docs Architecture page (served from public/).
- Add the PNG to the README "What it is" section.

Generated with the excalidraw-diagram skill's design methodology; published as a
dependency-free SVG (the skill's CDN-based render pipeline can't egress in this
sandbox, so the .excalidraw is included as the editable source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:36:54 -04:00
Bailey DixonandClaude Opus 4.8 3128d8cf66 fix(chat): preserve client-only message details across the post-turn reload
loadMessageHistory wholesale-replaces the transcript from server data, which
rebuilds content/tool-calls/reasoning but carries NONE of the per-message state
the server does not persist: token usage + cost, provenance badges, tapped-card
confirmations, and the voice/realtime sync traces. Each had to be patched
individually (badges were; tokens were not), so a normal reply lost its
input/output token subtext the moment the turn finished -- the error bubble kept
it only because errored turns skip the reload.

Replace the badge-only carry map with an id-keyed priorById and carry ALL
client-only fields forward for any message id that still matches -- preserve by
default, instead of a per-field whitelist the next new field always forgets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:19:19 -04:00
Bailey DixonandClaude Opus 4.8 9475f8bec4 feat(chat): session-scoped model display + "show system messages" debug toggle
Model display: the chat header subtitle and the agent detail sheet now resolve
the model from the session scope (selectedModelOverride -> gateway session.info
-> profile -> server default), matching the input chip and footer, so a
mid-session switch shows everywhere. The agent-sheet header took the global model
name but the session provider (showed "gpt-5.5 . xAI Grok"); it now takes a
sessionModelName so model+provider come from one scope, and adds a quiet
"Server default: ..." caption only when the session runs a different model than
the host default -- the always-visible global-vs-session split.

Debug toggle: a default-off "Show system messages" switch in Chat Settings
(DataStore-backed, mirrors parseToolAnnotations) drives ChatHandler.showSystemMarkers
to reveal the otherwise-hidden upstream "[System: ...]" steering markers.

Updates CHANGELOG (Unreleased) and DEVLOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:10:10 -04:00
Bailey DixonandClaude Opus 4.8 bb3d89d5c4 fix(chat): land model switch on the live session + stop swallowing gateway errors
Model switch: selectModel called fire-and-forget prewarm() then setModel(), so
config.set{key:"model"} ran with no live session and upstream applied it as a
GLOBAL write instead of switching the session. Added suspending prewarmAwait()
that selectModel awaits before setModel, so the switch lands session-scoped (the
same _apply_model_switch path the CLI/TUI /model uses) -- or defers to the next
session.create override when there is genuinely no session, never writing global
config.

Errors: dispatchOn (the main-thread turn-callback wrapper) omitted onStatusUpdate,
so the server's terminal-error lifecycle line hit a default no-op -- the turn was
never badged Error and onComplete's post-turn history reload wiped the client-only
error bubble. Wired onStatusUpdate through dispatchOn (also restores live gateway
status lines) and hardened loadMessageHistory to re-inject local Error-badged
messages the server transcript lacks, so no reload path can swallow a failure.

Also hides upstream role:system "[System: ...]" steering markers from the
transcript by default (desktop/TUI parity), behind a ChatHandler.showSystemMarkers
flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:09:44 -04:00
Bailey DixonandClaude Opus 4.8 4f291e1625 refactor(naming): rename user-facing "Standard" -> "Vanilla Hermes"
"Standard" was overloaded — it read as both an app feature tier and the
unmodified-upstream server state, which was confusing. Rename all
user-visible strings, docs, onboarding copy, and the matching test
assertions to "Vanilla Hermes" so the no-plugin path reads unambiguously.
Code identifiers, enum constants, and the persisted "standard" route value
are unchanged — that is an internal name only.

Also lands this session's architecture work:
- docs/path-architecture.html — connection-path + chat-transport
  resolution flowchart, plus the build-flavor (googlePlay/sideload)
  capability axis.
- user-docs CombineModel "how the pieces combine" three-tier model and
  the release-tracks/index wording that makes the plugin-vs-flavor
  prerequisites explicit.
- Aligns docs/security.md, upstream-surface-matrix.md, and spec.md on the
  device-control 403 codes (device_control_sideload_only / sideload_only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:05:32 -04:00
Bailey Dixon ddb691a3f3 Merge: connection-UX + cold-start perf + profile-swap audit fixes into dev 2026-06-18 16:58:52 -04:00
Bailey DixonandClaude Opus 4.8 800cc0b6ec feat(voice): note that standard voice uses the host's global TTS, not the profile
Standard (no-plugin dashboard) voice rides upstream POST /api/audio/speak, which
is text-only global TTS — TTSSpeakRequest has no profile field and
text_to_speech_tool has no profile scope (web_server.py) — so switching the chat
profile does not change the spoken voice on standard-only installs. The relay
voice path IS profile-aware and is left untouched.

- On a profile change, when the EFFECTIVE voice route is Standard and the
  profile is non-default, record a quiet Voice diagnostics line explaining the
  limitation and pointing to the Relay plugin for profile-aware voice.
- AutoVoiceAudioClient gains effectiveRoute, resolving Auto against live
  readiness (relay-first) so the notice never claims the relay path has this
  limitation.
- StandardHermesVoiceClient passes profile= on /api/audio/speak defensively
  (upstream ignores extra fields today; forward-compatible if upstream adds
  profile-aware TTS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:53:17 -04:00
Bailey DixonandClaude Opus 4.8 9200b25224 feat(chat): agent-sheet toggles say "confirms on your next message" when ready
The YOLO/Fast controls showed an indefinite "Checking…" spinner whenever their
value was null. After a new chat or profile switch the value is intentionally
unconfirmed and only re-settles from session.info on the user's next message —
so an endless spinner reads as broken. When the gateway is Ready (socket up) but
the value is still null, the placeholder now reads "Confirms on your next
message" instead; the spinner is reserved for the genuine still-probing state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:53:04 -04:00
Bailey DixonandClaude Opus 4.8 0800ddeb4b fix(chat): keep yolo/fast/effort/personality per-session across new chats + profile switches
Setting reasoning effort, fast, or YOLO BEFORE a new chat's first message ran a
sessionless gateway config.set, which upstream applies as GLOBAL writes — and
YOLO via os.environ["HERMES_YOLO_MODE"], leaking approval-bypass into every
other session. Profile switches also leaked stale state: a stale personality
overlay was injected onto the new profile's first SSE turn, and reasoning effort
was re-fetched sessionless (reading the launch/global profile's value, not the
newly-selected one).

Verified against upstream tui_gateway/server.py: session.create consumes
model/provider (model_override), reasoning_effort (create_reasoning_override),
and fast (priority service tier) as PER-SESSION overrides, but does NOT accept
yolo.

- GatewaySessionModel now carries nullable reasoningEffort + fast (model also
  nullable) and binds them on session.create with upstream's param names; null
  fields leave the profile/server default intact.
- selectReasoningEffort/setFast/setYolo skip the sessionless config.set on a
  brand-new chat (no live session); effort/fast ride session.create, YOLO is
  stashed and applied session-scoped from the turn's onSessionId.
- _selectedReasoningEffort is now nullable (null = unknown) so a profile/
  connection switch shows the chip as unconfirmed until session.info, never a
  stale value that could ride session.create.
- Profile/connection switches reset personality to default + effort to unknown
  (alongside yolo/fast) so neither a stale overlay nor chip carries over; the
  optimistic getReasoningSettings() fetch in activateGatewayProfile is dropped.
- GatewayChatClientTest gains reasoning_effort/fast session.create binding cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:52:53 -04:00
Bailey DixonandClaude Opus 4.8 2fed7bc479 fix(android): profile drawer — whole-pill badges + expandable descriptions
Badges (Active / "N skills" / SOUL / model) no longer split internally
(maxLines=1, softWrap=false, Clip) — so no vertical "S O U L" or "141\nskills"
under width pressure — and wrap as WHOLE pills in their own FlowRow on a
dedicated line below the description. Long profile descriptions truncate to 2
lines with a gated "More"/"Show less" affordance (only shown on real overflow),
so a long description can't crunch the badges. One new optional ProfileRadioRow
param (secondaryExpandable, default false); only the profile-list caller opts in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:16:40 -04:00
Bailey DixonandClaude Opus 4.8 bb0c9f76eb feat(android): cold-start perf + clearer, honest connection UX
Cold-start keystore contention (~2.9s -> ~0.95s to Paired, 3 keyset builds -> 1):
- SecureStoreCache (sync ConcurrentHashMap.computeIfAbsent) builds each prefs
  file's Tink keyset once process-wide; buildRawTokenStore shared factory.
- Defer the throwaway legacy-sentinel AuthManager's keyset build (eagerHydrate);
  re-gate the pre-StrongBox migration on file name + a marker (read legacy once).
- Unify the dashboard cookie store onto the connection's token keyset
  (tokenStoreKey provider) with a one-shot, marker-gated cookie migration.

Honest loading, never stale, never hidden:
- LoadedFadeIn / RelaySkeletonLine; fade-ins on header subtitle, agent sheet,
  context meter, session drawer, Manage.
- Standard upstream controls (Model, YOLO, Fast, reasoning effort) never hidden:
  live when ready, "checking..." while loading, disabled-with-reason when the
  transport can't use them (GatewayToggleControl); bounded picker loading rows.

Connection clarity:
- Session-path summary in the agent sheet (friendly transport + route + honest
  capability chips), absorbing the old "Show routes" expander.
- Redesigned the Connections detail screen (removed API/Voice/Relay redundancy,
  lighter hierarchy).
- Injected-context "media capability" is transport-aware (no false "not set" on
  the gateway, where the relay renders server-local images client-side).

UI polish:
- Connection toast -> live stepper + finger-tracking dismiss + error link.
- Chat header: approvals -> amber icon, Share -> overflow, endpoint chip dropped
  (footer strip now tappable -> Connections), no "none" personality.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:37:11 -04:00
Bailey Dixon e27f5e8b9a Merge pull request #93 from Codename-11/Codename-11/fix-ui-ux-issues
fix(chat): apply model pick on new chats, render relay images, smooth profile switch
2026-06-18 14:16:18 -04:00
Bailey DixonandClaude Opus 4.8 f3aba63977 fix(chat): apply model pick on new chats, render relay images, smooth profile switch
UI/UX fixes from a profile-switching + chat-composer audit, verified against
upstream tui_gateway/server.py.

* Model picker now applies on a fresh chat. The gateway model is a per-session
  override; we set it via config.set on live sessions only, so a brand-new
  chat's config.set carried no session_id (upstream no-ops it) and
  session.create omitted the model -> the agent ran on the account's global
  default. Added a live GatewayChatClient.sessionModelProvider (mirrors
  sessionProfileProvider) that binds model/provider onto session.create, which
  upstream honors as the session's model_override -- matching the desktop
  client. Mid-session switches still use config.set; a profile switch retires
  an explicit pick (the profile owns its model) and seeds the picker label
  up-front so it doesn't lag the round-trip. SSE paths already carried the
  model. GatewayChatClientTest gains 3 model-binding cases.

* Server-local images render through the relay. Markdown ![](/path) images only
  understood http(s) -> a server path fell to an "image is on the server"
  notice that never consulted the relay (only the MEDIA: marker path did).
  Added a RelayServerImageResolver CompositionLocal (provided by ChatScreen from
  ChatViewModel.resolveServerImage) that fetches an absolute path via the relay
  /media/by-path route, decodes, caches (bounded LRU), and renders inline with
  tap-to-zoom. On SSE the agent is also told it can surface images/files by path
  when a relay route is configured (shown in the "What the agent sees" sheet).
  Standard no-plugin connections are unchanged.

* Smoother profile switch. switchProfileContext no longer clears the message
  list before the async history fetch; the previous transcript is held and
  swapped atomically, so the LazyColumn's animateItem() cross-fades old->new
  instead of blanking to an empty/Loading state.

Verified: :app:compileSideloadDebugKotlin + unit-test compile + the
GatewayChatClientTest suite are green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:15:25 -04:00
Bailey Dixon acfe55f958 Merge pull request #92 from Codename-11/fix/dashboard-ci-and-agent-parity
ci(dashboard): restore requests dep + close agent-framework parity gaps
2026-06-18 11:13:26 -04:00
Bailey DixonandClaude Opus 4.8 8f45c7a4cd ci(dashboard): also install relay reqs (aiohttp) for plugin.relay import
First pass added `requests` and got the suite collecting (18 tests ran), but
one test imports `plugin.relay.tailscale`, which loads plugin/relay/server.py
-> `import aiohttp`. Restore `-r relay_server/requirements.txt` (aiohttp +
pyyaml) alongside fastapi/httpx/requests so the full import chain resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:08:58 -04:00
Bailey DixonandClaude Opus 4.8 b996b379a7 docs(agents): close framework-parity gaps
Make the agent guidance work across frameworks that don't read AGENTS.md
natively, and make AGENTS.md self-sufficient beyond Android.

- AGENTS.md: add the Plugin (Python 3.11 aiohttp) and Desktop CLI (Node >=21,
  zero-dep) stack rules to the non-negotiables (was Android-only).
- GEMINI.md: thin pointer to AGENTS.md for Gemini CLI.
- .github/copilot-instructions.md: thin pointer + quick non-negotiables for
  GitHub Copilot.

Both shims point at AGENTS.md as the single source of truth (which links on to
CLAUDE.md for depth) so rules are single-sourced and can't drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:02:54 -04:00
Bailey DixonandClaude Opus 4.8 b0db3638f0 ci(dashboard): restore requests dep dropped by streamline
The repo-automation streamline trimmed the dashboard API test deps to
`fastapi httpx`, but `python -m unittest plugin.dashboard.test_plugin_api`
imports the `plugin` package, whose __init__ eagerly loads android_tool and
desktop_tool — both of which `import requests`. Without it the test module
fails to import (ModuleNotFoundError: requests), failing CI on dev.

Restore just `requests` (the only third-party need in that chain beyond the
already-present fastapi/httpx); no need to bring back relay_server/requirements
or pytest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:02:44 -04:00
Bailey Dixon bcfd509d35 Merge branch 'Codename-11/repo-automation' into dev 2026-06-18 10:45:19 -04:00
Bailey Dixon d92d18a607 chore: streamline repo automation 2026-06-18 10:45:11 -04:00
Bailey Dixon 4dfa1ecd18 Merge pull request #91 from Codename-11/fix/terminal-tui-and-chrome
feat(terminal): scrollable compact key bar, TUI-correct input, isolated tmux
2026-06-18 10:18:01 -04:00
Bailey DixonandClaude Opus 4.8 64e5a5f75e feat(terminal): scrollable compact key bar, TUI-correct input, isolated tmux
Refines the terminal screen against Orca's mobile terminal and hardens the
relay PTY for correct TUI behavior.

Android:
- Extra-keys bar: horizontalScroll with fixed-min-width keys (labels no
  longer clip), compacted to ~32dp keys / 12sp to match Orca's sizing.
- Mode-aware special keys: window.termSendKey reads xterm's DECCKM and
  encodes arrows/Home/End as SS3 vs CSI; PASTE routes through term.paste()
  for bracketed paste so multi-line paste no longer auto-runs.
- Compact header: custom ~52dp row replaces the 64dp TopAppBar; status shown
  once inline (dot + word, ellipsized) and tappable for the info sheet. Tab
  strip hidden for single-tab sessions (new-tab "+" moves to the header).
- Removed a redundant navigationBarsPadding gap below the keys; added an 8px
  bottom gap in the terminal so the last row clears the key bar.

Relay:
- Terminal sessions spawn on a dedicated -L hermes-relay tmux socket with a
  generated config: escape-time 0, tmux-256color + truecolor, mouse,
  focus-events, set-clipboard, aggressive-resize, status off. Isolated from
  the user's own tmux; persistence unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:57:26 -04:00
Bailey Dixon 839c279da7 Merge pull request #90 from Codename-11/docs/native-encryption-devlog
docs(devlog): backfill native secure routes entry (#88)
2026-06-17 22:53:40 -04:00
Bailey DixonandClaude Opus 4.8 fcc6e7601d docs(devlog): backfill native secure routes entry (PR #88)
PR #88 (feature/native-encryption) landed without a DEVLOG entry; record the split connection model (Features vs Route) + plugin secure-proxy route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:53:11 -04:00
Bailey Dixon bebaeb7418 Merge pull request #89 from Codename-11/docs/native-encryption-changelog
docs(changelog): native secure routes entry (backfill for #88)
2026-06-17 22:50:31 -04:00
Bailey DixonandClaude Opus 4.8 9d23eb32ad docs(changelog): add native secure routes (connections features vs routes) entry
Backfills the [Unreleased] CHANGELOG bullet for PR #88 (feature/native-encryption), which landed without one: connections now split Features from Route, plus a plugin Secure proxy route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:49:41 -04:00
Bailey Dixon 99239c8417 Merge pull request #87 from Codename-11/Codename-11/app-theming-enhancements
feat(theme): theme-aware brand tokens, app themes, and hot-swappable sphere
2026-06-17 22:17:02 -04:00
Bailey Dixon 208a1a6ebc Merge pull request #88 from Codename-11/feature/native-encryption
feat(android): native secure routes — split connection features from routes
2026-06-17 22:12:41 -04:00
Bailey DixonandClaude Opus 4.8 a11e7f9420 fix(theme): qualify LocalContext reference in RelayApp
RelayApp never imports LocalContext (every other use is fully qualified
as androidx.compose.ui.platform.LocalContext); the sphere-skin wiring
used the short form, breaking compilation. Match the file convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:03:46 -04:00
Bailey DixonandClaude Opus 4.8 1224414a80 feat(theme): theme-aware brand tokens, app themes, and hot-swappable sphere
Fix the formerly hardcoded-dark chat/Manage surfaces and add real app
themes plus a pluggable agent sphere.

Brand tokens: convert the dark-only RelayRefresh object into a
snapshot-backed facade over an active BrandPalette, so the ~150 existing
RelayRefresh.X call sites repaint with the theme without edits. The
Material ColorScheme is now derived from the palette (toColorScheme),
and a new LocalBrand CompositionLocal backs new code. Flourishes and
markdown syntax highlighting across 13 files now follow the active
palette (LocalBrand.current.isDark) rather than the system setting.

App themes: ship 8 looks via an AppThemes registry — Hermes Relay
(light+dark) plus ports of the Nous Hermes dashboard baselines (Teal,
Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rose). Hybrid model: the
brand honors Light/Dark/Auto; character themes are fixed-mode. New
appTheme pref + swatch gallery in Appearance.

Hot-swappable sphere: a SphereSkin layer over the untouched core
algorithm (parity mirror preserved). Built-in Adaptive (follows theme),
Classic, Aurora, Solar, Mono skins plus user-authored JSON skins
(SphereSpec/SphereSkinLoader, data-only + validated). Reactivity
(voice/tools/intensity) is declared per skin, gated in the renderer, and
shown as capability badges. Auto-follow-theme with per-skin override.
Format documented in docs/sphere-spec.md.

Reviewed, not compiled (no SDK in worktree). gradlew lint + on-device
verify pending via Android Studio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:52:24 -04:00
Bailey DixonandClaude Opus 4.8 b7d6a2fb31 fix(docs-site): keep hero sphere canvas backing store synced to its css box
On mobile the hero phone-preview morphing sphere rendered at ~1/3 size and
hugged the top-left of the frame. The canvas backing store (sized once in
resize() from a clientWidth snapshot, with the dpr transform) drifted from
drawSphere()'s live per-frame clientWidth reads, so the grid was drawn into a
coordinate space that no longer matched the store — and canvas drawing starts
at (0,0), hence the top-left pin. Mobile triggered it via late-resolving 88cqw
container-query width (resize() bailed on cw<=0, leaving the 300x150 default
store with no dpr transform that the truthy-width guard never retried) and via
the 88cqw->80cqw boot->chat width tween that never resized screenEl.

Add syncCanvasSize(): measure the real box with getBoundingClientRect(),
reallocate the backing store only on an actual pixel-size change (re-applying
the dpr transform), and return the css-px dims to draw against. drawSphere()
now calls it every frame and draws against that single measurement, so the
store and draw math can no longer diverge and a not-ready layout self-heals on
the next frame. Point the ResizeObserver at the canvas (not screenEl) so the
boot->chat width tween is tracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:50:43 -04:00
Bailey Dixon f38870a4c5 Merge pull request #86 from Codename-11/feature/chat-ux-transparency
feat(chat): injected-context audit sheet + spoken-turn badges; UX polish
2026-06-17 21:47:52 -04:00
Bailey Dixon dbfde1ffc4 Merge remote-tracking branch 'origin/dev' into feature/chat-ux-transparency
# Conflicts:
#	DEVLOG.md
2026-06-17 21:20:04 -04:00
Bailey Dixon 2cea7d1618 merge: native encryption route model 2026-06-17 21:18:58 -04:00
Bailey Dixon 54337826bd feat(android): split connection features from routes 2026-06-17 21:18:29 -04:00
Bailey Dixon 7daa301075 feat(android): merge permissions review screen 2026-06-17 21:13:25 -04:00
Bailey Dixon b5bdf0a81f feat(android): add permissions review screen 2026-06-17 20:54:57 -04:00
Bailey DixonandClaude Opus 4.8 fa18e1c88e docs: changelog + devlog for chat transparency batch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:53:03 -04:00
Bailey DixonandClaude Opus 4.8 c71751cd06 feat(chat): spoken-turn badges + injected-context audit sheet
Voice-mode replies get a Voice chip and realtime replies keep Realtime Agent; both share a speaker glyph (MessagePathBadge gained an optional leading icon). composeInjectedContext() single-sources the per-turn system_message build for both startStream (sent) and previewInjectedContext() (shown); tapping the ContextMeterBar opens InjectedContextSheet, with the gateway persona labeled server-side. loadMessageHistory now preserves provenance badges by id across the post-turn reload, also fixing the pre-existing Stopped/Error loss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:53:03 -04:00
Bailey DixonandClaude Opus 4.8 578f67a352 fix(ui): clearer version-skew error + opaque connection toast
RelayErrorClassifier maps a 400 whose body names an unsupported field to a non-retryable Relay-update-needed message, distinct from a bad value such as an unsupported codec. ConnectionStatusToast composites its container over the theme surface so the floating overlay is opaque; the in-flow banner stays translucent by design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:53:03 -04:00
Bailey DixonandClaude Opus 4.8 9739b88af3 fix(docs-site): keep hero sphere canvas backing store synced to its css box
On mobile the hero phone-preview morphing sphere rendered at ~1/3 size and
hugged the top-left of the frame. The canvas backing store (sized once in
resize() from a clientWidth snapshot, with the dpr transform) drifted from
drawSphere()'s live per-frame clientWidth reads, so the grid was drawn into a
coordinate space that no longer matched the store — and canvas drawing starts
at (0,0), hence the top-left pin. Mobile triggered it via late-resolving 88cqw
container-query width (resize() bailed on cw<=0, leaving the 300x150 default
store with no dpr transform that the truthy-width guard never retried) and via
the 88cqw->80cqw boot->chat width tween that never resized screenEl.

Add syncCanvasSize(): measure the real box with getBoundingClientRect(),
reallocate the backing store only on an actual pixel-size change (re-applying
the dpr transform), and return the css-px dims to draw against. drawSphere()
now calls it every frame and draws against that single measurement, so the
store and draw math can no longer diverge and a not-ready layout self-heals on
the next frame. Point the ResizeObserver at the canvas (not screenEl) so the
boot->chat width tween is tracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:44:29 -04:00
Bailey Dixon e04e55c35c Merge pull request #84 from Codename-11/Codename-11/connectionviewmodel-decomposition
refactor(viewmodel): decompose ConnectionViewModel into transport/pairing/profile collaborators (ADR 34 follow-up)
2026-06-17 19:23:21 -04:00
Bailey Dixon 2b7b698285 Merge remote-tracking branch 'origin/dev' into Codename-11/connectionviewmodel-decomposition
# Conflicts:
#	DEVLOG.md
2026-06-17 19:22:18 -04:00
Bailey Dixon 7ce8e6270a Merge pull request #85 from Codename-11/docs/changelog-voice-enhancements
docs(changelog): voice-mode enhancements [Unreleased] entry
2026-06-17 19:19:16 -04:00
Bailey DixonandClaude Opus 4.8 c43b6a6014 docs(changelog): add Unreleased entry for voice-mode enhancements
Public-facing Keep-a-Changelog entry (Added/Changed/Fixed) for the voice work
merged in #83: enhanced voice control (Gemini & xAI), render-path visibility,
spoken-output formatting, and the realtime/synthesis fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:18:31 -04:00
Bailey Dixon 52e4159c4c Merge pull request #83 from Codename-11/Codename-11/voice-mode-enhancements
feat(voice): relay fixes + provider-aware enhanced voice (Gemini + xAI) + diagnostics
2026-06-17 19:01:18 -04:00
Bailey DixonandClaude Opus 4.8 ab646eba27 Merge origin/dev into voice-mode-enhancements
Resolved conflicts from dev's ADR 34 network package fence:
- StandardHermesVoiceClient.kt: took dev's refactored network/upstream version
  (the interface/adapter/AutoVoiceAudioClient now live in network/shared +
  network/relay), then re-applied the standard-voice polish (25MB transcribe
  guard, 413/400 copy, MAX_TRANSCRIBE_BYTES).
- network/relay/RelayVoiceAudioClientAdapter.kt: re-applied the
  enhancedOverridesProvider param (RelayApp's auto-merged call requires it).
- DEVLOG.md: kept dev's entries + prepended the voice-mode-enhancements entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:00:18 -04:00
Bailey DixonandClaude Opus 4.8 2b6fb2c3c8 docs(devlog): record ConnectionViewModel decomposition (3 collaborators, Relay deferred)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:55:21 -04:00
Bailey DixonandClaude Opus 4.8 a3fb37fc94 docs: voice enhanced-voice surface, route ownership, render-path troubleshooting
- upstream-surface-matrix.md: "Voice Surfaces (standard vs. relay)" with an
  explicit route-ownership table (every /voice/* route is relay-owned; only
  dashboard /api/audio/* is upstream; no upstream streaming/WS audio route) and
  an enhanced-voice matrix across both relay paths.
- spec.md Phase V: /voice/synthesize overrides, tts.enhanced block, and the
  voice_output auto_speech_tags control.
- user-docs/features/voice.md: "Enhanced Voice (Gemini & xAI)" section, the
  streaming speech-tags toggle, the settings Render-path row + Diagnostics
  breadcrumb in troubleshooting, and corrected the stale ~/voice-memos note.
- DEVLOG: session entry covering the fixes, enhanced voice, and docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:50:08 -04:00
Bailey DixonandClaude Opus 4.8 334a4f0ee4 feat(app): voice spoken-output hint, enhanced-voice UI, render-path visibility
- VoiceViewModel: enrich STABLE_VOICE_INTERFACE_CONTEXT so the model formats
  replies for speech (rides the non-persisted system_message slot, no history
  pollution). ChatViewModel forces voice turns onto SSE since the gateway
  prompt.submit has no system-message slot.
- Enhanced-voice UI: EnhancedVoiceOverrides + EnhancedVoiceCapabilities;
  RelayVoiceClient.synthesize sends the generic override fields; provider-aware
  "Enhanced Voice (<provider>)" Voice Settings card (curated dropdown for
  Gemini, free-text for xAI; persona for Gemini, language for xAI).
- Streaming: VoiceOutputConfig.auto_speech_tags + updateVoiceOutputConfig
  param + an "Expressive speech tags" switch in the Hermes Chat + Voice Output
  card (xai_tts), persisted with the existing Save buttons.
- Render-path visibility: a per-session DiagnosticsLog entry naming the active
  path (streaming /voice/output vs basic /voice/synthesize), plus a persistent
  "Render path" row in the settings card derived from voiceOutputConfig.
- Standard voice polish: pre-flight 25MB transcribe guard + friendly 413/400
  copy; harden the dashboard audio HEAD probe to also try /api/audio/speak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:49:53 -04:00
Bailey DixonandClaude Opus 4.8 b3562dd6cb feat(relay): voice fixes + provider-aware enhanced voice (Gemini + xAI)
Correctness fixes:
- broker.py: non-native realtime-agent loop dropped playback.drained, tearing
  down sessions every turn on non-native providers. Extract a shared
  _handle_common_client_message dispatcher used by both loops so they can't
  drift; add input_audio.clear to the non-native path. Preserves the native
  loop's per-message provider_task.done() break.
- voice.py: synthesize now owns a temp output_path and deletes it after
  streaming (no more ~/voice-memos leak).
- realtime_voice.py: bind the lab WS session to its creating principal
  (_auth_matches_session), mirroring voice_output.py.

Enhanced voice (per-request, no fork; upstream imports isolated in
upstream_voice.py):
- /voice/synthesize accepts voice/model/audio_tags/persona_prompt/language,
  mapped onto Gemini (_generate_gemini_tts) or xAI (_generate_xai_tts).
- /voice/config advertises a provider-aware tts.enhanced capability block.
- /voice/output streaming renderer honors xAI auto_speech_tags as a per-profile
  voice_output: setting (threaded through config/env/YAML/settings/session/
  provider_options/config_payload/PATCH, mirroring text_normalization); the
  relay applies upstream_voice.apply_xai_speech_tags() per chunk. No Gemini
  streaming provider in voice_lab, so Gemini enhanced voice is synthesize-only.

Tests: non-native playback.drained regression (red-on-bug), Gemini + xAI
synthesize overrides, enhanced-block + extract pure-function coverage,
auto_speech_tags PATCH round-trip, apply_xai_speech_tags call-through/fail-soft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:49:38 -04:00
Bailey DixonandClaude Opus 4.8 0d969468a8 refactor(viewmodel): extract ProfileController from ConnectionViewModel
Move the agent-profiles cluster into
viewmodel/connection/ProfileController.kt:

- the merged agentProfiles list (relay auth.ok union dashboard
  /api/profiles) + refreshDashboardProfiles + profile-scoped
  session/message fetch
- the per-connection selected-profile state machine
  (selectProfile / resolvePendingProfileFrom / pending-name resolution)
- the three persistence stores (selection / session / displayAlias,
  exposed as public vals so the ViewModel's connection-lifecycle
  orchestrators keep their clear/persist call sites byte-identical)
- profileDisplayAlias + activeSessionTransport + per-profile
  last-session restore

ConnectionViewModel keeps its public getters/functions and delegates.
Because the profile state machine is co-driven by ViewModel-level
lifecycle observers (connection switch, active-connection change,
agent-profile arrival, gateway-availability settle), those observers
stay in the ViewModel and call profileController.* lifecycle hooks in
their original order — the orchestration stays put; only the state +
logic moved, so the state machine is now unit-testable in isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:44:09 -04:00
Bailey DixonandClaude Opus 4.8 ceb707581e refactor(viewmodel): extract UpstreamTransportController from ConnectionViewModel
Move the upstream dashboard/gateway transport cluster into
viewmodel/connection/UpstreamTransportController.kt:

- per-connection encrypted DashboardCookieStore cache + accessors
- a single consolidated DashboardApiClient factory (was 4+ build sites)
- the cached GatewayChatClient (lazy build, mid-turn LAN/Tailscale
  retarget) + gateway availability tier + sticky-Unsupported verdict
- the per-endpoint capability snapshot + chatMode, and the
  streamingEndpoint-preference resolution that reads them

ConnectionViewModel keeps its public getters/functions and delegates;
rebuildApiClient pushes the probed capability snapshot via
setCapabilitiesAndMode. The @Synchronized gateway-cache lock moves with
the state (now the controller instance), preserving mutual exclusion.

Deliberately NOT moved: the HermesApiClient SSE/runs client
(_apiClient/_chatApiClient), API-server reachability/health, and
rebuildApiClient/rebuildChatApiClient — those are written inline by
several ViewModel-level orchestrators (saveStandardApiConnection,
saveApiAndProbeVoice, testApiConnection, updateApiServerUrl, revalidate)
interleaved with diagnostics + callbacks; lifting them would need a wide
mutable surface that relocates the coupling rather than removing it (per
the decomposition plan's stop-if-too-entangled rule).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:30:08 -04:00
Bailey DixonandClaude Opus 4.8 5e5f76076f refactor(viewmodel): extract PairingController from ConnectionViewModel
Move the paired-devices list (GET /sessions) + management
(load/revoke/extend/revokeChannelGrant) and the insecure-ack DataStore
flags into viewmodel/connection/PairingController.kt. ConnectionViewModel
keeps its public getters/functions and delegates unchanged — a pure
mechanical lift, behavior preserved verbatim.

First step of the ConnectionViewModel decomposition (ADR 34 follow-up).
The pairing orchestrator (applyPairingPayload) stays in the ViewModel:
it is glue across the upstream/relay/connection-store collaborators, not
a cohesive unit that moves cleanly behind this seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:16:48 -04:00
Bailey Dixon e00d439b61 Merge pull request #82 from Codename-11/docs/cvm-decomposition-plan
docs(plans): ConnectionViewModel decomposition plan
2026-06-17 17:52:38 -04:00
Bailey DixonandClaude Opus 4.8 19a7c84c18 docs(plans): ConnectionViewModel decomposition plan (ADR 34 follow-up)
Worktree-runnable plan to break the 5.5k-line ConnectionViewModel god object
into focused viewmodel/connection/ controllers (Upstream/Relay transport,
Pairing, Profiles) behind its frozen public surface, plus an optional
ChatTransportProvider seam. Behavior-preserving extraction only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:52:14 -04:00
Bailey Dixon 7739a372a9 Merge pull request #81 from Codename-11/feature/upstream-relay-isolation
refactor(network): fence vanilla-upstream from Relay surfaces (ADR 34)
2026-06-17 17:42:16 -04:00
Bailey DixonandClaude Opus 4.8 a014ce8707 docs(devlog): record upstream/relay isolation work (ADR 34)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:34:34 -04:00
Bailey DixonandClaude Opus 4.8 afc9b3fd1a test(ci): vanilla-upstream route-surface contract (ADR 34)
Prove the Android standard-path route surface exists on unmodified
NousResearch/hermes-agent — the invariant CLAUDE.md asserts but that was never
tested (the staging server runs a fork with relay routes compiled in).

- scripts/check-upstream-route-contract.py source-parses upstream's declared
  routes (aiohttp add_* + FastAPI decorators): no server boot, no pip install,
  no model keys. Two tiers: REQUIRED standard-path routes fail the build if
  missing; mode-dependent routes (auth-gate, /api/pty, /v1/models) only warn.
  Refuses to pass against our fork via a fork-marker guard.
- .github/workflows/ci-contract.yml checks out vanilla upstream with NO relay
  bootstrap, asserts the checkout is vanilla, runs the contract. Weekly
  schedule tracks upstream main as a drift siren; PR/push use a pinned ref.

Verified locally against the upstream clone: 12/12 REQUIRED routes present;
auth-gate routes correctly advisory (absent in the loopback-token build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:27:13 -04:00
Bailey DixonandClaude Opus 4.8 0448fd36df test(network): enforce upstream/relay/shared fence with Konsist
Add a JUnit-level architecture test (Konsist) asserting the ADR 34 package
fence on production code: network.upstream must not import network.relay and
vice-versa, and network.shared imports neither. Turns the "standard path =
vanilla upstream" invariant from a review convention into a failing test.

- Add com.lemonappdev:konsist 0.17.3 as a testImplementation dependency.
- ArchitectureBoundaryTest uses scopeFromProduction() so test-only cross-refs
  can't false-fail the boundary.
- Wire it into the ci-android.yml explicit --tests list (the broad aggregate
  hangs per issue #32, so the boundary test must be named or it never runs).

Verified: :app:testSideloadDebugUnitTest --tests "*ArchitectureBoundaryTest"
BUILD SUCCESSFUL — Konsist resolves cleanly on Kotlin 2.3.21.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:27:13 -04:00
Bailey DixonandClaude Opus 4.8 ea33bc9944 refactor(network): fence network/ into upstream/relay/shared packages
Physically separate vanilla-upstream network surfaces from Relay additions so
changes from either side have a contained blast radius (ADR 34). No behavior
change — pure package move plus import repointing.

- Split app/.../network/ into network/{upstream,relay,shared} (main + mirrored
  test sources). Upstream: Hermes/Gateway/Dashboard clients, chat payloads,
  ChatHandler, session models. Relay: ConnectionManager, ChannelMultiplexer,
  RelayHttp/Voice clients, BridgeCommandHandler, Envelope. Shared: connectivity/
  endpoint/LAN/profile-URL utilities + the voice routing seam.
- Split VoiceAudioClient.kt three ways: the VoiceAudioClient interface +
  AutoVoiceAudioClient router -> shared; StandardHermesVoiceClient -> upstream;
  RelayVoiceAudioClientAdapter -> relay. Co-locating them would force one file
  to import both worlds.
- Extract LocalDispatchResult to shared. The move surfaced the one real hidden
  upstream->relay coupling: ChatHandler (chat) renders phone-action bubbles from
  the bridge's LocalDispatchResult DTO via a same-package reference. As a passive
  DTO it belongs in shared; both sides now depend only on shared to speak it.
- ChatHandler placed in upstream (not shared): per ADR 3 chat never flows through
  the relay multiplexer; the handler is fed only by upstream transports.
- Update AndroidManifest GatewayKeepAliveService FQCN and the ci-android.yml
  RelayUrlDeriverTest path (it moved to network.relay).

Verified: :app:compileSideloadDebugKotlin and
:app:compileSideloadDebugUnitTestKotlin both BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:20:12 -04:00
Bailey DixonandClaude Opus 4.8 d0b33140ce docs(decisions): ADR 34 — structural fence for upstream/relay isolation
Record the decision to physically separate vanilla-upstream network surfaces
from Relay additions via three net-additive changes: a package fence
(network/{upstream,relay,shared}), a Konsist import-rule JUnit test, and a
vanilla-upstream route-contract CI job. Documents the placement calls decided
by reading (ChatHandler -> upstream per ADR 3; VoiceAudioClient.kt split three
ways) and the rejected alternatives (ConnectionViewModel transport-strategy
split deferred as too risky; custom ktlint/detekt rule deferred in favor of a
Konsist test that reuses existing JVM test infra).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:45:26 -04:00
Bailey Dixon 0a34e73ab5 Merge pull request #80 from Codename-11/Codename-11/docs-site-mobile-hero-fix
fix(docs-site): keep hero sphere canvas backing store synced to its css box
2026-06-17 16:45:03 -04:00
Bailey DixonandClaude Opus 4.8 92e507206f fix(chat): scroll bounce/false-FAB on bubble growth + honest resume context
Bubble-growth scroll bugs (Telegram-style tail-follow without reverse layout):
- A bare isAtBottom flip from a growing streaming bubble was misread as "user
  scrolled away" — it popped the scroll-to-bottom FAB and aborted auto-follow
  though the user never touched the screen. Now userScrolledAway is driven only
  by a genuine scroll GESTURE (isScrollInProgress falling edge); content growth
  never sets that, so it can't false-trigger. Reaching the bottom re-arms follow.
- Tail-follow is now an atomic single scrollToItem(bottom) per growth instead of
  the multi-frame settle loop, which collectLatest cancelled mid-settle on the
  next token (~every frame) and stranded the viewport — the visible bounce.

Resume context: on a COLD resume the server's per-session token counters +
compressor are reset, so session.info reports context_used=0 until the first
turn rebuilds the prompt. Painting that would show a misleading 0% on a session
with real history, so only adopt a non-zero figure (warm resume / post-turn);
cold resumes fill on the first exchange. (Server has no pre-turn context to give.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:43:29 -04:00
Bailey DixonandClaude Opus 4.8 c53b7cabb9 fix(docs-site): keep hero sphere canvas backing store synced to its css box
On mobile the hero phone-preview morphing sphere rendered at ~1/3 size and
hugged the top-left of the frame. The canvas backing store (sized once in
resize() from a clientWidth snapshot, with the dpr transform) drifted from
drawSphere()'s live per-frame clientWidth reads, so the grid was drawn into a
coordinate space that no longer matched the store — and canvas drawing starts
at (0,0), hence the top-left pin. Mobile triggered it via late-resolving 88cqw
container-query width (resize() bailed on cw<=0, leaving the 300x150 default
store with no dpr transform that the truthy-width guard never retried) and via
the 88cqw->80cqw boot->chat width tween that never resized screenEl.

Add syncCanvasSize(): measure the real box with getBoundingClientRect(),
reallocate the backing store only on an actual pixel-size change (re-applying
the dpr transform), and return the css-px dims to draw against. drawSphere()
now calls it every frame and draws against that single measurement, so the
store and draw math can no longer diverge and a not-ready layout self-heals on
the next frame. Point the ResizeObserver at the canvas (not screenEl) so the
boot->chat width tween is tracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:33:31 -04:00
Bailey DixonandClaude Opus 4.8 4fad1c5de5 fix(chat): stop scroll-to-bottom FAB flicker; subtle approvals-off marker
- Scroll-to-bottom FAB no longer blinks during streaming. It now hides while
  we're actively auto-pinning — a programmatic scroll in flight, or
  streaming-and-following (smoothAutoScroll on, not scrolled away) — since a
  content burst can momentarily make the list scrollable-forward for a frame
  before the re-pin. The FAB appears only once the user actually scrolls up.
- Subtle approval-bypass marker: when the server reports approvals effectively
  off (YOLO toggle, --yolo, or global approvals.mode=off — all folded into the
  session.info `yolo` boolean), the chat header subtitle carries a quiet amber
  "⚡ approvals off" so the risk is visible without opening the agent drawer.
  The loud toggle + warning stay in the agent drawer (desktop parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:30:26 -04:00
Bailey DixonandClaude Opus 4.8 75a9fdbbce feat(chat): on-resume context, ephemeral model-switch, opt-in recents
- Context bar on resume: session.info carries upstream's usage block
  (context_used/context_max), emitted on session resume. Parse it into a new
  serverContext flow and paint the context bar immediately instead of waiting
  for the first turn's usage event.
- Model switch is now ephemeral-only: drop the injected "Model switched to X"
  system bubble. The pill updating is the confirmation; server warnings/errors
  surface via a new transientNotice → snackbar channel (never a chat bubble,
  never dropped).
- Recent-prompt chips are now a config option, OFF by default
  (chatRecentPromptsEnabled in ConnectionViewModel + a toggle in Chat settings);
  the composer row is gated on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:22:43 -04:00
Bailey DixonandClaude Opus 4.8 977566c70c feat(chat): live session.info sync (reasoning/credential/yolo/fast) + stale-state refreshes
Augment the gateway surface to match the official desktop without showing stale
state. Audit found we dropped most session.info fields and fetched several server
lists once; contract verified against upstream, parallel review confirmed the new
config.set calls match exactly.

- session.info interceptor now also surfaces reasoning_effort, credential_warning,
  yolo, fast → serverReasoningEffort/serverCredentialWarning/serverYolo/serverFast
  flows; startGatewayStateSync gains one guarded collector each. A /reasoning change
  on desktop/TUI reflects live (not just on turn-complete).
- credential_warning surfaced once per distinct warning as a system notice (dedup'd
  against the constant session.info echoes, cleared when the key is fixed) — turns
  with a missing provider key no longer fail silently.
- YOLO + Fast toggles in the agent sheet: config.set yolo (value 1/0, scope session)
  + config.set fast (value fast/normal), optimistic set+rollback, live state from
  session.info, reset across every session/profile/connection switch. YOLO renders
  loud (error caption + "Approvals are OFF" banner) and stays session-ephemeral.
- refreshSkills()/refreshModels() on agent-sheet open so server-side skill/model
  changes appear without an app reload.
- review fixes: activateGatewayProfile nulls yolo/fast (missing 5th clear site);
  setYolo/setFast rollback re-checks client identity after prewarm and only rolls
  back if it still owns the optimistic value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:47:56 -04:00
Bailey DixonandClaude Opus 4.8 29793481a7 feat(chat): tool-card collapse persistence, recent-prompt recall, queue mgmt
Phase 2 (native-Chat desktop-TUI parity) — all enhancements to the existing
Compose chat, no TUI/xterm surface:

- 2.1 Tool-call cards keep their expand/collapse across scroll-off and
  re-render (rememberSaveable keyed per tool call, namespaced by the message
  item key). The chevron/rail tree affordance already existed.
- 2.3 Recent-prompt recall: a soft keyboard has no up-arrow, so the composer
  surfaces recent prompts as tappable chips while empty (recentPrompts flow,
  bounded 15, slash-commands excluded). Tap prefills for tweak-and-resend;
  hides on typing / when a queue or fresh chat shows.
- 2.5 Queue management: the queue was count-only. Each queued message is now a
  row — tap to edit (pull back into composer), ✕ to drop one (removeQueuedAt /
  takeQueuedForEdit). Reorder omitted.

2.2 (context bar) landed earlier; 2.4 (session picker) was already adequate.
Plan doc updated — both phases complete; only 1.7 (inline rename) deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:34:40 -04:00
Bailey DixonandClaude Opus 4.8 02595210dd feat(terminal): unread-output dots + jump-to-latest pill
Two more mobile-ergonomics wins, completing Phase 1 of the terminal/chat
parity plan:

- Unread dots: background tabs keep rendering output (stacked WebViews) with
  no signal. TabState.unreadOutput is set when terminal.output lands on a
  non-active tab and cleared on selectTab; a small dot shows on the inactive
  tab chip.
- Jump-to-latest: xterm onScroll reports atBottom via a new onScrollPosition
  bridge method into TabState.scrolledUp; a tappable pill appears over the
  terminal while scrolled up and snaps back to the live tail.

Plan doc updated: 1.1 (history) reclassified — the toolbar up-arrow already
sends ESC[A so shell-native history works; 1.6 (render parity) reclassified —
font cascade + resize contract already present, WebGL addon not vendored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:19:29 -04:00
Bailey DixonandClaude Opus 4.8 bb9ca52945 docs(play): listing copy + markdown tweaks
Wording ("server" -> "instance"), bullet spacing, and HTML-entity/link
escaping for the Play Console description. (WIP from the parallel session.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:11:03 -04:00
Bailey DixonandClaude Opus 4.8 e2d0a7b214 docs: terminal + chat-parity tracking plan
Companion checklist to the e2e UX audit, scoped from the 3-way comparison
(Android terminal vs upstream desktop TUI vs web dashboard). Phase 1 terminal
ergonomics, Phase 2 native-Chat parity (explicitly enhancement, not a TUI
replacement).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:11:02 -04:00
Bailey DixonandClaude Opus 4.8 c73567a74c feat(terminal): copy-selection and keyboard-toggle keys
Two mobile-ergonomics gaps in the remote shell. COPY reads the xterm
selection via a new window.getSelectionText() hook and commits non-empty
text to the system clipboard (WebView long-press copy is unreliable; pairs
with the existing PASTE). The new keyboard key toggles the soft keyboard via
WindowInsetsControllerCompat on the active tab, focusing xterm on show, since
tapping the terminal doesn't reliably raise the IME on phones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:10:48 -04:00
Bailey DixonandClaude Opus 4.8 29693bba3d feat(chat): per-session context-usage bar + faster cold-open transport
Context bar: expose absolute per-session token counts (ContextWindowUsage +
contextWindow flow) from the gateway usage events, reset at all four
per-session points. Rewrite ContextMeterBar from an invisible <50% hairline
into a clean desktop-style gauge: filled bar + `NN% · used/max` readout,
color-graded green/amber/orange/red, shown whenever the server reports a
context window. Drop the redundant header "NN% ctx" suffix.

Cold-open: the effort chip (and transport-gated UI) could lag ~30s because
the dashboard probe that flips gatewayAvailability to Ready was only retried
on the 30s health tick. Add a bounded fast-probe on chat foreground while the
verdict is still Unknown, collapsing it to ~1-3s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:09:31 -04:00
Bailey DixonandClaude Opus 4.8 6b15ae511f fix(ui): throttle idle animations to ~30fps; drop dead ARR workaround
The ambient orb (MorphingSphere) and the always-on ConnectionStatusBadge
heartbeat drove the whole Compose window at the panel refresh (120Hz)
forever — even idle — which on Android 15 makes the platform log
setRequestedFrameRate every frame and wastes battery. Replace the
infinite transitions with a shared frame-throttled driver:

- New rememberAmbientPhase() runs ~30fps and parks when not running.
- MorphingSphere advances on a manual withFrameNanos loop: full-rate while
  active (thinking/streaming/voice), ~30fps idle. dt-accumulation keeps the
  motion speed identical.
- ConnectionStatusBadge + the two pulse banners use rememberAmbientPhase.
- Remove ComposeArrWorkaround (+ its 4 call sites): it reflected a field
  `isArrEnabled` that became a hardcoded SDK>=35 method in Compose 1.11.2,
  so it had been a silent no-op. The NaN log is a platform log of every
  ARR vote and is not suppressible from app code; only redraw frequency is.

Measured idle: ~114fps -> ~43fps (~62% fewer draws/logs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:09:11 -04:00
Bailey DixonandClaude Opus 4.8 cdb6eb4b4f fix(chat): server-owned personality on the gateway + picker-command handling
/personality is a picker command upstream (model/skin/personality) — the
desktop/TUI never raw-forward it; a named/none value is applied via config.set,
persisted to display.personality + the live session, and echoed on session.info.
The app forwarded it to slash.exec (dead-end on mobile), had no `none` concept,
and never consumed session.info — so it kept injecting a stale per-turn persona
prompt that fought the server.

- preserve `system-notice-` bubbles across the post-turn reconcile so slash
  results (incl. the disappearing /personality bubble) no longer vanish
- GatewayChatClient: serverPersonality/serverModel/serverProvider flows,
  getPersonality()/setPersonality() (config.get/set), session.info interceptor
- ChatViewModel: selectPersonality() pushes config.set on the gateway and syncs
  _selectedPersonality + the model pill from session.info; bare /personality and
  /model intercepted as picker commands; refreshPersonalities() on sheet open so
  server-supplied changes need no app reload
- startStream: gateway sends no persona/profile prompt (server owns SOUL +
  overlay) — fixes profile-SOUL double-injection; SSE keeps client injection
- ConnectionInfoSheet: drop the synthetic "Default" row; show None + the
  server-provided personalities (server default tagged); AgentDisplay treats
  none/neutral as cleared aliases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:38:29 -04:00
Bailey Dixon fc5b4d0522 Merge feature/ux-audit-wave-1 into dev
e2e UX audit + chat fixes: model-alias guard, gateway error surfacing,
searchable provider-aware model picker, agent-drawer provider line, Stop
polish, and the disappearing-reply (errored-turn reconcile) fix.
2026-06-16 22:55:22 -04:00
Bailey DixonandClaude Opus 4.8 f62bcc4fe3 fix(chat): don't reconcile (and wipe) the error bubble on a failed turn
The post-turn server reconcile (loadMessageHistory in onCompleteCb, added in the
1.1.0 session-UX pass) ran unconditionally for gateway/sessions turns. A turn that
ends in an error has NO assistant message persisted server-side, so the reconcile
replaced [user, assistant-error] with the server's [user] — the assistant error
bubble vanished while the user message stayed (the "disappearing reply" regression;
1.0.0 didn't reconcile gateway turns, hence was unaffected).

Skip the message reconcile when the turn carries the "Error" badge (gateway ❌
lifecycle), keeping the local error visible; still refresh the drawer + drain queue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:48:44 -04:00
Bailey DixonandClaude Opus 4.8 a8247637aa feat(chat): show provider next to model in the agent drawer header
Detail views show "model · provider" (e.g. "gpt-5.5 · Codex"); the chat composer
pill stays model-only by design. Provider resolved from the live gateway current
provider via the model.options provider list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:36:48 -04:00
Bailey DixonandClaude Opus 4.8 e9fba1f6f6 feat(chat): provider-aware model picker — searchable sheet + availability routing
- model.options now parses authenticated / unavailable_models / free_tier /
  total_models (the picker hints upstream already sends via build_models_payload).
- the picker is current-provider-first and DISABLES models the account can't use
  (free-tier / no-credits → "Not on your plan") and flags unauthenticated
  providers ("Needs setup") — matching the desktop picker, so a switch can't land
  on a model that 400s / credits-fails (e.g. nous gpt-5.5 with no balance).
- the model pill now opens a full searchable ModelPickerSheet (CommandPalette
  style: search + provider group headers + selected check) instead of the cramped
  inline dropdown. The composer pill itself stays model-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:20:01 -04:00
Bailey DixonandClaude Opus 4.8 42bcc01510 fix(chat): guard hermes-agent model alias, surface gateway errors, stronger Stop
- never send a generic agent alias ("hermes-agent" / "hermes_agent" / "hermes
  agent") as a model on any send-path (in-chat override, gateway setModel /
  reset-to-default, SSE modelOverride). The server 400s on it and falls back to
  a paid model the account can't afford — the real cause of "no replies."
  Resolving the alias to null sends no model, so the server uses its true
  configured default. Adds AgentDisplay.requestModelName().
- surface gateway status.update lifecycle (model fallback, retries, errors) as
  a live status line above the composer, and stamp an "Error" badge on a turn
  that ends in a ❌ error so a failure no longer reads as a normal answer.
- Stop: firm LongPress haptic + a persistent "Stopped" badge on the cancelled
  turn (was a near-imperceptible TextHandleMove + transient toast only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:00:53 -04:00
Bailey DixonandClaude Opus 4.8 6cb18ad364 fix(release): wire Play Console "What's new" into the release flow
gradle-play-publisher reads the Play "What's new" from
app/src/<flavor>/play/release-notes/<locale>/<track>.txt, which never existed —
so the v1.1.0 Production draft uploaded with EMPTY release notes
(RELEASE_NOTES.md only feeds the GitHub Release body, not Play).

- Add app/src/googlePlay/play/release-notes/en-US/default.txt (Play "What's new",
  <=500 chars; seeded with the 1.1.0 text).
- bump-android-version.sh "Next steps" now reminds to update it + adds it to the
  git-add line.
- RELEASE.md section 2 documents it (separate from RELEASE_NOTES.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:32:07 -04:00
Bailey Dixon 79bc7eaf15 docs: add GitHub issue templates 2026-06-16 21:18:14 -04:00
Bailey DixonandClaude Opus 4.8 f2778f50c3 docs: add e2e UX audit and UX fix-tracking plan
High-level end-to-end UX / daily-use audit (first install -> pair -> standard vs
relay -> all surfaces -> Hermes management), benchmarked against the Hermex client
and the Hermes desktop dashboard, plus a phased fix checklist tracked as work lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:03:00 -04:00
Bailey DixonandClaude Opus 4.8 121da28767 fix(ux): apply audit waves 1-2 — onboarding, gates, safety, recovery & feedback
Wave 1 (quick wins):
- onboarding: replace "Standard/Advanced" tier cards with capability copy
- power-feature gate: name the server-side Relay-plugin prerequisite
- destructive-verb confirm: make Deny the dominant button, Allow low-emphasis amber
- bridge safety summary: reframe counts as protections, not capabilities
- notification companion: lead with Status + grant action
- chat: send suggestion chips on tap; disable the unimplemented Auto-TTS toggle

Wave 2 (recovery & feedback):
- add RelayUiState.Expired so a revoked/restarted relay session shows
  "Pairing expired — tap to pair again" instead of looping a doomed reconnect
  (wired through asBadgeState/statusText and the Settings relay pill)
- method-aware pairing-verify timeout copy
- camera-permission denial falls through to manual pairing
- "Stopped" acknowledgment on cancel; "Still working…" after a slow first token
- terminal PASTE key (clipboard -> PTY)

Verified: builds (assembleSideloadDebug) and installs to device.
See docs/audits/2026-06-16-e2e-ux-audit.md and 2026-06-16-ux-fix-plan.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:03:00 -04:00
Bailey Dixon 94a95162af Merge pull request #79 from Codename-11/dev
Release: Hermes-Relay 1.1.0 (Android + plugin)
2026-06-16 20:58:11 -04:00
Bailey DixonandClaude Opus 4.8 3016eb1a0b chore(release): Hermes-Relay 1.1.0 (Android + plugin)
Android appVersionName 1.1.0 / appVersionCode 13; plugin 1.1.0 (already in
sync across pyproject/manifest/package.json). CHANGELOG [1.1.0] cut; Android
and plugin GitHub-Release bodies written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:44:48 -04:00
Bailey Dixon 742486bc84 Merge pull request #78 from Codename-11/feat/plugin-enhancements
feat(plugin): env prompts, native install, /relay slash commands, session-start hook
2026-06-16 20:37:22 -04:00
Bailey Dixon 4a19a0e002 Merge pull request #76 from Codename-11/fix/dashboard-button-styling
fix(dashboard): Nous DS button/badge contract + relay-status slot widget
2026-06-16 20:37:19 -04:00
Bailey Dixon 2a13e0d1c1 Merge pull request #77 from Codename-11/docs/refresh
docs: refresh skill + user docs for gateway-first chat + accurate counts
2026-06-16 20:37:15 -04:00
Bailey Dixon 6bf94c562b Merge pull request #75 from Codename-11/fix/settings-ui-cleanup
feat(android): settings UI cleanup — exception-only pills, plugin badge, section reorg
2026-06-16 20:37:12 -04:00
Bailey DixonandClaude Opus 4.8 100d4a7b69 feat(dashboard): relay-status header-slot widget
Registers a compact "Relay · connected/offline/unpaired" Badge into the host
dashboard's `header-right` slot via window.__HERMES_PLUGINS__.registerSlot, so
relay state shows on every dashboard page. Polls the plugin's loopback overview
every 15s, derives state, and catches all fetch errors to "offline" — never
throws in the header. Uses the host Nous DS Badge `tone` (success/warning/
secondary) directly. Manifest declares slots:[header-right] for discovery.

Built on the button/badge adapter fix in this PR; bundle rebuilt with both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:32:37 -04:00
Bailey DixonandClaude Opus 4.8 8eda3699f9 feat(plugin): env-key prompts, native install path, /relay slash commands, session-start hook
Adopt four upstream plugin surfaces for easier setup/use:
- requires_env rich form: declare the optional voice-provider keys (XAI/OpenAI/
  ElevenLabs) so `hermes plugins install` prompts for them with a "get yours"
  link instead of hand-editing ~/.hermes/.env. Standard path needs none.
- Native install: document/support `hermes plugins install
  Codename-11/hermes-relay/plugin` for tools-only setups (additive; the full
  relay still uses the curl install.sh).
- /relay slash commands (status/devices/pair) usable mid-chat from any platform,
  reusing existing relay logic; every path guarded.
- A minimal on_session_start hook: one 0.5s-timeout guarded /health ping,
  returns None, can't slow or crash the gateway.

Verified against upstream/main plugin contract (register_command, register_hook
on_session_start, requires_env shape, plugins install subdir).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:30:43 -04:00
Bailey DixonandClaude Opus 4.8 d1e086a8ae docs: refresh skill + user docs for gateway-first chat + accurate tool/version counts
- Skill docs: fix the broken `hermes-relay-doctor` command (-> `hermes relay
  doctor`), dead ROADMAP anchor, stale 0.6.0/0.2.0 version samples, and the
  pre-gateway "chat -> API server" framing in the pair skill.
- user-docs: gateway-first chat framing across direct-api / relay-server /
  architecture pages + README; desktop tool count 9 -> 23 (computer-use marked
  experimental); fixed the unsourced "v0.8.0+" requirement.
- Dev docs (relay-protocol.md, relay-server.md, relay_server/SKILL.md): same
  gateway-first correction.
- plugin/dashboard/README.md: drop the leftover "Hackathon submission" section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:03:34 -04:00
Bailey DixonandClaude Opus 4.8 453d12c804 fix(dashboard): translate button/badge props to the Nous DS contract
The host dashboard's __HERMES_PLUGIN_SDK__.components.Button is the Nous DS
button (boolean flags outlined/ghost/invert/destructive + size, NO `variant`
prop); Badge uses `tone`. The plugin passed shadcn-style `variant=...`, which
was silently dropped, so every button collapsed to the solid default
(bg-midground, near-white on this theme) with its label hidden by a
`color: inherit` reset — the "blank white boxes". Added Button/Badge adapters in
ui-shims.jsx mapping our props to the DS contract (+ theme-token fallbacks),
dropped the label-hiding reset, switched tabs/PairDialog to the adapters, and
rebuilt dist/. Generalises the #71 fix (which targeted .bg-primary while the DS
button uses .bg-midground).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:01:06 -04:00
Bailey DixonandClaude Opus 4.8 567e4bf851 feat(android): settings UI cleanup — exception-only pills, plugin badge, section reorg
Status pills are now exception-only (quiet when healthy); Power tools shows a
single state-aware "Plugin active/required/offline" badge instead of a per-card
"Relay paired" chip; Connections moved to the top, Diagnostics + Developer
options to the App section; status chips restyled to the app's translucent
language and the brand blue deepened. Also fixes the Chat-settings streaming
picker wrapping and makes the system-prompt preview reflect enabled toggles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:00:05 -04:00
Bailey Dixon 21938a670e Merge pull request #74 from Codename-11/fix/installer-uv-venv
fix(installer): support uv-managed hermes-agent venvs
2026-06-16 18:39:21 -04:00
Bailey DixonandClaude Opus 4.8 c364bee003 fix(installer): support uv-managed hermes-agent venvs (no pip)
install.sh step 2 assumed `python -m pip` exists in the hermes-agent venv,
but venvs created by uv (the upstream default) ship no pip module, aborting
the editable install with "No module named pip". Detect a pip-less venv and
bootstrap pip via ensurepip, or fall back to `uv pip`, with a tolerant version
readback. Venvs that already have pip are unaffected. Verified against the
docker-server uv venv (Python 3.11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:20:55 -04:00
Bailey Dixon 01bd6b402d feat(android): polish chat session UX 2026-06-16 16:20:41 -04:00
Bailey DixonandClaude Opus 4.8 86fd744baa Merge origin/dev fixes (#70 force-close, #71 button contrast) into dev
Brings PR #73 (corrupt-keyset connect force-close fix + dashboard button
contrast fix) together with the dev branch work (per-surface release
notes, Play auto-publish, :ui-preview, dashboard rework, chat UX).

Conflict reconciliation:
- plugin/dashboard/src/styles.css: my #71 contrast rules auto-merged on
  top of the dashboard rework, but that rework switched the theme to the
  --color-* token convention. Updated .bg-primary / .bg-secondary /
  .bg-destructive to var(--color-*-foreground, ...) (chaining the old
  names + a hardcoded fallback) so they pick up the reworked theme
  instead of falling through to the fallback. dist/style.css regenerated
  from src via the package copyFileSync step.
- CHANGELOG.md: combined the dev Added/Changed entries with the #70/#71
  Fixed entries under [Unreleased].
- DEVLOG.md: kept all three 2026-06-16 entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:58:55 -04:00
Bailey Dixon b35dac8bd2 Merge pull request #73 from Codename-11/Codename-11/fix-dashboard-contrast-and-connect-force-close
fix: connect force-close from corrupt keyset (#70) + dashboard button contrast (#71)
2026-06-16 15:44:30 -04:00
Bailey DixonandClaude Opus 4.8 c054094b60 docs: changelog + devlog for connect force-close and dashboard contrast fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:43:11 -04:00
Bailey DixonandClaude Opus 4.8 b6ece0a1cf fix(dashboard): restore button label contrast on solid variants
The scoped reset ".hermes-relay-plugin button { color: inherit }" lands
at specificity (0,1,1), which outranks the host shadcn Button's
text-*-foreground utilities (0,1,0), so solid-variant buttons painted
their label in the inherited container foreground -- which on the
dashboard theme nearly matches the button background, leaving labels
unreadable. Re-assert the paired foreground colour on .bg-primary,
.bg-secondary and .bg-destructive at (0,2,0) so they win back over the
reset without !important; ghost/outline buttons and inputs keep
inheriting, which is what they want. dist/style.css re-synced via the
package's copyFileSync build step.

Fixes #71

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:43:11 -04:00
Bailey DixonandClaude Opus 4.8 48ddba5fb7 fix(auth): heal corrupt token-store keyset to stop connect force-close
EncryptedSharedPreferences decrypts its Tink keyset eagerly during
construction, so a corrupt legacy keyset (the classic post-upgrade /
post-restore case, where the encrypted blob outlives the hardware
master key it was sealed against) threw AEADBadTagException straight out
of LegacyEncryptedPrefsTokenStore's constructor and force-closed the app
right after a successful pair, on both standard and relay connections.
Every accessor already healed via resetPrefs(), and KeystoreTokenStore
hides construction behind tryCreate's try/return-null, but the
directly-constructed legacy store had no such guard (AuthManager.kt:340).

LegacyEncryptedPrefsTokenStore now builds via buildPrefsResilient(),
which deletes the corrupt file and rebuilds a fresh keyset on failure.
AuthManager.store() wraps the legacy fallback in runCatching and
degrades to a new non-persistent InMemoryTokenStore if even the rebuild
fails, so token-store construction can never force-close. Confirmed
against the android-v1.0.0 stack trace: the frames resolve exactly to
AuthManager.kt:340 and SessionTokenStore.kt:260/266.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:43:10 -04:00
Bailey DixonandClaude Opus 4.8 6bef10f89b docs(claude): document Gradle modules in repo layout + Key Files
CLAUDE.md's Repository Layout showed a flat single-app tree while
settings.gradle.kts has :app, :relay-core, :relay-ui, :ui-preview, and the
quest included build. Add all of them to the layout and Key Files. The
relay-core/relay-ui/quest Quest/XR port modules are flagged [EXPERIMENTAL]
/ in-development (not shipped); ui-preview is the dev-only desktop hot-reload
harness added this session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:07:04 -04:00
Bailey DixonandClaude Opus 4.8 46261ff9b3 ci(release): give plugin and CLI per-release notes files (Android parity)
Plugin and CLI GitHub Release bodies were static boilerplate baked into the
workflow YAML. Move them to hand-written PLUGIN_RELEASE_NOTES.md /
CLI_RELEASE_NOTES.md (Summary + Added/Changed/Fixed + Install/Verify), the same
format as Android's RELEASE_NOTES.md.

- release-plugin.yml / release-cli.yml: render the notes file (sed-substituting
  __VERSION__, plus __TAG__ for CLI) and pass it via body_path instead of inline
  body, so install/pin commands stay version-accurate without manual edits.
- release-cli.yml publish-release: add actions/checkout (it previously only
  downloaded build artifacts, so the notes file was absent).
- RELEASE.md: §2 cross-refs all three per-surface files; plugin recipe commits
  PLUGIN_RELEASE_NOTES.md; CLI CI section documents CLI_RELEASE_NOTES.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:58:32 -04:00
Bailey Dixon c40ab728cb fix(android): refine chat command and session UX 2026-06-16 13:43:14 -04:00
Bailey DixonandClaude Opus 4.8 fd343932e5 docs(release): correct Play service-account setup nav (Users and permissions)
Play Console reorganized its navigation — there is no longer a "Setup > API
access" group. Update §3 to the current path: create the service account +
JSON key in Google Cloud Console, then authorize it via Play Console >
Users and permissions > Invite new users with the granular Release
permissions. Verified against developers.google.com/android-publisher/getting_started.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:08:05 -04:00
Bailey DixonandClaude Opus 4.8 505eb51586 chore(dev): add Play auto-publish, worktree doc, and :ui-preview hot-reload module
- CI: release-android.yml uploads the googlePlay AAB to Production as a DRAFT
  when PLAY_SERVICE_ACCOUNT_JSON is set (stable tags only). sideload publishing
  is disabled structurally via playConfigs so only googlePlay can reach Play.
- docs/worktree-workflow.md: one-worktree-per-feature mental model, Orca-manages-
  worktrees note, raw git-worktree fallback, and mapping onto the main/dev contract.
- :ui-preview: JVM-only Compose for Desktop hot-reload harness (CMP 1.10.3), sharing
  the platform-agnostic MorphingSphereCore from :relay-ui via a srcDir include.
- RELEASE.md (secrets table + §5 note), CHANGELOG [Unreleased], DEVLOG, .gitignore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:52:51 -04:00
Bailey Dixon a28703b652 Merge branch 'wip/preserve-dev-dirty-settings-layout' into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt
2026-06-16 11:07:23 -04:00
Bailey Dixon 572c7a7fca feat(android): polish chat session UX 2026-06-16 11:05:32 -04:00
Bailey Dixon e7fb1dc1de chore(release): migrate plugin and cli tag tracks 2026-06-16 10:50:32 -04:00
Bailey Dixon 2376ee64b9 chore(release): rename release workflows by surface 2026-06-16 10:23:09 -04:00
Bailey Dixon 3325f33c9e docs(release): normalize surface release names 2026-06-16 10:16:13 -04:00
Bailey Dixon 429fda9f0c Merge branch 'Codename-11/relay-plugin-audit' into dev 2026-06-16 09:36:32 -04:00
Bailey Dixon c090169545 feat(plugin): bundle relay management surface
Align the relay plugin/server metadata to 1.1.0 and add a version-track checker for Android, server/plugin, and desktop release surfaces.
2026-06-16 09:36:07 -04:00
Bailey Dixon 57e94d8e92 Merge branch 'feature/settings-power-tools-layout' into dev 2026-06-15 22:00:06 -04:00
Bailey Dixon 0192de05dd feat(android): reorganize settings power tools 2026-06-15 21:58:54 -04:00
Bailey Dixon 2aaee0e9cb chore: sync main into dev
# Conflicts:
#	DEVLOG.md
2026-06-15 18:15:40 -04:00
Bailey Dixon bae409d02a chore(deps): batch Android dependency updates (#69) 2026-06-15 14:29:55 -04:00
dependabot[bot]andBailey Dixon 8109ed9cc2 chore(deps): bump actions/setup-node from 4 to 6 (#20)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bailey Dixon <10284999+Codename-11@users.noreply.github.com>
2026-06-15 13:44:59 -04:00
dependabot[bot]andBailey Dixon b52a5d1249 chore(deps): bump actions/configure-pages from 5 to 6 (#19)
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bailey Dixon <10284999+Codename-11@users.noreply.github.com>
2026-06-15 13:38:57 -04:00
dependabot[bot]andBailey Dixon 8433c9daae chore(deps): bump actions/upload-pages-artifact from 3 to 5 (#37)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bailey Dixon <10284999+Codename-11@users.noreply.github.com>
2026-06-15 13:38:20 -04:00
dependabot[bot]andBailey Dixon 1727d6e372 chore(deps): bump actions/deploy-pages from 4 to 5 (#38)
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bailey Dixon <10284999+Codename-11@users.noreply.github.com>
2026-06-15 13:37:23 -04:00
dependabot[bot]andBailey Dixon d0d7951fd2 chore(deps): bump gradle-wrapper from 9.4.1 to 9.5.1 (#50)
Bumps [gradle-wrapper](https://github.com/gradle/gradle) from 9.4.1 to 9.5.1.
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.4.1...v9.5.1)

---
updated-dependencies:
- dependency-name: gradle-wrapper
  dependency-version: 9.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bailey Dixon <10284999+Codename-11@users.noreply.github.com>
2026-06-15 12:59:07 -04:00
Bailey Dixon 43d6cca601 Merge pull request #68 from Codename-11/fix/claude-review-main-hotfix
fix(ci): unblock Claude review for bot PRs
2026-06-15 12:43:23 -04:00
Bailey Dixon c7a6f03dc2 fix(ci): skip claude review for bot-authored PRs 2026-06-15 12:42:08 -04:00
Bailey Dixon ddccae7ec2 Merge pull request #67 from Codename-11/fix/claude-review-bot-skip
fix(ci): skip claude review for bot-authored PRs
2026-06-15 12:37:40 -04:00
Bailey Dixon ae82340b19 fix(ci): skip claude review for bot-authored PRs 2026-06-15 12:36:16 -04:00
Bailey DixonandClaude Opus 4.8 8142a399b9 docs(release): make the Play Console upload track-neutral (Production for GA)
§5 hardcoded "Release > Testing > Internal testing" as the upload step, which is
wrong for a stable GA on a live listing. Reframe: the AAB is track-agnostic, a GA
publishes straight to Production (the D-U-N-S org account is exempt from the
closed-testing gate), and Internal/Open/Closed are opt-in channels, not a mandatory
ladder. Also corrects the Play "What's new" source (docs/play-store-listing.md,
not RELEASE_NOTES.md) and the automated-upload track flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:48:50 -04:00
Bailey Dixon 4992d5e0ec Merge pull request #61 from Codename-11/dev
Release v1.0.0 (android-v1.0.0): dev → main
2026-06-14 22:11:57 -04:00
Bailey DixonandClaude Opus 4.8 d284d7a1e5 fix(ci): detect release PR by base+head, not a title prefix
The Claude Code Review job skips the aggregate dev -> main release PR (feature
work is reviewed before landing on dev; release PRs are gated by CI + release
metadata). Detection required the title to start with "release:", but the actual
release PR is titled "Release vX.Y.Z …", so IS_RELEASE_PR was false — the full
review ran on the entire release diff and hit the action timeout, failing a
required check and blocking the release merge. Per the branching model main only
receives release merges from dev, so base==main && head==dev is the release flow;
drop the fragile title check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:58:10 -04:00
Bailey Dixon c1ca2c1b97 Merge branch 'main' into dev
Reconcile main's 2026-06-12 "deploy refreshed site" snapshot (5c7d649) with dev's
continued docs rework. The 6 conflicting user-docs files (HeroDemo.vue, custom.css,
theme/index.ts, getting-started.md, guide/index.md, quick-start.md) are resolved in
favor of dev — the deliberate, newer, more-complete rechrome that supersedes the
earlier snapshot (e.g. dev's quick-start adds the API-key + QR-scan guidance;
getting-started is the reworked 492-line Google-Play-first funnel vs the 322-line
snapshot). Theme imports verified self-consistent (all 9 components present).

This unblocks the dev -> main release PR for android-v1.0.0.
2026-06-14 21:31:17 -04:00
Bailey DixonandClaude Opus 4.8 99b51c5611 fix(android): transport-aware session persistence + drawer refresh
Non-default agent chats forked a new session on every send. The api_server
(SSE) and gateway transports store sessions in different DBs with different id
namespaces, so a session created by one cannot be resumed by the other on a
non-default profile: api_server (api_* ids) persists to the launch state.db and
ignores ?profile=, while the gateway (YYYYMMDD_* ids) binds the profile's own
state.db. A stale api_ id resumed over the gateway 404s -> fork.

- ProfileSessionStore is now keyed by SessionTransport (GATEWAY/SSE) as well as
  connection+profile, so a gateway session and an SSE session never clobber one
  slot.
- saveLastSessionId buckets by the session id's namespace (the prefix is the
  server's ground truth about what can resume it).
- refreshLastSessionForProfile restores the active transport's slot and defers
  while the gateway probe is Unknown; a gatewayAvailability collector re-runs the
  restore once it settles. A null save clears only the active known transport
  slot, never mid-defer or right after a connection switch.

Also: a newly created session was missing from the drawer until a manual reload
(the only post-creation list refresh fired mid-stream, before the session was
persisted server-side). onCompleteCb now refreshes the session list after the
turn, and the drawer refreshes on open.

Verified on-device via ADB (no fork, clean resume; drawer shows new sessions
without reload). ProfileSessionStoreTest rewritten for the transport key with
slot-independence, forSessionId/forEndpoint, and clear-scope coverage; lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:22:54 -04:00
Bailey DixonandClaude Opus 4.8 088fbabe52 fix(android): profile-scope post-turn history reconciliation
The gateway/sessions post-turn reload (onCompleteCb) and its error-recovery path
reloaded the server-authoritative transcript via the bare api_server
`/api/sessions/{id}/messages` (no `profile=`). A gateway turn on a non-default
profile persists into THAT profile's own state.db, so that read 404s →
getMessages maps it to emptyList() → loadMessageHistory silently wiped the
just-finished turn (it then reappeared in the drawer, which is profile-scoped).
Route both reloads through loadSessionHistory(sid), which prefers the `?profile=`
dashboard loader on gateway connections. Default profile was unaffected.

Confirmed on-device via logcat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:58:18 -04:00
Bailey DixonandClaude Opus 4.8 c87fadea7e docs(devlog): depersonalize for public distribution
Rewrite DEVLOG.md as a factual, third-person engineering log: drop personal-name
attributions and AI/assistant process self-narration, and scrub real server LAN /
Tailscale IPs and the tailnet hostname to neutral placeholders. Technical content,
dates, commit refs, and the public signing-cert identity are preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:55:54 -04:00
Bailey DixonandClaude Opus 4.8 7e30156635 docs(release): polish v1.0.0 notes for public distribution
- CHANGELOG: condense the [1.0.0] block to crisp Keep-a-Changelog bullets
  (Added/Changed/Fixed), scrub personal names from historical blocks, add the
  ephemeral-vs-server-wide profile note, set the release date.
- whats_new.txt / RELEASE_NOTES.md / play-store-listing: add per-conversation
  profiles; refine the Play "What's new" around the standard-vs-advanced path,
  upstream no-plugin support, UI/UX, QoL, and polish (<=500 chars).
- RELEASE.md: add a "Scrub for public distribution" step to release-prep.
- CLAUDE.md / AGENTS.md (new) / CONTRIBUTING.md: codify public-repo writing
  hygiene (no personal names, no private infra, no AI process narration; crisp
  changelog at release-prep; depersonalized devlog).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:20:08 -04:00
Bailey DixonandClaude Opus 4.8 787982098c feat(android): confirm before the Manage tab's server-wide Activate Profile
The Manage tab's "Activate Profile" sets the server's persistent default agent
(POST /api/profiles/active) for every client — distinct from the ephemeral,
per-conversation profile switch in chat. Route it through the existing confirm
dialog with copy that spells out the server-wide effect, so it can't be mistaken
for the in-chat switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:19:49 -04:00
Bailey DixonandClaude Opus 4.8 ae5b93f9e5 docs: redesign README and clarify Hermes server setup guidance
README: feature-banner hero + screenshot gallery, Google Play marked live, lean renamed CLI section; drop the stale embedded demo video (GitHub CSP won't render external/Pages video) in favor of a link to the docs demo.

user-docs (getting-started, quick-start): defer first-time server setup to upstream Hermes docs, annotate the API/dashboard config, frame the API key as a user-chosen value, add 0.0.0.0 security notes, document the LAN-scan / manual / agent-generated-QR connect paths, and add non-technical skip-path + 'dashboard is optional' signposts.

Remove orphaned assets/chat_demo.mp4 + poster; the user-docs/public copies the docs site serves are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:12:01 -04:00
Bailey DixonandClaude Opus 4.8 ca2c626c10 fix(android): hydrate agent profiles at connect, not lazily on sheet-open
Cold start showed the default agent in the header even with a profile persisted;
opening the agent sheet then fetched the profile list, resolved the persisted name
(e.g. "Gary"), and visibly snapped the header + re-scoped the chat.

Root cause: a profile selection is persisted as a NAME and only resolves once the
connection's profile LIST arrives. On a dashboard/gateway connection the relay
auth.ok list is empty and _dashboardProfiles was fetched lazily — only by the agent
sheet's LaunchedEffect — so the pending name couldn't resolve until the picker
opened. Now ConnectionViewModel calls refreshDashboardProfiles() eagerly at the end
of activeConnectionId.collect, and clears _dashboardProfiles on a connection switch
so a pending name can't resolve against the previous connection's list. The
agentProfiles collector resolves the pending name as soon as the eager fetch lands.

(Chat profile selection stays ephemeral/per-session via session.create/resume
{profile} — this only changes WHEN the list is fetched, no new server writes.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:52:29 -04:00
Bailey DixonandClaude Opus 4.8 60f9b7a564 fix(android): per-profile sessions via dashboard REST, not gateway session.list
Re-verified against upstream NousResearch/hermes-agent (tui_gateway/server.py,
hermes_cli/web_server.py, apps/desktop). The gateway `session.list` RPC reads one
process-global SessionDB pinned to the launch profile — it can't scope per-profile
over a single socket — so the prior a1a758d approach showed the launch profile's
sessions regardless of the active profile.

Switch the drawer to the dashboard `GET /api/sessions?profile=<name>` surface (and
load each tapped session's transcript via `…/{id}/messages?profile=<name>`), which
opens that profile's own state.db directly — exactly how the official desktop
sidebar scopes, same id-space the gateway resume reads. Without the messages half,
opening a non-default profile's session would render empty.

Also fixes the switch UX + adds the picked QoL polish:
- activateGatewayProfile no longer calls createNewChat() — the profile-context
  switch already cancels the in-flight turn and resets the thread; the second reset
  raced it (the "reply typing, then a new chat appears" jank).
- A: empty chat reads "Chat with <Agent>" + the agent's description (desktop intro).
- B: leading delay(160) in the profile-context effect coalesces the lastSessionId
  null->value churn, skipping the intermediate empty paint on a switch with history.
- C: updateSessions preserves the active optimistic row past the min_messages=1
  refresh; sendMessageInternal stamps a new chat's drawer row with the first message.
- D: drawer shows a spinner instead of flashing "No sessions yet" while loading.

Removed the misleading gateway listSessions() + its test; added DashboardApiClient
listSessions/getSessionMessages request-shape tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:38:27 -04:00
Bailey DixonandClaude Opus 4.8 a1a758d011 feat(android): per-profile session drawer via gateway session.list
Sessions are profile-bound (each in its profile's state.db), but the drawer
listed via the api_server /api/sessions, which reads ONE shared DB with no
profile concept (verified upstream: _handle_list_sessions takes only
limit/offset/source). So the drawer couldn't scope to a profile.

Match the desktop: add GatewayChatClient.listSessions() → the `session.list`
RPC (the call the desktop session picker uses), which reads the active
profile's own DB and so returns only that profile's sessions. refreshSessions()
now routes through it on gateway connections (api_server /api/sessions stays the
SSE / fallback path), so the drawer re-scopes to the active profile's
conversations and switching a profile shows that agent's sessions.

Test: listSessions parses the gateway session list into SessionItems.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:12:34 -04:00
Bailey DixonandClaude Opus 4.8 b7e5c67714 fix(android): switch gateway agent profiles via profile-bound sessions (verified upstream)
The previous attempts (config.set {key:"profile"}, then setActiveProfile) were
wrong: the gateway rejected the config key, and the dashboard's active-profile
route doesn't touch a live gateway session — so the header read the new profile
while the running agent still answered as the old one.

Verified against upstream tui_gateway: a profile is a FULL agent (its own
HERMES_HOME/state.db, model, SOUL, personality, skills); sessions are
PROFILE-BOUND (the agent is built once at session.create from the session's
profile and a live session never adopts a new one); there is no profile-switch
RPC — the desktop passes `profile` on session.create / session.resume.

So:
- GatewayChatClient carries the selected profile on session.create AND
  session.resume via a live sessionProfileProvider (wired by ChatViewModel from
  the selected-profile provider), so a session is built as that agent.
- activateGatewayProfile drops the old session and starts a fresh chat — the
  next session.create binds the new profile, so the agent actually becomes it.
- Removed the wrong GatewayChatClient.setProfile (config.set / setActiveProfile).

Tests: session.create binds the selected profile; omits it when none selected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:00:45 -04:00
Bailey DixonandClaude Opus 4.8 331c3eb333 fix(android): agent-name slot shows the NAME, not the SOUL summary; drop avatar ring
Loading dashboard profiles into agentProfiles regressed the header: a dashboard
profile's description is a verbose SOUL summary ("Builds and maintains…"), and
two paths surfaced it in the agent-name slot.

- effectiveProfile no longer falls back to the advertised "default" profile, so
  with no explicit pick the main agent's name comes from the personality
  ("Victor") instead of the default profile's summary.
- profileDisplayName is now name-first: the profile NAME goes in the name slot;
  the description is only a blank-name last resort. A selected profile shows its
  name, not its summary.

Also drop the avatar's customized accent ring: the avatar letter already swaps
to the active agent, so the ring was a redundant overlay (and it read as
offset, drawn on a separate gapped box). The avatar is now a plain circle whose
letter swaps. Removed the now-unused `customized` flag + `border` import.

Tests updated: effectiveProfile returns null without an explicit pick; agentName
uses the profile name even when a verbose description exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:35:06 -04:00
Bailey DixonandClaude Opus 4.8 35d544b9cf fix(android): profile switch via /api/profiles/active + cleaner agent display
- Profile hot-swap key was wrong: the gateway's config.set has no `profile`
  key (it answered "unknown config key: profile"), unlike `model`. Switch
  GatewayChatClient.setProfile to the dashboard POST /api/profiles/active
  (setActiveProfile) — the route Manage and the official desktop use; the live
  gateway session adopts the new active profile on its next turn. Dropped the
  now-wrong config.set unit test (the route is covered by
  DashboardApiClientTest.profileActions_useActiveAndDeleteRoutes).

- Top-bar subtitle: show a NON-default personality BEFORE the model
  ("Catgirl · gpt-5.5"); the default personality is implied, so it's just the
  model. The primary line stays the agent name (unchanged).

- Profile cards cleaner: the profile NAME is the headline, the friendly
  description + model share one subtitle, and the verbose "profile: … ·
  compatibility overlay · active" caption is gone. Status stays visible — a
  prominent "Active" badge on the running profile (plus the green dot), and the
  relay-specific Overlay/API badge is dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:15:47 -04:00
Bailey DixonandClaude Opus 4.8 5a661fee42 feat(android): show dashboard agent profiles in the chat profile picker
The agent sheet's Profile section sourced only the relay's auth.ok profile list,
which is empty on a dashboard-only (non-relay) connection — so the host's actual
Hermes agent profiles (the ones set via Manage → Profiles, like the official
desktop) never appeared. Load them from the dashboard instead:

- DashboardApiClient.listProfiles() — GET /api/profiles, deserialized straight
  into the shared Profile type (the @SerialName fields already match the JSON).
  Tolerant of the array ({profiles:[…]}/{items:[…]}) and object-map
  ({profiles:{name:{…}}}) shapes; a sparse row gets name (map key) + empty model
  injected rather than failing the list.
- ConnectionViewModel: _dashboardProfiles, merged into agentProfiles as
  relay.ifEmpty { dashboard } (relay-paired connections unchanged), plus
  refreshDashboardProfiles(); the agent sheet refreshes it on open.

Because dashboard profiles map into the existing Profile type, the Profile
dropdown, selectProfile, the top bar, and the config.set {key:"profile"}
hot-swap all work unchanged — and the picked profile being in the list dodges
the resolvePendingProfileFrom reset.

Tests: listProfiles parses array + object-map shapes into Profiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:54:55 -04:00
Bailey DixonandClaude Opus 4.8 928e830044 feat(android): collapsible Profile / Personality / Model pickers in the agent sheet
The agent sheet rendered all three lists in full, so a server with many
personalities or models pushed Session/stats far down. Add CollapsiblePickerSection
— a tappable header (SectionLabel + current value + chevron) that collapses its
option rows by default and expands on tap — and wrap the Profile, Personality,
and Model sections in it. The rich rows (SOUL/skills badges, provider-grouped
models, runtime dots) are unchanged; they just live behind the header now, so
the header reads "Personality — Catgirl" until expanded.

Pure wrap, no row rewrite — zero behavior change beyond render-on-expand.
Compile + lint + assemble green; on-device layout pending review.

Also: CHANGELOG/DEVLOG entries for this and the profile hot-swap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:08:34 -04:00
Bailey DixonandClaude Opus 4.8 d8d9ce76cf feat(android): hot-swap gateway profiles from the chat picker
Selecting a gateway profile did nothing to the agent: selectProfile only set
client state + rebuilt the SSE client, and the gateway's bare prompt.submit
carries no profile, so the running agent kept the server's active profile.
(SSE turns were fine — they send the profile per-request as profileName.)

Mirror the verified model switch: GatewayChatClient.setProfile(name) dispatches
config.set {key:"profile", value, session_id} — the session-scoped path, so the
live session's agent (SOUL + model + skills) hot-swaps in place with no new
session and no lost context, matching the official desktop's clean profile
swap. ChatViewModel.activateGatewayProfile() wires it (mirrors selectModel):
prewarm → setProfile → "Switched to <profile>" notice (a failed/unknown key
surfaces as an error, not a silent no-op) → refresh model.options so the picker
reflects the profile's model. The agent-sheet profile rows call it alongside
the existing selectProfile state update.

Test: setProfile hot-swaps the live session via config set asserts the RPC
shape (key=profile, value, session_id=live-1). The exact upstream key mirrors
_apply_model_switch; live behavior to be confirmed on-device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:56:03 -04:00
Bailey DixonandClaude Opus 4.8 a6cb1e023e docs(whats-new): mention open/save images in the in-app release notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:09:27 -04:00
Bailey DixonandClaude Opus 4.8 b4a8c7cfef fix(android): render the in-app What's New cleanly
WhatsNewDialog pasted the raw whats_new.txt into one Text, so bullets showed as
literal "*" and the Chat/Manage/Voice/Polish section headers had no emphasis.
Parse the format instead — version line -> primary subtitle, blank-separated
sections -> bold headers, "* " bullets with indented continuations -> real "•"
bullets with hanging indent and spacing. Same source file (also the Play
"What's new" field); only the in-app rendering changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:41:54 -04:00
Bailey DixonandClaude Opus 4.8 3f0866e97f Merge feature/gateway-chat-transport into dev (v1.0.0)
Gateway chat transport (live thinking via dashboard /api/ws) and the
desktop-parity wave: attachments, steer, interactive ask cards, edit/resend,
subagent lanes, context meter, server slash commands, turn-complete + keep-alive
notifications, latency tracing, network-blip survival + route-following, the
in-chat model picker, generated-image rendering, open/save images & attachments,
and the cold-start connect-flash fix. Version 1.0.0 (appVersionCode 12).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:32:43 -04:00
Bailey DixonandClaude Opus 4.8 d433d09906 docs: v1.0.0 release prep
- CHANGELOG: fold the [Unreleased] open/save-attachments + cold-start-flash
  entries into [1.0.0] (the tag isn't cut yet; it's all release-day work).
- DEVLOG: add the open/save + cold-start session entry with on-device verify.
- CLAUDE.md: Key Files entries for MediaSaver / ChatImageViewer / ChatImageContent
  and the InboundAttachmentCard long-press menu.
- README / RELEASE_NOTES / whats_new / play-store listing / privacy / security /
  user-docs: 1.0.0 release-prep refresh (standard-first story, version pins,
  branding).
- Assets: regenerated play-store feature graphic (RelayRefresh indigo, Play-
  accurate trio) via new scripts/gen-feature-graphic.mjs; chat demo poster
  jpg -> png.
- Tooling: pnpm lockfile + workspace for the user-docs build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:22:05 -04:00
Bailey DixonandClaude Opus 4.8 37a24c00fa feat(android): open/save chat images & attachments + fix cold-start connect flash
Open/save: tapping an image in chat (generated/inline assistant image OR an
inbound attachment) opens a full-screen viewer — pinch-zoom/pan, double-tap
1x/2.5x, Share/Save/Close. Save lands in Pictures/Hermes-Relay with no storage
permission on API 29+ (MediaStore scoped storage); pre-Q and any failure path
fall back to the system share sheet. Non-image attachment cards gain a
long-press Open/Share/Save menu (files -> Download/Hermes-Relay); tap still
opens externally. Saves preserve original bytes (read back from the cached
content:// or base64, never a re-encode); a magic-byte sniff fixes the
extension for remote images that arrive without a usable content-type (also in
stageForShare, so a shared image is named .jpg not .bin).

New: util/MediaSaver.kt (save/share/open + remote fetch + sniff),
ui/components/ChatImageViewer.kt (viewer + ChatImageViewerSource decoupling
Coil-model/bitmap display from a suspend bytesProvider). Wired into
ChatImageContent (remote inline) and InboundAttachmentCard (attachment image +
file-card menu).

Cold-start flash: the chat empty-state briefly showed the loud "Connect to
Hermes" CTA during launch while ConnectionStore hydrated DataStore async (an
empty store and a not-yet-loaded store were indistinguishable). Added
ConnectionStore.isHydrated -> ConnectionViewModel.chatConnectState
(Connecting/Ready/NeedsConnection, seeded Connecting); the empty-state shows a
quiet "Connecting to Hermes..." spinner (with a "Manage connections" escape
hatch) until hydration confirms nothing is configured, only then the CTA.

Verified e2e on-device (gpt-5.5 echoed a picsum image -> rendered -> tap ->
viewer -> Save wrote sunset.jpg + toast; share sheet reads "1 image";
cold-start shows no connect flash). lint + assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:21:26 -04:00
Bailey DixonandClaude Opus 4.8 e4f2fdd70d feat(android): keep-alive FGS, latency tracer, slide-down handoff toast
Lands the gateway desktop-parity wave files that the prior integration
commits referenced but left untracked, so the tree builds consistently.

- Keep connected in background (opt-in, both flavors): GatewayKeepAliveService
  (specialUse FGS holding the process up so the gateway socket survives
  background/Doze) + GatewayKeepAlivePrefs (shared KEY_GATEWAY_KEEP_ALIVE +
  setter); declared in the main manifest so googlePlay ships it too. Driven by
  the Chat Settings toggle; MainActivity hands consent before startForeground.
- Turn latency tracing: TurnLatencyTracer emits one durations-only TurnLatency
  INFO line per turn (warm/cold connect/session/submit/ttfe/ttft/done) across
  the gateway + 3 SSE paths for desktop-comparable diagnosis.
- Slide-down status + update toasts: ConnectionHandoffBanner / UpdateBanner
  become floating overlays (swipe-to-dismiss, status-bar inset) instead of
  banners that pushed the UI down.
- Gateway carries no phone-context preamble: PhoneStatusPromptBuilder note +
  the gateway path keeps prompt.submit bare (preamble persisted into the
  transcript and was visible from desktop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:21:02 -04:00
Bailey DixonandClaude Opus 4.8 23a3f97caf fix(android): model picker reads the real upstream models + clean switch
The model picker showed only `hermes-agent` (the api_server /v1/models generic
alias) and a tap reported a spurious "/model failed: not a quick/plugin/skill
command" even though the switch applied. Both are now fixed to match the
upstream desktop/TUI picker:

- SOURCE: fetch the curated provider/model list from the gateway `model.options`
  RPC (the same source the desktop picker uses) — real models grouped by
  authenticated provider: x-ai/grok, openai/gpt-5.5, anthropic/claude-opus-4.8,
  google/gemini, etc. Falls back to /v1/models + profile models on SSE. Rides
  the live socket (after a gateway turn / when Ready / on picker open), never a
  cold /api/ws open for metadata.
- DISPATCH: switch via the gateway `config.set {key:"model", value:"<model>
  --provider <slug>"}` RPC (the `_apply_model_switch` path) instead of the
  `/model` SLASH path, whose `command.dispatch` fallback reported the spurious
  failure. Now shows a clean "Model switched to <model>." notice (+ any
  provider warning).
- UI: the Model section renders provider→model groups (provider name header +
  model rows) like the desktop two-stage picker, flattened into the agent sheet.

Verified on-device: picker lists grok / gpt-5.5 / claude / gemini by provider;
tapping openai/gpt-5.5 switched the session (session.info model=gpt-5.5
provider=openai-api) and showed "Model switched to openai/gpt-5.5." with no
failure card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:51:57 -04:00
Bailey DixonandClaude Opus 4.8 edbc3bfc14 docs(devlog): image render, model switcher, route-following verified on-device
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:08:26 -04:00
Bailey DixonandClaude Opus 4.8 154b48367f feat(android): in-chat model switcher + gateway route-following
Model switching (b):
- GET /v1/models -> in-chat Model picker in the agent sheet (alongside
  Profile/Personality), augmented with the configured profiles' models since
  /v1/models often collapses to a single generic alias.
- Picking a model dispatches `/model <name>` on the gateway (surfacing the
  model-info confirmation card) and sets a per-turn override for SSE; "Server
  default" clears it. Gateway is warmed first so a pick before the first turn
  of a session still has a live session for slash.exec.
- Verified on-device: picker renders, tap switches the model + shows the
  confirmation.

Gateway route-following (c):
- The gateway client's dashboard target is now mutable: on a SUSTAINED mid-turn
  route switch (LAN->Tailscale), activeGatewayChatClient RETARGETS the
  in-flight client (reconnect via the new route, keep the live session id) so
  the turn follows the route instead of being stranded on the dead one. The
  resolved API URL is a key on the gateway-client effect so the retarget
  actually fires on a route change.
- Verified on-device: forced sustained Wi-Fi drop -> 'gateway route changed
  mid-turn - retargeting active client to follow the route' -> reconnect via
  Tailscale keeping the session, turn NOT cancelled, UI not wedged.
- A fresh socket can't replay an in-flight turn's events (upstream
  session.resume doesn't reattach), so after a retarget the turn gets a short
  30s settle instead of the full 180s watchdog; the reconcile-on-error then
  recovers the server's answer. Full live-follow needs an upstream
  resume-reattach / per-socket subscription.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:07:40 -04:00
Bailey DixonandClaude Opus 4.8 850309431e docs(devlog): gateway turn survival + chat UI session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:06:32 -04:00
Bailey DixonandClaude Opus 4.8 71a6c60bb5 feat(android): render generated images in chat + Telegram-style scroll follow
Generated/inline images now render in chat instead of a blank element:
- Add Coil 3 (coil-compose + coil-network-okhttp) with an explicit singleton
  ImageLoader (OkHttp fetcher) so http(s) image URLs load reliably.
- Parse markdown image links (![alt](src)) out of assistant content and
  render them: remote http(s) URLs load via Coil with loading/error states;
  a server-local path (or a load failure) degrades to an inline notice that
  explains WHY it can't be shown (with the path / tap-to-open), rather than
  the empty space the markdown renderer produced for ![](...).
- The image-link token is stripped from the markdown body so it doesn't
  double-render; surrounding prose is preserved.

Scroll: add a small slop to the chat list's at-bottom check so a burst of
streaming content (or a sub-frame layout gap before the auto-follow re-pins)
doesn't read as "user scrolled away" and drop the Telegram-style follow.

Note: image rendering compiles + Coil resolves; on-device visual check is
pending (device was locked during the autonomous run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:59:35 -04:00
Bailey DixonandClaude Opus 4.8 1d10cae5f7 fix(android): keep gateway chat turns alive across network blips + chat UI polish
Mid-turn network handling was cancelling or losing gateway chat turns:

- session.resume mints a NEW live session id + fresh agent upstream, so the
  old "rejoin via resume" orphaned the running turn (its thread keeps
  emitting on the OLD id). Reconnect the socket only and KEEP the live
  session id; retry with backoff up to 20s instead of giving up in ~24ms.
- A transient Wi-Fi blip marked the active endpoint unreachable and switched
  routes (LAN->Tailscale) mid-blip, rebuilding the chat client and
  cancelling the turn. Defer the loss reaction behind a 6s grace, add
  endpoint hysteresis (don't switch DOWN in priority on a transient probe
  miss), and stop route-change rebuilds from cancelling an in-flight gateway
  turn: activeGatewayChatClient keeps an active-turn client, updateApiClient
  skips gateway turns, and the route-driven rebuild is deferred while a turn
  streams.
- Reconcile server history on error too, so a turn that fails on the client
  after the server finished it still surfaces the answer.

Chat UI:
- Suppress the empty timestamp-only assistant bubble (a message carrying
  only thinking/tool calls, both rendered outside the bubble).
- A transport failure no longer wedges the composer in "streaming" behind a
  dead Stop button; the cancellation flag is reset at each new turn and the
  streaming UI is finalized even on a swallowed cancel.

Test: rewrote the mid-turn rejoin test to assert the real no-resume
recovery (tail on the original session id) instead of the prior
resume-based assumption. Verified e2e on-device via forced Wi-Fi drop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:41:45 -04:00
Bailey DixonandClaude Fable 5 aadac40843 docs(assets): refresh 02_chat.png with the redesigned input bar
Re-shot the chat screenshot on-device. The old capture showed the
previous footer (separate "/" slash button + mic glyph). The new one
shows the redesigned input bar — pill field, one morphing trailing slot,
GraphicEq waveform voice glyph, no slash button — in the proven
uptime/memory demo, alongside the live "Thought process" thinking cards
and a terminal tool card. Same 1080x2244 framing (top 96px status bar
cropped) as the other assets/screenshots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:11:30 -04:00
Bailey DixonandClaude Fable 5 1da8adce99 docs: rework Android getting-started, add Google Play badge, refresh chat guide
- getting-started.md: replace the flat wall of setup commands with a
  three-step funnel (install -> point at Hermes -> connect). The
  Get-it-on-Google-Play badge is the primary install action; all server
  setup, sideload install + SHA256/cert verification, dashboard auth, and
  build-from-source detail is preserved behind collapsible details blocks
  and OS code-group tabs so new users aren't scared off.
- Add a self-hosted Google Play badge SVG and a reusable <StoreBadge>
  component (registered globally), also slotted into the home hero.
- HeroDemo: rebuild the phone-mockup input bar to the redesigned chatbar
  (no slash button, one morphing Send/Voice/Stop trailing slot, GraphicEq
  waveform voice glyph).
- chat.md: document the new input bar, steering, edit-and-resend, the
  context meter, subagent lanes, interactive ask cards, turn-complete
  notifications, and the gateway mobile-preamble behavior.
- Normalize "Hermes Relay" -> "Hermes-Relay" in phone-control-tools/voice.
- CHANGELOG + DEVLOG entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:41:33 -04:00
Bailey DixonandClaude Fable 5 5249b7c2ea fix(android): carry mobile app-context preamble on gateway turns
The phone-context block (PhoneStatusPromptBuilder.buildPromptBlock) was
forwarded only on the SSE/runs/sessions paths via system_message. The
gateway's prompt.submit is bare text (no system slot — verified upstream),
so when the gateway transport is auto-preferred (Manage signed in) the
agent stopped receiving any phone context.

Add buildGatewayPreamble(), which returns just the non-sensitive mobile
preamble gated by the app-context master toggle, and prepend it to the
gateway wire text as "[preamble]\n\n<message>" — guarded to skip slash
commands (a prepended "/cmd" no longer starts with "/" and would break
server-side slash routing). The local user bubble and session title keep
the clean message; only the persisted wire copy carries the marker. The
richer bridge/permission/safety block stays SSE-only and on the
android_phone_status tool, to avoid bloating every persisted user turn.

Also normalize the product name to "Hermes-Relay" (hyphenated) in
user-facing app strings; bare "Relay" now only ever means the relay
server/plugin component.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:40:17 -04:00
Bailey DixonandClaude Fable 5 62f8403c58 docs: changelog/devlog/key-files for the gateway desktop-parity wave
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:54 -04:00
Bailey DixonandClaude Fable 5 9d4c857e11 feat(android): gateway parity — integration (steer flow, asks, edit/resend, slash, notify)
ChatViewModel: mid-turn gateway sends steer (rejected → queue + honest
caption; steered text = local "steer-" bubble preserved across reloads);
pendingAsk flow → ask HermesCards, answerAsk dispatches respond RPCs
answer-before-collapse (failed RPC leaves the card retryable; double-tap
guarded); regenerateFromMessage (0-based USER ordinal excluding local
traces, local truncate, 500-message safety gate, returns Boolean so the
edit chip never eats text); contextUsage flow; server slash catalog
(fetch only on ready socket or post-turn — never cold-opens) + slash.exec
→ 4018 → command.dispatch routing (exec/plugin/skill → notice, send →
prompt, prefill → composer); turn-complete notification (settings-gated,
backgrounded-only, never on cancel); image attachments ride the gateway
(SSE fallback narrowed to non-image); cancelled preflight no longer
resurrects on SSE.

ChatHandler: generating-tool adoption, subagent lane mutations
(interrupted ≠ success), ask-card append/stamp, truncateMessagesFrom,
generating/lane sweeps on BOTH complete and error paths. ChatScreen:
ChatInputBar swap, 5-state trailing derivation, lanes, meter + ctx
subtitle, edit-mode chip, server-command merge, cards keep empty bubbles
alive. Manifest: POST_NOTIFICATIONS (main — googlePlay could never post
on 13+). MainActivity: cancel notification on resume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:53 -04:00
Bailey DixonandClaude Fable 5 89475439a0 feat(android): gateway parity — UI components (input bar, ask cards, lanes, meter, notifier)
- ChatInputBar (new): Telegram-clean bar — pill BasicTextField, no slash
  button (typing "/" keeps autocomplete; long-press "+" opens the full
  palette), ONE trailing slot morphing Send/Voice/Stop/Steer/Queue via
  AnimatedContent, caption row above the bar during streaming-with-text,
  waveform voice glyph with one-shot hint pill + amber needs-setup badge.
- Ask cards: HermesCard gains an input slot (choice chips + free text,
  masked secret with reveal toggle + "Not stored in chat history",
  sudo hold-to-confirm 650ms press-fill + countdown, approval reuses
  plain actions); new ask.* built-in types; SUBMIT_ASK dispatch mode
  excluded from session sync so secret values never leave the card.
- SubagentLane (new): per-taskIndex lane — guide rail, compact tool rows,
  auto-collapse to a one-line summary; interrupted ≠ success.
- ContextMeterBar (new): 2dp strip, silent <50%, Relay→Amber@75%→
  Danger@90%.
- ToolProgressCard/CompactToolCall: "preparing" state for tool.generating
  (MoreHoriz + alpha-breathe, faded mono args preview, no progress bar).
- TurnCompleteNotifier (new): channel chat_turn_complete, BigText,
  tool-count subtext, tap deep-links to chat, cancel on resume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:29 -04:00
Bailey DixonandClaude Fable 5 3cb97e8a63 feat(android): gateway parity — network layer (steer, asks, attachments, catalog, subagents)
Wire contracts verified against upstream tui_gateway source (spec workflow,
file:line evidence). GatewayChatClient gains: session.steer (Queued/
Rejected/Failed — only accepted mid-tool-batch); the four ask-response
RPCs (clarify/sudo/secret request_id-keyed, approval session-scoped;
secrets/passwords never logged); image.attach_bytes uploads between
session establish and prompt.submit (60s timeout, one legacy
image.attach.bytes fallback on -32601, per-socket name memory; upload
failure → preflight fallback, prompt never submitted); commands.catalog
(per-socket cache, connectIfNeeded gate so composition never cold-opens
sockets) + slash.exec/command.dispatch with JSON-RPC error codes
surfaced; truncate_before_user_ordinal on prompt.submit; ask-aware turn
watchdog (a blocked clarify produces 300s of legitimate event silence —
the flat 180s watchdog was killing the turn and force-denying the ask).

Mapper: tool.generating pre-mints synthetic preparing tools adopted by
the next tool.start (per-name FIFO); five subagent.* cases →
GatewaySubagentEvent; asks re-shaped into structured GatewayAsk
(requestId preserved; approval has none by contract); usage gains
context_used/max/percent. GatewayTurnCallbacks members are REQUIRED —
the compiler forces dispatchOn main-thread wrapping for every addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:05 -04:00
Bailey DixonandClaude Fable 5 40a2859a71 fix(android): reconcile gateway turns against server history on complete
Tool cards required an app restart to appear after a gateway turn: live
tool events are gated server-side by display.tool_progress (off on
Bailey''s host — the same key that silences tool-progress spam on chat
platforms; default installs emit, which is why upstream desktop shows
live cards), and the gateway branch skipped the post-turn history reload
the sessions path has always done.

Gateway turns now reload server-authoritative messages on
message.complete — tool cards + persisted reasoning appear immediately
after the reply regardless of the server''s live-event config, and events
lost in a mid-turn rejoin gap are recovered the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:13:59 -04:00
Bailey DixonandClaude Fable 5 798365959c feat(android): restore persisted reasoning on history load + card timestamps
Caching audit (Bailey): tool calls already persist server-side and
reconstruct on history load, but per-message reasoning — which the server
also persists — was dropped during rehydration, so Thought-process blocks
existed only for the live turn and vanished on returning to a chat.
MessageItem now parses reasoning/reasoning_content and loadMessageHistory
restores it into thinkingContent. Server session DB stays the single
source of truth (no client-side store) — the gap was a dropped field, not
a missing cache layer.

Timestamps: right-aligned h:mm a on the ThinkingBlock header (hidden
while streaming) and on ToolProgressCard merged with duration
("3.1s · 5:32 PM"), matching the time message bubbles already show.
History-restored tool calls fall back to the parent message timestamp
(the OpenAI wire format has no per-call clock).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:44:44 -04:00
Bailey DixonandClaude Fable 5 3900d23037 feat(android): mid-turn gateway rejoin — reconnect + session.resume on socket loss
Two mid-session "Software caused connection abort" drops on-device today
(Samsung Wi-Fi power-save/roam), one of which killed a turn 90s into its
reasoning phase. The server keeps generating through a disconnect (orphan
reaper holds the session), and tui_gateway rebinds emits to the new
transport on session.resume — the same recovery the desktop TUI uses.

Socket loss with a turn in flight now triggers a bounded rejoin (max 2
per turn): fresh ticket, reconnect, session.resume, stream continues on
the new socket. Reentrancy-guarded so a connect failure inside a rejoin
cannot spawn a second one; cooldown is bypassed for active turns. Rejoin
failure surfaces the stream error as before.

Tests: mid-turn close → rejoin → completion on the new socket (fresh
ticket asserted); unreachable rejoin → stream error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:44:24 -04:00
Bailey DixonandClaude Fable 5 c931206ca0 docs(devlog): gateway on-device round 1 — transport confirmed, UI fixes, tool-card investigation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:09:48 -04:00
Bailey DixonandClaude Fable 5 ef3e595421 feat(android): per-event gateway frame logging
Tool cards did not render on a gateway turn and the only way to localize
it was reading log absences. Log every gateway event SSE-style: delta
types log length only, everything else logs a 300-char payload excerpt —
one tool-calling turn now shows definitively whether tool.start arrives
(client issue) or never leaves the server (display.tool_progress config /
agent callback path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:09:48 -04:00
Bailey DixonandClaude Fable 5 fb65ffbaa9 fix(android): single typing indicator + instant bottom-follow during streaming
Two on-device regressions surfaced by gateway-speed deltas:

- Double typing dots: ChatScreen rendered a standalone StreamingDots
  item below the list on top of MessageBubble''s in-bubble dots. The
  bubble keeps its dots; the outer item is gone (Telegram-style single
  indicator).
- Bottom-pinned stutter during live thinking: the auto-follow ran
  animateScrollToItem per delta under collectLatest. At gateway token
  frequency (vs SSE''s ~190-char bursts) that is a cancel/restart storm —
  every cancellation strands the viewport mid-animation on earlier
  content before the next delta yanks it back. Same-turn growth now pins
  the bottom instantly (scrollToItem); the animation is reserved for
  discrete new-bubble appends. Trailing spacer no longer animateItem()s —
  its position shifts on every delta of the bubble above it and a
  constant 8dp gap gains nothing from placement animation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:09:32 -04:00
Bailey DixonandClaude Fable 5 019986a833 feat(android): INFO logs for gateway connect + per-turn submit
On-device verification had to infer the transport from the ABSENCE of
SSE logs — the gateway happy path was completely silent. One line on
/api/ws ready and one per submitted turn (with the stored session id)
makes logcat show positively which transport served a send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:37:18 -04:00
Bailey DixonandClaude Fable 5 5d96bb74d6 docs: gateway transport changelog/devlog + standard-path-upstream-only principle
- CLAUDE.md: new first Key Instruction — the Standard (no-plugin) path
  must work against unmodified upstream hermes-agent (Google Play users;
  server-side needs go through upstream PRs or the relay plugin). Noted
  the /api/ws event-richness gap (tui_gateway is the only surface with
  live reasoning.delta) and added Key Files entries for the three new
  gateway files.
- CHANGELOG: [Unreleased] entry for the gateway chat transport.
- DEVLOG: session entry — latency diagnosis (49–71s reasoning dead air),
  upstream surface verification, what shipped, bugs the tests caught,
  deferred follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:23:22 -04:00
Bailey DixonandClaude Fable 5 82f24d3c2d feat(android): wire gateway chat transport — auto-prefer + per-turn SSE fallback
Live thinking lands: with Manage signed in, "auto" now resolves chat to
the gateway transport and reasoning.delta streams into the existing
ThinkingBlock + sphere Thinking state during the previously-dead
reasoning window. Standard-path constraint holds — vanilla upstream only,
no server changes.

- ChatViewModel: activeStream retyped EventSource? → ActiveTurnHandle so
  all cancel/teardown sites are transport-agnostic; SSE dispatch
  extracted to dispatchSse() and the gateway branch falls back to it per
  turn (no client wired / attachments — prompt.submit is bare text /
  preflight failure). "sessions" fallback degrades to "completions" when
  no server session exists. Voice-intent/card synthetic traces stay
  unsynced on gateway turns. Interactive asks (clarify/approval/sudo/
  secret) render as a SYSTEM notice via ChatHandler.addSystemNotice —
  display-only (desktop CLI v0.1 precedent), never spoken by voice.
- ConnectionViewModel: GatewayAvailability piggybacks on the standard-
  voice dashboard probe (/api/status + /api/auth/me — no ticket-burn);
  sticky markGatewayUnsupported() on WS-upgrade rejection, reset on
  connection switch; gateway client cached per (connection, dashboard
  URL) sharing the Manage cookie store; resolution delegated to the pure
  resolveStreamingEndpointPreference().
- RelayApp: gatewayAvailability keys the endpoint-resolution effect so a
  mid-session Manage sign-in flips auto → gateway without a restart.
- ChatSettingsScreen: 5th endpoint option "Gateway" + sign-in hint row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:23:08 -04:00
Bailey DixonandClaude Fable 5 6467601464 feat(android): GatewayChatClient — JSON-RPC chat over dashboard /api/ws
Newline-delimited JSON-RPC 2.0 over OkHttp WebSocket against the upstream
tui_gateway surface, authenticated with a FRESH single-use ws-ticket per
connect attempt (DashboardApiClient.requestWsTicket — shares the Manage
tab cookie session).

- Connect: 2-attempt loop (stale pooled connections can poison the first
  try after a server restart), gateway.ready handshake gate, 5s failure /
  300s rate-limit cooldowns, sticky onGatewayUnsupported on 404/403
  upgrades.
- Turns: sendTurn() resumes the stored session id (session.create
  fallback rotates it via onSessionId), prompt.submit, 180s watchdog
  reset on every event, cancel → best-effort session.interrupt.
  onPreflightFailure fires only when nothing started server-side, so the
  caller can re-dispatch the turn on an SSE endpoint.
- Lifecycle: lazy connect on first send, 30s grace close after app
  background (server parks sessions in its orphan reaper; resume picks
  them back up), no background reconnect loops.
- onClosing acks peer-initiated close frames — OkHttp does NOT do this
  automatically, and without the ack the socket sits half-closed for the
  ~60s close timeout, stalling reconnects.
- Tests: MockWebServer WS harness — handshake order, fresh ticket per
  reconnect, resume→create fallback, foreign-session drop, cancel →
  interrupt, mid-turn socket loss → stream error, preflight fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:49 -04:00
Bailey DixonandClaude Fable 5 721c6890ca feat(android): gateway wire models + event mapper
Foundation for the Gateway chat transport (upstream tui_gateway JSON-RPC
over the dashboard /api/ws — the surface hermes-desktop speaks, and the
only vanilla-upstream surface streaming reasoning live).

- GatewayModels: GatewayAvailability, GatewayConnectionState,
  ActiveTurnHandle (transport-agnostic turn cancel), GatewayTurnCallbacks,
  and pure resolveStreamingEndpointPreference() — "auto" prefers gateway
  when the dashboard probe says Ready.
- GatewayEventMapper (pure JVM): per-turn event→callback mapping.
  reasoning.delta/thinking.delta stream into the existing thinking UI;
  message.complete backfills text/reasoning when nothing streamed and
  translates tui_gateway usage keys (input/output/total — NOT the SSE
  input_tokens scheme); unknown event types are silently ignored
  (forward compat); synthetic FIFO tool ids when tool_id is absent;
  interactive asks surface via onInteractionRequest.
- Tests: full mapping table as fixtures + resolution matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:31 -04:00
Bailey Dixon 5c7d6490f1 docs(user-docs): deploy refreshed site 2026-06-12 16:10:23 -04:00
Bailey DixonandClaude Fable 5 bd60f06916 feat(docs): code-driven hero demo replacing homepage video embed
HeroDemo.vue rewritten as a ~20s looping recreation of the app: DOM chat
chrome over a canvas running the real preview/web/sphere.js algorithm,
driven through the product state machine (boot gate -> typed prompt ->
execute_code card with toolCallBurst -> streamed answer -> idle).

- Sphere tween rig runs on a monotonic clock (looped scene time fed the
  tweens a negative elapsed at every wrap; smoothstep extrapolation
  slammed char indices to the ramp floor - rings of periods through the
  eye). shadowStrength 0 to match the app's pearl shading.
- Header/navbar 1:1 with the live app: hamburger, light avatar, filled
  LAN pill, separate share / code / tune buttons, navy active tab.
- ?demoT=<seconds> scrubber freezes any timeline point for review and
  headless capture; reduced-motion gets the completed scene statically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:15:46 -04:00
Bailey DixonandClaude Fable 5 0c7877919d docs(media): re-shoot screenshots + demo video, drop orphaned foreground-service clip
Programmatic re-capture on S25 Ultra (demo mode, 96px status-bar crop in
post): 8 fresh 1080x2244 stills and a new 47s chat demo video + poster,
replacing the outdated set in assets/ and user-docs/public/. Removes the
orphaned foreground_service_demo.mp4 (23.5MB, unreferenced).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:15:33 -04:00
Bailey DixonandClaude Fable 5 588151cd40 Merge fix/health-retry-burst-gate-diagnostic: health fast-retry burst + startup-gate timeout diagnostic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:32:58 -04:00
Bailey DixonandClaude Fable 5 06ba7f1b00 fix(android): fast-retry burst on unreachable health verdict + gate-timeout diagnostic
Camera bug #2: "loading conversation" varied ~6-28s against the same
LAN server. Structural cause: the API health loop is a flat 30s ticker,
so one transient checkHealth() miss (cold-start race with the route
resolver, Wi-Fi settling, mid-route-swap) parked apiServerReachable
false for a full tick -- the gate holds, the 12s backstop dumps to the
CTA, chat heals at the next tick (the ~28s tail; the rest of the
variance was the one-time keystore hint priming after the reinstall).

- Bounded fast-retry burst: on a transition INTO Unreachable, three
  quick re-probes (2.5s/5s/7.5s), re-armed only by a Reachable verdict.
  StateFlow dedup makes repeat failures un-retriggerable; a genuinely
  down server fails one burst and settles back to the 30s cadence. The
  2-consecutive-failures route-re-resolve escalation is untouched.
- Requested diagnostic: when the 12s backstop (not readiness, not a
  settled error) opens the startup gate, DiagnosticsLog records a
  Warning naming the unmet conditions (chatReady / historySettled /
  narration stage / health / route) so future variance is explainable
  from Settings -> Diagnostics instead of needing a camera.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:32:57 -04:00
Bailey DixonandClaude Fable 5 1d09c7bac4 Merge fix/startup-gate-chatready: reveal gate keys on the chat surface''s own readiness signal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:54:47 -04:00
522 changed files with 62151 additions and 7322 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+90
View File
@@ -0,0 +1,90 @@
name: Bug report
description: Report a reproducible problem in Hermes-Relay.
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Before submitting, remove secrets, access tokens, real hostnames/IPs, private deployment names, and personal names. Public example IPs such as `192.168.1.100` are fine.
- type: dropdown
id: area
attributes:
label: Affected area
description: Pick the closest surface.
options:
- Android app
- Standard Hermes chat or voice
- Relay plugin or server
- Desktop CLI or tray
- Dashboard plugin
- Docs or installer
- CI, release, or packaging
- Unsure
validations:
required: true
- type: textarea
id: summary
attributes:
label: What happened?
description: State the behavior you saw and what you expected instead.
placeholder: |
Observed:
Expected:
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: Include the smallest sequence that reproduces the issue.
placeholder: |
1. Pair or configure...
2. Open...
3. Tap or run...
4. See...
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Include only the fields that apply.
value: |
- Hermes-Relay version/tag:
- Install surface: Google Play / sideload APK / local build / plugin / desktop CLI
- Android device and OS:
- hermes-agent version or commit:
- Connection mode: LAN / Tailscale / public TLS / other
validations:
required: true
- type: textarea
id: logs
attributes:
label: Sanitized logs, screenshots, or traces
description: Paste the smallest useful log excerpt. Remove tokens, private URLs, hostnames, IPs, and user-identifying data.
render: shell
- type: textarea
id: upstream
attributes:
label: Upstream or standard-path notes
description: If relevant, note whether this reproduces against unmodified upstream hermes-agent or only with the relay plugin enabled.
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I searched existing issues first.
required: true
- label: I removed secrets, tokens, private infrastructure, and personal names.
required: true
- label: I included the affected version or install surface where known.
required: true
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: Report a security vulnerability (private)
url: https://github.com/Codename-11/hermes-relay/security/advisories/new
about: Report privately via GitHub Security Advisories — do not open a public issue. See SECURITY.md for the full policy.
- name: User documentation
url: https://codename-11.github.io/hermes-relay/
about: Read setup, pairing, remote access, and troubleshooting docs.
- name: Contributing guide
url: https://github.com/Codename-11/hermes-relay/blob/main/CONTRIBUTING.md
about: Review local setup, branch, commit, changelog, and test conventions.
+64
View File
@@ -0,0 +1,64 @@
name: Documentation or setup issue
description: Report unclear, stale, or missing docs and setup guidance.
title: "[Docs]: "
labels: ["documentation"]
body:
- type: markdown
attributes:
value: |
Use this for docs, installer, setup, release-note, or contribution-guide problems. Remove private hostnames/IPs, tokens, and personal names before posting.
- type: dropdown
id: area
attributes:
label: Documentation area
options:
- README
- User docs site
- Android setup
- Relay plugin setup
- Desktop CLI or tray setup
- Release notes or changelog
- Contributor docs
- Other
validations:
required: true
- type: input
id: location
attributes:
label: Page, file, or section
description: Link the page or name the file and heading.
placeholder: user-docs/guide/getting-started.md, README install section, etc.
validations:
required: true
- type: textarea
id: issue
attributes:
label: What is wrong or missing?
description: Explain what was unclear, outdated, misleading, or absent.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Suggested correction
description: Optional. Include the wording, command, screenshot need, or structure that would help.
- type: textarea
id: context
attributes:
label: Context
description: Optional. Include the version, install path, device, or command you were following.
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I checked that this is not already covered in current docs.
required: true
- label: I removed secrets, private hostnames/IPs, internal deployment names, and personal names.
required: true
@@ -0,0 +1,78 @@
name: Feature request
description: Propose a product, workflow, or platform improvement.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Keep requests focused on user-visible outcomes. Do not include private infrastructure, secrets, personal names, or branch/workspace plumbing.
- type: dropdown
id: area
attributes:
label: Affected area
options:
- Android app
- Standard Hermes chat or voice
- Relay plugin or server
- Desktop CLI or tray
- Dashboard plugin
- Docs or installer
- CI, release, or packaging
- Unsure
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem or workflow
description: What is hard, missing, slow, confusing, or unsafe today?
placeholder: Describe the concrete user workflow this would improve.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed behavior
description: Describe the outcome, not just an implementation detail.
placeholder: After this change, a user should be able to...
validations:
required: true
- type: textarea
id: standard_path
attributes:
label: Standard upstream compatibility
description: If this touches chat, voice, dashboard, API routes, or server behavior, note whether it can work against unmodified upstream hermes-agent.
placeholder: This should work on vanilla upstream because... / This requires the relay plugin because...
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Optional. Mention current workarounds or related approaches.
- type: textarea
id: acceptance
attributes:
label: Acceptance criteria
description: What would make the request complete?
placeholder: |
- Users can...
- The app/server handles...
- Documentation covers...
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I searched existing issues first.
required: true
- label: I described the user outcome and affected surface.
required: true
- label: I removed private infrastructure details and personal names.
required: true
+13 -4
View File
@@ -6,11 +6,20 @@
-
## Verification
<!-- List the checks you ran, or explain why a check is not applicable. -->
-
## Checklist
- [ ] `./gradlew assembleDebug` succeeds
- [ ] `./gradlew test` passes
- [ ] Tested on emulator or device (if UI change)
- [ ] Target branch is `dev` unless this is a release PR
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
- [ ] Desktop changes: `npm run build` or a narrower documented check ran, or rationale is listed above
- [ ] Docs/site changes: docs build or link check ran, or rationale is listed above
- [ ] UI changes were tested on emulator/device or desktop surface when applicable
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
- [ ] CHANGELOG.md updated (if user-facing)
- [ ] No credentials or secrets in committed files
- [ ] Public writing hygiene checked: no secrets, private infrastructure, personal names, or AI/process narration
+22
View File
@@ -0,0 +1,22 @@
# GitHub Copilot instructions — Hermes-Relay
This file exists so GitHub Copilot (which reads `.github/copilot-instructions.md`,
not `AGENTS.md`) picks up the project's agent guidance.
**Read [AGENTS.md](../AGENTS.md) first — it is the single source of truth**
for agent guidance: the entry point, the non-negotiables, and the public-repo
writing hygiene. It links on to `CLAUDE.md` for the deep reference
(architecture, upstream Hermes API, repository layout, per-language code style,
the dev loop, and the Key Files map). Follow those; don't restate them here.
Quick non-negotiables (the full list and rationale are in `AGENTS.md`):
- **Standard path = vanilla upstream only.** The default no-plugin connection
must work against unmodified upstream hermes-agent; server-side needs go
through upstream PRs or the optional relay plugin, never fork patches.
- **Conventional Commits**, `main`/`dev` branching — feature branches off
`dev`, `--no-ff` merges, tags cut from `main`.
- **Android:** Jetpack Compose (no XML), kotlinx.serialization (no Gson),
OkHttp (no Ktor), `wss://` only; run `./gradlew lint` before pushing Kotlin.
- **Public repo:** no personal names, no private infrastructure, no
AI/assistant self-narration in committed prose.
+63 -19
View File
@@ -3,7 +3,14 @@
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
# Android-affecting paths so Python-only changes don't spin up the JVM.
#
# Pipeline: lint -> build + test (parallel) -> upload artifacts
# Pipeline: lint, build, and focused tests run concurrently. PRs build debug
# APKs before merge; dev pushes keep lint/tests only to avoid duplicate
# post-merge packaging. Main pushes keep APK artifacts.
#
# A release-build smoke (bundleRelease assembleRelease) runs on dev/main pushes
# and on the dev→main release PR so release-only breakage (R8/minify rules,
# resource shrinking, bundletool OOM) is caught BEFORE the android-v* tag,
# instead of mid-release. It is debug-signed, so it needs no signing secrets.
name: CI — Android
@@ -38,11 +45,12 @@ concurrency:
jobs:
# ──────────────────────────────────────────────
# Android Lint — gate for build and test jobs
# Android Lint
# ──────────────────────────────────────────────
lint:
name: Lint (Android)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -55,25 +63,20 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
# Prefer ktlintCheck if configured; fall back to Android lint
- name: Run lint checks
run: |
if ./gradlew tasks --all 2>/dev/null | grep -q "ktlintCheck"; then
echo "Running ktlintCheck..."
./gradlew ktlintCheck
else
echo "ktlintCheck not found, falling back to Android lint..."
./gradlew lint
fi
- name: Run Android lint
run: ./gradlew lint --console=plain
# ──────────────────────────────────────────────
# Android Build — assembleDebug + upload APK
# Android Build — assembleDebug for PRs and main pushes
# ──────────────────────────────────────────────
build:
name: Build (Android)
needs: lint
if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -86,12 +89,15 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
- name: Build debug APK
run: ./gradlew assembleDebug
run: ./gradlew assembleDebug --console=plain
- name: Upload debug APK
uses: actions/upload-artifact@v7
if: ${{ github.ref == 'refs/heads/main' }}
with:
name: debug-apk
# Product flavors (googlePlay, sideload) nest APKs under
@@ -109,7 +115,6 @@ jobs:
# ──────────────────────────────────────────────
test:
name: Test (Android)
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 20
# Advisory on dev, strict on main. Evaluates to false (= strict) for
@@ -128,6 +133,8 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
# The broad Gradle `test` aggregate currently hangs in deferred JVM test
# suites tracked by issue #32. Keep CI release-relevant until that suite is
@@ -136,15 +143,52 @@ jobs:
- name: Run focused Android unit tests
run: |
./gradlew :app:testSideloadDebugUnitTest \
--tests com.hermesandroid.relay.network.RelayUrlDeriverTest \
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
--console=plain
# Upload test reports even if tests fail, for debugging
# Upload reports only for failures. Successful PR report uploads add
# noticeable latency and are rarely inspected.
- name: Upload test reports
uses: actions/upload-artifact@v7
if: always()
if: failure()
with:
name: test-reports
path: app/build/reports/tests/
retention-days: 7
# ──────────────────────────────────────────────
# Release build smoke — exercises the release variant the android-v* tag
# build runs (./gradlew bundleRelease assembleRelease, both flavors), so
# release-only breakage (R8/minify, resource shrinking, bundletool OOM) is
# caught BEFORE the tag instead of mid-release. Debug-signed — no secrets,
# so it also runs on fork PRs. Runs on dev/main pushes (early signal after
# each merge) and on the dev→main release PR (hard pre-tag gate); skipped on
# dev-targeted feature PRs to avoid re-running a ~12-min build per iteration.
# ──────────────────────────────────────────────
release-smoke:
name: Release build smoke (Android)
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main') }}
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
# Mirrors release-android.yml's build step. No keystore is provided here,
# so app/build.gradle.kts falls back to debug signing — fine for a build
# smoke; the goal is to exercise the build, not to produce a shippable AAB.
- name: Build release bundles + APKs (both flavors, debug-signed)
run: ./gradlew bundleRelease assembleRelease --console=plain
+89
View File
@@ -0,0 +1,89 @@
# Hermes-Relay — Vanilla-Upstream Route Contract (ADR 34)
#
# Proves the Android *standard path* (no-plugin) route surface exists on
# UNMODIFIED NousResearch/hermes-agent — the invariant CLAUDE.md asserts but
# that was never tested. Source-parses upstream's declared routes (no server
# boot, no pip install, no model keys); see scripts/check-upstream-route-contract.py
# for the design + tradeoff (catches renamed/removed routes; not runtime auth).
#
# PR/push runs check a pinned ref (non-flaky); the weekly schedule tracks
# upstream `main` as a drift siren so a route rename surfaces on our clock.
name: CI — Upstream Contract
on:
push:
branches: [main, dev]
paths:
- "scripts/check-upstream-route-contract.py"
- ".github/workflows/ci-contract.yml"
- "app/src/main/kotlin/com/hermesandroid/relay/network/upstream/**"
pull_request:
branches: [main, dev]
paths:
- "scripts/check-upstream-route-contract.py"
- ".github/workflows/ci-contract.yml"
- "app/src/main/kotlin/com/hermesandroid/relay/network/upstream/**"
schedule:
- cron: "0 6 * * 1" # Mondays 06:00 UTC — upstream-drift siren (tracks main)
workflow_dispatch:
inputs:
upstream_ref:
description: "NousResearch/hermes-agent ref to check (branch, tag, or SHA)"
required: false
default: ""
concurrency:
group: ci-contract-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
jobs:
route-contract:
name: Vanilla-upstream route contract
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout hermes-relay
uses: actions/checkout@v6
- name: Resolve upstream ref
id: ref
run: |
# PR/push runs use a known-good NousResearch/hermes-agent commit so
# normal CI is stable. The weekly schedule below intentionally tracks
# main as the upstream-drift siren.
DEFAULT_REF="ef4b897a1843cd32c4f141f55db60f0f0602cc98"
if [ "${{ github.event_name }}" = "schedule" ]; then
REF="main" # weekly drift siren
elif [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
REF="${{ github.event.inputs.upstream_ref }}" # manual override
else
REF="$DEFAULT_REF"
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
echo "Checking standard-path route contract against upstream ref: $REF"
- name: Checkout vanilla upstream (no plugin, no bootstrap)
uses: actions/checkout@v6
with:
repository: NousResearch/hermes-agent
ref: ${{ steps.ref.outputs.ref }}
path: _upstream
fetch-depth: 1
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Assert upstream checkout is vanilla (no relay bootstrap/plugin)
run: |
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
echo "FAIL: upstream checkout contains a relay bootstrap — not vanilla."; exit 1
fi
echo "OK: upstream checkout carries no relay plugin/bootstrap."
- name: Run route-surface contract
run: python scripts/check-upstream-route-contract.py "_upstream"
+7 -5
View File
@@ -5,13 +5,11 @@ on:
branches: [main, dev]
paths:
- "plugin/dashboard/**"
- "scripts/check-server-version-sync.py"
- ".github/workflows/ci-dashboard.yml"
pull_request:
branches: [main, dev]
paths:
- "plugin/dashboard/**"
- "scripts/check-server-version-sync.py"
- ".github/workflows/ci-dashboard.yml"
permissions:
@@ -49,11 +47,15 @@ jobs:
with:
python-version: "3.11"
- name: Verify server-owned version metadata
run: python scripts/check-server-version-sync.py
- name: Verify plugin-owned version metadata
run: python scripts/check-plugin-version-sync.py
- name: Install dashboard API test deps
run: pip install -r relay_server/requirements.txt fastapi httpx pytest requests
# The suite imports the `plugin` package transitively: __init__ loads
# android_tool/desktop_tool (`import requests`), and one test imports
# `plugin.relay`, whose server.py needs `aiohttp` (+ pyyaml) from
# relay_server/requirements.txt. fastapi+httpx cover plugin_api itself.
run: pip install -r relay_server/requirements.txt fastapi httpx requests
- name: Run dashboard API tests
run: python -m unittest plugin.dashboard.test_plugin_api
+6 -10
View File
@@ -14,6 +14,10 @@ on:
permissions:
contents: read
concurrency:
group: ci-desktop-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
jobs:
typecheck-and-build:
name: Type-check + build
@@ -25,7 +29,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
@@ -47,16 +51,8 @@ jobs:
# prebuilt dist/ that references a source file that moved.
run: node bin/hermes-relay.js --version
- name: Upload dist/
uses: actions/upload-artifact@v4
with:
name: desktop-dist
path: desktop/dist
retention-days: 7
smoke-help:
name: Smoke — --help + --version work on every target OS
needs: typecheck-and-build
strategy:
fail-fast: false
matrix:
@@ -69,7 +65,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
@@ -1,12 +1,12 @@
# Hermes-Relay — Python Server CI Pipeline
# Hermes-Relay — Plugin CI Pipeline
#
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
# server-affecting paths so Android-only changes don't spin up the
# plugin-affecting paths so Android-only changes don't spin up the
# Python toolchain.
#
# Pipeline: syntax-check -> focused server tests
# Pipeline: syntax-check and focused plugin tests run concurrently.
name: CI — Server
name: CI — Plugin
on:
push:
@@ -17,18 +17,17 @@ on:
- "plugin/cli.py"
- "plugin/pair.py"
- "plugin/plugin.yaml"
- "plugin/dashboard/manifest.json"
- "plugin/dashboard/package.json"
- "plugin/dashboard/package-lock.json"
- "plugin/relay/**"
- "plugin/tools/**"
- "plugin/tests/**"
- "relay_server/**"
- "hermes_relay_bootstrap/**"
- "pyproject.toml"
- "scripts/check-plugin-version-sync.py"
- "scripts/check-server-version-sync.py"
- "scripts/bump-plugin-version.sh"
- "scripts/bump-server-version.sh"
- ".github/workflows/ci-server.yml"
- ".github/workflows/ci-plugin.yml"
pull_request:
branches: [main, dev]
paths:
@@ -37,27 +36,26 @@ on:
- "plugin/cli.py"
- "plugin/pair.py"
- "plugin/plugin.yaml"
- "plugin/dashboard/manifest.json"
- "plugin/dashboard/package.json"
- "plugin/dashboard/package-lock.json"
- "plugin/relay/**"
- "plugin/tools/**"
- "plugin/tests/**"
- "relay_server/**"
- "hermes_relay_bootstrap/**"
- "pyproject.toml"
- "scripts/check-plugin-version-sync.py"
- "scripts/check-server-version-sync.py"
- "scripts/bump-plugin-version.sh"
- "scripts/bump-server-version.sh"
- ".github/workflows/ci-server.yml"
- ".github/workflows/ci-plugin.yml"
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
concurrency:
group: ci-server-${{ github.ref }}
group: ci-plugin-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
jobs:
# ──────────────────────────────────────────────
# Python Server — py_compile syntax sanity
# Python Plugin — py_compile syntax sanity
# ──────────────────────────────────────────────
syntax-check:
name: Syntax check (Python)
@@ -72,10 +70,7 @@ jobs:
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r relay_server/requirements.txt
- name: Syntax check (server/plugin.relay — canonical location)
- name: Syntax check (plugin relay — canonical location)
run: |
python -m py_compile plugin/relay/server.py
python -m py_compile plugin/relay/channels/terminal.py
@@ -87,19 +82,18 @@ jobs:
- name: Syntax check (relay_server shim)
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
- name: Validate Server version metadata
run: python scripts/check-server-version-sync.py
- name: Validate Plugin version metadata
run: python scripts/check-plugin-version-sync.py
# ──────────────────────────────────────────────
# Python Server — focused route/auth/session tests
# Python Plugin — focused route/auth/session tests
#
# Tests are ADVISORY on dev (push or PR) so WIP commits don't block the
# merge queue. Strict on main — the dev → main release-merge PR surfaces
# any real failures before release.
# ──────────────────────────────────────────────
unit-tests:
name: Focused Server tests (Python)
needs: syntax-check
name: Focused Plugin tests (Python)
runs-on: ubuntu-latest
timeout-minutes: 10
# Advisory on dev, strict on main. Evaluates to false (= strict) for
@@ -120,7 +114,7 @@ jobs:
pip install -r relay_server/requirements.txt
pip install pytest responses
- name: Run focused Server tests
- name: Run focused Plugin tests
run: |
python -m pytest \
plugin/tests/test_relay_security.py \
+1 -1
View File
@@ -2,7 +2,7 @@
# branch protection on `main` has a check name it can rely on, regardless
# of which paths the PR touches.
#
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-server.yml`,
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-plugin.yml`,
# `ci-desktop.yml`) are scoped via `paths:` filters so a docs-only or
# desktop-only PR doesn't spin up the Android toolchain. Branch protection's
# "required status checks" treat a check that doesn't run as failing — so
+39 -4
View File
@@ -25,7 +25,16 @@ jobs:
issues: read
id-token: write
env:
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' && startsWith(github.event.pull_request.title, 'release:') }}
# Any dev -> main PR is, by the branching model, the aggregate release PR
# (main only ever receives release merges from dev). Detect it by base+head
# alone — a title-format match (e.g. "release:") is fragile and silently
# let a "Release v1.0.0 …"-titled PR run the full review and time out.
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' }}
# Bot-authored PRs such as Dependabot do not receive the same secret
# surface as human-authored PRs, and Claude Code rejects bot actors unless
# explicitly allow-listed. Keep the required check green with a no-op and
# rely on the dependency CI/status checks for those PRs.
IS_BOT_PR: ${{ github.event.pull_request.user.type == 'Bot' }}
steps:
- name: Skip aggregate release PR review
@@ -34,14 +43,40 @@ jobs:
echo "Skipping Claude Code Review for aggregate dev -> main release PR."
echo "Feature work is reviewed before it lands on dev; release PRs are gated by CI and release metadata checks."
- name: Skip bot-authored PR review
if: env.IS_BOT_PR == 'true'
run: |
echo "Skipping Claude Code Review for bot-authored PR."
echo "Bot PRs are gated by Required checks plus their path-specific CI jobs."
- name: Checkout repository
if: env.IS_RELEASE_PR != 'true'
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
uses: actions/checkout@v4
with:
fetch-depth: 1
# Depth 2 includes the pull_request merge commit's first parent, which
# lets the next step detect whether this PR changes the workflow file.
fetch-depth: 2
- name: Detect Claude review workflow changes
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
id: changed-workflow
shell: bash
run: |
if git rev-parse --verify HEAD^1 >/dev/null 2>&1 &&
git diff --name-only HEAD^1 HEAD | grep -Fxq ".github/workflows/claude-code-review.yml"; then
echo "claude_review_workflow=true" >> "$GITHUB_OUTPUT"
else
echo "claude_review_workflow=false" >> "$GITHUB_OUTPUT"
fi
- name: Skip Claude review workflow self-change
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow == 'true'
run: |
echo "Skipping Claude Code Review because this PR changes the review workflow itself."
echo "The Claude action requires this workflow file to match the default branch before it can exchange the app token."
- name: Run Claude Code Review
if: env.IS_RELEASE_PR != 'true'
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow != 'true'
timeout-minutes: 15
id: claude-review
uses: anthropics/claude-code-action@v1
+10 -6
View File
@@ -36,14 +36,18 @@ jobs:
fetch-depth: 0 # Full history for lastUpdated timestamps
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
# Node 24 ships npm 11, matching the npm that generates
# user-docs/package-lock.json. On npm 10 (Node 20), `npm ci` rejects
# the lock over the optional `search-insights` peer dep of bundled
# docsearch. Keep this aligned with the npm used to write the lock.
node-version: 24
cache: npm
cache-dependency-path: user-docs/package-lock.json
- name: Install dependencies
run: npm install
run: npm ci
working-directory: user-docs
- name: Build VitePress site
@@ -51,10 +55,10 @@ jobs:
working-directory: user-docs
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v5
with:
path: user-docs/.vitepress/dist
@@ -68,4 +72,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+104
View File
@@ -0,0 +1,104 @@
name: Play Store Listing
on:
pull_request:
paths:
- "assets/screenshots/**"
- "assets/play-store-icon-512.png"
- "assets/play-store-feature-1024x500.png"
- "docs/media/screenshots.json"
- "app/src/googlePlay/play/default-language.txt"
- "app/src/googlePlay/play/listings/**"
- "scripts/screenshots.py"
- ".github/workflows/play-listing.yml"
push:
branches:
- main
- dev
paths:
- "assets/screenshots/**"
- "assets/play-store-icon-512.png"
- "assets/play-store-feature-1024x500.png"
- "docs/media/screenshots.json"
- "app/src/googlePlay/play/default-language.txt"
- "app/src/googlePlay/play/listings/**"
- "scripts/screenshots.py"
- ".github/workflows/play-listing.yml"
workflow_dispatch:
inputs:
publish_listing:
description: "Publish Play Store listing metadata after validation"
required: true
default: false
type: boolean
permissions:
contents: read
jobs:
validate:
name: Validate Listing Assets
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install image tooling
run: python -m pip install --upgrade rich Pillow
- name: Validate screenshots and listing metadata
run: python scripts/screenshots.py validate
publish-listing:
name: Publish Listing Metadata
needs: validate
# Auto-publish the listing when its assets change on `main` (the release
# branch; the path filters above already scope this to screenshot/graphic/
# text changes). `dev` pushes and PRs validate only. A manual dispatch with
# `publish_listing` still works as an on-demand republish.
if: >-
${{ (github.event_name == 'workflow_dispatch' && inputs.publish_listing)
|| (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
- name: Write Play service account
id: sa
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
# Skip gracefully (no red CI) when the secret isn't configured — e.g.
# an auto-publish push to main before the service account is set up.
echo "::notice::PLAY_SERVICE_ACCOUNT_JSON not configured — skipping listing publish."
echo "configured=false" >> "$GITHUB_OUTPUT"
else
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
echo "configured=true" >> "$GITHUB_OUTPUT"
fi
- name: Publish Play Store listing
if: ${{ steps.sa.outputs.configured == 'true' }}
run: ./gradlew publishGooglePlayReleaseListing
- name: Remove Play service account
if: always()
run: rm -f play-service-account.json
+37 -4
View File
@@ -3,7 +3,7 @@
# Triggered when an Android release tag (android-v*) is pushed.
# Validates the tag matches the app version in libs.versions.toml,
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
# GitHub Release. Server/Python package releases use server-v* tags.
# GitHub Release. Plugin/Python package releases use plugin-v* tags.
name: Release Android
@@ -60,9 +60,8 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Build debug APK
run: ./gradlew assembleDebug
with:
cache-read-only: false
# Keep the tag release gate aligned with CI — Android's broad Gradle
# `test` aggregate currently hangs in deferred JVM suites tracked by
@@ -90,6 +89,8 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
- name: Decode release keystore
env:
@@ -151,6 +152,38 @@ jobs:
app/build/outputs/bundle/*Release/*.aab
app/build/outputs/SHA256SUMS.txt
- name: Upload to Play Console (production draft)
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
# Runs only when the Play service-account secret is configured AND this is
# a stable tag (prereleases — versions containing a dash — are skipped so
# an `-rc.N` build never lands on the production listing). HERMES_KEYSTORE_PATH
# was exported into $GITHUB_ENV by the "Decode release keystore" step above
# and persists across steps in this job, so the AAB is release-signed.
#
# `publishGooglePlayReleaseBundle` is the flavor-scoped task — only the
# googlePlay AAB is uploaded (sideload is disabled via playConfigs in
# app/build.gradle.kts). The play{} block pins releaseStatus = DRAFT, so the
# build lands on the Production track as a DRAFT: CI does the upload, a human
# clicks "Start rollout" in Play Console. A bad tag can never auto-go-live.
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON != '' && !contains(needs.validate.outputs.version, '-') }}
run: |
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
./gradlew publishGooglePlayReleaseBundle --track=production
rm -f play-service-account.json
- name: Play upload skipped (no secret)
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON == '' }}
run: |
echo "ℹ️ PLAY_SERVICE_ACCOUNT_JSON not set — skipped Play Console upload." \
"GitHub Release artifacts are still published; upload to Play manually" \
"(see RELEASE.md §5)." >> "$GITHUB_STEP_SUMMARY"
- name: Release summary
env:
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
@@ -1,8 +1,8 @@
name: Release Desktop
name: Release CLI
on:
push:
tags: ['desktop-v*']
tags: ['cli-v*']
permissions:
contents: write
@@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node.js (for npm ci + tsc)
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
@@ -89,7 +89,7 @@ jobs:
- name: Upload CLI release assets
uses: actions/upload-artifact@v4
with:
name: desktop-cli-release
name: cli-binaries
path: |
desktop/dist/bin/hermes-relay-win-x64.exe
desktop/dist/bin/hermes-relay-linux-x64
@@ -147,10 +147,13 @@ jobs:
- name: Smoke-test tray exe launch
shell: pwsh
run: |
$home = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
New-Item -ItemType Directory -Force -Path $home | Out-Null
$env:USERPROFILE = $home
$env:HOME = $home
# $HOME is a read-only automatic variable in PowerShell (names are
# case-insensitive), so use a distinct scratch name; only the
# $env:HOME / $env:USERPROFILE environment vars are writable.
$smokeHome = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
New-Item -ItemType Directory -Force -Path $smokeHome | Out-Null
$env:USERPROFILE = $smokeHome
$env:HOME = $smokeHome
$proc = Start-Process -FilePath tray/src-tauri/target/release/hermes-relay-desktop.exe -WindowStyle Hidden -PassThru
Start-Sleep -Seconds 5
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
@@ -160,7 +163,7 @@ jobs:
- name: Upload Windows tray release asset
uses: actions/upload-artifact@v4
with:
name: desktop-windows-tray-release
name: cli-windows-tray-installer
path: desktop/dist/tray/hermes-relay-desktop-windows-x64-setup.exe
retention-days: 7
@@ -171,9 +174,13 @@ jobs:
- build-cli-binaries
- build-windows-tray-installer
steps:
- name: Extract desktop version
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
# (the other publish-release steps only consume downloaded build artifacts).
- uses: actions/checkout@v4
- name: Extract CLI version
id: version
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
run: echo "version=${GITHUB_REF_NAME#cli-v}" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v4
with:
@@ -188,54 +195,31 @@ jobs:
| sed -E 's#release-assets/[^/]+/##' > release-assets/SHA256SUMS.txt
cat release-assets/SHA256SUMS.txt
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
# (cli-v0.3.0) so the install/pin commands stay accurate without manual edits.
- name: Render release notes
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ github.ref_name }}
run: |
sed -e "s/__VERSION__/${VERSION}/g" -e "s/__TAG__/${TAG}/g" \
CLI_RELEASE_NOTES.md > cli_release_notes_rendered.md
echo "=== rendered release body ===" && cat cli_release_notes_rendered.md
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
name: Hermes-Relay-CLI v${{ steps.version.outputs.version }}
tag_name: ${{ github.ref_name }}
draft: false
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
fail_on_unmatched_files: true
body: |
# Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
**Experimental phase.** Assets are unsigned - Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows now ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
## Install
**Windows tray app (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
**Windows CLI only:**
```powershell
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
**macOS / Linux CLI:**
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
Pin this specific release with `HERMES_RELAY_VERSION=${{ github.ref_name }}`.
## Verify
```text
hermes-relay --version
hermes-relay pair --remote ws://<host>:8767
hermes-relay shell
```
Open **Hermes Relay Desktop** from the Windows Start menu for tray pairing, devices, task log, settings, pause, and emergency stop.
See [Desktop docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
body_path: cli_release_notes_rendered.md
files: |
release-assets/desktop-cli-release/hermes-relay-win-x64.exe
release-assets/desktop-cli-release/hermes-relay-linux-x64
release-assets/desktop-cli-release/hermes-relay-darwin-x64
release-assets/desktop-cli-release/hermes-relay-darwin-arm64
release-assets/desktop-windows-tray-release/hermes-relay-desktop-windows-x64-setup.exe
release-assets/cli-binaries/hermes-relay-win-x64.exe
release-assets/cli-binaries/hermes-relay-linux-x64
release-assets/cli-binaries/hermes-relay-darwin-x64
release-assets/cli-binaries/hermes-relay-darwin-arm64
release-assets/cli-windows-tray-installer/hermes-relay-desktop-windows-x64-setup.exe
release-assets/SHA256SUMS.txt
@@ -1,16 +1,16 @@
name: Release Server
name: Release Plugin
on:
push:
tags:
- "server-v*"
- "plugin-v*"
permissions:
contents: write
jobs:
validate:
name: Validate Server release
name: Validate Plugin release
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
@@ -20,15 +20,15 @@ jobs:
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
run: echo "version=${GITHUB_REF#refs/tags/plugin-v}" >> "$GITHUB_OUTPUT"
- name: Verify Server version sync
run: python scripts/check-server-version-sync.py --expect "$TAG_VERSION"
- name: Verify Plugin version sync
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
env:
TAG_VERSION: ${{ steps.version.outputs.version }}
test:
name: Test Server package
name: Test Plugin package
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -55,7 +55,7 @@ jobs:
python -m py_compile plugin/tools/desktop_tool.py
python -m py_compile relay_server/__init__.py relay_server/__main__.py
- name: Run focused Server tests
- name: Run focused Plugin tests
run: |
python -m pytest \
plugin/tests/test_relay_security.py \
@@ -63,7 +63,7 @@ jobs:
plugin/tests/test_session_grants.py
package:
name: Build and publish Server package
name: Build and publish Plugin package
needs: [validate, test]
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -86,32 +86,25 @@ jobs:
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
# Render PLUGIN_RELEASE_NOTES.md (hand-written per release) into the GitHub
# Release body, substituting the version token so the Install command stays
# accurate without a manual edit. The file is the single source of the notes;
# see RELEASE.md "Plugin / Python package release".
- name: Render release notes
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
sed "s/__VERSION__/${VERSION}/g" PLUGIN_RELEASE_NOTES.md > release_notes_rendered.md
echo "=== rendered release body ===" && cat release_notes_rendered.md
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
tag_name: server-v${{ needs.validate.outputs.version }}
name: Hermes-Relay-Plugin v${{ needs.validate.outputs.version }}
tag_name: plugin-v${{ needs.validate.outputs.version }}
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
fail_on_unmatched_files: true
body: |
# Hermes-Relay-Server v${{ needs.validate.outputs.version }}
This release contains the server/Python plugin package.
Android releases use `android-v*` tags. Desktop releases use
`desktop-v*` tags. Historical server releases before this lane
rename used `relay-v*` tags.
## Install
```bash
pip install hermes-relay==${{ needs.validate.outputs.version }}
```
## Verify
```bash
python -m relay_server --help
```
body_path: release_notes_rendered.md
files: |
dist/*.whl
dist/*.tar.gz
+5
View File
@@ -26,10 +26,15 @@ local.properties
/app/build/
/relay-core/build/
/relay-ui/build/
/ui-preview/build/
/quest/build/
/app/release/
*.apk
*.aab
# Scratch / working directory (local pet packs, generated test assets, etc.)
/tmp/
/build-*.log
*.jks
*.keystore
/captures
+49
View File
@@ -0,0 +1,49 @@
# AGENTS.md
Universal agent instructions for **Hermes-Relay**. This is the entry point for any
coding agent (Claude Code, Codex, Cursor, etc.).
## Read this first
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
architecture, the upstream Hermes API reference, repository layout, per-language
code style, the dev loop, and the Key Files map. Read it before touching code,
then `docs/spec.md` and `docs/decisions.md`.
- Release process → **[RELEASE.md](RELEASE.md)**
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
## Non-negotiables (the short list)
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection —
chat via the API server, Vanilla Hermes voice via the Hermes dashboard — must work
against unmodified upstream hermes-agent. Server-side needs go through upstream
PRs or the optional relay plugin, never fork patches.
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
- **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints
everywhere, structured `logging` (no `print`). **Desktop CLI (Node ≥21):**
zero runtime deps, strict TS + ES modules, ship compiled `dist/`. Full
per-language style and the dev loop live in CLAUDE.md → "Code Style".
## Public-repo writing hygiene
Everything committed is public. In CHANGELOG, DEVLOG, README, docs, and release
notes:
- **No personal names** — attribute impersonally; identity lives in git + the
signing cert.
- **No private infrastructure** — real hostnames/IPs, internal deployment names,
`~/SYSTEM.md`. (Generic example IPs in setup docs are fine.)
- **No AI/assistant process self-narration** ("I should have…", course
corrections) — state the technical conclusion only.
- **No internal jargon or fork/branch plumbing** in user-facing notes.
- **CHANGELOG** uses Keep-a-Changelog grouping; condense the version block to
crisp public bullets at release-prep (see RELEASE.md §2 "Scrub for public
distribution"). **DEVLOG** is a depersonalized, factual engineering log.
+203 -94
View File
@@ -8,101 +8,208 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added
- **Persistent Realtime Agent conversation.** Realtime Agent voice now keeps one provider session/socket open across turns instead of creating a fresh session per utterance, so the provider retains the live conversation (follow-up references work) and turns skip session-setup latency. The relay needed no change — it already supported multiple turns on one socket. A **Voice Settings → Realtime Agent → Persistent session** toggle (default on) falls back to the legacy per-utterance path. See `docs/plans/2026-05-24-realtime-persistent-session.md`.
- **Background Hermes runs in Realtime Agent voice (ADR 33).** Long Hermes tasks no longer freeze the realtime conversation. A run that exceeds a grace window is promoted to a tracked background task: the provider speaks a short handoff ("I'm on it"), the conversation stays responsive, and the answer is spoken once the run finishes. `hermes_run_task(mode="background")` starts a durable run immediately. New relay events `hermes.run.promoted` and `hermes.run.background_completed`, plus `tier`/`floor` fields on `hermes.run.progress`.
- **Relay audio floor owner.** A single-owner audio floor (provider / relay-TTS / Android-filler) makes explicit the serialization that the old blocking design provided implicitly, so a completed background result never barges in and two voices never overlap.
- **Voice Settings → Realtime Agent → Background tasks.** New controls to enable/disable promotion, toggle the spoken handoff, and choose result delivery (speak when idle / notify / show only). A persistent "working on it" chip appears in the voice overlay while a background task runs.
- **Provider idle-tolerance probe.** `scripts/realtime-provider-idle-probe.py` records a per-provider verdict (hold-floor-ok / needs-keepalive / must-reopen) for holding a realtime socket quiescent during a background run; see `docs/realtime-voice-poc.md`.
- **Per-route reachability verdicts in the Routes card.** Every route row now shows the result of its last health probe — "Reachable", or "Unreachable" with the actual reason ("TLS failed — server may be http://, not https://", "Connection refused", "No answer (timed out)", "HTTP 404 from /health") — and "Re-check" shows a live checking state instead of doing invisible background work. Verdicts persist between probes so you can see what the network last said.
- **Manage parity with the hermes-desktop dashboard.** The Manage tab can now do what the desktop dashboard can: **Models** — change the main model from the full provider/model catalog (`/api/model/options` → `/api/model/set`), including the expensive-model confirmation round-trip; new **Keys** tab — view, set (write-only, masked), reveal (server rate-limited), and clear provider keys / env secrets; **Profiles** — create profiles (clone-from-default), edit descriptions, set per-profile models, and **edit SOUL.md** in a full-file editor; **Skills** — browse the multi-source skills hub with search, SKILL.md preview-before-install, install/uninstall (async server-side), and update-all.
- **Manage data survives app restarts.** The Manage payload cache now mirrors to a plain-JSON file in the app's private cache directory and hydrates at startup, so a cold app launch renders the last-seen dashboard data instantly while fresh data loads quietly behind it. Signing in or out wipes the disk mirror along with the in-memory cache. (Deliberately a flat file rather than encrypted prefs — the payload carries no credentials, and every encrypted-prefs build costs seconds under the Keystore's process-global lock.)
- **Desktop CLI: `hermes-relay audit`.** Shows what the remote agent has actually run on this machine through the desktop tools — tool, status, and a short detail per call — read from a local log, no network or auth. Answers "what did the agent just do?" at a glance.
- **Desktop CLI: `hermes-relay relay`.** Inspect the relay server itself: `relay info` (version, uptime, sessions — on the relay host), `relay security` (runtime auth toggles), and `relay context` (audit the system-prompt context the relay injects into the agent, which works from a remote machine with your session).
- **Desktop CLI: background daemon.** `hermes-relay daemon start` runs the headless tool router in the background (no console window, survives closing the terminal), with `daemon stop` and `daemon status` to manage it. `daemon status` reports state, uptime, relay, and advertised-tool count; bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
- **Desktop CLI: per-command help.** Every subcommand now answers `--help`, and `devices`/`sessions`/`plugins`/`voice`/`relay` print their own usage (sub-commands, flags, examples) instead of a terse "unknown sub-verb".
- **Desktop CLI: startup banner.** A slim "Hermes Relay" wordmark shows atop `--help`, the first-run welcome, and the chat REPL — and `hermes-relay logo` prints it on demand. Suppressed for piped/`--json`/`--no-color` output.
### Changed
- **Docs site rechromed to the relay cockpit theme and repositioned around the two-path story.** `user-docs/` now mirrors the app's `RelayRefresh` palette (navy-black base, warm-white ink, electric-indigo accent, grid/dot texture) instead of the old neutral-gray/purple chrome. The homepage leads with "Runs on your machine. Lives on your devices.", a quick-path-first funnel ("Just connect" — no server install, just a running Hermes — above the "Give it hands" relay-plugin power path), role-framed surface cards (companion app / remote-hands CLI), and a How-it-works strip. "Desktop CLI" is now plain "CLI" with a Windows-today / macOS-Linux-coming-soon status on every availability claim; "self-hosted" dropped as a qualifier. Sidebar gained five previously unreachable pages (voice, voice intents, phone control tools, relay server, flavor differences); stale version pins refreshed. Sphere gaze tracking on the homepage no longer drifts over time or snaps between scroll/cursor modes (`lightAngleBlend` partial-mix leak — blend is now exactly 1 with ambient life moved into the wander term).
- **Standard (no-plugin) voice now rides the Hermes dashboard surface.** STT/TTS for the standard route uses the dashboard's `/api/audio/transcribe` + `/api/audio/speak` (the hermes-desktop voice contract) with the same cookie session Manage signs in with — a vanilla hermes-agent install needs no Relay plugin for voice. Previously the client targeted the API server, which has no audio routes, so standard-only voice always failed.
- **Auto STT/TTS route prefers Relay when paired.** Paired Relay voice is profile-aware and needs no dashboard sign-in; the standard dashboard route is the zero-plugin fallback. Voice Settings now shows live per-route status (ready / sign-in required / unreachable / unsupported build) with a "Sign in via Manage" shortcut, and the Realtime Agent engine is clearly marked as requiring a paired Relay.
- **Softened the active connection card.** The full-card Electric blue fill on the active connection was overpowering against body text; it now uses a muted indigo wash while small accents keep the vivid brand blue.
- **Connection wizard capability card now includes Voice.** Finishing setup shows Chat / Manage / Voice / Relay readiness in one card — voice availability (ready / unlocks with dashboard sign-in / build too old) is probed in the same pass, so the result is accurate the moment you connect.
- **No more relay warnings on standard-only connections.** Voice Settings no longer fetches Relay voice configs (and no longer shows "unavailable" rows or error snackbars) when no Relay is configured — relay-backed sections are replaced by a quiet note that speech uses the server's configured TTS/STT, with Relay pairing called out as the way to pick providers from the phone.
- **Skills hub opens with featured content.** The browse dialog lists the configured hub sources and the index's featured skills before the first search instead of starting blank.
- **Onboarding feature pages got real content.** Chat / Manage / Power tools pages now show three concrete feature rows each (streaming + profiles + voice; control + skills hub + one sign-in; terminal + bridge + realtime) instead of a single sentence.
- **Floating status pill.** The bottom status strip is now an inset rounded capsule floating above the gesture area instead of an edge-to-edge bordered bar that clashed with rounded display corners.
- **Ambient mode is now a gesture.** The top-bar sphere toggle is gone; long-press the conversation background to enter the fullscreen sphere, tap anywhere to return (a transient "tap to return to chat" pill teaches the exit on entry). Message long-press (copy) is unaffected.
- **Media settings labeled Relay-only.** The Media screen now states that its inbound-attachment controls apply to Relay-delivered files only, not to standard connections or images you attach in chat.
- **Quote in reply.** Long-pressing a message now offers Copy and "Quote in reply" — quoting drops the message into the input as a Markdown blockquote.
- **Share conversation.** A share icon in the chat top bar exports the visible conversation as Markdown through the system share sheet.
- **Manage cards declutter.** Cards with five or more actions (profiles) keep the three most-used buttons inline and fold the rest behind "More".
- **Ambient gesture is documented in Appearance.** Settings → Appearance now explains the long-press-to-enter / tap-to-return gesture, keeping it discoverable (including for screen-reader users) without a visible control.
- **User docs: Quick Start.** New two-minute Quick Start page leads the guide; the dashboard page documents the full phone Manage surface (skills hub, models, keys, profile + SOUL editing); voice docs lead with the standard no-Relay route.
- **Routes are now editable in Settings → Connections.** The Routes card gains "Add route" plus per-route Edit/Remove (the primary route mirrors the connection's API URL and stays protected) — the standard path's manual equivalent of the Relay QR's multi-endpoint provisioning. Add your server's Tailscale or public URL after the fact and the phone roams to it automatically; the wizard's optional Tailscale field remains the setup-time shortcut.
- **URL fields accept bare hosts and explain their ports.** Typing `100.71.8.56` (or any bare host/IP) into the API URL, wizard Tailscale, or route-editor fields now saves `http://100.71.8.56:8642` — scheme and API port defaulted, and the route editor previews exactly what will be saved ("Will save: http://100.71.8.56:8642") before you commit. Field copy now states which port is which (API `8642`, dashboard `9119`) and that `https://` should only be used when the server actually has TLS. Route rows display the full URL including the scheme, since an invisible `https` was the classic cause of a route that never won a probe.
- **Manage remembers its data and pre-warms it.** Dashboard payloads now live in a process-lifetime cache instead of screen state, so leaving and re-entering Manage shows the last data instantly (entries older than 30 s refresh quietly in the background — content stays put, only a thin progress bar shows). When a connection's saved dashboard status says it was reachable and signed in, the app pre-warms all Manage sections at startup (and again after a LAN↔Tailscale route handoff), so even the first open lands on real data. Signing in or out still clears the cache.
- **Manage's full load dropped from ~40 round trips to ~12.** Every section fetch used to re-run the dashboard auth preamble (status → providers → session → ws-ticket) before its payload — eight sections, strictly one after another, which over a Tailscale link read as 5–10 seconds of "still loading". The preamble is now fetched once per sweep and shared, and the section payloads download concurrently, so a full load costs roughly one preamble plus one payload's worth of latency.
- **Cold start no longer waits 15 seconds to learn there's no API key.** On devices with StrongBox secure hardware (recent Samsungs), every keystore operation takes ~half a second and they all run one at a time — a measured cold start spent 15 seconds decrypting the credential store before the app could even build its HTTP client, only to find the connection had no API key (the normal local setup). A plain non-sensitive "has API key?" hint now lets key-less connections build the client immediately — chat, health, and the conversation restore start within a couple of seconds — while keyed connections still wait for the real decrypt (a stale hint can only ever make startup slower, never strip auth). The startup checks also now count the route prober's successful health probe as "hermes online" instead of waiting for the client-based probe to repeat the same check.
- **The startup reveal can no longer flash the "Connect Standard Hermes" card.** The gate was releasing on the route prober's early health evidence while the chat screen renders its connect CTA from a stricter signal (client built + reachability verdict) that lands a few hundred milliseconds later — so the fade-out could briefly expose the disconnected card before the full chat snapped in. The gate's happy path and the "conversation" check line now key on the chat surface's own readiness signal, so what's revealed is exactly what was verified.
- **Startup checks visibly check, and the OS splash blends into the sphere.** The sphere's check lines now resolve strictly top-to-bottom, each holding a brief spinner beat before its ✓ lands — with the fast cold-start path everything could already be true before the sphere faded in, and an all-✓-at-once reveal read as "nothing was actually verified". The gate waits for the ticking to finish (~1.5 s) before showing chat. The system splash (which Android always draws first and can't be replaced) now uses the app's exact background color — the old splash was a visibly different navy — and its icon is properly transparent, so launch reads as one continuous dark screen that the sphere fades into.
- **The startup sphere is now the actual loading screen.** Cold starts used to flash a slideshow of half-ready states — the disconnected "connect" prompt, then the connected state, then the conversation, each revealing separately — because the splash gate released on the first health verdict (often a probe against the old route, moments before the resolver switched) and force-hid itself after 5.5 s no matter what. The sphere now holds until the app is presentable — server answering AND the last conversation restored — or until an unreachable verdict survives a settle window (then the normal UI takes over with its offline status), with a 12 s backstop. While it holds, terminal-style check lines narrate progress at the bottom (state restored · route · hermes online · conversation), so a longer wait reads as work instead of a hang.
- **Terminal and Settings headers gained back buttons.** Both are pushed destinations (reached from the Chat/Manage header chrome), but neither offered a way back except the system gesture; they now carry the same header back arrow as every other pushed screen. The footer status pill also hugs the bottom edge slightly tighter.
- **"Use now" no longer silently becomes a preference.** The Routes card's "Use now" is now a true one-time switch: it moves traffic immediately and holds only until the next disconnect, without touching the saved route preference. Making a route sticky is the explicit "Prefer this route" action in the row's ⋮ menu (now a toggle, with "Stop preferring" when set). The Current line says which mode picked the route — automatic, preferred, or "manual (until disconnect)" — and dedicated "Cancel manual switch" / "Stop preferring" actions undo each layer separately. Tailscale is intentionally not auto-preferred: automatic resolution already promotes it the moment the LAN route stops answering, and keeps the faster LAN path when you're home.
- **Manage loading and overview polish.** The cold-load skeleton is now one progress bar plus quiet content-shaped ghost cards — previously four stacked progress bars with fake narrative labels ("Checking dashboard session"…) that read like three different failures. The cryptic KPI glyphs (`ok / … / !`) are replaced by three cards: section count, a tone-colored dashboard state word (ready / sign-in / offline / error), and the server version (handy for confirming which host answered after a route handoff). The dashboard status banner is now two lines — state + identity with Sign out, then URL · route · checked time — so nothing truncates, and its duplicate "Connection" button is gone (the Connections tile sits directly below).
- **Manage names its dashboard target and explains per-route sign-in.** The Manage tab now shows exactly which dashboard URL it's talking to ("Dashboard: http://… · Tailscale route") above the content, and "Dashboard unavailable" errors name the URL that failed — the dashboard (`:9119`) is a separate server from the API (`:8642`), so "chat works" never proved Manage's target was reachable. When the resolver has moved Manage onto a different host (e.g. roamed to Tailscale), the sign-in card now explains that dashboard sign-ins are per host and a one-time sign-in on this route keeps both sessions — the same hint voice already had.
- **Remote access is discoverable, not an easter egg.** The standard setup form now shows a "Remote access — Tailscale URL (optional)" field in the main flow (previously buried under Advanced), with a hint when Tailscale is detected on the phone; the setup result card gains a "Remote" readiness line that calls out LAN-only connections; the "Hermes API unreachable" status now diagnoses the likely cause ("Away from the server's network? Add a Tailscale or public route") instead of just reporting; and the Connections card offers an "Add Tailscale route" shortcut when the phone is on Tailscale but the connection has no Tailscale route.
- **README + Play listing refresh.** Both rewritten around the standard-first story. The README quick start now mirrors the app's capability card (Chat / Manage / Voice / Remote / Relay), voice is no longer described as relay-only, Manage and remote access become headline features, the desktop CLI section is trimmed and clearly marked alpha (with its planned refocus into a remote "hands" connector), and the stale CI badge, broken in-page anchors, and version-pinned "What's new in v0.6.0" section are gone. The Play listing (`docs/play-store-listing.md`) gets an end-user-first short description, a quick-start beat, Manage/remote-access feature blocks, a corrected no-plugin voice story, and v0.8.1 release notes.
- **Desktop CLI: visual + ergonomics refresh.** A single color theme across the CLI, aligned tables for `devices`/`sessions`, status dots for on/off states, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
### Fixed
- **App-start UI freeze (frozen sphere) from Keystore lock contention.** Cold starts could freeze the UI for many seconds (logcat: `Skipped 1386 frames`, `Davey! duration=11596ms`): every `EncryptedDashboardCookieStore` eagerly built its Keystore-backed prefs in its constructor — a 1–4 s operation on StrongBox devices that serializes through a process-global Tink lock — and several code paths (Manage section loads, connection validation, the Manage pre-warm) each constructed their own instance, stacking multi-second lock holds that main-thread keystore users then queued behind. The store now builds lazily on first cookie access (always an I/O thread), all dashboard-surface consumers share one cached instance per connection, and the pre-warm uses a single client plus the shared store for its whole sweep instead of one of each per section.
- **Crash when a dashboard connection drops mid-check.** A transient network blip on the dashboard session check (e.g. a pooled connection aborting over Tailscale) could close the app: the check returned a result type but re-threw the network error instead of reporting it, and it surfaced on the main thread. The check now reports the failure cleanly, and the connection probe degrades gracefully instead of ever crashing.
- **"Re-check" / "Use now" no longer fail silently.** When every saved route failed its probe, the user-triggered re-probe early-returned without publishing anything: the Routes card sat on "Current: Resolving" forever (showing the internal relay URL underneath, which read as "stuck on the internal route") with zero feedback. The probe now always publishes its outcome, the card states "No route reachable — using saved URL …" explicitly, and per-route rows show why each candidate failed. The old 100 ms post-probe delay — always shorter than a real resolve, leaving the follow-up health checks pointed at the stale route — is replaced by actually awaiting the resolve.
## [1.2.3] - 2026-06-23
- **Standard (no-Relay) connections now follow LAN ↔ Tailscale network changes.** The ADR 24 network-aware route switching only activated when a Relay socket was open: the connectivity callback registered inside `connect()` and bailed without a socket URL, so a standard connection that left home Wi-Fi kept probing the dead LAN route until the app was backgrounded and reopened. The callback now registers at construction and re-resolves routes (debounced) even with no socket — chat, Manage, and standard voice follow the resolved endpoint automatically.
### Fixed
- **Standard voice follows the resolved route.** The standard voice client and its availability probe targeted the connection's persisted dashboard URL instead of the resolver's active route, so voice stayed pinned to the LAN host (and gated off) while away from home even after chat had switched to Tailscale. Both now ride `effectiveDashboardUrl`.
- **Crash on connect over TLS / Tailscale.** Connecting to a server over an encrypted link (Tailscale Serve or public HTTPS) could hard-close the app with `NetworkOnMainThreadException`. Tearing down an HTTP client closed live SSL sockets on the main thread, and a TLS socket close performs a network write — which Android forbids on the main thread. Client shutdown now always closes sockets off the main thread, so connecting over a secured link no longer crashes. (#118, #124; likely the v1.1.0 / Tailscale crash in #70)
- **Stale probe cache can't pin a dead route.** App-resume and network-change revalidation now clear the endpoint resolver's probe cache, so a route that died moments ago can't win re-resolution for the remainder of its 60-second positive cache window. The periodic health check also escalates two consecutive unreachable probes into a full cache-cleared re-resolve — the safety net for handoffs Android never surfaces as connectivity changes (always-on VPN keeps "internet available" true throughout).
## [1.2.2] - 2026-06-22
- **Editing URLs no longer wipes fallback routes.** Saving an API or Relay URL rebuilt the connection's route-candidate list from just the edited URL, silently dropping the setup wizard's Tailscale route (or extra endpoints from a pairing payload). Edits now merge: the touched route is rebuilt, stored extras are preserved verbatim.
### Added
- **Per-route sign-in is explained.** Dashboard sessions are cookie-based and per-host, so a Manage sign-in at home doesn't carry to the Tailscale host. When voice is gated on sign-in because the route moved, Voice Settings and the chat mic toast now say so ("sign in once in Manage on this route") instead of showing a bare sign-in nag that looks broken.
- **Diagnostics: status timeline.** Diagnostics now opens full-screen and leads with a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a clear pass / warning / fail state and, when something's wrong, the reason why; tap a failing check for full detail. The recent-activity log stays below it.
- **A network change can no longer resurrect a deliberately disconnected relay socket.** The route-switch path force-reconnected whenever the resolved winner differed from the last URL, even after an explicit Disconnect; socket actions are now gated on reconnect intent while route publication for HTTP surfaces continues.
### Changed
- **Connections wording simplified.** The default connection is now just "Hermes" (previously "Vanilla" / "Standard Hermes"), and the optional power features are labelled "Relay" / "Relay plugin", across the connection setup, switcher, voice, and permissions screens.
- **Clean chat mode shows more text.** The distraction-free chat view gives its text a noticeably taller, scrollable area instead of capping it near a third of the screen.
### Fixed
- **Deleting a session on a non-default profile now sticks.** Removing a chat while a non-default agent profile was active could leave it on the server, so it reappeared after the list refreshed; the delete is now scoped to the active profile.
- **Session drawer opens on the right profile from a cold start.** When launching with a non-default profile selected, the session list could briefly show the default profile's chats and then snap to the correct ones; it now waits for the profile to resolve and loads the right list directly.
## [1.2.1] - 2026-06-21
### Added
- **Profile lock.** Settings → Profile lock pins the app to a single agent profile and hides the rest from the pickers; the lock screen stays the one place that lists every profile, with a clear notice if the locked profile isn't on the current server.
- **In-app What's New & changelog.** A new Settings entry shows the current and past release notes any time — not just the post-update popup.
- **Diagnostics: tap for detail + report.** Logged errors now carry clean titles and open a detail view with Copy / Share / Create-GitHub-issue (the same flow as crash reports); classified errors across voice, chat, and connection are captured centrally.
- **Update-available nudge.** A dismissable in-app banner when a newer version is live — Google Play In-App Update on Play installs, GitHub Releases on sideload. Per-version dismissal, throttled, never nags.
### Changed
- **Crash reports can be shared without GitHub.** The crash dialog now has a **Share** action alongside Copy and Report, handing the full report to the system share sheet (email, chat apps, notes, Drive). This covers users without a GitHub account and sideload installs that Play vitals never sees. Every outbound path stays user-initiated — nothing is sent automatically.
### Fixed
- **Voice override applies in Auto mode.** A chosen per-profile/enhanced voice now takes effect when the engine is on Auto with the relay paired — previously only "Relay" mode applied it. Per-profile voice settings are also namespaced by connection.
- **Realtime voice "Stop" stops immediately.** Tapping Stop while the agent is speaking now halts realtime playback at once; over-chatty spoken status is throttled; and long background tasks no longer time out the turn (relay keeps the session alive while the task runs).
- **Realtime Agent: brokered Hermes turns no longer fail (relay).** When the Realtime Agent reached back to Hermes for context or tool work, a session-namespace mismatch could make the API Server reject the turn with `session_not_found`. The relay now mints or reuses a valid API Server session and retries once, and reads the API Server's current nested create-session response. Provider-native turns are unaffected.
- **Hold-to-talk no longer releases on accidental drift.** The mic button holds until the finger genuinely lifts, instead of cancelling when it drifts off the button.
- **Voice overlay is readable.** The voice dropdown panel and its status bubbles are opaque (no bleed-through), and the Focus/Overlay/Exit labels no longer wrap to two lines; invalid engine/route combinations are no longer selectable.
- **Connection status overlay clears faster.** Resolved (error/warning) connection toasts auto-dismiss within ~5s instead of lingering.
## [1.2.0] - 2026-06-20
### Added
- **Sensitive-media classification (relay).** The relay teaches the agent — server-side, via a removable system-prompt block — to mark private/NSFW media so the phone blurs it per your setting. **On by default for relay installs** (installing the relay is itself the opt-in); reversible from the "Agent context" toggle in the Relay dashboard, or `RELAY_AGENT_CONTEXT_ENABLED=0`. The exact injected instruction is visible in the chat "What the agent sees" sheet under "Relay context (server-side)". No on-device or relay-side classifier — sensitivity stays model-emitted. Vanilla upstream (no plugin) is unaffected. See `docs/plans/2026-06-20-relay-enhancement-layer.md`.
- **Transport path is visible (chat).** The chat status strip now shows which streaming path is actually in use — ⚡ Gateway (live thinking), 📡 Sessions, Completions, or Runs — instead of a generic "api online", and Chat Settings adds a basic→best tier ladder explaining the active path and its fallback.
- **Injected-context audit (chat).** Tap the context-usage meter in chat to open a "What the agent sees" sheet showing the exact extra context prepended to your next turn — persona/profile, phone status, and any per-turn (voice) hint. On the gateway path it notes the persona is applied server-side, so the audit is honest about what the phone does and doesn't send.
- **Spoken-turn badges (chat).** Voice-mode replies now carry a "Voice" chip and realtime replies a "Realtime Agent" chip — both with a speaker glyph — so spoken turns are distinguishable from typed ones in the scrollback.
- **App themes.** A new theme picker in Settings → Appearance ships eight looks: the signature Hermes Relay brand (with full light/dark) plus ports of the Nous Hermes baselines — Hermes Teal, Nous Blue (light), Midnight, Ember, Mono, Cyberpunk, and Rosé. The whole app — brand chrome, accents, and chat background — follows the chosen theme. Light/Dark/Auto applies to themes that ship both modes; fixed-mode themes show their own complete look.
- **Hot-swappable agent sphere.** The orb is now a pluggable "skin": an Adaptive skin that recolors to match your theme, built-in Classic / Aurora / Solar / Mono looks, and support for **user-authored skins** loaded from a small JSON spec. Each skin declares which live signals it reacts to (voice, tool bursts, activity), shown as capability badges in the picker. See `docs/sphere-spec.md`.
- **Connections separate features from routes (Android).** Connection settings now distinguish what a connection can *do* (a **Features** section) from how this phone *reaches* Hermes (a **Route** section), so you can enable Relay features over whichever transport you prefer. A plugin-provided **Secure proxy** route is surfaced alongside LAN, Tailscale, public, and custom routes. The standard direct-to-upstream path is unchanged and still needs no plugin. See `docs/plans/2026-06-18-native-secure-routes.md`.
- **Enhanced voice control (Gemini & xAI).** When the relay uses a Gemini or xAI voice provider, Voice Settings can now steer it: pick a Gemini voice and model and turn on expressive tone tags (with optional natural-language voice direction), or set an xAI voice with expressive speech tags. Expressive tags also apply to xAI on the streaming voice-output renderer. Standard (no-plugin) voice stays configured server-side.
- **Voice render-path visibility.** Voice Settings shows which path is rendering speech (streaming vs. basic), and Diagnostics records it each session, making voice issues easier to troubleshoot.
- **Agent pets — a living, swappable avatar.** The orb can be replaced with an animated "pet" that reacts to what the agent is doing: idle / thinking / writing / speaking / listening states, a distinct **working** pose during tool calls, one-shot **greet** / **celebrate** reactions, and a loop that quickens as output streams. Add or remove pets right in Settings → Appearance (no `adb` needed), with a live state preview, a playback-speed slider, and optional frame auto-stabilization; capability badges (Voice · Tools · Activity) show honestly what each pet actually reacts to. Pets are pure data — an AI authoring kit and a JSON schema let you generate one from sprite art. See `docs/pet-spec.md` and the custom-avatars guide.
- **Per-profile agent icon + single-image avatars.** Each agent profile can wear its own small icon beside its name (client-side, never sent to Hermes), shown in chat, the agent sheet, the top bar, and Settings. Importing an avatar now also accepts a single image (auto-wrapped as a one-frame pet) — no animated pack required.
- **In-app crash reporting.** If the app ever force-closes, the next launch shows a clean dialog with the stack trace — **Copy** it, or **Report** to open a pre-filled GitHub issue from the bug template. The report persists until you acknowledge it, and the handler re-raises so the OS still records the crash in Play vitals.
- **Clean text-flow mode (chat).** A distraction-free chat layout where your sent text slides up into a continuous flow, paired with the swappable-avatar/pet system.
- **Permissions review screen.** A central page makes the permission model explicit — standard Chat and Manage need no phone-control permissions, while voice, camera, notifications, and sideload Device Control stay opt-in — reading the same live grants Bridge does.
- **In-app attachment previews + richer capture.** Attachments preview inline before sending, sensitive media is blurred per your setting, and the capture flow is richer.
### Changed
- **Much faster cold start.** The app was building several hardware-keystore-encrypted stores at launch, which serialize on a process-global lock and stalled the chat header (model, personality, approvals) for seconds. It now builds a single keyset and the dashboard cookies share it, cutting measured time-to-connected from ~2.9 s to ~1 s after first frame, with the keystore lock contention gone. Existing sign-ins are migrated automatically on first launch.
- **Honest loading, never stale, never hidden.** Model, personality, and approvals now show a brief "checking…" state and fade in once the server confirms them, instead of popping in or showing a possibly-wrong value. Standard upstream controls (Model, YOLO, Fast, reasoning effort) are no longer hidden while loading or when unavailable — they always appear: a live control when ready, "checking…" while a value loads, or a cleanly disabled control with the reason (e.g. "available over the gateway transport") when this connection can't use them. The chat composer's reasoning-effort chip now shows alongside the model chip instead of lagging seconds behind the gateway check, and picker lists (models, personalities) show a brief, bounded "loading…" cue. The same fade-in is applied to the context meter, session drawer, and Manage panels.
- **Tidier chat header.** The LAN/Tailscale chip was dropped from the top bar (the bottom status strip already shows the route, and is now tappable to open Connections), and a `none` personality is no longer shown — leaving more room for the model name.
- **Connection toast reads like the cold-start screen.** The floating connection status toast now shows a live checklist — Route / API / Relay each with a spinner, ✓, or ✕ as the checks land — instead of flat text, matching the splash screen's stepper. Swiping it up now tracks your finger (slide + fade) rather than snapping, and connection problems get an explicit "Open Connections →" link at the bottom so the path to the detailed view is obvious.
- **Tidier chat header.** The "approvals off" warning moved out of the agent subtitle into a single amber ⚡ icon in the top bar (tap for the full explanation in the agent sheet), and Share folded into a ⋮ overflow menu — so the personality · model subtitle no longer gets clipped by the trailing action icons.
- **Voice replies are formatted for listening.** In voice mode the assistant is now guided to answer in short, conversational sentences without markdown, emoji, or raw URLs — without changing what is stored in chat history.
- **Leaner terminal screen (Android).** The extra-keys bar scrolls horizontally with compact, fully-legible keys (no more clipped "CTRL"), the header is a single compact row showing one inline connection-status dot plus state, and the tab strip is hidden for single-tab sessions — the new-tab "+" moves into the header — reclaiming vertical space for the terminal.
- **Relay terminals run on an isolated, TUI-tuned tmux.** Sessions now use a dedicated tmux server/socket with its own config — instant ESC (`escape-time 0`), truecolor `$TERM`, mouse and focus events on, and no status bar — so editors and full-screen tools behave correctly, without touching the user's personal tmux.
- **"Standard" is now "Vanilla Hermes" throughout.** The user-facing name for the no-plugin upstream path is now **Vanilla Hermes**, so it's clear the default path runs on a plain Hermes agent.
- **QR pairing degrades gracefully on unusual cameras.** On foldables and devices where the camera can't initialize, the scanner now shows a "camera unavailable — pair manually" card instead of force-closing.
- **Image & attachment viewers rotate to landscape.** The full-screen image / attachment viewers can rotate to landscape even though the rest of the app stays portrait-locked.
### Fixed
- **Clearer error when a feature needs a newer relay.** Toggling a setting an older relay plugin doesn't recognize (e.g. xAI expressive speech tags) now shows "Relay update needed" instead of a generic HTTP 400 with a dead Retry button. Genuine input errors are unaffected.
- **Connection status toast is no longer see-through.** The floating connection-lost/switching toast renders fully opaque so content behind it no longer bleeds through and hurts legibility.
- **Provenance badges survive the post-turn history reload.** "Voice", "Realtime Agent", "Stopped", and "Error" chips are now preserved when the conversation reloads after a turn, instead of silently vanishing.
- **Chat and Manage no longer stay dark in Light mode.** Brand-styled surfaces bypassed the theme and were effectively hardcoded dark; they now follow the selected theme and light/dark mode, and the glow/border flourishes key off the active theme rather than the system setting.
- **Realtime voice no longer drops the conversation mid-session with some providers.** A normal end-of-turn signal was being rejected on certain voice providers, ending the session every turn.
- **Relay voice synthesis no longer leaves temporary audio files behind** on the server.
- **Clearer voice errors and an oversize-recording guard.** Standard voice now rejects an over-long recording before uploading it and shows a helpful message for audio the server can't read, instead of a generic HTTP error.
- **Terminal paste no longer auto-runs multi-line text.** The key-bar PASTE now uses bracketed paste, so multi-line content lands intact in shells and editors instead of executing line by line.
- **Terminal on-screen arrows behave inside TUIs.** Arrow/Home/End keys follow the running app's cursor-key mode (application vs. normal), so they work correctly in vim, less, and fzf.
- **Terminal footer spacing.** A small gap now keeps the last terminal row clear of the key bar (it could previously look like the footer overlapped it), and a redundant navigation-bar inset that left empty space below the keys was removed.
- **In-chat model picker now actually applies on a new chat.** Picking a model and provider in the chat composer (e.g. Grok 4.3 via your xAI subscription) is bound to the new conversation, so the agent runs on the picked model instead of silently falling back to the account's global default. Switching profiles retires an explicit pick so the profile's own model takes over, and the picker label updates immediately instead of lagging a round-trip.
- **Server-generated images render in chat when paired to the relay.** An assistant image that points at a server-side file path is now fetched through the relay's media route and shown inline (tap to zoom), instead of degrading to an "image is on the server" notice. On the SSE chat path the agent is also told it can surface images and files by path when a relay route is configured (visible in the chat "What the agent sees" sheet). Standard (no-plugin) connections are unchanged.
- **Smoother profile switching.** Switching profiles no longer blanks the conversation to an empty/"Loading…" state before the new history loads; the previous transcript is held and cross-fades to the new one.
- **In-chat model switch now applies mid-conversation, not just on new chats.** Picking a model in an already-started chat switches the live session in place — the same path the desktop/TUI `/model` uses — instead of racing into a global-default write, so the turn runs the model you picked.
- **Server-side turn errors always surface.** A failed turn (e.g. a provider rejecting the request) now stays on screen as an error bubble with the message, instead of appearing for a moment and then vanishing when the conversation reconciled after the turn.
- **The model shown in chat matches the live session.** The chat header and the agent detail sheet now show the model the current session is actually running (reflecting a mid-session switch) rather than the profile/global default, and the agent sheet no longer pairs the global default model name with the session's provider — it now also names the host's "Server default" when the session runs something different.
- **Server steering markers no longer appear as chat bubbles.** The "[System: the active model/personality changed]" notes the server injects into history for the agent's benefit are hidden from the transcript by default (matching the desktop/TUI); a new "Show system messages" debug toggle in Chat Settings can reveal them.
- **Per-reply token counts (and other per-message details) survive the post-turn reload.** The input/output token subtext, provenance badges, tapped-card state, and voice/realtime sync traces are now preserved when the conversation reconciles against the server after a turn — previously a normal reply lost its token line once the turn finished (the error bubble kept it only because errored turns skip that reload). The reloader now preserves client-only message details by default instead of dropping any it doesn't re-derive from the server.
- **PDF viewer no longer crashes when the document closes mid-render.** A PDF preview that was torn down during a layout pass could read a closed renderer and throw `IllegalStateException: Document already closed`; the renderer is now guarded so it returns nothing instead of crashing.
- **No crash opening a chat with a server-local image.** Rendering a relay-fetched image could throw `ClassCastException: kotlin.Result cannot be cast to byte[]` because a `suspend` function returned `kotlin.Result` (which collides with the coroutine machinery's own wrapper); a purpose-built result type fixes it.
- **Side-loaded avatars and sphere skins are reachable again.** Both loaders read internal storage while the docs (correctly) pointed `adb push` at external app-scoped storage, so a side-loaded pet or skin never appeared. Both now resolve through one external-preferred location, so the documented install path works.
- **Reopened chats paint the session's real model** (not the profile/global default), the model-picker "Server default" caption shows the true default rather than the active override, and a chat's media badge shows only when paired — with the underlying server-image fetch-failure reason surfaced when a fetch fails.
## [1.1.0] - 2026-06-16
### Added
- **Automated Play Console upload on release.** When a `PLAY_SERVICE_ACCOUNT_JSON` secret is configured, pushing a stable `android-v*` tag uploads the `googlePlay` App Bundle to the Production track as a draft (a human still starts the rollout). Prereleases are skipped, and the `sideload` flavor is structurally blocked from ever publishing to Play. Without the secret, the release builds publish to GitHub Releases exactly as before.
- **Desktop UI preview harness (`:ui-preview`).** A non-shipped Compose for Desktop module renders presentational composables in a window on the PC with Compose Hot Reload, for fast UI iteration without a device build/install loop. It reuses the shared sphere algorithm as its single source of truth.
- **Plugin: guided env-key setup.** The relay plugin declares its optional voice-provider keys (`XAI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`) in its manifest, so `hermes plugins install` prompts for them (masked, with a "get yours" link) instead of hand-editing `.env`. The standard no-plugin path needs none.
- **Plugin: native install path.** Tools-only setups can install via `hermes plugins install Codename-11/hermes-relay/plugin`; the full relay still uses the curl `install.sh`.
- **`/relay` slash commands.** `relay status · devices · pair` usable mid-conversation from any platform (CLI / Discord / TUI).
- **Dashboard relay-status widget.** A `Relay · connected / offline / unpaired` badge in the dashboard header, visible on every page.
- **Session-start relay health check.** A minimal, fully-guarded `on_session_start` hook records relay reachability without slowing the gateway.
### Changed
- **Release names normalized by surface.** Future GitHub Releases are named `Hermes-Relay-Android`, `Hermes-Relay-Plugin`, and `Hermes-Relay-CLI`, with future tags on `android-v*`, `plugin-v*`, and `cli-v*`. The CLI installer and updater still understand historical `desktop-v*` prereleases during the migration.
- **Per-surface release notes.** Plugin and CLI GitHub Releases now use hand-written `PLUGIN_RELEASE_NOTES.md` / `CLI_RELEASE_NOTES.md` files (Summary + Added/Changed/Fixed + Install/Verify) — the same format as Android's `RELEASE_NOTES.md` — instead of static boilerplate baked into the workflow. The release workflows substitute the version into the install commands automatically.
- **Settings screen overhaul (Android).** Status pills are now exception-only — they appear only when a surface needs attention and stay quiet when healthy. The Power tools section shows a single state-aware **Plugin active / required / offline** badge instead of an identical "Relay paired" chip on every card. Connections moved to the top (above the Hermes section), Diagnostics + Developer options moved into the App section, the status chips were restyled to match the app's translucent-bordered language, and the brand blue was deepened.
### Fixed
- **Force-close on connect when the stored credential keyset was corrupt.** A corrupt encrypted token store (which can happen after an app upgrade or device restore) threw during construction and crashed the app right after a successful pair, on both standard and relay connections. The token store now heals a corrupt keyset on the spot, and credential storage degrades to a re-pair instead of crashing if the device keystore is unusable.
- **Dashboard plugin: unreadable button labels.** Solid buttons in the relay dashboard panel inherited the container text colour, which matched their background. Solid button variants now keep their proper contrast colour.
- **Installer failed on uv-managed Hermes hosts.** `install.sh` assumed `pip` lived in the hermes-agent virtualenv, but environments created by `uv` (the upstream default) ship no `pip` module, so the editable install aborted at step 2. The installer now bootstraps `pip` via `ensurepip`, or falls back to `uv pip`, so the plugin installs cleanly on uv-managed cores.
- **Chat settings (Android).** The streaming-endpoint picker no longer wraps "Gateway"/"Sessions" onto a second line, and the system-prompt preview now reflects the enabled context toggles (foreground app, battery, safety rails) with representative placeholder values instead of looking inert.
- **Dashboard plugin: buttons rendered as blank boxes.** The host dashboard's Nous design-system `Button`/`Badge` use boolean variant flags (`outlined`/`ghost`/`invert`) and a `tone` prop — not the shadcn-style `variant` prop the plugin passed — so every button collapsed to a solid near-white fill with an invisible label. The plugin now translates its props to the design-system contract via an adapter, and drops a label-hiding CSS reset.
## [1.0.0] - 2026-06-14
### Added
- **Relay plugin diagnostics and install guidance.** `hermes relay doctor` now reports standard upstream API/dashboard reachability, Relay loopback state, dashboard plugin presence, plugin-manager layout, and whether the legacy bootstrap monkeypatch is installed. The plugin manifest now advertises its Android and desktop tools, and `after-install.md` gives the upstream plugin manager a first-run handoff.
- **Plugin-owned compatibility hook lifecycle.** `hermes relay compat status/install/remove` now owns the optional `hermes_relay_bootstrap.pth` startup hook, so the monkeypatch can be inspected, added, or removed without rerunning the legacy installer. The standard v1.0.0 path does not require this hook.
- **Legacy cleanup alignment.** The legacy installer now installs the optional `.pth` hook through the plugin compat lifecycle, and the uninstaller removes every shell shim it creates (`hermes-pair`, `hermes-status`, `hermes-relay`, `hermes-relay-update`, `hermes-relay-tailscale`) while delegating hook cleanup to `hermes relay compat remove` when available.
- **Gateway chat transport with live thinking.** Chat can ride the upstream dashboard `/api/ws` (the `tui_gateway` surface the official hermes-desktop client speaks) — the only vanilla-upstream path that streams reasoning *live*, so the Thinking block and sphere light up during generation. "Auto" prefers it when the dashboard is reachable and Manage is signed in, and falls back to the SSE endpoints per turn.
- **Gateway desktop parity.** Native image/PDF/file attachments (with an in-chat notice when a turn falls back to a transport that can't carry files), mid-turn **steering**, **edit & resend**, interactive **approval / clarify / sudo / secret** cards, live **subagent lanes**, a **context-window meter**, server **slash commands** in autocomplete, and **turn-complete notifications** when the app is backgrounded.
- **Gateway warm-start + Keep connected in background.** Pre-warming the gateway on foreground moves the cold session-setup cost off the send path. An opt-in foreground-service toggle (both flavors; `specialUse`, off by default) holds the socket open in the background so a long-backgrounded conversation resumes instantly.
- **Switch agent profiles from chat.** Pick a different agent — model, SOUL, personality, and skills — per conversation. The selection is **ephemeral** (bound to the session like the official desktop; it never changes the server's default agent for other clients). The session drawer scopes to the active profile and loads that profile's history, and the right agent is restored on cold start. The Manage tab's server-wide **Activate Profile** action now confirms first.
- **Manage parity with the desktop dashboard.** Change models from the full provider catalog, manage provider keys (write-only, masked, reveal), create/edit profiles and SOUL.md, and browse/install/update skills. Manage data is cached to disk for an instant cold launch.
- **Open & save chat images and attachments.** Tap an image for a full-screen viewer (pinch-zoom, double-tap, Share/Save); non-image attachments gain an Open/Share/Save menu. Saves land in `Pictures`/`Download/Hermes-Relay` with no permission on Android 10+, preserving the original bytes.
- **Persistent Realtime Agent voice + background runs (ADR 33).** The realtime engine keeps one session across turns (follow-ups retain context); a long Hermes run is promoted to a tracked background task and spoken when ready, so the conversation stays responsive.
- **Redesigned chat input bar.** A Telegram-clean pill field with one trailing button that morphs between Send / Voice / Stop / Steer / Queue; the slash button is gone (typing `/` still opens autocomplete).
- **Routes card reachability verdicts** ("Reachable", or the specific failure reason) and per-turn **latency tracing** (`TurnLatency`, durations only) for diagnosing transport speed.
### Changed
- **Relay plugin/server version aligned to v1.0.0.** The Python package, plugin manifest, dashboard manifest, and relay runtime now use the same `1.0.0` line as the stable Android release so a retagged source checkout describes one product version.
- **The standard (no-plugin) path is first-class.** Chat, Manage, and voice all work against an unmodified upstream Hermes agent; standard voice rides the dashboard audio surface (`/api/audio/*`) with the Manage sign-in, and relay-paired voice is the profile-aware fallback. The relay plugin is now purely additive.
- **Seamless connection UX.** LAN↔Tailscale handoffs and reconnects no longer reload the chat; connection and update status are now in-theme slide-down toasts over the content instead of banners that pushed the UI around.
- **Editable, roaming routes.** Add/edit/remove routes in Settings → Connections; bare-host URLs default their scheme and port (and preview what will be saved); remote-access (Tailscale) is surfaced in the main setup flow with a "Remote" readiness line.
- **Faster Manage.** A shared auth preamble plus concurrent payloads cut a full load from ~40 round trips to ~12; a process-lifetime cache and startup pre-warm render the last-seen data instantly, and Manage now names which dashboard URL it's talking to.
- **Faster, calmer cold start.** Key-less connections skip the multi-second keystore decrypt; the startup sphere is now the actual loading screen with narrated check lines, and the OS splash blends into it.
- **Docs + branding.** The docs site was rechromed to the app theme and repositioned around the two-path story; the README and Play listing were refreshed standard-first; product-name copy normalized to **Hermes-Relay**.
- **Quality-of-life.** Quote-in-reply, share-conversation-as-Markdown, ambient mode as a long-press gesture, a floating status pill, decluttered Manage cards, back buttons on pushed screens, and a softer active-connection card.
### Fixed
- **No "Connect to Hermes" flash on cold start.** The empty-state now distinguishes "still hydrating from disk" from "nothing configured" (`ConnectionStore.isHydrated` → `chatConnectState`), showing a quiet "Connecting to Hermes…" spinner until ready and the connect CTA only once hydration confirms no connection exists.
- **In-app What's New renders cleanly** — parsed into a version subtitle, bold section headers, and real bullets instead of raw text with literal `*`.
- **App-start UI freeze from Keystore lock contention.** The encrypted cookie store built its StrongBox-backed prefs eagerly in its constructor (1–4 s under a process-global lock) from several code paths at once; it now builds lazily on an I/O thread and is shared per connection.
- **Standard connections now follow LAN↔Tailscale changes**, standard voice follows the resolved route (not the persisted URL), and a stale probe cache can no longer pin a dead route after a handoff or resume.
- **Editing a URL no longer wipes fallback routes** (edits merge with stored extras instead of rebuilding from the edited URL alone); **"Re-check" / "Use now" no longer fail silently** (the probe always publishes its outcome and per-route failure reasons); and a network change can no longer resurrect a deliberately disconnected relay socket.
## [0.8.1] - 2026-05-26
@@ -130,7 +237,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Google Play Bridge Core split.** The Google Play Android track keeps relay pairing, chat, profiles, voice, terminal/TUI, media, notification companion, relay sessions, diagnostics, and status while removing AccessibilityService-backed Device Control declarations and permissions. Sideload remains the track for screen reading, gestures, screenshots, SMS/calls, contacts/location, overlays, wake locks, and unattended control.
- **Release lanes now use explicit product tags and names.** Future Android releases use `android-v*`, server/Python releases use `server-v*`, and desktop continues on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Server vX.Y.Z`, and `Hermes-Relay-Desktop vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
- **Release lanes now use explicit product tags and names.** Future Android releases use `android-v*`, plugin/Python releases use `server-v*`, and CLI releases continue on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Plugin vX.Y.Z`, and `Hermes-Relay-CLI vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
- **Realtime voice instructions are provider-neutral.** Realtime providers receive active interface context, local date/time, provider/model/voice/profile metadata, and guidance to ask Hermes for current facts, research, device/desktop state, project context, precise/versioned data, and any requested checks instead of guessing from model knowledge.
@@ -222,7 +329,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Desktop CLI alpha.14 — `Ctrl+A ?` chord re-displays the chord-help banner.** The attach-time banner scrolls off as soon as anything writes to the terminal, so users mid-session forgot the verb list and had to detach + re-attach (or guess). New `Ctrl+A ?` (and `Ctrl+A h` synonym) reprints the banner to stderr without leaving the session. Banner text refactored into a single `CHORD_HELP` constant so the attach-time print, the `?` chord, and the unknown-chord hint can't drift out of sync. Unknown-chord hint now also lists `?` as one of the known verbs.
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Bailey: *"This isn't cohesive — we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Reported gap: *"...we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
### Fixed
@@ -232,9 +339,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Android connection/profile state no longer leaks across switches.** Connection switches now clear the outgoing profile object immediately, load the destination connection's saved profile name only after that connection is active, and resolve it against the destination server's current profile list. The default local relay URL is now `ws://localhost:8767`, and auto-managed relay URLs are derived from the active API URL before reconnecting.
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** Bailey saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** A user saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** Bailey on alpha.9 ran `hermes-relay update --check`, expected to see alpha.10, got "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** On alpha.9, `hermes-relay update --check` expected to see alpha.10 but reported "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
- **Desktop CLI alpha.10 — `hermes-relay paste` always returned "No image on clipboard" on Windows even when an image was present.** Root cause: the PowerShell invocation in `captureClipboardWindows` (`src/chatAttach.ts`) was missing the `-STA` flag. `powershell.exe -Command` defaults to MTA (Multi-Threaded Apartment), and `[System.Windows.Forms.Clipboard]::GetImage()` only returns a valid image from STA threads — from MTA it silently returns null, indistinguishable from "no image present." Also affects the `chat` REPL's `/paste` command which routes through the same Windows code path. Fix: added `-STA` to the powershell args list (now `['-NoProfile', '-NonInteractive', '-STA', '-Command', ps]`). Live verification: empty clipboard returns null; a cyan 100×80 PNG placed via `[System.Windows.Forms.Clipboard]::SetImage` returns the expected 305-byte capture with correct dimensions. Affects `desktop-v0.3.0-alpha.7` through `desktop-v0.3.0-alpha.9`.
@@ -256,20 +363,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke added to `release-desktop.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke runs in `release-cli.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
- **`hermes-relay --version` printed `0.0.0` in compiled binaries.** `readVersion()` tried to read `package.json` via `__dirname + '../package.json'`, which doesn't resolve in a Bun `--compile` binary (no real filesystem layout). Replaced with a build-time-generated `src/version.ts` module (`npm run gen:version` writes the version from package.json before every build and every `build:bin:*`). `readVersion()` now just returns the embedded constant. Works identically in tsx / Node / Bun.
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from release-desktop.yml, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from the CLI release workflow, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
- **Installer couldn't find alpha-only releases.** GitHub's `/releases/latest/download/` URL deliberately skips prereleases, so the default `curl | sh` / `irm | iex` one-liner failed against alpha.1 with "maybe no Windows release for this version yet?" Both `install.sh` and `install.ps1` now query the Releases API directly (`GET /repos/.../releases`, filter to `desktop-v*` tags, take first) when `HERMES_RELAY_VERSION=latest`. Pinned versions unchanged.
### Added
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a Server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://172.16.24.250:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://192.168.1.100:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
- **Desktop CLI v0.2 — PTY shell, local tool routing, multi-endpoint pairing, reconnect + TOFU, devices, contextual banner.** The `@hermes-relay/cli` package at `desktop/` grew from a chat-only scripting surface into a full Hermes-experience thin client. Bare `hermes-relay` now drops into `shell` mode (interactive PTY pipe through the existing relay `terminal` channel → `tmux new-session -A` + post-attach `exec hermes` → the full local `hermes` banner/skin/session id verbatim, zero server changes). `Ctrl+A .` detaches preserving tmux; `Ctrl+A k` destroys it. New `devices` subcommand drives the relay's `GET/DELETE/PATCH /sessions` HTTP endpoints for listing, revoking, and extending server-side paired-device tokens. Status now surfaces `grants:` (per-channel expiry) and `expires:` (session TTL) pulled from the `auth.ok` handshake the transport already received — `RemoteSessionRecord` gained `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented` (additive, back-compat preserved via a `SaveSessionOptions | string | null` overload on `saveSession`). Contextual connect banner (`Connected via LAN (plain) — server 0.6.0`) replaces the flat `Connected (server X)` line across `chat` + `shell`. Multi-endpoint pairing (ADR 24): `--pair-qr <payload>` / `HERMES_RELAY_PAIR_QR` accepts a full v3 QR payload (compact JSON or base64), decodes the `endpoints[]` array, probes each candidate with strict-priority-within-tier racing (`Promise.any` + `AbortSignal.any`, 4 s per-candidate timeout, 60 s reachability cache), and auto-selects the first reachable — role propagates into the banner + stored record. Reconnect-on-drop: `RelayTransport` gained a `ReconnectState` machine (`idle|connecting|connected|reconnecting`), exponential backoff (1 s → 30 s, 5 min on 429), `reconnectGate` re-checked both at schedule time and post-backoff (matches Android's mid-sleep purge-race lesson), `'reconnecting'` + `'reconnected'` events, and bufferedEvents-cleared-on-reconnect. TOFU cert pinning: TLS probe runs before the WebSocket opens on `wss://`, extracts peer-cert SPKI sha256 (`sha256/<base64>`, OkHttp-compatible), compares against the stored pin or captures it first-time; mismatches error out with a human-readable "re-pair to reset" pointer. Client-side tool routing (Phase B): new `desktop` relay channel on the server (`plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` registering `desktop_read_file` / `desktop_write_file` / `desktop_terminal` / `desktop_search_files` / `desktop_patch`) forwards tool calls from Hermes to the connected Node CLI; client-side `DesktopToolRouter` dispatches to in-process handlers (`fs`, `terminal`, `search`) under a 30 s AbortController, 30 s heartbeat advertising the tool names. Gated behind a one-time per-URL consent prompt (`toolsConsented` on the session record) + `--no-tools` kill-switch; non-TTY stdin fails closed. New files on the client: `src/banner.ts`, `src/endpoint.ts`, `src/pairingQr.ts`, `src/certPin.ts`, `src/commands/devices.ts`, `src/tools/router.ts`, `src/tools/consent.ts`, `src/tools/handlers/{fs,terminal,search}.ts`. New files on the server: `plugin/relay/channels/desktop.py`, `plugin/tools/desktop_tool.py`, `docs/relay-protocol.md §3.5`. Still zero runtime deps on the client (Node ≥21 global `WebSocket` + `fetch` + `tls.connect` + `node:crypto` X509Certificate + `AbortSignal.any`). Build clean; live smoke passed for `status` / `tools` / `devices`; interactive `shell` + tool-call smoke pending user walk-through. Delivered as four parallel implementation agents (multi-endpoint, reconnect+TOFU, server-side desktop, client-side tool handlers) + one synthesis-and-integration pass; the `connectAndAuth → {relay, url, endpointRole}` return-shape refactor in `chat.ts` / `shell.ts` / `tools.ts` unifies how `--pair-qr`'s winning-endpoint URL overrides `--remote` across every subcommand.
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://172.16.24.250:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://192.168.1.100:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
### Changed
@@ -308,13 +415,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Changed
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of Bailey's double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of the double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
### Added
- **Card-dispatch → server session sync** (completes ADR 26). Every [HermesCardDispatch] now carries a `syncedToServer` idempotency flag; on the next chat send, `CardDispatchSyncBuilder` synthesizes unsynced dispatches into OpenAI-format `assistant`+`tool` pairs under a namespaced synthetic tool name `hermes_card_action` and splices them into the request body alongside the existing voice-intent synthetic messages. `ChatHandler.markCardDispatchesSynced` commits the flag after the API client accepts the request — same post-handoff timing as voice intents, so a thrown request-building exception leaves both streams retryable. Guarantees the LLM sees prior card interactions ("you approved the `Run shell command?` card") across server restarts and reconnects, including `open_url` dispatches that never go through `sendMessage`. Unit-tested under `CardDispatchSyncBuilderTest` (pure-function JVM tests, no Android deps).
- **Rich cards in chat via `CARD:{json}` inline markers** (ADR 26). Assistant messages can now surface structured Material 3 cards — skill results, approval prompts, link previews, calendar entries, weather — emitted as a single-line `CARD:{...}` alongside prose text. Follows the same streaming-endpoint-agnostic marker recipe as `MEDIA:`, so it works unchanged on `/v1/runs`, `/api/sessions/{id}/chat/stream`, and `/v1/chat/completions`. New `HermesCard` data class (`@Serializable`, `ignoreUnknownKeys=true` so newer agent schemas don't crash older phone builds) carries `title` / `subtitle` / `body` (markdown) / `fields` / `actions` / `footer` / `accent` (`info`/`success`/`warning`/`danger`). Built-in types: `skill_result`, `approval_request`, `link_preview`, `calendar_event`, `weather`; unknown types render via a generic fallback. `approval_request` intentionally mirrors Slack's exec-approval pattern (Allow / Deny with primary/danger button styles) so upstream Phase B adapter parity is a translation exercise, not a data-model rethink. Action dispatch (`send_text` default, `slash_command`, `open_url`) routes through `ChatViewModel.dispatchCardAction`, which stamps a `HermesCardDispatch` on the owning message before forwarding so the card collapses into a "Chose: X" confirmation even if the side effect fails. Renderer is `HermesCardBubble.kt` — accent stripe + Icon + Title/Subtitle + markdown body + fields table + FlowRow of action buttons. Cards render between the assistant's prose and any attachments in `MessageBubble`.
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (Bailey's call: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (deliberate: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
- **MorphingSphere on the docs site.** New `SphereMark.vue` component (in `user-docs/.vitepress/theme/components/`) renders a 58×34 sphere directly above the "Install in 30 seconds" block — mounted in the `home-hero-after` slot alongside `InstallSection` for a hero → sphere → install stack. Imports `preview/web/sphere.js` directly so `MorphingSphereCore.kt` remains the single source of truth across app / preview / docs. The cursor reactivity is **eye-only** — the sphere body stays anchored while the bright-spot gaze tracks the pointer (no canvas translate / body bounce). Gaze composition: **scroll-tracking is the always-on baseline** — the eye anchors to the Install section's top edge (via `.install-section` DOM query), not to the viewport center. `installGap = installRect.top − viewportH` is the runway until install enters view; as it shrinks below 50 % viewport-height, `scrollVy` ramps linearly to 1, so by the time install's top crosses into the viewport the eye is already looking straight down at it. Before that runway, the eye sits forward (`scrollVy = 0`). **Cursor-tracking is a soft overlay** — inside a rectangular detection band (full viewport width × container height, linear falloff over 1.0 × container height past the top/bottom edges) the cursor's unit-vector direction crossfades into the scroll target via `cursorWeight`. The eye always has one coherent target — no mode switching, no fbm drift fighting the cursor at the band boundary, no eye-flip between modes. Palette retarget Idle ↔ Listening is gated on `cursorWeight` (0.2 / 0.5 hysteresis) so the sphere reads as *calmly watching* at the scroll baseline and *attentive* on direct hover. A tiny fbm wander (±0.07 on top of the target) keeps the eye breathing when both scroll and cursor are stationary. Fallback when the install element isn't on the page: viewport-center reference preserves the gaze-follows-scroll feel without the anchor. Pointer inputs pass through a per-frame EMA low-pass (180 ms direction / 280 ms proximity time constants) before any math runs — stops the per-event jitter from `pointermove`'s big discrete jumps; asin/acos inputs are capped at ±0.9 so we stay off the infinite-slope end of the inverse-trig curves. Canvas is square (`aspect-ratio: 1 / 1`, `clamp(280px, 48vw, 420px)`) so the sphere fills the frame at the algorithm's natural 0.60-envelope sizing — no dead space between the phone video and the Install block. Respects `prefers-reduced-motion` (zeroes the gaze blend so the eye stops tracking but the ambient animation continues), pauses drawing while scrolled off-screen via `IntersectionObserver`, and resizes via `ResizeObserver` on the container. SSR-safe without a `<ClientOnly>` wrapper — `sphere.js` has no side-effectful imports and all DOM access lives inside `onMounted`, which Vue 3 never runs on the server.
- **`SphereFrame` gaze-bias fields in `MorphingSphereCore.kt` (mirrored in `sphere.js`).** New `lightAngleBiasX`, `lightAngleBiasY`, `lightAngleBlend` (all default 0f / 0) let callers aim the sphere's bright spot at a specific direction without touching the sphere body. The light-angle computation blends between the natural `t * lightSpeedX + noise` rotation (`blend = 0`) and the caller-supplied bias (`blend = 1`). Defaults preserve byte-identical behavior for every existing caller — Android `MorphingSphere.kt` composable, the parity test, and the JS parity harness all stay green because they never set the new fields. First consumer: `SphereMark.vue` on the docs site, which uses the bias to make the sphere's eye track the reader's cursor without bouncing the canvas.
- **`SphereFrame.shadowStrength`** (mirrored in `sphere.js`, default 0f / 0). Darkens `distBrightness` on the hemisphere facing away from the light, scaling it by `(1 − shadowStrength · (1 − directionalLight))` — the lit side is untouched, the shadow side dims proportionally. At 0 the legacy uniform "pearl" shading is preserved byte-for-byte. Docs-site `SphereMark.vue` uses 0.6 so the eye reads clearly against the unlit half of the sphere; Android composable doesn't set it and stays on legacy shading.
@@ -691,7 +798,7 @@ sees the toggle, never installs the wake lock, and never invokes
### Added — Voice intent → server session sync (v0.4.1 fast-follow)
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per Bailey's 2026-04-14 on-device repro).
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per a 2026-04-14 on-device repro).
- **Implementation.** Each phone-local voice intent now records a structured `VoiceIntentTrace` (tool name, JSON args, success, JSON result envelope) on the post-dispatch chat-trace bubble it produces. `VoiceIntentSyncBuilder` walks the chat history before each `POST /v1/runs` / `POST /api/sessions/{id}/chat/stream` call and synthesizes OpenAI-format `assistant` (with `tool_calls`) + `tool` (with `tool_call_id`) message pairs from any unsynced traces. The synthesized array rides under the existing payload's new `messages` field — additive, ignored by older servers, picked up by anything OpenAI Chat Completions–shaped. Idempotency: traces flip to `syncedToServer=true` the moment the API client takes ownership of the request, so subsequent turns don't re-emit them.
- **Zero server changes.** Frontend-only, no hermes-agent edits needed.
- **Files.** `data/ChatMessage.kt` (new `voiceIntent: VoiceIntentTrace?` field), `voice/VoiceIntentSyncBuilder.kt` (pure-function builder + helpers), `network/HermesApiClient.kt` (optional `voiceIntentMessages` parameter on both stream methods), `viewmodel/ChatViewModel.kt` (build + sync + flag flip in `startStream`), `viewmodel/VoiceViewModel.kt` (extended dispatch callback wires the structured trace into the chat-trace bubble), `voice/VoiceBridgeIntentHandler.kt` (new `androidToolName` + `androidToolArgsJson` on `IntentResult.Handled`), sideload `VoiceBridgeIntentHandlerImpl.kt` populates them per intent, sideload + googlePlay `VoiceBridgeIntentFactory.kt` typealias updates. Tests in `test/voice/VoiceIntentSyncBuilderTest.kt` (12 cases — empty input, single success, failure with error_code, idempotency, chronological order, prefix gate, blank-args gate, call-id pairing, helpers) and `test/network/handlers/ChatHandlerTest.kt` (4 new cases for trace storage + `markVoiceIntentsSynced`).
@@ -1273,7 +1380,9 @@ MVP release — native Android companion app for Hermes agent with direct API ch
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...HEAD
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.0.0...HEAD
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
[0.8.0]: https://github.com/Codename-11/hermes-relay/compare/v0.7.0...android-v0.8.0
[0.7.0]: https://github.com/Codename-11/hermes-relay/compare/v0.6.1...v0.7.0
[0.1.0]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0-beta...v0.1.0
+92 -27
View File
@@ -4,24 +4,26 @@
## What This Is
A native Android app (Kotlin + Jetpack Compose) paired with a Python relay server (aiohttp) for the Hermes agent platform. Chat connects directly to the Hermes API Server via HTTP/SSE; bridge and terminal use a relay over WSS.
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Vanilla Hermes chat, Manage, and dashboard voice work against unmodified upstream Hermes. Relay adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management.
**Current state:** v0.8.0 (release-prep on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode (stable Hermes Chat + Voice Output plus opt-in provider-native Realtime Agent with reliable low-latency playback and a text/mic Voice Lab), bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, connection diagnostics, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative, Bridge Core without Device Control) and `sideload` (full-capability).
**Current state:** v1.0.0 stable. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Vanilla Hermes voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
## Architecture
```
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
Phone (WSS) → Relay Server (:8767) [bridge, terminal]
Phone (WS) -> Hermes dashboard (:9119) [vanilla Hermes gateway chat, live thinking]
Phone (HTTP/SSE) -> Hermes API Server (:8642) [vanilla Hermes chat fallback, sessions, runs]
Phone (HTTP) -> Hermes dashboard (:9119) [vanilla Hermes Manage + voice]
Phone (WSS/HTTP) -> Relay plugin/server (:8767) [optional bridge, terminal, relay voice, remote tools]
```
Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
The Vanilla Hermes path must stay upstream-only. API-server bearer auth and dashboard cookie auth are separate. Terminal and bridge require Relay pairing; Vanilla Hermes chat, Manage, and dashboard voice must not.
### Upstream Hermes API Reference
**IMPORTANT:** Always verify endpoints against the actual hermes-agent source (`gateway/platforms/api_server.py`). The upstream repo is the source of truth — not our docs, not our memory, not assumptions from other frontends.
**Standard endpoints (confirmed in hermes-agent source):**
**Vanilla Hermes endpoints (confirmed in hermes-agent source):**
| Endpoint | Purpose | Tool Call Format |
|----------|---------|-----------------|
@@ -42,7 +44,7 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
2. **Bootstrap compatibility** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path.
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
| Endpoint | Purpose | Provided by |
@@ -63,9 +65,9 @@ The Android client probes per-endpoint capability via `HermesApiClient.probeCapa
**Dashboard web server (separate surface — standard Manage / Desktop remote gateway):**
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and **`POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **standard (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and **`POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **Vanilla Hermes (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`; Android Chat still uses the API-server bearer path until a dashboard `/api/ws` chat adapter is wired. Android Manage may consume this dashboard surface directly, but relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`. Android uses it for Manage, Vanilla Hermes voice, and the gateway chat transport. `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths remain the SSE fallback. Relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
**Tool call rendering paths:**
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
@@ -73,9 +75,10 @@ Current upstream supports two auth modes on this surface. Loopback dashboards st
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
## Key Instructions
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection path — gateway/API chat, Manage, and Vanilla Hermes voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
- **Bootstrap maintenance:** Retire `hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
## Repository Layout
@@ -92,6 +95,10 @@ hermes-android/
│ ├── accessibility/ # HermesAccessibilityService, ScreenReader, ActionExecutor
│ ├── bridge/ # BridgeSafetyManager, BridgeForegroundService, BridgeStatusOverlay
│ └── notifications/ # HermesNotificationCompanion
├── relay-core/ ← [EXPERIMENTAL] Quest/XR shared core lib (com.axiomlabs.hermesrelay.core) — pairing, transport, terminal, voice, wire
├── relay-ui/ ← [EXPERIMENTAL] Quest/XR shared Compose UI lib — sphere, terminal WebView, QR scanner
├── quest/ ← [EXPERIMENTAL] Meta Spatial SDK Quest/XR app (gradle includeBuild; in development, not shipped)
├── ui-preview/ ← Desktop Compose Hot Reload harness for PC UI iteration (NOT shipped; shares MorphingSphereCore)
├── desktop/ ← Node thin-client CLI (`@hermes-relay/cli`)
│ ├── bin/hermes-relay.js # #!/usr/bin/env node shim → dist/cli.js
│ ├── src/
@@ -115,7 +122,7 @@ hermes-android/
│ ├── tools/ # android_navigate.py, android_notifications.py
│ └── dashboard/ # hermes-agent dashboard plugin — manifest, React UI, FastAPI proxy
├── relay_server/ ← Thin compat shim → plugin.relay (legacy entrypoint)
├── hermes_relay_bootstrap/ ← Runtime compatibility patch; retire per surface as upstream replaces it
├── hermes_relay_bootstrap/ ← Legacy import shim for older startup hooks
├── skills/devops/hermes-relay-pair/ ← /hermes-relay-pair slash command
├── scripts/ ← dev.bat, bridge-smoke.sh, bump-version.sh
└── docs/ ← spec, decisions, security, relay-server, mcp-tooling
@@ -124,11 +131,23 @@ hermes-android/
## Project Conventions
### File Structure
- **Root-level:** README.md, CLAUDE.md, AGENTS.md, DEVLOG.md, .gitignore
- **Root-level:** README.md, CLAUDE.md, AGENTS.md, DEVLOG.md, TODO.md, .gitignore
- **docs/** — spec, decisions, security, and any other long-form documentation
- **DEVLOG.md** — update at end of each work session with what was done, what's next, blockers
- **DEVLOG.md** — update at end of each work session with what was done + verification (the factual record of *what happened*). It churns; do NOT park forward work here.
- **TODO.md** — the single home for follow-ups / deferred work / known gaps ("what's next"). Record them here — never buried in DEVLOG or scattered through code/doc comments where they get lost.
- **CLAUDE.md hygiene:** Key Files entries must stay one line — implementation detail belongs in the file or `docs/`. Run `/revise-claude-md` after feature-heavy sessions to trim drift.
### Public-repo writing hygiene
This is a **public, distributed repo** — every committed file (CHANGELOG, DEVLOG, README, docs, release notes) is public-facing. Write accordingly:
- **No personal names** in prose — attribute impersonally ("a user reported", "observed"). Author identity lives in git history + the signing cert, not the changelog.
- **No private infrastructure** — real server hostnames/IPs, internal deployment names, `~/SYSTEM.md` contents. (Generic example IPs like `192.168.1.100` in setup docs are fine.)
- **No AI/assistant process self-narration** — no "I should have…", no course-correction confessionals. State the technical conclusion, not the path to it.
- **No internal jargon / fork-branch plumbing** in user-facing notes — keep *what changed*, drop *where we staged it*.
- **CHANGELOG** uses Keep-a-Changelog grouping (Added / Changed / Fixed). Detail may accumulate during iteration, but at **release-prep the version block is condensed to crisp public bullets** (1–2 lines each) — deep "how we debugged it" stays in commits/DEVLOG. See [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution".
- **DEVLOG.md** is a committed, factual engineering log — what changed, why, and verification — depersonalized and third-person, not a diary.
### Code Style — Android (Kotlin)
- **Jetpack Compose** — no XML layouts. Material 3 / Material You.
- **kotlinx.serialization** — not Gson. Type-safe, faster.
@@ -155,14 +174,14 @@ hermes-android/
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-server-version.sh` for `server-vX.Y.Z`, and `desktop/package.json` for `desktop-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-plugin-version.sh` for `plugin-vX.Y.Z`, and `desktop/package.json` for `cli-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
### Testing
- **Android:** JUnit + Compose testing for UI, MockK for mocks
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-server.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-plugin.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
## Key Files
@@ -170,14 +189,22 @@ hermes-android/
|------|-----|
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
| `AGENTS.md` | Tool usage patterns for the `android_*` toolset |
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp |
| `AGENTS.md` | Universal agent entry point — points here + the non-negotiables (standard-path, commits, writing hygiene) |
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp; `android_*` tool usage patterns |
| **App — Core** | |
| `ui/RelayApp.kt` | Main scaffold — bottom nav, Compose navigation |
| `viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
| `network/GatewayChatClient.kt` | Gateway chat transport — JSON-RPC over dashboard `/api/ws` (tui_gateway); live `reasoning.delta`; fresh ws-ticket per connect; per-turn SSE fallback via `onPreflightFailure`; `prewarm()` (connect+resume off the send path); `setKeepAliveInBackground()` suppresses the 120s idle-close |
| `network/GatewayKeepAliveService.kt` | Opt-in `specialUse` foreground service (BOTH flavors; declared in main manifest; Play needs a Console FGS declaration) holding the process up so the gateway socket survives background/Doze; driven by ConnectionViewModel from the `KEY_GATEWAY_KEEP_ALIVE` toggle; stops on task-removal |
| `data/GatewayKeepAlivePrefs.kt` | Shared `KEY_GATEWAY_KEEP_ALIVE` pref key + `Context.setGatewayKeepAlive()` — used by ConnectionViewModel (StateFlow/setter) and the FGS Stop action |
| `network/GatewayEventMapper.kt` | Pure-JVM gateway event→callback mapping for one turn; unknown event types silently ignored; tui_gateway usage-key translation |
| `network/GatewayModels.kt` | `GatewayAvailability`, `ActiveTurnHandle`, `GatewayTurnCallbacks` (all members REQUIRED — forces dispatchOn main-thread wrap), `GatewayAsk`, `GatewaySubagentEvent`, `resolveStreamingEndpointPreference()` |
| `ui/components/ChatInputBar.kt` | Redesigned input bar — pill field, one trailing slot morphing Send/Voice/Stop/Steer/Queue, no slash button (long-press + opens palette) |
| `ui/components/SubagentLane.kt` | Per-taskIndex subagent progress lane — guide rail, compact tool rows, auto-collapse |
| `notifications/TurnCompleteNotifier.kt` | Turn-complete local notification when backgrounded — channel `chat_turn_complete`, cancel on resume, settings-gated |
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
| `network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser |
@@ -216,12 +243,16 @@ hermes-android/
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
| **App — Media + Notifications** | |
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic |
| `util/MediaSaver.kt` | Save/share/open for chat media — MediaStore scoped-storage save (Pictures/Download `Hermes-Relay`, no perms on API 29+; pre-Q → share sheet); FileProvider share staging; remote-byte fetch; magic-byte image-MIME sniff for correct extensions |
| `ui/components/ChatImageViewer.kt` | Full-screen image viewer — pinch-zoom/pan (`detectTransformGestures`), double-tap 1×/2.5×, Share/Save/Close; `ChatImageViewerSource` decouples Coil-model/bitmap display from a suspend `bytesProvider` so Save keeps original bytes |
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic; image tap → ChatImageViewer, file card long-press → Open/Share/Save menu |
| `ui/components/ChatImageContent.kt` | Parses `![alt](src)` out of assistant content; remote http(s) → Coil (tap → ChatImageViewer), server-local/failed → inline "can't render" notice with the path |
| `data/HermesCard.kt` | `CARD:{json}` envelope (ADR 26) — type/accent/fields/actions; kotlinx.serialization |
| `ui/components/HermesCardBubble.kt` | Rich-card renderer — accent stripe + FlowRow actions + dispatch stamp collapse |
| `viewmodel/CardDispatchSyncBuilder.kt` | Twin of VoiceIntentSyncBuilder — synthesizes card dispatches as `hermes_card_action` OpenAI pairs for session memory |
| `notifications/HermesNotificationCompanion.kt` | NotificationListenerService; cold-start buffer (50); forwards via ChannelMultiplexer |
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
| `util/TurnLatencyTracer.kt` | One `TurnLatency` INFO line per chat turn — `warm/cold` + `connect/session/submit/ttfe/ttft/done@…ms`; gateway + 3 SSE paths use it for desktop-comparable latency diagnosis; durations only |
| **Relay — Server** | |
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers. `handle_pairing_mint` mirrors `pair.py:762` — top-level = API server, `relay.{url,code}` nested |
| `plugin/relay/auth.py` | PairingManager, SessionManager, RateLimiter; `math.inf` for never-expire |
@@ -236,9 +267,12 @@ hermes-android/
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
| `hermes_relay_bootstrap/` | Runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
| `hermes_relay_bootstrap/` | Legacy import shim for old `.pth` files and editable installs |
| **Plugin — Dashboard** | |
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
@@ -248,7 +282,17 @@ hermes-android/
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat` |
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat`; command-scoped `--help` falls through to each command |
| `desktop/src/lib/theme.ts` | Shared ANSI palette + `colorEnabled()` + `Theme` (semantic helpers, `statusDot`) — single visual language; `--no-color`/`NO_COLOR`/TTY aware |
| `desktop/src/lib/table.ts` | Zero-dep column-aligned table renderer (ANSI-width aware, last column flexes to terminal width) — used by devices/sessions/audit |
| `desktop/src/lib/spinner.ts` | Stderr braille spinner for slow ops (pair probe, gateway connect); no-op when piped/quiet/json |
| `desktop/src/lib/usage.ts` | `UsageSpec` + `renderUsage`/`printUsage`/`unknownSubcommand` — per-subcommand `--help` + self-documenting sub-verb fallback |
| `desktop/src/lib/hints.ts` | `suggestedFix(err, ctx)` → next-step command (re-pair on auth fail, etc.); `formatError` renders error + hint |
| `desktop/src/lib/logo.ts` | Slim box-drawing "Hermes Relay" wordmark; shown atop `--help`, first-run welcome, REPL header, and `hermes-relay logo`; theme/no-color aware |
| `desktop/src/lib/auditLog.ts` | Local desktop-tool audit JSONL (`~/.hermes/desktop-audit.jsonl`); router appends per dispatch; backs `audit` command (relay's ring is loopback-only) |
| `desktop/src/lib/daemonStatus.ts` | Daemon heartbeat file (`~/.hermes/daemon-status.json`) + `isPidAlive` liveness; backs `daemon --status` |
| `desktop/src/commands/audit.ts` | `hermes-relay audit` — tails the local audit log into a table (WHEN/TOOL/STATUS/DETAIL); `--limit`, `--json` |
| `desktop/src/commands/relay.ts` | `hermes-relay relay info/security/context` — relay-server management surface; info/security loopback-only, context works remote with bearer |
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
@@ -284,10 +328,17 @@ hermes-android/
| **Desktop CLI — dev iteration** | |
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
| `release-desktop.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
| `release-cli.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
| **Server — Desktop tool routing (Phase B)** | |
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
| **Gradle modules — experimental Quest/XR (in development)** | |
| `relay-core/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.core`) — shared pairing/transport/terminal/voice/wire for the Quest port; not yet wired into the shipped `:app` |
| `relay-ui/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.ui`) — shared Compose UI (sphere, terminal WebView, QR scanner) for the Quest port; carries its own sphere copy |
| `quest/` | [EXPERIMENTAL] Meta Spatial SDK Quest/XR app — gradle `includeBuild("quest")`; needs further development, not shipped |
| **Tooling — dev iteration (not shipped)** | |
| `ui-preview/` | Desktop Compose Hot Reload harness — JVM Compose for Desktop; source-shares `MorphingSphereCore` from `:relay-ui`; `Main.kt` gallery; see `ui-preview/README.md` |
| `app/src/test/.../screenshots/StoreScreenshotTest.kt` | Roborazzi host-side store/docs screenshot renderer — deterministic, no device, exact 1080×2160; reuses real components+chrome with mock data; `capture(name, themeId){…}` renders any view; see `docs/screenshot-automation.md` §Deterministic rendering (JDK-21 + no-plugin gotchas) |
## What NOT to Do
@@ -296,7 +347,8 @@ hermes-android/
- **Don't use Ktor for networking** — OkHttp for WebSocket
- **Don't use plaintext WebSocket** — `wss://` only, even in development
- **Don't put documentation in root** — long-form docs go in `docs/`
- **Don't forget DEVLOG.md** — update it
- **Don't forget DEVLOG.md** — update it (record *what happened*)
- **Don't bury follow-ups** — deferred work / known gaps go in `TODO.md`, never in DEVLOG or one-off code/doc comments
## MCP Tooling
@@ -335,7 +387,7 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
1. **Edit locally** — Windows checkout. Both plugin (`plugin/`) and app (`app/`) live here.
2. **Python syntax check** — `python -m py_compile plugin/<file>.py`. Full tests run on the server.
3. **Kotlin changes** — do NOT run `gradle build`. Bailey builds via Android Studio's ▶ button. Never `adb install` from Claude.
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs (see `.github/workflows/ci.yml` → `gradlew lint` fallback) and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Lint is a hard blocker in CI: Build + Test show "skipping" until lint passes, and lint prints only the **first failure** before aborting — so CI iterations reveal errors one at a time while a single local lint run surfaces all of them.
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Android CI runs lint alongside build/test for faster feedback, but a local lint run still surfaces issues before the workflow spends runner time compiling and packaging.
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
6. **Pull + restart on server** — see Server Deployment below.
7. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
@@ -354,6 +406,12 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
**Update:** `hermes-relay-update` (idempotent, re-fetches install.sh). Or manually: `git pull --ff-only && systemctl --user restart hermes-relay`.
**Compat hook:** `hermes relay compat status/install/remove` manages only the
optional `hermes_relay_bootstrap.pth` startup hook. New installs load the
plugin-owned bootstrap from `plugin/hermes_relay_bootstrap/`; the repo-root
package is only a legacy import shim. Vanilla Hermes chat, Manage, and dashboard voice
must not depend on this hook.
**Key conventions:**
- Phone re-pairs after each relay restart (SessionManager is in-memory; wiped on restart)
- Use `python -m unittest` not `pytest` — conftest imports `responses` which may not be installed
@@ -372,20 +430,25 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
See [RELEASE.md](RELEASE.md) for the full recipe.
- **Version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`)
- **Bump atomically:** `bash scripts/bump-version.sh <new-version>` — updates all three sources
- **`appVersionCode` is monotonic** — always increment across prereleases
- **Cut a release:** bump → commit → `git tag vMAJOR.MINOR.PATCH` → push tag → CI builds + GitHub Release
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
- **Relay plugin version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-plugin-version-sync.py`; bump with `scripts/bump-plugin-version.sh`
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, plugin, and CLI versions without forcing them to match
- **`appVersionCode` is monotonic** — always increment across Android prereleases
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `plugin-v*`, or `cli-v*` → push tag → CI builds + GitHub Release
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
## Integration Points
| Surface | Endpoint | Notes |
|---------|----------|-------|
| Chat (gateway) | Dashboard `POST /api/auth/ws-ticket` -> WS `/api/ws` | Vanilla Hermes dashboard/tui_gateway path; live thinking/reasoning; requires dashboard auth |
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Vanilla Hermes dashboard surface; do not proxy through Relay |
| Vanilla Hermes voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Vanilla Hermes no-plugin voice; uses dashboard session from Manage |
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
@@ -396,6 +459,8 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
| Plugin diagnostics | `hermes relay doctor --json` | Reports upstream route reachability, Relay loopback state, plugin layout, and legacy bootstrap state |
| Compat hook lifecycle | `hermes relay compat status/install/remove` | Optional legacy API compatibility hook; not required for the standard path |
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
+53
View File
@@ -0,0 +1,53 @@
# Hermes-Relay-CLI v__VERSION__
**Release Date:** 2026-06-21
**Since the previous CLI release:** a first-class command surface — activity audit, relay inspection, a background daemon, a polished visual layer, and v1.2.0 server parity.
This is a broad CLI uplift: new commands for seeing what the agent did and inspecting the relay, a daemon you can run in the background, and a consistent themed interface with per-command help. Everything is additive — existing commands, flags, and scripts keep working.
**Experimental phase.** Assets are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
## What's changed
### Added
- **`hermes-relay audit`** — see what the remote agent has run on this machine through the desktop tools (tool, status, detail), read from a local log. No network, no auth; works whether the relay is local or remote.
- **`hermes-relay relay`** — inspect the relay server: `relay context` audits the system-prompt context the relay injects into the agent (works from any paired machine), and `relay info` / `relay security` report server state for operators on the relay host.
- **Background daemon.** `hermes-relay daemon start` runs the headless tool router in the background — no console window, survives closing the terminal — with `daemon stop` and `daemon status` to manage it. Bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
- **Per-command help.** Every subcommand answers `--help`, and `devices` / `sessions` / `plugins` / `voice` / `relay` print their own usage (sub-commands, flags, examples) instead of a terse "unknown sub-verb".
- **Startup banner.** A slim "Hermes Relay" wordmark shows atop `--help`, the first-run welcome, and the chat REPL; `hermes-relay logo` prints it on demand. Suppressed for piped / `--json` / `--no-color` output.
### Changed
- **Visual + ergonomics refresh.** One consistent color theme across the CLI, aligned tables for `devices` / `sessions`, on/off status dots, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
- **Smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
- **Voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
## Install
**Windows tray app (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
**Windows CLI only:**
```powershell
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
**macOS / Linux CLI:**
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
Pin this specific release with `HERMES_RELAY_VERSION=__TAG__`.
## Verify
```text
hermes-relay --version
hermes-relay pair --remote ws://<host>:8767
hermes-relay shell
```
Open **Hermes Relay Desktop** from the Windows Start menu for tray pairing, devices, task log, settings, pause, and emergency stop.
See [Desktop docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
+78
View File
@@ -0,0 +1,78 @@
# Code of Conduct
Hermes-Relay adopts the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/),
version 2.1, as its code of conduct. The canonical, full text lives at that
link; the summary below states what it means for this project.
## Our Pledge
We as members, contributors, and maintainers pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Behavior that helps create a positive environment includes:
- Showing empathy and kindness toward others.
- Being respectful of differing opinions, viewpoints, and experiences.
- Giving and gracefully accepting constructive feedback.
- Taking responsibility, apologizing to those affected by our mistakes, and
learning from the experience.
- Focusing on what is best for the overall community, not just ourselves.
Behavior that is not acceptable includes:
- Harassment, intimidation, or discrimination in any form.
- Personal or political attacks, insults, or derogatory comments.
- Unwelcome advances or attention, including of a romantic or sexual nature.
- Publishing others' private information (such as a physical or email address)
without their explicit permission.
- Other conduct that could reasonably be considered inappropriate in a
professional setting.
For the complete, canonical list of standards and examples, see the
[Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing these standards
and will take appropriate and fair corrective action in response to any behavior
they deem inappropriate, threatening, offensive, or harmful.
Maintainers have the right and responsibility to remove, edit, or reject
comments, commits, code, issues, and other contributions that are not aligned
with this Code of Conduct, and will communicate reasons for moderation decisions
when appropriate.
## Scope
This Code of Conduct applies within all project spaces — the repository, issues,
pull requests, discussions, and the documentation site — and also applies when
an individual is officially representing the project in public spaces.
## Reporting & Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported privately to the maintainers at **`conduct@codename-11.dev`**. All
complaints will be reviewed and investigated promptly and fairly. Maintainers
are obligated to respect the privacy and security of the reporter of any
incident.
For the **Enforcement Guidelines** (the tiered Correction → Warning →
Temporary Ban → Permanent Ban ladder maintainers use to determine consequences),
see the corresponding section of the
[Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/#enforcement-guidelines).
## Attribution
This Code of Conduct is adapted from the
[Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
+10
View File
@@ -96,6 +96,16 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`,
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
## Changelog & writing conventions
This is a **public repo** — `CHANGELOG.md`, `DEVLOG.md`, the README, and everything under `docs/` ship publicly. Keep them clean:
- **`CHANGELOG.md`** follows [Keep a Changelog](https://keepachangelog.com/) (Added / Changed / Fixed). Append your change to the `## [Unreleased]` block in the PR. Entries can carry detail while they accumulate, but at release-prep the version block is **condensed to crisp public bullets** (1–2 lines each) — the deep "how we debugged it" narrative belongs in commit messages and `DEVLOG.md`, not the public changelog.
- **`DEVLOG.md`** is a factual engineering log — what changed, why, and how it was verified. Keep it depersonalized and third-person; it's a record, not a diary.
- **No non-public wording anywhere committed:** no personal names (attribute impersonally — identity lives in git history), no real server hostnames/IPs or internal deployment names, no AI/assistant process self-narration, no fork/branch plumbing in user-facing notes. Generic example IPs in setup docs are fine.
Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/play-store-listing.md`) are theme-framed and user-facing; see [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution" for the full checklist.
## Testing
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
+732 -156
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
# GEMINI.md
Agent instructions for **Hermes-Relay**. This file exists so Gemini CLI (which
does not read `AGENTS.md` natively) picks up the project's guidance.
**Read [AGENTS.md](AGENTS.md) — it is the single source of truth** for every
coding agent: the entry point, the non-negotiables (standard-path-is-vanilla-
upstream, verify-endpoints, Conventional Commits + `main`/`dev` branching, the
per-language stack rules), and the public-repo writing hygiene. It links on to
`CLAUDE.md` for the deep reference (architecture, upstream Hermes API, repo
layout, code style, the dev loop, and the Key Files map).
Do not restate rules here — keep them in `AGENTS.md` so they can't drift.
+29
View File
@@ -0,0 +1,29 @@
# Hermes-Relay-Plugin v__VERSION__
**Release Date:** June 22, 2026
**Since the previous plugin release:** Reliability fixes for the Realtime Agent voice path — brokered Hermes turns no longer drop with `session_not_found`, and long-running Hermes work no longer times out a live voice session.
This is a focused patch for the relay's Realtime Agent. When a spoken turn reached back into Hermes for context or tool work, a session-namespace mismatch could make the API Server reject the turn, and long background tasks could let the voice session lapse mid-run. Both paths are now resilient. Provider-native voice turns and vanilla upstream (no plugin) are unaffected.
## What's changed
### Fixed
- **Brokered Hermes turns no longer fail with `session_not_found`.** When the Realtime Agent reached back to Hermes for context or tool work, it could hand the API Server a session id from a different session namespace (the gateway/client store), which the API Server rejected. The broker now mints a valid API Server session and retries the turn once when that happens, reuses an existing API Server session when the id is already valid, and reads the API Server's current nested `{"session": {"id": …}}` create-session response (previously only the legacy flat shape) so session creation no longer errors with "created a session without an id."
- **Realtime voice survives long Hermes runs.** A heartbeat now keeps the realtime voice session alive while a long-running Hermes task is in flight, so the turn no longer times out before the work finishes.
## Install
```bash
pip install hermes-relay==__VERSION__
```
## Verify
```bash
python -m relay_server --help
```
---
Tag prefixes: Android releases use `android-v*`, CLI releases use `cli-v*`. Historical
relay/plugin releases used `relay-v*` tags.
+186 -164
View File
@@ -1,21 +1,23 @@
<p align="center">
<img src="assets/logo.svg" alt="Hermes-Relay" width="120">
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — your Hermes agent, in your pocket" width="800">
</p>
<h1 align="center">Hermes-Relay</h1>
<p align="center">
<strong>Runs on your machine. Lives on your devices.</strong><br>
A native Android companion for your <a href="https://github.com/NousResearch/hermes-agent">Hermes agent</a> — streaming chat, hands-free voice,
and full agent management. Plus a single-binary CLI that gives the agent hands on any machine you pair.
</p>
<p align="center">
<strong>Your self-hosted Hermes agent, native on your phone.</strong><br>
Chat, voice, and full agent management over your own infrastructure —<br>
plus an experimental desktop CLI that gives the agent hands on your computer.
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Surface%201-Android-green.svg" alt="Android"></a>
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI%20%28alpha%29-orange.svg" alt="Desktop CLI (alpha)"></a>
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Android-8.0%2B-3DDC84.svg?logo=android&logoColor=white" alt="Android 8.0+"></a>
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
<a href="https://github.com/Codename-11/hermes-relay/releases"><img src="https://img.shields.io/github/v/release/Codename-11/hermes-relay?filter=android-v*&label=release&color=8B5CF6" alt="Latest release"></a>
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-alpha-orange.svg" alt="CLI (alpha)"></a>
</p>
<p align="center">
@@ -25,149 +27,208 @@
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
</p>
---
## What it is
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
- **📱 Android app** — streaming chat, hands-free voice, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. On sideload builds, the agent can read your screen and act on it.
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**. Add the optional relay only when you want terminal, phone control, or the CLI's tools. **Pair once from either surface; both work.**
<p align="center">
<video src="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo.mp4" poster="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo_poster.jpg" autoplay loop muted playsinline width="280"></video>
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — Vanilla Hermes (Chat, Manage, Voice) runs with no plugin; the optional Relay plugin adds Terminal, Bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build." width="900">
</p>
---
## Two surfaces, one pair
| Surface | What | Status |
|---------|------|--------|
| **[Android app](#quick-start-android)** | Native phone client — streaming chat, hands-free voice, full agent management (models, keys, skills, profiles), and on sideload builds the agent can read your screen and act on it. | Available — Google Play (Internal testing) + sideload APK |
| **[Desktop CLI](#desktop-cli-alpha)** | The agent reaching back to **your machine** — local tool routing (files, terminal, screenshots, clipboard) plus a remote shell to the host. | **Alpha** — `desktop-v*` releases, expect heavy changes |
Both share the same WSS relay and credentials store. **Pair once from either, both work.**
---
## Quick Start (Android)
Install → connect → talk, in about two minutes. A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**.
Install → connect → talk, in about two minutes.
### 1. Install the app
### 1 · Install the app
- **Google Play** — coming soon (currently on Internal testing)
- **APK** — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Full walkthrough — integrity verification, signing fingerprint, what's in each build — in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
Sideload builds check GitHub for new releases and show a one-tap update banner when you're behind; Play builds update through the Play Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
### 2. Have Hermes running
### 2 · Have Hermes running
Run upstream Hermes with its API server and dashboard enabled:
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
```bash
hermes setup --portal
hermes setup --portal # install / log in / pick a provider — skip if already done
mkdir -p ~/.hermes
API_SERVER_KEY="$(openssl rand -hex 32)"
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
cat >> ~/.hermes/.env <<EOF
API_SERVER_ENABLED=true
API_SERVER_HOST=0.0.0.0
API_SERVER_PORT=8642
API_SERVER_KEY=$API_SERVER_KEY
EOF
chmod 600 ~/.hermes/.env
echo "Android API URL: http://<this-computer-ip>:8642"
echo "Android API key: $API_SERVER_KEY"
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
hermes gateway
```
Windows commands, dashboard auth notes, and upstream links: [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
### 3. Connect and talk
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
Open the app, choose **Standard Hermes**, and enter your server's address and API key. The wizard probes everything and finishes with a capability card:
### 3 · Connect and talk
Open the app and pick how to connect — any of:
- **Vanilla Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
- **Vanilla Hermes** → type the address (`http://<host>:8642`) and key by hand.
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
The wizard probes everything and finishes with a capability card:
| Line | What it means |
|---|---|
|------|---------------|
| **Chat** | API server reachable — you can talk |
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
| **Remote** | Fallback route configured — keeps working away from home |
| **Relay** | Optional power tools — fine to leave unpaired |
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session also unlocks voice. That's the whole standard setup.
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole Vanilla Hermes setup.
**Going places?** Put your server's Tailscale URL in the setup form's "Remote access" field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
### 4. Optional: install Relay for power tools
### 4 · Optional: install Relay for power tools
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
hermes relay start --no-ssl
hermes pair
```
The installer clones to `~/.hermes/hermes-relay/`, registers the plugin/skill paths, and can install a systemd user service. Scan the QR from the phone's Connections screen; if you can't scan, use `hermes pair --register-code ABCD12` with the manual code from Android **Settings → Connections → Advanced**. (`/hermes-relay-pair` and the dashed `hermes-pair` shim remain for chat-surface and older builds.)
Use the legacy installer instead if you also want the systemd user service,
shell shims, and the full clone/update workflow:
- **Updating:** `hermes-relay-update` — idempotent; or re-run the install one-liner.
- **Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` — reverses every step, never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a "Relay" tab (paired devices, bridge activity, media tokens) appears in the web UI.
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
```
The plugin-manager install owns the plugin code, dashboard tab, CLI commands,
and agent tools. `hermes relay compat status/install/remove` manages only the
optional legacy API compatibility hook when an older Hermes build needs it. Scan
the QR from the phone's Connections screen — or use
`hermes pair --register-code ABCD12` with the manual code from Android
**Settings → Connections → Advanced**.
- **Plugin-manager uninstall:** `hermes relay compat remove --all` if you installed the optional hook, then `hermes plugins remove hermes-relay`.
- **Legacy installer update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
- **Legacy installer uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — removes the service, shims, clone, external skill path, editable package, and compat hook. It never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a **Relay** tab (paired devices, bridge activity, media tokens) appears in the web UI.
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
**Requirements:** Android 8.0+ (SDK 26) · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server · macOS / Linux / Windows for the desktop CLI.
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
## Desktop CLI (alpha)
## Screenshots
> **Alpha — expect heavy changes.** With [hermes-desktop](https://hermes-agent.nousresearch.com) now covering chat and management on the desktop, this surface is being refocused into a pure remote **"hands" connector**: the agent reaching back through the relay to run tools on your machine (files, terminal, screenshots, clipboard, editor). The chat and shell features that overlap hermes-desktop will be removed in a future release. Binaries are unsigned during the experimental phase — SmartScreen/Gatekeeper warnings are expected.
<table>
<tr>
<td align="center" width="25%"><img src="assets/screenshots/01_startup.png" alt="Cold start" width="100%"><br><sub><b>Cold start</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/02_chat.png" alt="Streaming chat" width="100%"><br><sub><b>Streaming chat</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/03_voice.png" alt="Hands-free voice" width="100%"><br><sub><b>Hands-free voice</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/04_sessions.png" alt="Session history" width="100%"><br><sub><b>Session history</b></sub></td>
</tr>
<tr>
<td align="center" width="25%"><img src="assets/screenshots/05_themes.png" alt="App themes" width="100%"><br><sub><b>App themes</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/06_manage.png" alt="Manage your agent" width="100%"><br><sub><b>Manage your agent</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/07_connections.png" alt="Connections and routes" width="100%"><br><sub><b>Connections &amp; routes</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/08_appearance.png" alt="Agent avatar &amp; skins" width="100%"><br><sub><b>Avatars &amp; skins</b></sub></td>
</tr>
</table>
The agent's brain stays on the host; the CLI lets it call `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, and more **on your machine** over the same WSS relay — with a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch. No Node required; installs are self-contained native binaries.
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
**Install** (Windows PowerShell / macOS / Linux):
## Features
### Android
- **Streaming chat** — rides vanilla Hermes, preferring the dashboard gateway (`/api/ws`, live thinking) when signed in to Manage and falling back to API-server SSE otherwise, with live markdown, tool-call cards, session history, a searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing.
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage keys (write-only, masked, rate-limited reveal), create and edit profiles including `SOUL.md`, and browse/install/update skills. One dashboard sign-in covers it all.
- **Hands-free voice** — talk on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice and an opt-in provider-native Realtime Agent with background task handoff.
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
- **Multi-Connection + profiles** — pair multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay a profile's model + `SOUL.md` per chat.
- **Phone control (bridge)** — with Relay paired, the agent reads the screen and acts: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros. Guarded by per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
- **Notification companion** — opt-in access so the agent can triage, summarize, and route incoming notifications.
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
## Hands on any machine — the Hermes-Relay CLI&nbsp;<sub>(alpha)</sub>
> **Alpha · Windows today** (macOS / Linux coming soon). A single self-contained binary — no Node required. Binaries are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
```powershell
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
```bash
hermes-relay pair --remote ws://<host>:8767 # once
hermes-relay daemon # headless tool router — agent reaches you anytime
hermes-relay # interactive Hermes TUI in tmux (legacy, being refocused)
hermes-relay update # self-update via GitHub Releases
```
- **Docs:** [Desktop guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
- **Release track:** tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on a separate `cli-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=cli), with old alpha prereleases still visible under `desktop-v*`.
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
## Features
## How It Works
### Android
```
Phone (HTTP/WSS) --> Hermes Dashboard (:9119) [chat gateway, manage, vanilla voice]
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat fallback, sessions, runs]
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
CLI (WSS) --> Relay (:8767) [machine tools, tui, terminal]
```
- **Streaming chat** — direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage provider keys (write-only, masked, server-rate-limited reveal), create and edit agent profiles including `SOUL.md`, and browse, install, and update skills from the hub. One dashboard sign-in covers it all
- **Voice mode** — talk hands-free on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice providers and an opt-in provider-native Realtime Agent with background task handoff
- **Works away from home** — add your server's Tailscale or public URL and the app roams automatically: LAN at home, fallback elsewhere. Routes are editable per connection, and an unreachable server gets a diagnosis ("away from the server's network? add a route"), not just a red dot
- **Multi-Connection + profiles** — pair with multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay an agent profile's model + `SOUL.md` per chat
- **Phone control (bridge)** — with the Relay plugin paired, the agent reads the screen and acts on it: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros, and event-driven waits. Guarded by safety rails: per-app blocklist (banking/payments/2FA default-blocked), destructive-verb confirmation, idle auto-disable, full activity log
- **Notification companion** — opt-in notification access so the agent can triage, summarize, and route incoming notifications
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts
Chat prefers the Hermes dashboard gateway when Manage auth is ready, then falls
back to the upstream API server SSE path with the API key. Manage and Vanilla Hermes
voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla
install needs no plugin for either. The optional relay on `:8767` adds the power
surfaces: terminal, bridge phone control, media handoff, machine tools, and
relay-side voice, which is preferred automatically when paired. One QR can
configure API, dashboard, and relay routes without merging their auth models.
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
## Documentation
### Desktop CLI
| | |
|---|---|
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, features, configuration — start here** |
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | Pairing, subcommands, local tool routing |
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `plugin-v*`, `cli-v*`) |
- **Local tool routing** — `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` run on your machine; agent-proposed patches render as colored diffs with interactive approval
- **Daemon mode** — headless tool router; the agent can reach you with no shell open
- **Multi-endpoint pairing, reconnect-on-drop, TOFU cert pinning** — same model as the Android app
- **Self-update** — `hermes-relay update` verifies SHA256 and atomic-swaps the binary
<details>
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
## Install with an AI agent
<br>
If an AI assistant (Claude, GPT, etc.) manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
If an AI assistant manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
```text
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a CLI + a Python plugin for the Hermes AI agent platform.
Read the canonical setup recipe before acting:
https://raw.githubusercontent.com/Codename-11/hermes-relay/main/skills/devops/hermes-relay-self-setup/SKILL.md
@@ -175,131 +236,92 @@ Read the canonical setup recipe before acting:
Then guide me through:
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via the plugin-provided `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
- Connecting my phone by Vanilla Hermes API URL/key first, then optionally pairing Relay via `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the Hermes-Relay CLI (`irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (CLI)
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
```
Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `/hermes-relay-self-setup` from any chat for re-setup or "is everything wired correctly?" checks.
## How It Works
```
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
Phone (HTTP) --> Hermes Dashboard (:9119) [manage + standard voice — cookie sign-in]
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
Desktop CLI (WSS) --> Relay (:8767) [desktop tools, tui, terminal]
```
Chat connects directly to the Hermes API Server with the API key — the same pattern used by Open WebUI and other Hermes frontends. The Manage tab and standard voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla install needs no plugin for either. The optional relay on `:8767` adds the power surfaces — terminal, bridge phone control, media handoff, desktop tools, and relay-side voice providers (preferred automatically when paired). One QR can configure API, dashboard, and relay routes without merging their auth models.
## Documentation
| | |
|---|---|
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, both surfaces, features, configuration — start here** |
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — pairing, subcommands, local tool routing |
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
| [Upstream Integration Sync](docs/upstream-integration-sync.md) | Supported Hermes extension points vs server-owned compatibility layers |
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, Desktop `desktop-v*`) |
---
</details>
## Development
### Quick Start
1. **File > Open** the repo root in Android Studio
2. Wait for Gradle sync
3. **Run** (Shift+F10) to deploy to emulator or device
### Dev Scripts
```bash
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
scripts/dev.bat build # Build debug APK
scripts/dev.bat release # Build signed release APK
scripts/dev.bat bundle # Build release AAB for Google Play
scripts/dev.bat run # Build + install + launch + logcat
scripts/dev.bat test # Run unit tests
scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start Server (dev, no TLS)
```
### Repository Structure
```
hermes-relay/
├── app/ # Android app (Kotlin + Jetpack Compose)
├── desktop/ # Desktop CLI thin-client (@hermes-relay/cli — TS + Bun-compiled binary)
├── relay_server/ # WSS Server (Python + aiohttp; thin shim → plugin/relay)
├── plugin/ # Hermes agent plugin
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, desktop tools)
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
├── skills/ # Hermes agent skills
│ └── devops/
│ ├── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
│ ├── hermes-relay-self-setup/ # AI-agent setup recipe (Android + desktop)
│ └── hermes-relay-desktop-setup/ # AI-agent recipe specifically for the desktop CLI
├── user-docs/ # VitePress documentation site (Android + desktop sections)
├── docs/ # Spec, decisions, security
├── scripts/ # Dev helper scripts
├── .github/workflows/ # CI + release pipelines (ci-android / ci-server / ci-desktop)
└── gradle/ # Wrapper (8.13) + version catalog
scripts/dev.bat relay # Start the relay server (dev, no TLS)
```
### Tech Stack
| Component | Stack |
|-----------|-------|
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
| **Desktop CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
| **Server** | Python 3.11+, aiohttp |
| **Android app** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
| **Server / plugin** | Python 3.11+, aiohttp |
| **Serialization** | kotlinx.serialization (Android) |
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (desktop) |
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
| **Min SDK** | 26 (Android 8.0) · Target SDK 35 |
### Server (optional — bridge, terminal, TUI, media, and relay voice routes)
<details>
<summary><b>Repository structure</b></summary>
```
hermes-relay/
├── app/ # Android app (Kotlin + Jetpack Compose)
├── desktop/ # Hermes-Relay CLI thin-client (TS + Bun-compiled binary)
├── relay_server/ # WSS server (Python + aiohttp; thin shim → plugin/relay)
├── plugin/ # Hermes agent plugin
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, machine tools)
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
├── skills/devops/ # Hermes agent skills (pairing, self-setup, CLI setup recipes)
├── user-docs/ # VitePress documentation site
├── docs/ # Spec, decisions, security
├── scripts/ # Dev helper scripts
├── .github/workflows/ # CI + release pipelines (ci-android / ci-plugin / ci-desktop)
└── gradle/ # Wrapper (8.13) + version catalog
```
</details>
<details>
<summary><b>Running the server / plugin from a clone</b></summary>
<br>
End users should install via the [one-liner](#4--optional-install-relay-for-power-tools) above. For local development:
```bash
hermes relay start --no-ssl # if you installed the plugin
# or from a repo checkout:
python -m plugin.relay --no-ssl
```
python -m plugin.relay --no-ssl # or from a repo checkout
Or with Docker:
```bash
# Docker:
docker build -t hermes-relay relay_server/ && docker run -d --network host --name hermes-relay hermes-relay
```
See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
### Hermes Plugin (for contributors)
End users should install via the [one-liner](#4-optional-install-relay-for-power-tools) above. For local development from a clone:
```bash
cp -r plugin ~/.hermes/plugins/hermes-relay
# Or symlink for live edits:
# Live-edit the plugin against a local Hermes:
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
```
Then restart hermes and run the plugin-provided `hermes pair` to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. `/hermes-relay-pair` and the dashed `hermes-pair` shim remain available for chat-surface and older-build compatibility.
Then restart hermes and run `hermes pair` to verify. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
## Hermes Agent
</details>
## Built for Hermes Agent
Hermes-Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — an open-source AI agent platform by [Nous Research](https://nousresearch.com). See the [Hermes Agent docs](https://hermes-agent.nousresearch.com) for server setup, gateway configuration, and plugin development.
## Found a bug? Let us know!
## Found a bug? Let us know
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line "this didn't work on my Pixel 7" / "the alpha.14 Windows binary segfaults on my Surface" is genuinely useful.
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line *"this didn't work on my Pixel 7"* is genuinely useful.
## Star History
+220 -91
View File
@@ -13,20 +13,23 @@ with optional prerelease identifiers.
- `PATCH` — bug fixes, backwards compatible
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
Hermes-Relay now ships three independently versioned surfaces:
Hermes-Relay now ships three independently versioned surfaces. Public GitHub
Release titles use product names (`Hermes-Relay-Android`,
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable
for automation.
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|---|---|---|---|---|
| Android app | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
| Server / Python package | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-server-version.sh` | `.github/workflows/release-server.yml` |
| Desktop CLI | `desktop-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-desktop.yml` |
| Hermes-Relay-Android | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
| Hermes-Relay-Plugin | `plugin-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
| Hermes-Relay-CLI | `cli-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-cli.yml` |
This split is intentional. The server now carries features for both Android
and desktop, so server fixes can ship without forcing an Android app
`versionCode` bump, and desktop CLI alphas can continue on their own cadence.
Historical Android releases before this naming split used bare `v*` tags, and
historical server releases used `relay-v*` tags. New releases use the explicit
surface prefixes above.
This split is intentional. The plugin carries relay features for both Android
and CLI clients, so plugin fixes can ship without forcing an Android app
`versionCode` bump, and CLI alphas can continue on their own cadence. Historical
Android releases before this naming split used bare `v*` tags. Historical
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used
`desktop-v*` tags. New releases use the explicit tag prefixes above.
### Android app versioning
@@ -70,35 +73,47 @@ bash scripts/bump-android-version.sh 0.6.2
`scripts/bump-version.sh` remains as a backward-compatible alias for the
Android script.
### Server / Python package versioning
### Plugin / Python package versioning
Server version metadata lives in these server-owned files and must stay in
Plugin version metadata lives in these plugin-owned files and must stay in
lockstep:
| File | Line | Purpose |
|---|---|---|
| `pyproject.toml` | `version = "..."` | Python package metadata |
| `plugin/relay/__init__.py` | `__version__ = "..."` | runtime version reported by `/health` |
| `plugin/relay/__init__.py` | `__version__ = "..."` | runtime version reported by `/health` and `/relay/info` |
| `plugin/plugin.yaml` | `version: ...` | Hermes plugin metadata |
| `plugin/dashboard/manifest.json` | `"version": "..."` | Hermes dashboard plugin metadata |
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
Always bump Server releases via:
Always bump Plugin releases via:
```bash
bash scripts/bump-server-version.sh 0.6.2
bash scripts/bump-plugin-version.sh 0.6.2
```
Check the current metadata with:
```bash
python scripts/check-server-version-sync.py
python scripts/check-plugin-version-sync.py
```
The `server-v*` release workflow validates the tag against the same metadata,
runs server tests, builds a wheel and sdist, generates checksums, and publishes
a GitHub Release with the package artifacts.
Check all release tracks at once with:
```bash
python scripts/check-version-tracks.py
```
This aggregate check reports Android, plugin, and CLI versions
side by side and validates that each track's own source files are internally
consistent. It deliberately does not require all three tracks to share the same
SemVer.
The `plugin-v*` release workflow validates the tag against the same metadata,
runs plugin tests, builds a wheel and sdist, generates checksums, and
publishes a `Hermes-Relay-Plugin vX.Y.Z` GitHub Release with the package
artifacts.
## Branching policy
@@ -156,15 +171,15 @@ Squash merges lose that detail and are **not** the house style.
### Version bumps happen at release-prep on `dev`, NOT on feature branches
Feature branches **never** touch `gradle/libs.versions.toml`,
server-owned version metadata, or `desktop/package.json`.
plugin-owned version metadata, or `desktop/package.json`.
If two feature branches both bumped a release version, they'd collide on
version files and, for Android, on `appVersionCode` (which must be
monotonic).
Version-bump commits live on `dev` as the last commit of release-prep
work. Android commits use `release(android): android-vX.Y.Z`; server commits
use `release(server): server-vX.Y.Z`; desktop commits use the existing
`release: desktop-vX.Y.Z` convention. A release PR then merges `dev` →
work. Android commits use `release(android): android-vX.Y.Z`; plugin commits
use `release(plugin): plugin-vX.Y.Z`; CLI commits use
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` →
`main` with `--no-ff`, and the matching tag is cut from the resulting
`main` tip.
@@ -173,7 +188,7 @@ use `release(server): server-vX.Y.Z`; desktop commits use the existing
Light branch protection is enabled:
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
here. PR must pass CI (Android + Server) before merge. Force push and
here. PR must pass CI (Android + Plugin) before merge. Force push and
branch deletion blocked.
- **`dev`** — direct pushes blocked for non-trivial work; feature
branches PR in. PR must pass CI. Force push and branch deletion
@@ -275,22 +290,34 @@ for the full text.
### 3. Play Developer API service account (optional)
Required only if you want `gradlew publishReleaseBundle` to upload directly
to Play Console. Manual UI uploads work without this.
Required for automated upload (the `android-v*` workflow's Play step, or local
`gradlew publishGooglePlayReleaseBundle`). Manual UI uploads work without this.
1. Open <https://console.cloud.google.com/> and select the project linked
to your Play Console account (Play Console > Setup > API access shows
which one).
2. **IAM & Admin > Service Accounts > Create Service Account** (e.g.
`hermes-relay-publisher`). No project roles needed.
3. On the new service account, **Keys > Add key > Create new key > JSON**
and download the file.
4. In Play Console > **Setup > API access**, find the service account,
click **Grant access**, and assign the **Release manager** role.
5. Save the JSON as `play-service-account.json` in the repo root (already
in `.gitignore`).
6. Verify with `gradlew bootstrapReleasePlayResources` — should succeed
without auth errors.
The service account is **created in Google Cloud Console** and then **authorized
in Play Console** — two separate consoles. (Play Console's older "Setup > API
access" page has been reorganized; there is no longer a "Setup" group. Use the
paths below.)
1. **Create the service account (Google Cloud Console).** Open
<https://console.cloud.google.com/iam-admin/serviceaccounts>, pick the project
(any project works; if Play Console's **API access** page already names a linked
project, use that one). **Create service account** → name it e.g.
`hermes-relay-publisher` → **Done**. No project roles needed.
2. **Create a JSON key.** On the new service account → **Keys** tab → **Add key >
Create new key > JSON** → download. This file's *contents* are the secret.
3. **Authorize it in Play Console.** Open the Play Console account-level left
sidebar → **Users and permissions** → **Invite new users** → paste the service
account's email (`...@...iam.gserviceaccount.com`). Under **App permissions**
(for `com.axiomlabs.hermesrelay`) or **Account permissions**, grant the
**Release** permissions — "Release apps to testing tracks" and "Release to
production, exclude devices, and use Play App Signing" — plus "View app
information". (Granting **Admin (all permissions)** also works but is broader
than needed.) **Invite user**.
4. **Use it.** For CI, paste the JSON contents into the `PLAY_SERVICE_ACCOUNT_JSON`
repo secret (step 4 / secrets table). For local publish, save the JSON as
`play-service-account.json` in the repo root (already in `.gitignore`).
5. Verify locally with `gradlew bootstrapGooglePlayReleaseResources` — succeeds
without auth errors once permissions propagate (allow a few minutes).
### 4. GitHub Actions secrets
@@ -350,6 +377,12 @@ the new app version and a higher `appVersionCode`.
### 2. Update release notes and changelog
> Each surface has its own GitHub-Release-body file, all in the same format
> (Summary + Added/Changed/Fixed + Install/Verify): `RELEASE_NOTES.md` (Android),
> `PLUGIN_RELEASE_NOTES.md` (plugin), `CLI_RELEASE_NOTES.md` (CLI). This step covers
> the Android artifacts; the plugin/CLI files are filled in their own release
> sections below but follow the identical scrub and Keep-a-Changelog grouping.
- `CHANGELOG.md` — promote the accumulated `[Unreleased]` block to a
versioned header. The block already exists: every feature PR has
been appending to it. All you do here is:
@@ -359,6 +392,14 @@ the new app version and a higher `appVersionCode`.
3. Skim the new versioned block and tighten / reorder if needed —
Keep-a-Changelog grouping (`Added` / `Changed` / `Fixed`) should
already be in place from the accumulator phase.
4. **Per-surface split.** `[Unreleased]` accumulates entries from *all
three* surfaces (Android + CLI + plugin), but releases are
per-surface. Move only the entries for the surface you're cutting into
the new versioned block, and leave the other surfaces' entries under
the fresh `[Unreleased]` for their own `cli-v*` / `plugin-v*` cut.
(Those tracks' GitHub-Release bodies come from `CLI_RELEASE_NOTES.md` /
`PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's
historical record.)
- `RELEASE_NOTES.md` — body of the GitHub Release for this version
(rewritten each release; the workflow uses this as-is). This is the
operator-facing summary, not the CHANGELOG mirror. Keep the
@@ -372,9 +413,43 @@ the new app version and a higher `appVersionCode`.
shown in the settings/about screen. Update with the version number
and a brief feature summary. Gets stale silently if forgotten
(v0.4.0 shipped with 0.1.0 content until caught post-release).
- `app/src/googlePlay/play/release-notes/en-US/default.txt` — the Play
Console **"What's new"** text, which gradle-play-publisher reads at
upload to fill the Production-draft release notes. This is **separate**
from `RELEASE_NOTES.md` (that one is only the GitHub Release body) — if
this file is missing or stale, the Play draft ships with empty/wrong
notes (shipped empty in v1.1.0 until caught post-release). Keep it
**≤500 chars per language**, user-facing, Android-only.
- `docs/play-store-listing.md` — Play Store listing copy. Update
the version reference and the "Release Notes" section that gets
pasted into the Play Console "What's new" field.
pasted into the Play Console "What's new" field. Keep the Play
"What's new" within **500 characters** and framed around the
release's themes, not a feature dump.
#### Scrub for public distribution
This is a **public repo** and these four files are user-facing. Before
promoting the `[Unreleased]` block and writing the notes, scrub the
versioned CHANGELOG block and all three release-notes artifacts for
wording that shouldn't ship publicly. The CHANGELOG accumulates in a
dev-log voice during the iteration phase — release-prep is where it
becomes public copy. Check for and remove/rewrite:
- **Personal names / quoted asides** — `git grep -niE "bailey|: \"" CHANGELOG.md`
on the new block. Attribute fixes impersonally ("a user reported"),
not by name. (Author identity already lives in git + the signing cert.)
- **Private infrastructure** — server hostnames/IPs, `~/SYSTEM.md`,
internal deployment names, anything that should stay in the operator's
environment and not the repo. `grep -niE "192\.168|10\.0\.|hermes-host|SYSTEM\.md"`.
(Example IPs like `192.168.1.100` in install docs are fine.)
- **Fork / branch plumbing + internal nicknames** — references to private
fork branches, rollout channels, or in-team incident nicknames read as
internal. Keep the *what changed*, drop the *where we staged it*.
- **Personal example data** — genericize sample profile/agent names to
neutral placeholders so the copy doesn't expose a specific setup.
The goal is that someone who has never seen the repo can read the block
and the release notes and learn only what the software does.
### 3. Build and verify locally
@@ -425,64 +500,100 @@ Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.
which builds, signs, checksums, and creates a GitHub Release. Watch the
run under the **Actions** tab.
Server/Python version files are intentionally not part of an Android app
release unless the server package itself is also being released.
Plugin/Python version files are intentionally not part of an Android app
release unless the plugin package itself is also being released.
### Server / Python package release
### Plugin / Python package release
Use this when Server behavior changes independently of Android app
delivery, for example desktop channel support, bridge routes, pairing
server fixes, voice auth, or packaging changes.
Use this when plugin or relay behavior changes independently of Android app
delivery, for example CLI channel support, bridge routes, pairing server fixes,
voice auth, dashboard plugin UI, or packaging changes.
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for
`plugin-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
Summary and the Added/Changed/Fixed groups from the plugin-relevant bullets in the
promoted `CHANGELOG.md` block, keep the `__VERSION__` token in the Install command
(the workflow substitutes it), and apply the same public-distribution scrub as §2.
```bash
git checkout dev
git pull --ff-only origin dev
bash scripts/bump-server-version.sh 0.6.2
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md
git commit -m "release(server): server-v0.6.2"
bash scripts/bump-plugin-version.sh 0.6.2
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md PLUGIN_RELEASE_NOTES.md
git commit -m "release(plugin): plugin-v0.6.2"
git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff.
# After merge, tag from the new main tip:
git checkout main
git pull --ff-only origin main
git tag server-v0.6.2
git push origin server-v0.6.2
git tag plugin-v0.6.2
git push origin plugin-v0.6.2
```
Pushing `server-v*` triggers `.github/workflows/release-server.yml`, which
validates all server-owned version metadata with
`scripts/check-server-version-sync.py`, runs server tests, builds a wheel and
sdist, generates `SHA256SUMS.txt`, and creates a GitHub Release for the server
package.
Pushing `plugin-v*` triggers `.github/workflows/release-plugin.yml`, which
validates all plugin-owned version metadata with
`scripts/check-plugin-version-sync.py`. Run
`python scripts/check-version-tracks.py` locally before tagging when a change
touches more than one release surface. The workflow also runs plugin tests,
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
Release named `Hermes-Relay-Plugin v<version>` for the plugin package.
### 5. Upload to Play Console
**Manual upload (default):**
> **If `PLAY_SERVICE_ACCOUNT_JSON` is configured as a repo secret, this step is
> automated for stable tags.** The release workflow runs
> `publishGooglePlayReleaseBundle --track=production` and the build appears as a
> Production **draft** — skip to the Play Console, confirm the draft, and click
> **Start rollout**. The manual path below is the fallback when the secret is
> unset (or for staging on a non-production track).
>
> This automated tag path is intentionally bundle-only. It uploads the
> `googlePlayRelease` AAB and release-scoped "What's new" notes, but it does
> not republish static listing assets such as screenshots, title, description,
> icon, or feature graphic. Use the Play Store Listing workflow when those
> assets change.
**Pick the track first.** The AAB is track-agnostic — the same
`-googlePlay-release.aab` goes to whichever track you publish on. Choose by intent,
not habit:
- **Production** — the default for a stable GA release (`android-vX.Y.Z`). The
listing is live, so this is where real releases land. The org account is
D-U-N-S-verified, so the 14-day / 12-tester closed-testing gate does **not**
apply — you can publish straight to Production.
- **Open / Closed testing** — only when you actually want a public/private beta
channel for this build.
- **Internal testing** — only for a throwaway pre-release smoke check (e.g. a
prerelease tag), not for a GA. Don't default here.
**Manual upload:**
1. Download the file ending in `-googlePlay-release.aab` from the GitHub
Release assets (for example, `hermes-relay-0.3.0-googlePlay-release.aab`),
Release assets (for example, `hermes-relay-1.0.0-googlePlay-release.aab`),
or use your local build at
`app\build\outputs\bundle\googlePlayRelease\hermes-relay-<version>-googlePlay-release.aab`.
2. In Play Console: **Release > Testing > Internal testing** (the 14-day
closed-testing rule does NOT apply to this account — see "Google Play
Console developer account" above).
2. In Play Console, open the track you chose above — for a GA that's
**Release > Production**.
3. **Create new release** > upload the AAB.
4. Paste `RELEASE_NOTES.md` into the release notes field.
5. **Review release** > **Start rollout.**
4. Paste the Play "What's new" from `docs/play-store-listing.md` (≤500 chars) into
the release notes field. (`RELEASE_NOTES.md` is the GitHub-Release body, not the
Play field — don't paste that; it's over the limit.)
5. **Review release** > **Start rollout** (set the staged-rollout percentage if you
want a gradual production ramp).
**Automated upload (if `play-service-account.json` is configured):**
```bat
scripts\dev.bat bundle
gradlew publishReleaseBundle
gradlew publishReleaseBundle --track=production
```
Defaults to the `internal` track with `DRAFT` status (configured in the
`play { }` block in `app/build.gradle.kts`). Override per-invocation with
`--track=alpha` (= Closed testing), `--track=beta` (= Open testing), or
`--track=production`.
The `play { }` block in `app/build.gradle.kts` defaults to the `internal` track
with `DRAFT` status as a safety net for unattended runs, so pass `--track` explicitly
for a real release: `--track=production` (GA), or `--track=alpha` (Closed) /
`--track=beta` (Open) for a beta channel.
To promote an existing release between tracks without rebuilding:
@@ -490,18 +601,24 @@ To promote an existing release between tracks without rebuilding:
gradlew promoteReleaseArtifact --from-track=internal --promote-track=alpha
```
### 6. Promote through tracks
### 6. Tracks (a menu, not a mandatory ladder)
Typical path:
The org account is exempt from the 14-day / 12-tester closed-testing rule, so a
stable GA publishes **straight to Production** — there is no required promotion
chain. The other tracks are opt-in tools, not steps you must climb:
1. **Internal testing** — personal smoke test (no tester or time minimum)
2. **Closed testing (alpha)** — optional for staged rollout; Axiom-Labs'
org account is exempt from the 14-day / 12-tester rule, so you can skip
straight from Internal to Production if the build is ready
3. **Open testing (beta)** — optional public beta
4. **Production** — live on the Play Store
- **Production** — live on the Play Store. Where GA releases go.
- **Open testing (beta)** — opt-in public beta channel.
- **Closed testing (alpha)** — opt-in private beta (named tester lists).
- **Internal testing** — throwaway smoke check (e.g. a prerelease tag), no tester
or time minimum.
Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
If you *do* stage through tracks, promote an existing release without rebuilding via
the Play Console UI or:
```bat
gradlew promoteReleaseArtifact --from-track=internal --promote-track=production
```
### 7. After release
@@ -522,9 +639,9 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
## CI Behavior
Android, Server, dashboard, and desktop now have separate CI/release lanes.
Android, Plugin, dashboard, and desktop now have separate CI/release lanes.
This keeps a dashboard CSS fix from running the full server suite, and keeps
server changes from forcing an Android app `versionCode` bump.
plugin changes from forcing an Android app `versionCode` bump.
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
@@ -544,20 +661,25 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
that the artifacts are debug-signed and unsuitable for Play Store.
On every push of a tag matching `server-v*`,
`.github/workflows/release-server.yml`:
On every push of a tag matching `plugin-v*`,
`.github/workflows/release-plugin.yml`:
1. Validates the tag matches all server-owned version metadata checked by
`scripts/check-server-version-sync.py`.
2. Runs server syntax checks and the focused route/auth/session test slice.
1. Validates the tag matches all plugin-owned version metadata checked by
`scripts/check-plugin-version-sync.py`.
2. Runs plugin syntax checks and the focused route/auth/session test slice.
3. Builds the Python wheel and sdist with `python -m build`.
4. Generates `dist/SHA256SUMS.txt`.
5. Creates a GitHub Release named `Hermes-Relay-Server v<version>` with the wheel,
5. Creates a GitHub Release named `Hermes-Relay-Plugin v<version>` with the wheel,
sdist, and checksum file attached.
On every push of a tag matching `desktop-v*`,
`.github/workflows/release-desktop.yml` builds and publishes the desktop
CLI binaries. Dashboard-only changes are covered by
On every push of a tag matching `cli-v*`,
`.github/workflows/release-cli.yml` builds and publishes the CLI binaries and
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
substitutes `__VERSION__` (bare, e.g. `0.3.0`) and `__TAG__` (full, e.g.
`cli-v0.3.0`) so the install/pin commands stay accurate. Fill its Summary and
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
Dashboard-only changes are covered by
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
runs the dashboard API tests, and verifies the modal CSS markers are present
in the built bundle.
@@ -570,6 +692,13 @@ in the built bundle.
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
| `HERMES_KEY_ALIAS` | Key alias | Alias set during `keytool -genkey` |
| `HERMES_KEY_PASSWORD` | Key password | Usually the same as the store password |
| `PLAY_SERVICE_ACCOUNT_JSON` | **Optional** — Play auto-upload | Paste the full Play Developer API service-account JSON (step 3) |
If `PLAY_SERVICE_ACCOUNT_JSON` is set, the `android-v*` release workflow uploads
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable
tags only — prereleases are skipped). CI does the upload; you still click **Start
rollout** in Play Console. If the secret is unset, the workflow skips the upload
and you upload manually (§5) — nothing else changes.
## Hotfix Recipe
@@ -595,9 +724,9 @@ For an Android app hotfix:
`dev`'s `appVersionCode` lags behind `main` and the next app release
bump collides.
For a Server hotfix, branch from the affected `server-v*` tag, apply
the fix, run `bash scripts/bump-server-version.sh <next-version>`, merge to
`main`, and tag `server-v<next-version>`. Do not touch
For a Plugin hotfix, branch from the affected `plugin-v*` tag, apply
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
`main`, and tag `plugin-v<next-version>`. Do not touch
`gradle/libs.versions.toml` unless an Android app release is also shipping.
## Troubleshooting
+17 -26
View File
@@ -1,44 +1,35 @@
# Unreleased
# Hermes-Relay-Android v1.2.3
## Changed
**Release Date:** June 23, 2026
**Since v1.2.2:** A connection-stability hotfix. Connecting to a server over an **encrypted link** (Tailscale Serve or public HTTPS) could hard-close the app the moment the connection came up; that crash is fixed, so securing your connection no longer force-closes Hermes-Relay.
- Android now defaults to a standard Hermes layout with **Chat**, **Manage**, and **Settings** in bottom navigation. Terminal and Bridge remain available under **Settings → Power tools** and through existing routes.
- Added a native **Manage** surface backed by the Hermes dashboard/admin API for Skills, Cron, MCP servers/catalog, Profiles, Models, and Config. It supports dashboard sign-in, common management actions, cron run details, and read-only profile SOUL details without requiring relay pairing.
- Relay-only features now show a consistent **Requires pairing** / **Pair to unlock** gate when the active connection is not paired.
- Connections now model API auth, dashboard auth, and relay pairing separately. Dashboard URLs derive from the API host on port `9119` by default.
---
# Hermes-Relay-Android v0.8.1
**Release Date:** May 26, 2026
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
v1.2.3 is a focused fix for anyone connecting over Tailscale or public TLS. Plain-LAN connections were never affected.
---
## Download
v0.8.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
v1.2.3 ships in two Android build flavors. APK and AAB filenames are version-tagged:
| Flavor | File | Who it's for |
|---|---|---|
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-0.8.1-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-0.8.1-sideload-release.aab` | Parity/testing artifact. |
| Google Play | `hermes-relay-1.2.3-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
| sideload | `hermes-relay-1.2.3-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-1.2.3-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-1.2.3-sideload-release.aab` | Parity/testing artifact. |
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
---
## Fixed
## Highlights
### Voice mode crash with barge-in on legacy TTS playback
### Fixed
- **No more crash on connect over TLS / Tailscale.** Connecting over an encrypted link (Tailscale Serve or public HTTPS) could force-close the app with a `NetworkOnMainThreadException` as the connection came up — a live SSL socket was being closed on the main thread during client teardown, and a TLS close performs a network write. Socket teardown now always runs off the main thread, so connecting over a secured link is stable. Plain-LAN connections were never affected.
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
---
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
## Upgrade notes
- This is an app-side fix on **both** flavors — no Device Control or server changes needed.
- If you were crashing on connect over Tailscale or HTTPS, update and reconnect.
- `appVersionCode` is **17**.
+3 -3
View File
@@ -14,7 +14,7 @@ Native Android companion for the [Hermes agent platform](https://github.com/Nous
### Desktop track (parallel lane to Android) — **experimental**
Release tags: `desktop-v*` (separate cadence from Android `android-v*` and Server `server-v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
Release tags: `cli-v*` (separate cadence from Android `android-v*` and Plugin `plugin-v*`). Historical alpha prereleases used `desktop-v*`, and the installer/updater keep a migration fallback. Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-cli.yml`](.github/workflows/release-cli.yml).
**Shipped (2026-04-23 — first tagged release `desktop-v0.3.0-alpha.1`):**
@@ -47,11 +47,11 @@ Release tags: `desktop-v*` (separate cadence from Android `android-v*` and Serve
**Earlier alpha.2–alpha.5 workstreams (now in-flight / done — see DEVLOG 2026-04-23 entries for specifics):**
- **`hermes-relay update` subcommand + auto-update nudge.** The binary today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls the GitHub Releases API, filters to `desktop-v*`, compares to `readVersion()`, and either shells out to the installer or downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
- **`hermes-relay update` subcommand + auto-update nudge.** The binary self-update path polls the GitHub Releases API, prefers `cli-v*`, falls back to historical `desktop-v*` prereleases during migration, compares to `readVersion()`, and downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
- **Workspace-awareness — desktop client sends cwd/git/hostname on connect.** Biggest lingering "is the agent working against the right tree?" problem. On WSS auth, the client advertises an ephemeral workspace descriptor — `cwd`, `git_root`, `git_branch`, `git_status_summary` (staged/modified counts), `repo_name`, `hostname`, `platform`, `active_shell`. Server-side `DesktopHandler` stashes it as live session metadata (NOT persistent state). New hermes-agent plugin hook injects a one-line ephemeral prompt prefix into the session context — *"Active desktop workspace: machine=Bailey-PC · repo=hermes-relay · branch=dev · staged=3"* — so the LLM reads it every turn without the operator having to explain. Also default `desktop_terminal` / `desktop_read_file` / `desktop_search_files` `cwd` to the repo root when unset. Expose the snapshot in `hermes-relay doctor` + `hermes-relay status` + a new `hermes-relay workspace` subcommand + a relay dashboard tab so both operator and agent have a common view. Pair with a `.hermes/workspace-context.json` file-based fallback for when the socket path can't be reached. Requires: new WSS envelope (`desktop.workspace` on connect), hermes-agent plugin hook for ephemeral context injection, schema coordination with the upstream `ContextVar` multi-client work.
- **Service installers** — `scripts/install-service-{win,linux,mac}.{ps1,sh}` — Windows Service via `sc.exe create`, `systemd --user` unit with `loginctl enable-linger`, `launchctl load` plist for macOS. Auto-start on login so the daemon is always reachable.
- **Multi-client routing on the `desktop` channel** — replace single-client MVP with per-token indexing + device-id reconnect handoff. Hermes session state carries `desktop_session_token` via a new `ContextVar` in `gateway/session_context.py` (hermes-agent PR candidate — won't affect Android). Natural pairing with the workspace-awareness envelope — the ContextVar scheme determines which client's workspace the active session sees.
- **Harden `release-desktop.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
- **Harden `release-cli.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
- **Signed binaries** — Windows EV code-signing (~$300/yr, DigiCert or SSL.com) + Apple Developer ID + notarization ($99/yr). Removes SmartScreen/Gatekeeper warnings. Prerequisite for the auto-update path.
- **npm registry publication** — future v1.0 distribution work. The package name is local workspace metadata today; current install paths are GitHub Release binaries or local clone + `npm link`.
- **HMAC verification on QR payloads** — defer until a client-accessible secret story exists (same deferral as the Android app). Not blocking GA.
+91
View File
@@ -0,0 +1,91 @@
# Security Policy
Hermes-Relay can give a remote AI agent real control of a phone and, via the
CLI, of a paired desktop. We take security reports seriously and welcome
responsible disclosure.
For the architecture, threat model, and the `googlePlay` vs. `sideload`
capability boundary, see [`docs/security.md`](docs/security.md). This document
covers **how to report a problem**.
## Reporting a Vulnerability
**Please do not open a public issue, discussion, or pull request for a security
vulnerability.** Public reports expose users before a fix is available.
Use one of these private channels instead:
1. **GitHub Private Vulnerability Reporting (preferred).** Go to the
repository's **Security** tab → **Report a vulnerability**, or
[open a draft advisory directly](https://github.com/Codename-11/hermes-relay/security/advisories/new).
This keeps the whole exchange private and threaded with the code.
2. **Email** — `security@codename-11.dev`. Use this if you can't use GitHub.
If you'd like to encrypt the report, say so in a first contact message and
we'll arrange a key.
### What to include
A good report lets us reproduce and assess impact quickly:
- The affected surface — **Android app** (and which flavor, `googlePlay` or
`sideload`), **relay plugin / server**, **desktop CLI**, or the **docs site**.
- Affected version(s) — app version/code, plugin version, or CLI version.
- A clear description of the issue and its security impact.
- Step-by-step reproduction, a proof of concept, or a minimal example.
- Any suggested remediation, if you have one.
> ⚠️ **Scrub secrets before sending.** Remove API keys, relay session tokens,
> pairing codes, real hostnames/IPs, and personal data from logs, traces, and
> screenshots.
## What to Expect
This is an indie, open-source project, so timelines are best-effort rather than
contractual:
- **Acknowledgement** of your report — typically within **5 business days**.
- An initial **assessment and severity triage** after we can reproduce it.
- **Coordinated disclosure:** we'll work with you on a fix and a disclosure
timeline, and credit you in the advisory and release notes if you'd like
(or keep you anonymous if you prefer).
- A public GitHub Security Advisory and a `CHANGELOG.md` entry once a fix ships.
## Scope
**In scope** — vulnerabilities in code this project ships:
- The Android app (`app/`) on either flavor.
- The relay plugin and server (`plugin/`).
- The desktop CLI (`desktop/`).
- The pairing, auth, transport, media, and tool-routing surfaces.
**Out of scope** — please report these to the right place instead:
- **Your own Hermes server configuration** (missing TLS, an exposed dashboard,
weak provider keys). The relay connects only to endpoints you configure; how
you deploy and secure your Hermes host is outside this app. See
[`docs/security.md`](docs/security.md) and the relay-server docs for hardening
guidance.
- **Upstream [hermes-agent](https://github.com/NousResearch/hermes-agent)**
issues — report those to the upstream project (a heads-up to us is welcome if
it affects how Hermes-Relay should behave).
- **Third-party dependencies** — report upstream; if a dependency issue affects
Hermes-Relay users, tell us so we can pin or patch.
- Findings that require a **rooted device, a physical-access attacker, or a
malicious app already granted Accessibility/overlay permissions** — these are
outside the model documented in `docs/security.md`, though we'll still read
the report.
## Safe Harbor
We consider security research conducted in good faith under this policy to be
authorized. We will not pursue or support legal action against researchers who:
- Make a good-faith effort to avoid privacy violations, data destruction, and
service disruption.
- Test only against **their own devices, installs, and Hermes servers** — never
another person's data or infrastructure.
- Report promptly and give us a reasonable chance to remediate before any
public disclosure.
Thank you for helping keep Hermes-Relay and its users safe.
+186 -40
View File
@@ -6,60 +6,147 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## User-Added:
- [x] **Clean-chat: taller scrollable text viewport** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Replaced the fragile `screenHeightDp*0.34f` cap with a weight split (sphere `weight(1f)` / flow `weight(1.1f)` ≈ 52% of the vertical slack); kept the internal scroll + top-fade + `min=96.dp` floor. `AgentTextFlow.kt` (`1dca285`).
- [ ] Verify profile selection retains voice config selections in all voice modes/configuration combinations - enhance UI/configurability/management for this.
- [x] **Session delete on a non-default profile now persists** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Root cause: a non-default profile's sessions live in that profile's own `state.db`, but the delete went through the unscoped api_server `DELETE /api/sessions/{id}` (shared DB) so the row survived and the next profile-scoped list resurrected it. Fix routes gateway deletes through the dashboard profile-scoped surface (write twin of the list path) + `refreshSessions()` after success. `DashboardApiClient`/`ConnectionViewModel`/`ChatViewModel`/`RelayApp` (`6552566`).
- [x] **Voice-settings profile override in 'auto' mode** *(impl 2026-06-21, orchestration batch — unbuilt; verify in Studio. See DEVLOG + "Orchestration batch (2026-06-21)" below.)* Root cause: `VoiceViewModel.shouldPreferRealtimeVoice()` gated on `.route` (configured) not `.effectiveRoute` (resolved), so 'auto'+relay never engaged the override-capable relay path and fell back to host-global Standard `/api/audio/speak` (no override slot). Fixed + wired `connectionId` for per-profile voice-prefs namespacing. Original note: *Look into the voice-settings profile specific capabilities - in 'auto' mode the user-override voice wasn't applied (system default used) despite being displayed; only 'Relay' applied it.*
- [x] **Analytics + Diagnostics overhaul** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Diagnostics is now a full-screen `DiagnosticsScreen` (new `Screen.Diagnostics` route, replacing the modal sheet) led by a vertical status-check timeline — Network, API server, capabilities, chat transport, pairing/auth, relay, voice — each a green/amber/red/gray dot on a connecting rail with an inline failure reason; checks backed by a logged error are tappable into `DiagnosticDetailDialog`. Derived read-only from existing `ConnectionViewModel` flows + recent `DiagnosticsLog` via a pure `buildStatusChecks()`; recent-activity log kept below. Analytics hierarchy tidied. `c3098a9`. See follow-ups below.
- [x] **Realtime voice stall + over-chatty status** *(client half impl 2026-06-21, orchestration batch — unbuilt; server half deferred, see below.)* Client now relaxes the 90s idle watchdog on promoted/long runs (5-min backstop kept) and throttles spoken status (≥22s gap, ≤3/turn); realtime waveform now gates on real playback-start. Original note: *Realtime voice mode stalls/times-out when calling a background Hermes task and repeatedly reports status vocally when not necessary.*
- [x] **Connections reframe: "Vanilla/Standard Hermes" → "Hermes"** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* 28 user-facing display strings across 10 connection/voice/permissions files; "Hermes-Relay plugin" → "Relay plugin" where it reads naturally. Display text only — no enum names, sealed types, when-branches, or stored route values touched. `c9fa8f7`.
- [x] **Lock app to a specific profile** *(impl 2026-06-21, orchestration batch — unbuilt; verify in Studio.)* Per-connection lock: new `ProfileLockStore`, `ProfileController` lock flows + enforcement, `ConnectionInfoSheet` collapses the picker to a static "Locked to <name>" row, `SettingsScreen` adds the lock card + dialog (the one surface still listing all profiles). Original note: *Allow locking app to a specific profile, hiding all other profiles except from this setting - cleanly hide profile specific UI elements based on this gate.*
- [x] **Profile icon in the floating voice overlay** *(impl 2026-06-21, orchestration batch — unbuilt.)* `VoiceModeOverlay` header pill now shows the per-profile icon (`LocalAgentIconPath`); sphere/pet stays the fallback.
- [x] **Voice dropdown state mixes + label overflow** *(impl 2026-06-21, orchestration batch — unbuilt.)* Invalid engine/route combos made unreachable (RealtimeAgent disabled without relay, unavailable routes disabled, `coerceAudioRoute` auto-corrects); long dropdown/provider labels get `maxLines=1`+ellipsis. Original note: *Fix the voice dropdown mode toggles to not allow weird state mixes - labels need overflow control to prevent 2 lines or crunching.*
- [x] **Per-profile agent icon + static-image avatar (shipped 2026-06-20 —** `d827e46`**, see DEVLOG).** Per-profile icon: client-side `ProfileIconStore` (per `(connection, profile)`, never sent to Hermes; stores a copied-file path) → small Coil image beside the agent name in `MessageBubble` via `LocalAgentIconPath`; picker is `AgentIconRow` under the local-name row in `ConnectionInfoSheet`. Static image: "Add a pet" accepts a single image (magic-byte detect → one-frame static pet). Scope shipped: small name-adjacent icon only; big avatar stays global. Follow-ups: on-device smoke (import an image as a pet; set a profile icon, confirm it shows by the name + persists across restart); optionally also show the icon in the profile picker.
## Orchestration batch (2026-06-22) — deferred follow-ups
Four User-Added items resolved via a 4-worker orchestration pass (disjoint file ownership, coordinator-serialized commits): clean-chat viewport (`1dca285`), connections reframe (`c9fa8f7`), diagnostics/analytics (`c3098a9`), session-delete fix (`6552566`). Plus a follow-on profile-isolation fix raised mid-session: cold-start session-drawer hydration (`889273a`). **Committed to `dev`, NOT built/linted/verified.** Remaining:
- **Build + lint + on-device verify all five (Studio).** Run `./gradlew lint` and a Studio build before pushing `dev` (workers couldn't run gradle). Then confirm on device: clean-chat shows a noticeably taller text area that scrolls; deleting a session on a *non-default* profile sticks (no resurrection after the drawer re-fetches); the Diagnostics screen renders honest per-check status + failure reasons and opens detail on a failing tappable row; connections/voice/permissions copy reads "Hermes"/"Relay"; **and on a cold start while a non-default profile is selected, the session drawer loads that profile's sessions directly with no flash of the server-default list.**
- **Profile isolation — broader sweep (cold-start race).** The session drawer + restored session context are now gated on `ProfileController.selectionSettled` (`889273a`), so they no longer load the server-default profile before the persisted profile resolves. Other profile-scoped surfaces read the *live* `selectedProfile.value` and self-correct when it resolves but aren't gated: voice prefs (`VoiceViewModel.onProfileChanged` at the `RelayApp` voice effect), `profileDisplayAlias`, `profileIcon`. They re-seed on resolution (no visible content-flash like the drawer), but if any shows a wrong-profile beat on cold start, gate its first use on `profileSelectionSettled` the same way. Also: `selectionSettled`'s decision logic is unit-testable (pure over connId/selected/pending/profiles) — add a `ProfileControllerSettledTest` when convenient.
- **Diagnostics: no live re-probe trigger.** The status checks reflect the *last* probe state (read-only snapshot). A "Re-run checks" button would need `ConnectionViewModel` to expose probe methods — deferred so the diagnostics work didn't have to edit a concurrently-owned VM.
- **Diagnostics: Pass checks lack a last-checked timestamp/duration.** `StatusCheck` carries `timestampMs`/`durationMs`, but the VM doesn't expose probe timing, so passing rows show no "checked Ns ago". Wire when/if the VM surfaces probe timestamps.
- **Connections reframe — out-of-scope occurrences left intentionally.** `ConnectionViewModel.kt`, `VoiceAudioClient.kt`, `VoiceViewModel.kt`, `BridgeCoreScreen.kt`, and `RelayApp.kt` still contain "Standard"/"Vanilla" in code identifiers/log strings; only user-facing display copy was reframed. Revisit if any of those surface to users.
## Orchestration batch (2026-06-21) — deferred follow-ups
Client-side profile-lock + voice fixes (the items marked above) landed via a planning→implementation orchestration pass, **built + deployed to device as 1.2.1 (versionCode 15)**; new unit suite green (36 Kotlin + 11 Python). On-device behaviour verification still pending. Remaining from that batch:
- **Realtime voice: server-side half (Python) — DONE + DEPLOYED 2026-06-21.** `plugin/relay/realtime_agent/broker.py`: `_send_hermes_run_progress` now heartbeats while `session.hermes_task` is unfinished (helper `_should_continue_heartbeat`), closing the 90s stall at the source; spoken-status repeat raised 30s→90s and gated on a *coarse* status change (`_coarse_spoken_status_key` / `_should_repeat_spoken_status`) so tool-message churn no longer re-narrates. `plugin/tests/test_realtime_heartbeat.py` 11/11; `test_realtime_promotion` regression 5/5. Deployed: committed `d1820fb` → pushed to `origin/dev` → server `~/.hermes/hermes-relay` fast-forwarded + `hermes-relay` restarted (active, clean startup) — both client + server halves now live end-to-end (re-pair the phone after the relay restart). Optional follow-up: flip `promotion_enabled` default to True so long runs detach.
- **Voice override on the streaming path (open question).** The `.route`→`.effectiveRoute` fix makes 'auto'+relay engage the override-capable path, but the streaming `/voice/output` renderer reads the relay's server-saved `voice_output:` config, not the UI `enhancedVoice` override. Decide whether the override card should also push to `updateVoiceOutputConfig`, or whether an override should force the basic `/voice/synthesize` path.
- **Per-profile voice on Standard (upstream).** `/api/audio/*` is host-global/text-only; the Standard surface still can't carry a per-request voice. Needs the upstream profile-voice / `/v1/audio/*` PR. Until then the client prefers the relay path; consider surfacing an honest "override needs Relay" state when Standard is the effective surface.
- **Profile lock: ChatScreen glyph + export.** The optional lock glyph on the chat-header avatar was skipped (`ChatScreen.kt` is owned by a concurrent session). Decide whether the per-connection lock belongs in settings export/import (it rides the `profile_selections` DataStore).
- **Unit tests — DONE 2026-06-21 (36/36 pass via `:app:testSideloadDebugUnitTest`).** `ProfileLockStoreTest` (9 — uses an in-memory `DataStore` harness; the file-backed factory hits a Windows write-rename/instance race), `ProfileControllerLockTest` (8, Robolectric), `CoerceAudioRouteTest` (7), `VoiceStatusGatesTest` (12).
- **CHANGELOG.** Add `[Unreleased]` entries (Profile lock → Added; voice override + realtime → Fixed) at build-verify/PR time.
- **On-device verification.** Override applies in 'auto'+relay; realtime survives a &gt;90s background task without stalling and stops over-narrating; Speaking waveform unfolds at first audible frame; profile lock hides pickers + holds on a missing profile; overlay shows the profile icon.
## Hands-free agentic voice backlog
Goal: make Hermes usable for hands-free work without leaving the operator blind
to tool state, safety prompts, or the current task.
- **Waveform output-start sync** — current input waveform timing feels good, but
the agent-output waveform can unfold and begin movement before audible speech
starts. Split "preparing audio" from "speaking audio" in the visual layer, or
gate the unfolded Speaking waveform on the first real playback frame/audio
amplitude. Processing can stay as the folded circular spinner until output is
actually audible.
the agent-output waveform can unfold and begin movement before audible speech
starts. Split "preparing audio" from "speaking audio" in the visual layer, or
gate the unfolded Speaking waveform on the first real playback frame/audio
amplitude. Processing can stay as the folded circular spinner until output is
actually audible.
- **Voice command layer** — reserve local commands that bypass normal agent
routing: "pause", "resume", "stop talking", "cancel", "repeat that", "open
overlay", "return to Hermes", and "new chat". These should work while the
agent is thinking, speaking, or using tools.
routing: "pause", "resume", "stop talking", "cancel", "repeat that", "open
overlay", "return to Hermes", and "new chat". These should work while the
agent is thinking, speaking, or using tools.
- **Spoken tool progress** — when Hermes uses tools, voice mode should speak
short status updates such as "I'm checking the relay logs" or "I found an
error" without waiting for final assistant text. Long tool calls should emit
periodic, low-noise progress updates.
short status updates such as "I'm checking the relay logs" or "I found an
error" without waiting for final assistant text. Long tool calls should emit
periodic, low-noise progress updates.
- **Realtime tool timeline parity** — the voice overlay should render the same
live thinking blocks, streaming assistant text, and tool call progress as the
normal chat surface without requiring exit/reload.
live thinking blocks, streaming assistant text, and tool call progress as the
normal chat surface without requiring exit/reload.
- **Hands-free confirmation flow** — risky actions need first-class spoken and
visual confirmation: "yes", "no", "cancel", "confirm", plus a visible and
audible countdown for destructive actions.
visual confirmation: "yes", "no", "cancel", "confirm", plus a visible and
audible countdown for destructive actions.
- **Voice session memory/status** — add a compact "where are we?" summary for
the current voice task: active objective, last tool result, pending next step,
and whether the agent is waiting on the user.
the current voice task: active objective, last tool result, pending next step,
and whether the agent is waiting on the user.
- **Mode presets** — add presets such as Hands-free, Low latency, Careful tool
mode, and Quiet/visual-only. Hands-free should favor Continuous listening,
spoken tool progress, confirmations, and overlay availability.
mode, and Quiet/visual-only. Hands-free should favor Continuous listening,
spoken tool progress, confirmations, and overlay availability.
- **Barge-in hardening** — keep barge-in experimental until echo/self-recording
is solved. The target path is proper AEC, playback-ducking, and a rule that
output audio can never become a user turn.
is solved. The target path is proper AEC, playback-ducking, and a rule that
output audio can never become a user turn.
- **Audio quality guardrails** — normalize output volume across realtime and
fallback TTS providers, keep pronunciation hints/profile voice tuning, and
measure provider-specific delay, chunk gaps, and tail clipping.
fallback TTS providers, keep pronunciation hints/profile voice tuning, and
measure provider-specific delay, chunk gaps, and tail clipping.
- **Pluggable Realtime Agent media transports** — add an OpenAI-first WebRTC
transport option for Realtime Agent so mobile audio can use provider-native
jitter buffering, interruption, and media handling instead of only relay
WebSocket PCM. Design this as a provider transport interface
(`websocket`, `webrtc`, future `livekit`/SIP-style bridges) so other
realtime providers can opt in without forking the Hermes broker/tool
contract. Hermes must still own tools, memory, confirmations, current data,
and durable transcript state.
transport option for Realtime Agent so mobile audio can use provider-native
jitter buffering, interruption, and media handling instead of only relay
WebSocket PCM. Design this as a provider transport interface
(`websocket`, `webrtc`, future `livekit`/SIP-style bridges) so other
realtime providers can opt in without forking the Hermes broker/tool
contract. Hermes must still own tools, memory, confirmations, current data,
and durable transcript state.
- **Voice engine selector** — implemented as an opt-in experimental Realtime
Agent engine in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
Follow-up work is provider-native turn-taking, richer confirmation handling,
and quality/latency evaluation before promotion beyond Experimental.
Agent engine in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
Follow-up work is provider-native turn-taking, richer confirmation handling,
and quality/latency evaluation before promotion beyond Experimental.
- **Realtime-native Hermes bridge prototype** — first relay-brokered slice
implemented in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
Remaining work: let OpenAI/xAI realtime sessions own more of the live speech
turn while still proxying every tool, confirmation, memory, and Android bridge
action through Hermes/relay safety.
implemented in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
Remaining work: let OpenAI/xAI realtime sessions own more of the live speech
turn while still proxying every tool, confirmation, memory, and Android bridge
action through Hermes/relay safety.
---
@@ -77,7 +164,7 @@ Things to look into:
- **Skill distribution as separate from plugin distribution** — right now skills ride along with the plugin install via `external_dirs`. Should skills be installable independently (e.g. `hermes skill install <git-url>`)? Would that fragment maintenance or improve reuse?
- **Tool registration discoverability** — `android_*` tools register at gateway import time. There's no canonical "list installed plugin tools" API. Would adding one to upstream make sense, or is `gateway tool list` already enough?
- **Versioning + compatibility ranges** — `pip install -e` doesn't enforce version pins between hermes-agent and our plugin. A breaking change in upstream's plugin loader could silently break us. Do we need a `hermes_compat: ">=0.8.0,<1.0.0"` field somewhere?
- **`hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla upstream. This is intentional but feels like a hack. Upstream PR #8556 (`feat/session-api`) will eventually let us delete it — verified 2026-04-15 that its scope covers the full bootstrap surface (sessions, memory, skills, config, available-models). Track that PR's status periodically.
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to #8556. Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
@@ -94,6 +181,65 @@ When the answer becomes clearer, this section becomes either an ADR in `docs/dec
- **Wave 3 voice-bridge multi-turn confirmation** — currently a 5s TTS countdown with cancel; conversational confirmation is the follow-up
- **LLM client wiring for `android_navigate`** — `_default_vision_model` is stubbed; production swap to a real Anthropic/OpenAI vision client
- **Real screenshots of each flavor's a11y permission dialog** — for `user-docs/guide/release-tracks.md`
- **`llms.txt` standard** — explicitly skipped in favor of the `hermes-relay-self-setup` SKILL.md path; revisit if the standard gains traction in the agent ecosystem
- **`markdown-renderer` 0.40.x API update** — pinned at `0.30.0` in `gradle/libs.versions.toml` because 0.40.2 introduced breaking API changes that `app/src/main/kotlin/com/hermesandroid/relay/ui/components/MarkdownContent.kt` hasn't been updated for. Specifically: `markdownColor()` drops `codeText`/`linkText`, `MarkdownCodeBlock`/`MarkdownCodeFence` inner lambdas now take a 3rd `TextStyle` arg, and `MarkdownHighlightedCode`'s 3rd param is now `TextStyle` instead of `Highlights.Builder`. Dependabot auto-merged the bump on 2026-04-13 which silently broke CI; reverted for the v0.3.0 release. Update requires reading the new library API docs and testing in Studio — not a blind fix. Consider adding a dependabot ignore rule for `markdown-renderer` major bumps until this is handled.
- `**llms.txt` standard** — explicitly skipped in favor of the `hermes-relay-self-setup` SKILL.md path; revisit if the standard gains traction in the agent ecosystem
- `**markdown-renderer`/`lifecycle` compileSdk ceiling — RESOLVED via compileSdk 37 (2026-06-22).** `MarkdownContent.kt` is on the 0.4x API, and `markdown-renderer 0.42.0` / `lifecycle 2.11.0` (the Dependabot bumps) require `compileSdk 37`. The project moved to **compileSdk 37** (`206d182`, across app/quest/relay-core/relay-ui; `targetSdk` stays 35), which satisfies them — so the temporary 1.2.2-prep pins (0.41.0 / 2.10.0 on compileSdk 36) were dropped when integrating `origin/dev`. **CLAUDE.md still says "Compile SDK 36" — update it to 37 to match the build.** A Dependabot ignore rule is still worth adding so a future bump that raises the compileSdk floor again fails loudly rather than silently (see next item).
- **Dependabot auto-merge guardrails** — Dependabot merged breaking bumps despite CI failing. Investigate why `.github/workflows/dependabot-auto-merge.yml` isn't gating on CI status, and consider adding an ignore rule for packages we know need manual attention on major bumps (`markdown-renderer`, compose BOM, activity-compose).
---
## Crash reporting + foldable hardening (shipped 2026-06-20)
Triggered by a Play Store review: app "keeps crashing" during setup on a Samsung Galaxy Z Fold7 (Android 16 / SDK 36, version code 13). Shipped: in-app crash capture (`util/CrashReporter.kt` — uncaught handler that persists a report then re-raises so Play vitals still collects; `ui/components/CrashReportDialog.kt` — show-once dialog with Copy + pre-filled GitHub-issue "Report"); QR camera-init hardening (`QrPairingScanner.kt` — try/catch around `ProcessCameraProvider.get()` and `InputImage.fromMediaImage()`, graceful `CameraUnavailableCard` → manual pairing instead of force-close).
Follow-ups:
- **Confirm the actual crash from Play vitals.** Pull the top crash cluster for Galaxy Z Fold7 / version code 13 (Quality → Android vitals → Crashes &amp; ANRs) to verify the camera path is the real cause vs. another setup-path throw. The hardening is correct regardless, but the trace closes the loop.
- **Portrait lock is moot on large screens under SDK 36.** `android:screenOrientation="portrait"` is largely ignored by Android 16's mandatory large-screen orientation override on foldables/tablets. Decide whether to keep the lock (it still applies on phones) or make it conditional; either way it does not *cause* the crash.
- **Foldable camera lifecycle races (from the 2026-06-20 audit, not yet fixed).** `QrPairingScanner` can still hit bind/unbind races on rapid fold/unfold recomposition (the `DisposableEffect` `unbindAll()` vs. an in-flight `addListener` bind), and `mapBoxToViewport` runs on possibly-stale `viewportSizePx` during a fold transition. Not crash-fatal after the try/catch hardening (logged + skipped), but worth a fold-aware guard if foldable adoption grows.
- **Optional: surface crash history in Settings.** The reporter keeps only the most recent crash (`files/crash/last-crash.json`, consumed on view). If repeat-crash diagnosis becomes common, keep a small ring of recent reports + a Settings entry to view/copy them.
---
## Relay enhancement layer + agent-context injection (shipped 2026-06-20 — `docs/plans/2026-06-20-relay-enhancement-layer.md`)
Shipped: `plugin/enhancements/` (registry + fail-open `context_injection` wrap of `AIAgent._build_system_prompt`), the `media-sensitivity` block, `GET /context/injected` audit route, dashboard toggles, client sensitivity re-thread + "Relay context (server-side)" audit section, and the transport-path UI (`ChatTransportStatusBadge` / `RelayStatusStrip` + tier ladder). OFF by default, removable, vanilla-safe.
Follow-ups:
- **Confirm the `AIAgent` seam on the live host before relying on it.** `context_injection._resolve_ai_agent_class()` tries `agent.system_prompt` / `run_agent`. When you flip `RELAY_AGENT_CONTEXT_ENABLED=1`, verify `GET /context/injected` shows the block AND that it actually lands in the prompt (the wrap is fail-open, so a wrong module = inert, not broken). If the class lives elsewhere, widen the module list.
- **Retire the monkey-patch when upstream adds a plugin context hook.** Drop `context_injection` (and migrate to the native hook) the moment hermes-agent ships a first-class system-prompt contributor — same as we retire bootstrap routes for native upstream routes.
- **Incremental bootstrap migration.** Fold the existing `hermes_relay_bootstrap` route-patches into `plugin/enhancements/` per-surface (startup phase) so patching is one surface; don't big-bang the working compat.
- **Structured media channel** — `docs/plans/2026-06-20-structured-media-channel.md` (design only). Replace fragile `MEDIA:`/markdown text markers with a structured channel carrying `sensitive` natively; lead with a relay `relay_send_media(path, sensitive, …)` tool.
- **Gateway voice-ephemeral via the same slot.** The enhancement layer's server-side injection can carry per-turn voice instructions on the gateway (which has no ephemeral `system_message`), letting voice stay on the gateway instead of being forced to SSE. Wire when the voice path is revisited.
---
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
- **A6 — multi-image gallery.** N images in one message → grid + swipe-across viewer (Telegram media-group parity).
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
- **D5 — outbound upload progress.** No per-attachment progress during the 60s gateway PDF-render window.
## Voice overhaul (shipped 2026-06-18 — `docs/plans/2026-06-18-voice-overhaul.md`)
- **Per-profile voice on Standard (upstream PR).** Upstream `/api/profiles/*` has no voice field and `/api/audio/*` is host-global. Long-term: PR a voice section to the profile config + make `/api/audio/*` honor the active/`?profile=` profile. The relay path already carries per-profile voice; ship that first.
- **Wire connectionId for per-profile voice namespacing.** `VoicePreferencesRepository` is scope-aware (`base_connId_profile`), but `RelayApp` passes only the profile *name* to `onProfileChanged`, so `connectionId` is null and keys namespace by profile-only. Wire `setVoicePrefsConnection` to `ConnectionViewModel.activeConnectionId` (in `RelayApp`) so two connections with same-named profiles don't share voice settings.
- **Realtime-PCM waveform output gating.** The basic-TTS output waveform is now Visualizer-accurate (gated on real playback amplitude), but the realtime path gates `outputAudioActive` on `audioSeen` (first decoded PCM bytes) in `VoiceViewModel.handleRealtimeVoiceEvent`, which can still lead audible output by the `RealtimePcmPlayer` start prebuffer. Gate realtime on actual playback-start (head moved) to match the basic-TTS path.
## Chat clean-mode + pets (shipped 2026-06-18 — `docs/plans/2026-06-18-chat-clean-mode-and-pets.md`)
- **Part-A chat polish (optional bundle).** Per-code-block copy + horizontal scroll, visible copy affordance, mid-stream stall feedback, profile/skill-aware empty-state chips, the ~40-flow recomposition hotspot at the top of `ChatScreen`. (Sphere `contentDescription`/reduced-motion was handled by the clean-mode a11y work.)
- **Pet hot-load + in-app add/remove (shipped 2026-06-20).** Pets now live-refresh: an `avatarsRefreshTick` keys the avatar `produceState` in `RelayApp`, and Appearance re-scans `pets/` on open and after in-app import/delete — no app restart. Appearance gained "Add a pet" (SAF `.zip` import via `PetImporter`, zip-slip/zip-bomb guarded + validated through `toAvatar`) and an "Installed pets" list with per-pet remove (`PetLoader.deletePet`, confirm dialog, Sphere fallback). Remaining:
- **Sphere-skin parity.** Skins are still process-scoped + `adb push` only — the live tick and the importer cover pets, not skins. Extend the tick to `loadUserSkins` and add a `.json` skin import if hot-loading/adding skins in-app is wanted.
- `**adb push` into `Android/data` hangs on Samsung scoped storage.** Confirmed: pushing a pet pack to `/sdcard/Android/data/<pkg>/files/pets/` stalls (no bytes written) although `adb shell ls` of the dir works. In-app `.zip` import is the supported path; `/sdcard/Download` pushes fine. Consider softening `docs/pet-spec.md` + user-docs to lead with in-app import over adb.
- **On-device import/delete smoke.** Import `/sdcard/Download/lucy.zip` via Add a pet → confirm Lucy appears, selects, and animates all states; then remove it and confirm the avatar falls back to the Sphere.
- **Pet state-change re-decode can flash one blank frame.** When the agent state switches clips, the first frame of the new clip may briefly be blank during decode; prewarm/hold-last-frame to smooth it. Root cause is the same as the next item: `PetAvatar.Render` re-decodes from disk on every clip change.
- **Pet frame-sequence memory: no cap or downsample (audit 2026-06-19).** `decodeClip` decodes every frame of the selected clip into `List<ImageBitmap>` at full resolution with no `inSampleSize` downscale to the display size and no frame-count/dimension ceiling — a long sequence of large PNGs can use a lot of RAM and a single very large image can OOM `BitmapFactory`. Add `inSampleSize` downsampling to the avatar's draw size and/or a documented hard cap. Spec now warns authors (prefer sprite sheets), but the renderer doesn't enforce it.
- **Pet decoded-clip cache (audit 2026-06-19).** `PetAvatar.Render` keys `produceState` on `clip`, so idle→thinking→speaking→idle within one turn re-runs `BitmapFactory.decodeFile` from disk each transition (repeated I/O + GC churn, and the blank-frame flash above). Add a small per-avatar `Map<SphereState, PetFrames>` decode cache.
- **Pet behavior model — richer state association (spec'd 2026-06-19, `docs/pet-spec.md` "Agent states &amp; pet behavior").** Shipped: the honesty clamp (declared reactivity ∩ `PET_RENDERER_CAPABILITIES`), the friendly `writing` alias, the `**working`/tool-use overlay** (pet-local sub-state from `toolCallBurst`; opt-in `working` clip drives both the swap and the Tools badge), the **one-shot reaction layer** (`greet`/`wake` on appear, `done`/`celebrate` on turn-finish — opt-in, play-once-then-revert, transition-derived; `ONE_SHOT_MAX_MS` backstop), and `**intensity` modulation** (opt-in `reactive.intensity` → live playback speedup ≤1.6× via `rememberUpdatedState`; un-clamps the Activity badge). Voice · Tools · Activity reactivity is now complete. Remaining:
- `**attention` one-shot (only deferred behavior).** A reaction on notification arrival — needs a host event the avatar doesn't yet receive (unlike `greet`/`done`, which ride state transitions). Would plumb a notification edge into `AvatarRenderState` (or a side channel) + a `PetOneShot.Attention`. Low priority: the avatar is rarely on-screen when notifications land (backgrounded) — see the value analysis; revisit only if the avatar becomes an always-on surface (persistent overlay / Quest port).
- **On-device verification (working + one-shots + intensity).** Best seen in clean mode (`AgentTextFlow` feeds `toolCallBurst` + `streamingIntensity` + state transitions). Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Watch for the known clip re-decode flash on each swap (separate TODO — decoded-clip cache).
- **Undecodable-but-present image appears valid (audit 2026-06-19).** A file that exists but isn't a decodable image passes the loader's `isFile` check, so the pet shows in the picker but renders blank. Documented as a caveat; consider a cheap header sniff at load time if false-valid pets become a support issue.
+53 -1
View File
@@ -27,7 +27,7 @@ android {
// and `applicationId` is the runtime install identity; they don't have
// to match.
namespace = "com.hermesandroid.relay"
compileSdk = 36
compileSdk = 37
defaultConfig {
// Axiom-Labs, LLC Play Console listing. Changed from the original
@@ -106,6 +106,19 @@ android {
}
}
// Structural guard: the sideload flavor is distributed via GitHub Releases /
// F-Droid / ADB and must NEVER be uploaded to Play Console (it declares the
// unattended Device Control surface Play forbids). gradle-play-publisher
// generates a publish task per variant, so the aggregate `publishReleaseBundle`
// would otherwise try BOTH flavors. Disabling sideload here means only
// `publishGooglePlayReleaseBundle` can ever reach Play — see the `play { }`
// block below and .github/workflows/release-android.yml.
playConfigs {
register("sideload") {
enabled.set(false)
}
}
buildTypes {
debug {
buildConfigField("boolean", "DEV_MODE", "true")
@@ -166,6 +179,10 @@ android {
// Robolectric (VoicePlayerTest) needs merged Android resources +
// manifest on the unit-test classpath to bootstrap its sandbox.
unitTests.isIncludeAndroidResources = true
// [POC] Roborazzi runs without its Gradle plugin (the plugin needs AGP's
// removed TestedExtension). Force record mode via the test-JVM system
// property the plugin would otherwise inject, so captureRoboImage writes.
unitTests.all { it.systemProperty("roborazzi.test.record", "true") }
}
}
@@ -185,6 +202,17 @@ kotlin {
jvmToolchain(17)
}
// [screenshots] Host-side screenshot tests render MessageBubble -> MarkdownContent,
// whose code-highlighter (dev.snipme.highlights) ships Java-21 bytecode. The build
// toolchain pins test execution to JDK 17, which can't load class-file v65, so run
// unit tests on a 21 JVM. Compile target stays 17; on-device (dexed) is unaffected.
// foojay (settings.gradle.kts) auto-provisions the 21 JDK if absent.
tasks.withType<Test>().configureEach {
javaLauncher.set(
javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(21)) }
)
}
dependencies {
// Compose BOM
val composeBom = platform(libs.compose.bom)
@@ -226,10 +254,23 @@ dependencies {
// Bundled ONNX Silero model (~2.2 MB); pulled from JitPack.
implementation(libs.android.vad.silero)
// Google Play In-App Update — googlePlay flavor ONLY (FLEXIBLE flow).
// Scoped via the `googlePlayImplementation` configuration so it never
// ships in the sideload APK, which updates via the GitHub-releases
// UpdateChecker instead. The `app/src/googlePlay/.../update/` impl
// references AppUpdateManager; the `app/src/sideload/.../update/` impl
// never touches this library.
"googlePlayImplementation"(libs.play.app.update)
"googlePlayImplementation"(libs.play.app.update.ktx)
// Markdown rendering
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer.code)
// Coil 3 — async image loading for generated images in chat
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
// QR Code scanning (ML Kit + CameraX)
implementation(libs.mlkit.barcode)
implementation(libs.camera.core)
@@ -266,8 +307,19 @@ dependencies {
// across priority groups against real local sockets so the behavior we
// validate matches on-device.
testImplementation(libs.okhttp.mockwebserver)
// Konsist — enforces the ADR 34 upstream/relay/shared package fence as a JUnit test
testImplementation(libs.konsist)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
// Renders real composables on the JVM at an exact canvas — no device, no
// status bar, no clipping. See StoreScreenshotTest.
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.43.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.43.1")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
}
@@ -126,7 +126,7 @@ class OnboardingFlowTest {
navigateToPage(4)
composeTestRule
.onNodeWithText("Standard Hermes")
.onNodeWithText("Vanilla Hermes")
.assertIsDisplayed()
}
@@ -135,7 +135,7 @@ class OnboardingFlowTest {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Standard Hermes").performClick()
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
composeTestRule.waitForIdle()
composeTestRule
@@ -151,7 +151,7 @@ class OnboardingFlowTest {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Standard Hermes").performClick()
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
composeTestRule.waitForIdle()
composeTestRule
@@ -172,6 +172,17 @@ class OnboardingFlowTest {
.assertIsDisplayed()
}
@Test
fun powerPage_linksToPermissionReview() {
setOnboardingContent()
navigateToPage(3)
composeTestRule
.onNodeWithText("Review permissions")
.assertIsDisplayed()
.assertIsEnabled()
}
@Test
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
setOnboardingContent()
@@ -0,0 +1,45 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollTo
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
import org.junit.Test
class PermissionsStatusScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun permissionsScreen_showsStandardAndOnDemandRows() {
composeTestRule.setContent {
HermesRelayTheme {
PermissionsStatusScreen(
onBack = {},
onOpenBridge = {},
)
}
}
composeTestRule
.onNodeWithText("Permissions and capabilities")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Chat and Manage")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("No Android runtime permission needed. API/dashboard auth is configured separately.")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Camera")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Microphone")
.performScrollTo()
.assertIsDisplayed()
}
}
@@ -0,0 +1,199 @@
package com.hermesandroid.relay.update
import android.app.Activity
import android.content.Context
import android.util.Log
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.InstallState
import com.google.android.play.core.install.InstallStateUpdatedListener
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* === update (googlePlay flavor): factory ===
*
* Backs [UpdateAvailabilitySource] onto Google Play's In-App Update API,
* FLEXIBLE flow. Mirrors `voice/VoiceBridgeIntentFactory`'s flavor-split
* factory pattern: both flavors export this exact function signature +
* package, so the UI layer has one static call site and no reflection / no
* `#if` gating.
*/
fun createUpdateAvailabilitySource(context: Context): UpdateAvailabilitySource =
PlayUpdateAvailabilitySource(context.applicationContext)
private const val TAG = "PlayUpdate"
/**
* Google Play FLEXIBLE in-app update source.
*
* - [check] queries `AppUpdateManager.appUpdateInfo`. If Play reports
* `UPDATE_AVAILABLE` and FLEXIBLE is allowed, returns [UpdateStatus.Available]
* (or [UpdateStatus.Downloaded] / [UpdateStatus.Downloading] if a previously
* started flexible update is already mid-flight). Anything else →
* [UpdateStatus.UpToDate].
* - [startUpdate] launches Play's FLEXIBLE consent + background download and
* registers an [InstallStateUpdatedListener] so DOWNLOADED is reported back
* asynchronously via [onStatusChanged].
* - [completeUpdate] calls `AppUpdateManager.completeUpdate()` which restarts
* the app to install the staged APK.
*
* Robustness: every Play interaction is wrapped in try/catch. On any failure
* (no Play services, sideloaded "googlePlay" build on an AOSP device, RESULT
* errors) it degrades to [UpdateStatus.UpToDate] / [UpdateStatus.Unsupported]
* — the banner just never shows. Play is never a crash surface.
*/
private class PlayUpdateAvailabilitySource(
private val appContext: Context,
) : UpdateAvailabilitySource {
override var onStatusChanged: ((UpdateStatus) -> Unit)? = null
private val manager: AppUpdateManager? = runCatching {
AppUpdateManagerFactory.create(appContext)
}.getOrNull()
/** Cached label/code from the last [check] so async listener events can label themselves. */
@Volatile private var lastVersionCode: Long? = null
private val installListener = InstallStateUpdatedListener { state: InstallState ->
when (state.installStatus()) {
InstallStatus.DOWNLOADING ->
onStatusChanged?.invoke(
UpdateStatus.Downloading(
versionLabel = labelFor(lastVersionCode),
versionCode = lastVersionCode,
// bytesDownloaded()/totalBytesToDownload() are base
// app-update InstallState methods (Long); no ktx import.
bytesDownloaded = state.bytesDownloaded(),
totalBytes = state.totalBytesToDownload(),
)
)
InstallStatus.DOWNLOADED ->
onStatusChanged?.invoke(
UpdateStatus.Downloaded(
versionLabel = labelFor(lastVersionCode),
versionCode = lastVersionCode,
)
)
else -> Unit // INSTALLING / INSTALLED / FAILED / CANCELED → no banner change
}
}
@Volatile private var listenerRegistered = false
override suspend fun check(): UpdateStatus {
val mgr = manager ?: return UpdateStatus.Unsupported
return try {
val info = mgr.awaitAppUpdateInfo()
lastVersionCode = info.availableVersionCode().toLong()
when {
// A previously started FLEXIBLE update already finished downloading.
info.installStatus() == InstallStatus.DOWNLOADED -> {
ensureListener(mgr)
UpdateStatus.Downloaded(
versionLabel = labelFor(lastVersionCode),
versionCode = lastVersionCode,
)
}
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
info.installStatus() == InstallStatus.DOWNLOADING -> {
ensureListener(mgr)
UpdateStatus.Downloading(
versionLabel = labelFor(lastVersionCode),
versionCode = lastVersionCode,
)
}
info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) ->
UpdateStatus.Available(
versionLabel = labelFor(lastVersionCode),
versionCode = lastVersionCode,
openUrl = null,
)
else -> UpdateStatus.UpToDate
}
} catch (t: Throwable) {
Log.w(TAG, "appUpdateInfo check failed; treating as up-to-date", t)
UpdateStatus.UpToDate
}
}
override fun startUpdate(activity: Activity?): Boolean {
val mgr = manager ?: return false
if (activity == null) return false
return try {
ensureListener(mgr)
mgr.appUpdateInfo
.addOnSuccessListener { info: AppUpdateInfo ->
val canStart = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
val resuming = info.updateAvailability() ==
UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
if (canStart || resuming) {
runCatching {
mgr.startUpdateFlow(
info,
activity,
AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(),
)
}.onFailure { Log.w(TAG, "startUpdateFlow failed", it) }
}
}
.addOnFailureListener { Log.w(TAG, "startUpdate appUpdateInfo failed", it) }
true
} catch (t: Throwable) {
Log.w(TAG, "startUpdate failed", t)
false
}
}
override fun completeUpdate() {
val mgr = manager ?: return
runCatching { mgr.completeUpdate() }
.onFailure { Log.w(TAG, "completeUpdate failed", it) }
}
override fun dispose() {
val mgr = manager ?: return
if (listenerRegistered) {
runCatching { mgr.unregisterListener(installListener) }
listenerRegistered = false
}
onStatusChanged = null
}
private fun ensureListener(mgr: AppUpdateManager) {
if (!listenerRegistered) {
runCatching { mgr.registerListener(installListener) }
.onSuccess { listenerRegistered = true }
.onFailure { Log.w(TAG, "registerListener failed", it) }
}
}
// Play exposes only the numeric versionCode, not a marketing version
// string, so the banner copy stays generic ("A new version"). The code is
// still carried on the status for per-version dismissal keying.
private fun labelFor(@Suppress("UNUSED_PARAMETER") code: Long?): String = "A new version"
}
// === END update (googlePlay) ===
/**
* `await()` for Play's [AppUpdateInfo] task without pulling in
* `kotlinx-coroutines-play-services`. Named `await…` (not the ktx
* `requestAppUpdateInfo`) to avoid any overload ambiguity with the
* `app-update-ktx` suspend extension. Resumable + cancels cleanly if the
* coroutine is torn down.
*/
private suspend fun AppUpdateManager.awaitAppUpdateInfo(): AppUpdateInfo =
suspendCancellableCoroutine { cont ->
appUpdateInfo
.addOnSuccessListener { info -> if (cont.isActive) cont.resume(info) }
.addOnFailureListener { e -> if (cont.isActive) cont.cancel(e) }
}
@@ -1,8 +1,8 @@
package com.hermesandroid.relay.voice
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.handlers.LocalDispatchResult
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.shared.LocalDispatchResult
import com.hermesandroid.relay.network.relay.models.Envelope
/**
* Local in-process bridge dispatcher type. The Play flavor never invokes
@@ -0,0 +1 @@
en-US
@@ -0,0 +1,62 @@
Hermes-Relay is the native Android client for the Hermes agent platform. Point it at your own Hermes instance and chat with your agent, talk to it hands-free, and manage models, keys, skills, and profiles from anywhere.
It is not a hosted AI service. It is a companion app for the Hermes agent you run, and it talks only to the instances you configure.
QUICK START
1. Run hermes-agent with its API server and dashboard enabled on your computer or home server.
2. Install Hermes-Relay and enter your server address, for example http://192.168.1.100:8642.
3. The setup wizard checks what your server supports and shows a readiness card, then you are ready to chat.
A plain Hermes install is enough. Chat, management, and voice work with no plugin or extra service.
HOW IT WORKS
Chat streams directly from your Hermes API Server or dashboard gateway in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
GOOGLE PLAY BUILD
The Google Play build ships Hermes Bridge Core only. It has no AccessibilityService Device Control: it cannot read your screen, tap, type, swipe, screenshot, send SMS, place calls, or access contacts or location. Device Control is reserved for sideload builds distributed outside Google Play.
FEATURES
- Streaming Chat: real-time responses with reasoning, markdown, tool-call visibility, attachments, mid-turn steering, edit-and-resend, and a searchable command palette.
- Manage Your Agent: use your Hermes dashboard from your phone to switch models, manage provider keys, edit profiles, and browse, install, and update skills.
- Voice Mode: talk hands-free using your server's speech providers. Relay-paired setups add per-profile voices and an experimental realtime engine.
- Works Away From Home: add LAN, Tailscale, or public routes and the app chooses the best available path on connect.
- Sessions: create, switch, rename, and delete chats. Message history loads on demand.
- Multiple Servers and Profiles: connect to more than one server and switch in a tap; overlay an agent profile or personality per conversation.
- Relay Power Tools: optional QR pairing for remote terminal, relay-session management, media handoff, and per-feature grants.
- Notification Companion: optionally forward notification metadata to your paired relay so your assistant can summarize it. Toggle it anytime in system settings.
- Stats for Nerds: local-only counters for response timing, token usage, cost, and stream health.
- Material You: Material 3 dynamic color, light/dark/system themes, and haptics.
SECURITY AND PRIVACY
- API keys and relay tokens are stored in encrypted Android storage.
- HTTPS is enforced for remote connections; cleartext is limited to localhost or LAN setups.
- No telemetry, ads, tracking, or third-party analytics SDKs.
- Notification access and the microphone are optional and user-controlled.
- All app traffic goes only to servers you configure.
REQUIREMENTS
- Android 8.0 or later.
- A running Hermes agent for chat, management, and voice.
- Optional Hermes relay service for power tools such as terminal, notifications, and media.
- Network access to your server by local network, VPN, or internet.
OPEN SOURCE
Hermes-Relay is MIT licensed. Source, docs, and issue tracking are on GitHub.
This app is a community project and is not affiliated with or endorsed by NousResearch.
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

@@ -0,0 +1 @@
Your Hermes AI agent, in your pocket - chat, voice, and control.
@@ -0,0 +1 @@
Hermes-Relay
@@ -0,0 +1,3 @@
v1.2.3 — Connection crash fix.
• Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS). Connecting over a secured connection is now stable. Plain local-network connections were never affected.
+31 -1
View File
@@ -1,11 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Turn-complete chat notification (TurnCompleteNotifier) — runtime-requested
on API 33+ from the Chat Settings toggle. Lives in main (not just the
sideload overlay) so the googlePlay flavor can notify too. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Opt-in "Keep connected in background" (GatewayKeepAliveService). In main
(not the sideload overlay) so the googlePlay flavor ships it too — the
Home-Assistant-class persistent-connection use case Play permits. The
specialUse type requires a one-time Play Console foreground-service
declaration at submission. (Also already present in the sideload overlay
for the device-control bridge service; the merger dedups.) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
@@ -23,6 +37,8 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="portrait"
tools:ignore="LockedOrientationActivity"
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.HermesRelay.Splash">
@@ -54,6 +70,20 @@
</service>
<!-- === END PHASE3-notif-listener === -->
<!-- Opt-in "Keep connected in background" — holds the gateway chat
socket open while backgrounded. In main so BOTH flavors ship it
(Home-Assistant-class persistent connection). Off by default; only
runs while the user has explicitly enabled the toggle. specialUse
needs a Play Console foreground-service declaration at submission. -->
<service
android:name=".network.upstream.GatewayKeepAliveService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps the user's chat connection to their Hermes agent open while the app is backgrounded, only when the user has explicitly enabled 'Keep connected in background'." />
</service>
</application>
</manifest>
+181
View File
@@ -0,0 +1,181 @@
{
"versions": [
{
"version": "1.2.3",
"title": "Connection crash fix",
"date": "2026-06-23",
"sections": [
{
"header": "Stability",
"bullets": [
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
]
}
]
},
{
"version": "1.2.2",
"title": "Multi-profile polish",
"date": "2026-06-22",
"sections": [
{
"header": "Profiles that behave",
"bullets": [
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
]
},
{
"header": "Clearer diagnostics",
"bullets": [
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
]
},
{
"header": "Small touches",
"bullets": [
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
"Distraction-free chat mode gives its text a taller, scrollable area."
]
}
]
},
{
"version": "1.2.1",
"title": "Polish & control",
"date": "2026-06-21",
"sections": [
{
"header": "Yours to control",
"bullets": [
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
]
},
{
"header": "Find your way back",
"bullets": [
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
]
},
{
"header": "When something breaks",
"bullets": [
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
]
},
{
"header": "Voice fixes",
"bullets": [
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
"Realtime turns that reach back to Hermes no longer drop with a session error."
]
}
]
},
{
"version": "1.2.0",
"title": "Make it yours",
"date": "2026-06-20",
"sections": [
{
"header": "Personalize",
"bullets": [
"Eight app themes in Settings → Appearance — the Hermes Relay brand plus ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rosé), with light/dark.",
"Swap the agent orb for an animated pet that reacts to what the agent is doing — add, preview, and tune pets right in the app, or generate one from sprite art with the AI authoring kit.",
"Reskin the sphere, and give each agent profile its own icon."
]
},
{
"header": "See what's happening",
"bullets": [
"The chat status strip names the actual streaming path (Gateway, Sessions, Completions, Runs), with a basic→best tier ladder in Chat Settings.",
"Tap the context meter for a \"What the agent sees\" sheet — the exact extra context prepended to your next turn.",
"Voice and Realtime turns are badged in the scrollback."
]
},
{
"header": "Privacy",
"bullets": [
"When paired to the relay, the agent can mark private media and the phone blurs it per your setting — sensitivity stays model-emitted."
]
},
{
"header": "Faster & more reliable",
"bullets": [
"Cold start is about 3× faster, and model/personality/approvals load honestly instead of showing a maybe-wrong value.",
"In-app crash reporting offers a one-tap, pre-filled bug report.",
"QR pairing no longer force-closes on unusual cameras (foldables); fixed crashes opening server images and PDFs; in-chat model picks now apply."
]
},
{
"header": "Voice & terminal",
"bullets": [
"Enhanced voice control for Gemini and xAI providers.",
"Leaner terminal with TUI-correct input and an isolated, tuned tmux."
]
}
]
},
{
"version": "1.1.0",
"title": "Release plumbing & polish",
"date": "2026-06-16",
"sections": [
{
"header": "New",
"bullets": [
"Automated Play Console upload when a release tag ships (a human still starts the rollout).",
"/relay slash commands — status, devices, and pair from any platform — plus a relay-status badge in the dashboard header.",
"The relay plugin prompts for its optional voice-provider keys on install, and a tools-only native install path."
]
},
{
"header": "Improved",
"bullets": [
"Settings overhaul: status pills are now exception-only, Power tools shows a single Plugin active/required/offline badge, and Connections moved to the top.",
"Release names and notes are now split per surface (Android, plugin, CLI)."
]
},
{
"header": "Fixed",
"bullets": [
"No more force-close on connect when the stored credential keyset was corrupt — it now heals in place.",
"The installer works on uv-managed Hermes hosts, and the dashboard relay panel buttons are readable again."
]
}
]
},
{
"version": "1.0.0",
"title": "Stable launch",
"date": "2026-06-14",
"sections": [
{
"header": "Gateway chat with live thinking",
"bullets": [
"Chat can ride the upstream dashboard gateway — the only vanilla-upstream path that streams reasoning live, so the Thinking block and sphere light up during generation. \"Auto\" prefers it and falls back to the SSE endpoints per turn.",
"Desktop parity: native image/PDF/file attachments, mid-turn steering, edit & resend, approval/clarify/sudo/secret cards, live subagent lanes, a context-window meter, server slash commands, and turn-complete notifications.",
"Warm-start and an opt-in Keep connected in background toggle so long-backgrounded conversations resume instantly."
]
},
{
"header": "Agents, Manage & media",
"bullets": [
"Switch agent profiles per conversation — model, SOUL, personality, and skills — with the selection bound to the session, never changing the server default for other clients.",
"Manage parity with the desktop dashboard: change models, manage provider keys, edit profiles and SOUL.md, and browse/install skills.",
"Open and save chat images and attachments — full-screen viewer with pinch-zoom, plus an Open/Share/Save menu."
]
},
{
"header": "Standard path is first-class",
"bullets": [
"Chat, Manage, and voice all work against an unmodified upstream Hermes agent; the relay plugin is now purely additive.",
"Seamless connection UX — LAN↔Tailscale handoffs and reconnects no longer reload the chat, and status shows as in-theme slide-down toasts.",
"Persistent Realtime Agent voice that keeps one session across turns, with long runs promoted to tracked background tasks."
]
}
]
}
]
}
+52 -1
View File
@@ -26,7 +26,9 @@
left: 0;
right: 0;
bottom: 0;
padding: 8px 6px 0 8px;
/* Bottom gap so xterm's last row clears the extra-keys footer
instead of butting flush against it (read as an overlap). */
padding: 8px 6px 8px 8px;
box-sizing: border-box;
}
.xterm .xterm-viewport {
@@ -149,6 +151,18 @@
}
});
// Report scroll position so the host can show a "jump to latest" pill
// while the user is scrolled up into scrollback. atBottom is true when
// the viewport is pinned to the live tail.
const reportScroll = function () {
if (!(window.AndroidBridge && window.AndroidBridge.onScrollPosition)) return;
try {
const buf = term.buffer.active;
window.AndroidBridge.onScrollPosition(buf.viewportY >= buf.baseY);
} catch (_) {}
};
term.onScroll(function () { reportScroll(); });
// ── Inbound: Android → terminal ───────────────────────────────────
// Base64-encoded payloads avoid JS string-escaping headaches when the
// stream contains control characters, raw escape sequences, or bytes
@@ -223,6 +237,13 @@
try { term.focus(); } catch (_) {}
};
// Current xterm selection as plain text ('' when nothing selected).
// Read back via WebView.evaluateJavascript for the toolbar Copy key,
// since long-press copy is unreliable inside an Android WebView.
window.getSelectionText = function () {
try { return term.getSelection() || ''; } catch (_) { return ''; }
};
window.clearTerminal = function () {
try { term.clear(); } catch (_) {}
};
@@ -239,6 +260,36 @@
}
};
// Mode-aware encoder for the on-screen toolbar's special keys
// (arrows / Home / End / Page). Arrows must follow xterm's current
// DECCKM (application cursor keys) mode: when an app like vim, less,
// or readline has requested it, an arrow is SS3-encoded (\eOA) rather
// than CSI (\e[A). The old path always sent CSI from Kotlin, which the
// running TUI could misread. We read term.modes here (where the mode
// actually lives) and route bytes back through onInput so sticky
// modifiers still apply. Page keys are mode-independent.
window.termSendKey = function (name) {
var appCursor = false;
try {
appCursor = !!(term.modes && term.modes.applicationCursorKeysMode);
} catch (_) {}
var p = appCursor ? 'O' : '[';
var map = {
ArrowUp: p + 'A',
ArrowDown: p + 'B',
ArrowRight: p + 'C',
ArrowLeft: p + 'D',
Home: p + 'H',
End: p + 'F',
PageUp: '[5~',
PageDown: '[6~',
};
var seq = map[name];
if (seq && window.AndroidBridge && window.AndroidBridge.onInput) {
window.AndroidBridge.onInput(seq);
}
};
// ── Scroll shims + gesture ────────────────────────────────────────
// xterm.js ships a scrollback buffer (scrollback: 10000 above) but
// has no built-in mobile touch-to-scroll — its input handlers are
+5 -6
View File
@@ -1,7 +1,6 @@
v0.8.1 - Voice mode crash fix
v1.2.3 - Connection crash fix
Voice
* Fixed a crash that could hit voice mode when barge-in was enabled on the
legacy text-to-speech path — the agent's first words no longer cut off
into a crash. Barge-in is opt-in; the Realtime Agent and Voice Output
paths were never affected.
Stability
* Fixed a crash that could close the app right after connecting over an
encrypted link (Tailscale or HTTPS). Securing your connection no longer
force-closes the app. Plain-LAN connections were never affected.
@@ -1,34 +1,37 @@
package com.hermesandroid.relay
import android.app.Application
import android.os.Build
import androidx.compose.ui.ComposeUiFlags
import androidx.compose.ui.ExperimentalComposeUiApi
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.power.WakeLockManager
import com.hermesandroid.relay.util.AppForegroundTracker
import com.hermesandroid.relay.util.CrashReporter
class HermesRelayApp : Application() {
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
@OptIn(ExperimentalComposeUiApi::class)
override fun attachBaseContext(base: android.content.Context?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
}
super.attachBaseContext(base)
}
/**
* Coil's singleton image loader for the whole app. Registering the OkHttp
* network fetcher EXPLICITLY guarantees `http(s)` image URLs (e.g. a
* generated-image link in a chat reply) load, rather than relying on
* artifact auto-registration. Crossfade for a clean fade-in.
*/
override fun newImageLoader(context: PlatformContext): ImageLoader =
ImageLoader.Builder(context)
.components { add(OkHttpNetworkFetcherFactory()) }
.crossfade(true)
.build()
@OptIn(ExperimentalComposeUiApi::class)
override fun onCreate() {
super.onCreate()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// Compose's adaptive refresh-rate hint path on API 35 can emit
// `setRequestedFrameRate frameRate=NaN` from inside AndroidComposeView
// on every draw pass. Disable ARR globally until the upstream fix lands.
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
}
instance = this
// Install the crash handler FIRST so any failure in the rest of app
// init (or anywhere later) is captured and surfaced on next launch.
CrashReporter.install(this)
AppAnalytics.initialize(this)
// A8 — wire the bridge-gesture wake-lock wrapper so
// ActionExecutor.tap/tapText/typeText/swipe/scroll can hold
@@ -19,8 +19,8 @@ import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
import com.hermesandroid.relay.bridge.BridgeForegroundService
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
import com.hermesandroid.relay.ui.RelayApp
import com.hermesandroid.relay.util.ComposeArrWorkaround
import com.hermesandroid.relay.util.NavRouteRequest
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -37,7 +37,7 @@ class MainActivity : ComponentActivity() {
// We do NOT call MediaProjectionHolder directly from here. On Android
// 14+, getMediaProjection() must run from inside a foreground service
// that has already called startForeground(type=mediaProjection), and
// that startForeground call must happen AFTER consent. So we hand the
// that startForeground call must happen AFT consent. So we hand the
// result off to BridgeForegroundService, which:
// 1. Upgrades its FGS type to SPECIAL_USE | MEDIA_PROJECTION
// 2. Calls MediaProjectionHolder.acceptGrantInsideForegroundService
@@ -117,9 +117,6 @@ class MainActivity : ComponentActivity() {
setContent {
RelayApp()
}
window.decorView.post {
ComposeArrWorkaround.disableForViewTree(window.decorView)
}
}
override fun onNewIntent(intent: Intent) {
@@ -142,6 +139,9 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
// Returning to the app clears the one-slot "Hermes finished
// responding" notification — the chat surface is the answer.
TurnCompleteNotifier.cancel(this)
// v0.4.1 — register this activity as the host for
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
// we don't leak the Activity past its lifecycle. The unattended-
@@ -1551,7 +1551,7 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
* googlePlay as a dialer-opener" per the plan.
*
* The destructive-verb confirmation modal is fired in
* [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
* [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
* before we even get here — by the time this method runs, the user
* has explicitly approved the call.
*/
@@ -12,8 +12,8 @@ import android.util.Log
import com.hermesandroid.relay.bridge.BridgeSafetyManager
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.models.Envelope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
*
* # Master enable / disable
*
* The Android system toggle in `Settings → Accessibility → Hermes Relay` is
* The Android system toggle in `Settings → Accessibility → Hermes-Relay` is
* the hard switch — if it's off we never receive events. On top of that the
* user can flip a soft master in Settings (`bridge_master_enabled`); when
* that's false we still run (Android requires it to stay connected) but we
@@ -68,7 +68,7 @@ class HermesAccessibilityService : AccessibilityService() {
* service is not running. Written on [onServiceConnected],
* cleared on [onUnbind] / [onDestroy].
*
* Read by [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
* Read by [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
* and by the Bridge UI screen (bridge-ui) to check live status.
*/
@Volatile
@@ -38,7 +38,13 @@ import kotlin.math.sqrt
* The Visualizer is attached exactly once against the ExoPlayer's
* [ExoPlayer.getAudioSessionId]. There is a known gotcha where re-attaching
* the Visualizer on every track transition invalidates the session id — the
* single-attach lifecycle here sidesteps it entirely.
* single-attach lifecycle here sidesteps it entirely. The single attach is
* triggered by whichever of {playback became live, a real session id landed}
* arrives last, so a late AudioTrack allocation (deep-buffer cold-start) can't
* leave amplitude pinned at 0 for the turn — see [attachVisualizerIfPlaying].
* That promptness matters because the voice overlay gates its output waveform
* on the first real playback-amplitude frame, so the visual follows audible
* speech instead of leading it.
*
* @param context used for [ExoPlayer.Builder]. Application context is fine;
* the player holds no view references.
@@ -109,6 +115,21 @@ class VoicePlayer(
audioSessionId: Int,
) {
cachedAudioSessionId = audioSessionId
// Deep-buffer cold-start guard. On some OEM pipelines the
// AudioTrack — and therefore a real (non-zero) session id —
// isn't allocated until *after* onIsPlayingChanged(true) has
// already fired. In that race the isPlaying-driven attach
// below ran with id == 0, no-oped, and isPlaying will not
// toggle again for the rest of a continuous TTS turn, so the
// Visualizer would never attach and [amplitude] would stay
// pinned at 0 for the whole turn. The output waveform gates
// its unfold on the first real playback-amplitude frame, so a
// never-firing amplitude leaves it stuck in the folded
// processing/spinner shape even though audio is audible.
// Attaching here — the moment a real session id lands while
// playback is already live — makes the first-audible-frame
// signal reliable regardless of when the track allocates.
attachVisualizerIfPlaying()
}
})
exoPlayer.addListener(object : Player.Listener {
@@ -124,11 +145,11 @@ class VoicePlayer(
// runs on the main thread too, so reading the getter here
// is safe and guarantees the cache is warm by the time
// playback is audible (and thus by the time barge-in
// starts its IO reader).
// starts its IO reader). If the id isn't ready yet, the
// analytics callback above re-tries the attach the instant
// it lands (see attachVisualizerIfPlaying).
cachedAudioSessionId = exoPlayer.audioSessionId
if (!visualizerAttached) {
attachVisualizer(cachedAudioSessionId)
}
attachVisualizerIfPlaying()
}
}
@@ -308,6 +329,24 @@ class VoicePlayer(
exoPlayer.release()
}
/**
* Attach the [Visualizer] iff playback is live and we haven't attached for
* this session yet. Idempotent and main-thread-only: both call sites
* ([Player.Listener.onIsPlayingChanged] and the [AnalyticsListener]'s
* `onAudioSessionIdChanged`) are delivered on the player's application
* thread, so the [visualizerAttached] check needs no extra synchronization.
*
* The delegate [attachVisualizer] still no-ops (without latching
* [visualizerAttached]) when the cached session id is 0, which preserves
* the retry: whichever of {isPlaying, valid session id} arrives last drives
* the single attach. This is the cold-start race fix — see the
* `onAudioSessionIdChanged` comment in `init`.
*/
private fun attachVisualizerIfPlaying() {
if (visualizerAttached || !_isPlaying.value) return
attachVisualizer(cachedAudioSessionId)
}
private fun attachVisualizer(audioSessionId: Int) {
if (audioSessionId == 0) {
// ExoPlayer returns 0 before the audio track is allocated; retry
@@ -6,8 +6,8 @@ import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.models.Envelope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.booleanOrNull
@@ -92,6 +93,19 @@ class AuthManager(
* legacy connection intentionally keeps [Connection.LEGACY_TOKEN_STORE_KEY].
*/
private val tokenStoreKey: String? = null,
/**
* When false, [init] skips the eager session-token hydration (and the
* keyset decrypt it forces). Used for the throwaway LEGACY SENTINEL manager
* that `ConnectionViewModel` builds at field-init and replaces as soon as
* the active connection hydrates — decrypting its keyset only to discard it
* is a measured ~600 ms of wasted startup keystore work, and on a device
* whose active connection isn't connection 0 the sentinel's file has no
* token anyway. The real per-connection manager (created via the active
* connection, [eagerHydrate] = true) hydrates normally; the
* `restorePersistedActiveConnectionContext` path even awaits its
* Paired/Failed state. Channel handlers are still registered either way.
*/
private val eagerHydrate: Boolean = true,
) : ChannelMultiplexer.ChannelHandler {
companion object {
@@ -102,6 +116,10 @@ class AuthManager(
private const val KEY_API_KEY = "api_server_key"
private const val HINT_API_KEY_PRESENT = "api_key_present"
private const val KEY_PAIRED_META = "paired_session_meta_json"
// Marker (in the connection-0 token store) recording that the one-shot
// pre-StrongBox `hermes_companion_auth` → `hermes_companion_auth_hw`
// 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 val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
@@ -329,19 +347,29 @@ class AuthManager(
_store?.let { return it }
return storeMutex.withLock {
_store?.let { return it }
withContext(Dispatchers.IO) {
// Multi-connection: [tokenPrefsName] picks the
// EncryptedSharedPreferences filename for the bound
// connection. The legacy sentinel keeps the pre-multi-
// connection install on its original file so the existing
// paired device keeps working with no migration.
val picked: SessionTokenStore =
KeystoreTokenStore.tryCreate(context, tokenPrefsName)
?: LegacyEncryptedPrefsTokenStore(context, tokenPrefsName)
migrateFromLegacyIfNeeded(picked)
_store = picked
picked
val picked = withContext(Dispatchers.IO) {
// One keyset build per file, process-wide (see [SecureStoreCache]).
// The legacy sentinel is deferred (eagerHydrate=false) and the
// dashboard cookie store now shares this same file, so the active
// connection's token keyset is the ONLY one built on the cold-
// start critical path. [tokenPrefsName] picks the file.
//
// The build decrypts its Tink keyset eagerly, so a corrupt file
// can throw AEADBadTagException — KeystoreTokenStore.tryCreate
// degrades to null, the legacy store self-heals in its ctor, and
// a fundamentally broken keystore falls back to InMemory (the app
// stays up; the user re-pairs). See [buildRawTokenStore].
val s = SecureStoreCache.getOrBuild(tokenPrefsName) {
buildRawTokenStore(context, tokenPrefsName)
}
// Migration runs AFTER the (shared) build so the cookie store can
// trigger the build without needing token-migration logic; a
// marker makes it read the legacy file at most once ever.
migrateFromLegacyIfNeeded(s)
s
}
_store = picked
picked
}
}
@@ -353,14 +381,33 @@ class AuthManager(
*/
private fun migrateFromLegacyIfNeeded(picked: SessionTokenStore) {
if (picked is LegacyEncryptedPrefsTokenStore) return
// Multi-connection: only the legacy connection inherits from the pre-
// multi-connection `hermes_companion_auth` file. A freshly-minted
// per-connection store must NOT be seeded from the legacy file or
// Gate on the FILE, not the connection id. Only the legacy connection-0
// file (`hermes_companion_auth_hw`) inherits from the pre-multi-
// connection `hermes_companion_auth` file; a freshly-minted per-
// connection store (`hermes_auth_<id>`) must NOT be seeded from it or
// we'd copy connection 0's token into every new connection.
if (connectionId != CONNECTION_ID_LEGACY) return
//
// Why file-gated rather than `connectionId == CONNECTION_ID_LEGACY`:
// the store build is now cached/deduped across the legacy sentinel and
// the real connection-0 manager, so whichever one builds the file first
// runs this migration. Both share `tokenPrefsName == LEGACY_TOKEN_STORE_KEY`
// but only the sentinel had `connectionId == CONNECTION_ID_LEGACY`, so
// the old id-based gate would skip migration whenever the real manager
// won the race — dropping a pre-StrongBox user's token. The file name is
// the same for both, so gating on it is race-proof.
if (tokenPrefsName != Connection.LEGACY_TOKEN_STORE_KEY) return
// Read the legacy file at most ONCE ever. The build is now cache-shared
// (and the cookie store can trigger it without migrating), so without
// this marker every freshly-rebuilt connection-0 AuthManager would
// re-build the legacy `hermes_companion_auth` keyset just to find it
// already drained — re-introducing the startup cost we just removed.
if (picked.contains(KEY_LEGACY_MIGRATED)) return
val legacy = try {
LegacyEncryptedPrefsTokenStore(context)
} catch (_: Exception) {
// Legacy file unreadable/corrupt — nothing to inherit. Still mark
// done so its keyset isn't rebuilt on every launch.
picked.putString(KEY_LEGACY_MIGRATED, "1")
return
}
@@ -384,6 +431,7 @@ class AuthManager(
// backup copies of the session token lying around.
legacy.clearAll()
}
picked.putString(KEY_LEGACY_MIGRATED, "1")
}
/** Cert pin store — shared across all relay connections. */
@@ -512,24 +560,28 @@ class AuthManager(
// one-line change in [onMessage].
multiplexer.registerHandler("pairing", this)
// Check for existing session token off main thread
scope.launch {
val s = store()
val existingToken = s.getString(KEY_SESSION_TOKEN)
if (existingToken != null) {
_authState.value = AuthState.Paired(existingToken)
_currentPairedSession.value = loadStoredMetadata(existingToken)
Log.i(
TAG,
"init: hydrated existing session_token=${existingToken.take(8)}… " +
"→ authState=Paired (stale-at-startup unless this is a real continuous session)"
)
} else {
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
// Check for existing session token off main thread. Skipped for the
// throwaway sentinel (eagerHydrate=false) so it never pays the keyset
// decrypt for a store that's about to be replaced (see [eagerHydrate]).
if (eagerHydrate) {
scope.launch {
val s = store()
val existingToken = s.getString(KEY_SESSION_TOKEN)
if (existingToken != null) {
_authState.value = AuthState.Paired(existingToken)
_currentPairedSession.value = loadStoredMetadata(existingToken)
Log.i(
TAG,
"init: hydrated existing session_token=${existingToken.take(8)}… " +
"→ authState=Paired (stale-at-startup unless this is a real continuous session)"
)
} else {
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
}
// Converge the plain api-key-present hint with the decrypted
// truth (also repairs a hint that predates legacy migration).
recordApiKeyHint(!s.getString(KEY_API_KEY).isNullOrBlank())
}
// Converge the plain api-key-present hint with the decrypted
// truth (also repairs a hint that predates legacy migration).
recordApiKeyHint(!s.getString(KEY_API_KEY).isNullOrBlank())
}
}
@@ -651,6 +703,18 @@ class AuthManager(
pendingEndpoints = endpoints?.takeIf { it.isNotEmpty() }
}
/**
* Capability negotiation advertised in the first system/auth envelope.
* Older relays ignore this object; newer relays use it to send versioned
* `chat:stream.event` payloads instead of flattening Hermes SSE into text.
*/
private fun JsonObjectBuilder.putRelayClientSupports() {
put("supports", buildJsonObject {
put("typed_stream_events", true)
put("event_schema_version", 1)
})
}
/**
* Send auth envelope when connection is established.
*
@@ -686,6 +750,7 @@ class AuthManager(
}
put("device_id", deviceId)
put("device_name", android.os.Build.MODEL)
putRelayClientSupports()
}
}
else -> {
@@ -701,6 +766,7 @@ class AuthManager(
put("pairing_code", codeToSend)
put("device_id", deviceId)
put("device_name", android.os.Build.MODEL)
putRelayClientSupports()
pendingTtlSeconds?.let { put("ttl_seconds", it) }
pendingGrants?.let { grants ->
val obj = buildJsonObject {
@@ -6,6 +6,44 @@ import android.os.Build
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.util.concurrent.ConcurrentHashMap
/**
* Process-global cache for encrypted stores, keyed by prefs-file name.
*
* `EncryptedSharedPreferences.create()` unwraps a Tink keyset via a KeyStore op
* (~0.6–1 s on StrongBox), and Tink serializes those process-globally — so a
* second build of the SAME file is pure waste (the measured cold-start
* `Long monitor contention … AndroidKeysetManager.build()` with `waiters=1..4`).
*
* Caching by file name means each file's keyset builds ONCE process-wide. The
* cache is **synchronous** ([ConcurrentHashMap.computeIfAbsent], which holds a
* per-key lock so the build runs at most once per file) precisely so the SAME
* instance serves both the suspend token path (callers wrap this in
* [kotlinx.coroutines.Dispatchers.IO]) AND the synchronous OkHttp cookie-jar
* path — which is how the dashboard cookies now ride the connection's
* already-built token keyset instead of building a second one.
*
* The build is ~1 s on StrongBox: call only from IO / OkHttp threads, never the
* main thread.
*/
internal object SecureStoreCache {
private val instances = ConcurrentHashMap<String, SessionTokenStore>()
fun getOrBuild(prefsName: String, build: () -> SessionTokenStore): SessionTokenStore =
instances.computeIfAbsent(prefsName) { build() }
}
/**
* Build the raw encrypted store for [prefsName] — Keystore-backed when possible,
* self-healing legacy fallback, in-memory last resort. No migration. Shared by
* the token store and the dashboard cookie store so a given file always yields
* the SAME backend, via [SecureStoreCache].
*/
internal fun buildRawTokenStore(context: Context, prefsName: String): SessionTokenStore =
KeystoreTokenStore.tryCreate(context, prefsName)
?: runCatching { LegacyEncryptedPrefsTokenStore(context, prefsName) }
.getOrElse { InMemoryTokenStore() }
/**
* Abstraction over the storage backend for the relay session token + API key
@@ -72,9 +110,12 @@ class KeystoreTokenStore private constructor(
) : SessionTokenStore {
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
// file is deleted. Built lazily via [buildPrefs] so the constructor can't
// throw — [tryCreate] still controls the "is this device usable at all"
// decision via its init probe below.
// file is deleted. This field initializer runs [buildPrefs] eagerly, so it
// CAN throw (e.g. AEADBadTagException on a corrupt keyset) — but the
// constructor is private and only reachable via [tryCreate], which wraps
// construction in try/catch and degrades to the legacy store. The
// directly-constructed legacy path self-heals instead; see
// [LegacyEncryptedPrefsTokenStore.buildPrefsResilient].
private var prefs: SharedPreferences = buildPrefs()
private fun buildPrefs(): SharedPreferences {
@@ -257,7 +298,38 @@ class LegacyEncryptedPrefsTokenStore(
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
// file is deleted. See [KeystoreTokenStore.resetPrefs] for the rationale.
private var prefs: SharedPreferences = buildPrefs()
//
// Built via [buildPrefsResilient] so a corrupt keyset can't crash the
// constructor. Unlike [KeystoreTokenStore], this class is `new`-ed
// directly (it's the fallback when KeystoreTokenStore.tryCreate returns
// null, and the migration source), so there's no tryCreate-style guard
// upstream — the healing has to live here.
private var prefs: SharedPreferences = buildPrefsResilient()
/**
* Build the encrypted prefs, healing a corrupted keyset on the way.
*
* [EncryptedSharedPreferences.create] decrypts the Tink keyset eagerly, so
* a stale/corrupt legacy file throws [javax.crypto.AEADBadTagException]
* (AES-GCM tag mismatch) right here in the constructor. This is the classic
* post-upgrade / post-restore failure: the encrypted blob persists but the
* hardware master key it was sealed against is gone or rotated. Delete the
* file and rebuild a fresh keyset against the current master key rather
* than letting the exception escape and force-close the app — the token in
* the unreadable file was lost anyway, so the user simply re-pairs.
*/
private fun buildPrefsResilient(): SharedPreferences =
try {
buildPrefs()
} catch (e: Exception) {
Log.w(TAG, "Initial legacy prefs build failed — wiping corrupted file and rebuilding: ${e.message}")
try {
appContext.deleteSharedPreferences(prefsName)
} catch (e2: Exception) {
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e2.message}")
}
buildPrefs()
}
private fun buildPrefs(): SharedPreferences {
val masterKey = MasterKey.Builder(appContext)
@@ -342,3 +414,24 @@ class LegacyEncryptedPrefsTokenStore(
}
}
}
// ---------------------------------------------------------------------------
// In-memory last-resort implementation
// ---------------------------------------------------------------------------
/**
* Non-persistent [SessionTokenStore]. Used only when BOTH the Keystore and the
* (self-healing) legacy encrypted store fail to construct — i.e. the device's
* AndroidKeystore is so broken it can't even build a fresh key. Tokens live for
* the process lifetime only, so the user re-pairs on the next cold start, but
* the app stays up instead of force-closing. See [AuthManager.store].
*/
class InMemoryTokenStore : SessionTokenStore {
private val map = java.util.concurrent.ConcurrentHashMap<String, String>()
override val hasHardwareBackedStorage: Boolean = false
override fun getString(key: String): String? = map[key]
override fun putString(key: String, value: String) { map[key] = value }
override fun remove(key: String) { map.remove(key) }
override fun contains(key: String): Boolean = map.containsKey(key)
override fun clearAll() { map.clear() }
}
@@ -25,7 +25,6 @@ import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.hermesandroid.relay.ui.components.BridgeStatusOverlayChip
import com.hermesandroid.relay.ui.components.DestructiveVerbConfirmDialog
import com.hermesandroid.relay.util.ComposeArrWorkaround
import java.util.concurrent.ConcurrentHashMap
/**
@@ -158,7 +157,6 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
Log.w(TAG, "addView(chip) failed", it)
return
}
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
chipView = compose
chipUnattended = unattended
}
@@ -227,7 +225,6 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
onResult(false)
return
}
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
activeConfirmations[request.id] = compose
}
@@ -233,7 +233,7 @@ object UnattendedAccessManager {
* Acquire the screen-bright wake lock + opportunistically request
* keyguard dismiss. Synchronous — does not suspend. The caller (
* [com.hermesandroid.relay.accessibility.ActionExecutor] wrapper, or
* [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
* [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
* pre-dispatch hook) holds onto the result and decides whether to
* proceed with the action.
*
@@ -10,35 +10,73 @@ package com.hermesandroid.relay.data
*/
object AgentDisplay {
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
private val GENERIC_MODEL_ALIASES = setOf(
"hermes-agent",
"hermes_agent",
"hermes agent",
)
// Only an EXPLICIT pick drives request/session identity. The advertised
// "default" profile is an alias for server default, so falling back to it
// here would split chat, voice, or session scope.
@Suppress("UNUSED_PARAMETER")
fun effectiveProfile(
selectedProfile: Profile?,
profiles: List<Profile>,
): Profile? = selectedProfile
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
// Display can use the synthetic default profile's metadata without making
// it a request/session override. Verbose SOUL summaries are filtered by
// profileDisplayName below, so this is safe for headers/cards.
fun effectiveDisplayProfile(
selectedProfile: Profile?,
profiles: List<Profile>,
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
// The NAME goes in the name slot. Non-default profiles use their profile
// name first. The synthetic default profile uses its description only when
// that description looks like a concise human agent name ("Victor"), not a
// verbose SOUL summary.
fun profileDisplayName(profile: Profile?): String? {
if (profile == null) return null
if (isServerDefaultAlias(profile.name)) {
return defaultProfileDisplayName(profile)
}
return when {
profile.description.isNotBlank() -> profile.description.trim()
profile.name.isNotBlank() -> titleCase(profile.name.trim())
profile.description.isNotBlank() -> profile.description.trim()
else -> null
}
}
fun defaultProfileDisplayName(profile: Profile?): String? =
profile
?.description
?.trim()
?.takeIf { it.looksLikeConciseAgentName() }
?.let(::titleCase)
fun agentName(
profile: Profile?,
selectedPersonality: String,
defaultPersonality: String,
connectionLabel: String?,
localDisplayAlias: String? = null,
): String {
localDisplayAlias(localDisplayAlias)?.let { return it }
profileDisplayName(profile)?.let { return it }
// "none"/"neutral" are the upstream "cleared overlay" aliases — treat
// them like "default" for identity: fall through to the server default
// (or the base connection identity) rather than rendering the literal
// word as an agent name.
val personalityName = if (
selectedPersonality == "default" &&
isClearedPersonality(selectedPersonality) &&
defaultPersonality.isNotBlank()
) {
defaultPersonality
} else if (isClearedPersonality(selectedPersonality)) {
""
} else {
selectedPersonality
}
@@ -51,16 +89,43 @@ object AgentDisplay {
}
}
/** True for the upstream "clear the overlay" aliases (default == none == neutral). */
fun isClearedPersonality(value: String): Boolean =
value.trim().lowercase() in setOf("default", "none", "neutral", "")
fun personalityLabel(
selectedPersonality: String,
defaultPersonality: String,
): String = when {
// Explicit "none" — show "None" (or the configured default name, if any)
// so the cleared-overlay state is legible in the picker header.
selectedPersonality.trim().lowercase() in setOf("none", "neutral") ->
if (defaultPersonality.isNotBlank()) titleCase(defaultPersonality.trim()) else "None"
selectedPersonality != "default" && selectedPersonality.isNotBlank() ->
titleCase(selectedPersonality.trim())
defaultPersonality.isNotBlank() -> titleCase(defaultPersonality.trim())
else -> "Default"
}
fun displayModelName(model: String?): String? =
model
?.trim()
?.takeIf { it.isNotEmpty() }
?.takeUnless { it.lowercase() in GENERIC_MODEL_ALIASES }
/**
* A model string safe to SEND to the server as a model override or
* `config.set model=…`. Returns null for the generic agent placeholders
* ("hermes-agent", …) which are NOT real models — the server rejects them
* (HTTP 400) and falls back. Null means "send no model; use the server's
* configured default."
*/
fun requestModelName(model: String?): String? =
model
?.trim()
?.takeIf { it.isNotEmpty() }
?.takeUnless { it.lowercase() in GENERIC_MODEL_ALIASES }
fun isServerDefaultAlias(profileName: String?): Boolean =
profileName?.trim()?.equals("default", ignoreCase = true) == true
@@ -78,6 +143,22 @@ object AgentDisplay {
fun profileContextKey(connectionId: String?, profileName: String?): String =
"${connectionId.orEmpty()}::${profileSessionKey(profileName)}"
fun localDisplayAlias(value: String?): String? =
value
?.trim()
?.replace(Regex("\\s+"), " ")
?.takeIf { it.isNotEmpty() }
private fun String.looksLikeConciseAgentName(): Boolean {
if (isBlank() || length > 40 || contains('\n') || contains('\r')) {
return false
}
if (any { it == '.' || it == ':' || it == ';' }) {
return false
}
return trim().split(Regex("\\s+")).size <= 4
}
private fun titleCase(value: String): String =
value.replaceFirstChar { it.uppercase() }
}
@@ -46,7 +46,7 @@ data class ChatMessage(
/**
* Rich content cards emitted by the agent via `CARD:{json}` line
* markers in the text stream. Parsed in
* [com.hermesandroid.relay.network.handlers.ChatHandler.scanForCardMarkers]
* [com.hermesandroid.relay.network.upstream.ChatHandler.scanForCardMarkers]
* and rendered inline by
* [com.hermesandroid.relay.ui.components.HermesCardBubble]. Mirrors
* [attachments]' lifecycle — the marker line is stripped from
@@ -76,7 +76,7 @@ data class ChatMessage(
* The sync builder treats messages with [voiceIntent] != null and
* [VoiceIntentTrace.syncedToServer] == false as the inputs to its
* synthesis pass; on a successful send we flip [VoiceIntentTrace.syncedToServer]
* to true via [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
* to true via [com.hermesandroid.relay.network.upstream.ChatHandler.markVoiceIntentsSynced]
* so they're not re-sent on the next turn.
*/
val voiceIntent: VoiceIntentTrace? = null,
@@ -89,12 +89,33 @@ data class ChatMessage(
* the durable session turn; the provider's spoken summary is UI/runtime
* provenance, not another canonical assistant message.
*/
val realtimeTurn: RealtimeTurnTrace? = null
val realtimeTurn: RealtimeTurnTrace? = null,
/**
* True for bubbles that exist ONLY on the client and have no server-side
* row — slash-command notices, voice-intent traces, the steer echo, gateway
* ask cards, an errored turn the server never persisted, and a provider-only
* (non-Hermes-backed) realtime turn. The post-turn history reload
* ([com.hermesandroid.relay.network.upstream.ChatHandler.loadMessageHistory])
* preserves any client-only message whose id is absent from the reloaded
* server transcript; without the flag those orphans would be silently
* wiped by the reconcile.
*
* Replaces the old id-prefix whitelist (`voice-intent-`/`steer-`/`ask-`/
* `system-notice-`) + "Error"-badge sniffing: each creator now declares its
* own provenance instead of the reconcile having to know every id
* convention. Defaults false so every server-backed message and existing
* call site stays correct.
*
* NOTE: an "Error" badge alone does NOT make a message preservable — a turn
* can error *after* persisting server-side, and that message must still
* reconcile normally. Only [clientOnly] gates orphan preservation.
*/
val clientOnly: Boolean = false,
)
/**
* Structured details about a phone-local voice intent that was dispatched
* in-process via [com.hermesandroid.relay.network.handlers.BridgeCommandHandler.handleLocalCommand].
* in-process via [com.hermesandroid.relay.network.relay.BridgeCommandHandler.handleLocalCommand].
*
* Captured on a [ChatMessage] (id prefix `voice-intent-`) so the next chat
* payload can include synthetic OpenAI-format `assistant` + `tool` message
@@ -123,12 +144,12 @@ data class ChatMessage(
* includes an `error` field.
* @property resultJson Compact JSON object describing the dispatch outcome.
* On success, typically `{"ok":true,...}` with any tool-specific fields
* from [com.hermesandroid.relay.network.handlers.LocalDispatchResult.resultJson].
* from [com.hermesandroid.relay.network.shared.LocalDispatchResult.resultJson].
* On failure, an error envelope including `ok:false`, `error`, optionally
* `error_code`. Stored as a string and rendered verbatim into the
* synthetic `tool`-role message's `content` field.
* @property syncedToServer Idempotency guard. Flipped to true by
* [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
* [com.hermesandroid.relay.network.upstream.ChatHandler.markVoiceIntentsSynced]
* the moment we hand the request payload to the API client. Once true,
* the trace is excluded from future sync passes — the server-side
* session has already absorbed it.
@@ -186,7 +207,23 @@ data class Attachment(
/** Opaque token from `MEDIA:hermes-relay://<token>` — identifies the file on the relay. */
val relayToken: String? = null,
/** content:// URI from the FileProvider once bytes are cached to disk. */
val cachedUri: String? = null
val cachedUri: String? = null,
/**
* Whether this attachment was flagged sensitive (NSFW / spoiler) and should
* render blurred until the user taps to reveal — honored per the user's
* `MediaSettings.blurMode`.
*
* The flag is **model-emitted metadata, never an on-device or relay-side
* classifier** (see `docs/plans/2026-06-18-attachment-experience.md` §C): the
* agent annotates media it surfaces, the relay transports the bit
* authoritatively via the `X-Media-Sensitive` response header, and the
* client merely renders the blur. Populated for inbound attachments from
* [com.hermesandroid.relay.network.relay.RelayHttpClient.FetchedMedia.sensitive]
* when the bytes flip to [AttachmentState.LOADED]. Defaults false so every
* existing outbound/inbound call site stays valid and unflagged media
* renders exactly as before.
*/
val sensitive: Boolean = false
) {
val isImage: Boolean get() = contentType.startsWith("image/")
@@ -238,7 +275,27 @@ data class ToolCall(
val provenance: String? = null,
// Duration tracking
val startedAt: Long = System.currentTimeMillis(),
val completedAt: Long? = null
val completedAt: Long? = null,
/**
* Gateway `tool.generating` pre-start phase — the model is still
* streaming this tool's arguments. Cleared (flipped false) when the
* matching `tool.start` arrives and the call begins executing. Renders
* as the quiet "preparing" state in ToolProgressCard / CompactToolCall
* rather than the active running spinner.
*/
val isGenerating: Boolean = false,
/**
* Subagent lane index from gateway `subagent.*` events (`task_index`).
* Null = top-level tool call, rendered exactly as before. Non-null
* calls are grouped per index into a SubagentLane under the bubble.
*/
val taskIndex: Int? = null,
/**
* Human label for the owning subagent lane — the `subagent.start`
* goal truncated to 60 chars. Carried on each child call so the lane
* header can render without a separate lane registry.
*/
val taskLabel: String? = null
)
enum class MessageRole {
@@ -252,5 +309,16 @@ data class ChatSession(
val title: String?,
val model: String?,
val messageCount: Int = 0,
val updatedAt: Long = 0L
)
val updatedAt: Long = 0L,
val startedAt: Long = 0L,
val lastActivityAt: Long = 0L
) {
val activityTimestamp: Long
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
val startTimestamp: Long
get() = firstPositive(startedAt, updatedAt, lastActivityAt)
private fun firstPositive(vararg values: Long): Long =
values.firstOrNull { it > 0L } ?: 0L
}
@@ -30,7 +30,7 @@ data class DashboardConnectionStatus(
* open the token store.
*
* Switching connection is a HEAVY context swap — caller is expected to tear down
* the current [com.hermesandroid.relay.network.ConnectionManager],
* the current [com.hermesandroid.relay.network.relay.ConnectionManager],
* [com.hermesandroid.relay.auth.AuthManager], and API client, then construct
* fresh ones pointed at the new connection's `tokenStoreKey`.
*
@@ -290,6 +290,8 @@ data class Connection(
role = role.ifBlank { inferRouteRole(apiServerUrl) },
priority = priority,
api = ApiEndpoint(host = host, port = port, tls = tls),
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
?.let { DashboardEndpoint(url = it) },
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
)
}
@@ -100,6 +100,17 @@ class ConnectionStore private constructor(
private val _activeConnectionId = MutableStateFlow<String?>(null)
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
/**
* Flips to `true` once the initial DataStore hydrate completes (success OR
* failure). Until then [connections] / [activeConnection] hold their empty
* seed values, which are indistinguishable from a genuinely empty store.
* Consumers that must not mistake "still loading" for "nothing configured"
* — e.g. the chat empty-state, which would otherwise flash a "Connect to
* Hermes" CTA on every cold start — gate on this instead of on emptiness.
*/
private val _isHydrated = MutableStateFlow(false)
val isHydrated: StateFlow<Boolean> = _isHydrated.asStateFlow()
/**
* Derived: the active connection, or null when the active ID is missing
* or points to a deleted connection. Recomputes every time either
@@ -144,6 +155,11 @@ class ConnectionStore private constructor(
}
} catch (e: Exception) {
Log.w(TAG, "Initial hydrate failed: ${e.message}")
} finally {
// Mark hydration done even on failure — a failed read still
// means "we now know the store's state is empty", so the UI
// should stop showing the neutral loading gate.
_isHydrated.value = true
}
}
}
@@ -8,8 +8,8 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.auth.ConnectionAuthSecrets
import com.hermesandroid.relay.network.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.StoredDashboardCookie
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.StoredDashboardCookie
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -147,6 +147,7 @@ class DataManager(
dashboardCookies = EncryptedDashboardCookieStore(
context = context,
connectionId = connection.id,
tokenStoreKey = connection.tokenStoreKey,
).load().map { it.toBackup() },
)
}
@@ -185,6 +186,7 @@ class DataManager(
EncryptedDashboardCookieStore(
context = context,
connectionId = connection.id,
tokenStoreKey = connection.tokenStoreKey,
).save(secret.dashboardCookies.map { it.toStoredCookie() })
}
}
@@ -40,6 +40,10 @@ data class EndpointCandidate(
val priority: Int = 0,
val api: ApiEndpoint,
val relay: RelayEndpoint,
val dashboard: DashboardEndpoint? = null,
val proxy: ProxyEndpoint? = null,
val security: String? = null,
val recommended: Boolean = false,
)
/**
@@ -61,6 +65,17 @@ data class ApiEndpoint(
get() = "${if (tls) "https" else "http"}://$host:$port"
}
/**
* Dashboard/admin surface for an [EndpointCandidate]. This is optional so
* older v3 payloads that only carried API + Relay endpoints keep
* deserializing; when absent, Android derives the conventional same-host
* `:9119` dashboard URL from [ApiEndpoint].
*/
@Serializable
data class DashboardEndpoint(
val url: String,
)
/**
* The relay-server half of an [EndpointCandidate] — the WSS URL the phone
* opens for the bridge + terminal channels.
@@ -78,6 +93,22 @@ data class RelayEndpoint(
val transportHint: String? = null,
)
/**
* Optional plugin-owned secure proxy route. Unlike [api], [dashboard], and
* [relay], this is one app-facing base that can cover all Hermes-Relay
* supported traffic after pairing. It is deliberately optional so plugin
* proxy support can be advertised by newer payloads without changing the
* standard upstream connection model.
*/
@Serializable
data class ProxyEndpoint(
val url: String,
@SerialName("transport_hint")
val transportHint: String? = null,
@SerialName("pin_sha256")
val pinSha256: String? = null,
)
/**
* Returns true when [EndpointCandidate.role] is one of the built-in, styled
* roles: `lan`, `tailscale`, or `public`. Case-insensitive match — but the
@@ -89,7 +120,7 @@ data class RelayEndpoint(
*/
fun EndpointCandidate.isKnownRole(): Boolean {
return when (role.lowercase()) {
"lan", "tailscale", "public" -> true
"lan", "tailscale", "public", "plugin_proxy", "plugin-proxy", "https" -> true
else -> false
}
}
@@ -106,7 +137,15 @@ fun EndpointCandidate.displayLabel(): String {
return when (role.lowercase()) {
"lan" -> "LAN"
"tailscale" -> "Tailscale"
"public" -> "Public"
"public" -> if (api.tls) "HTTPS" else "Public"
"https" -> "HTTPS"
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
else -> "Custom VPN ($role)"
}
}
fun EndpointCandidate.hasSecureProxy(): Boolean =
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
role.equals("plugin_proxy", ignoreCase = true) ||
role.equals("plugin-proxy", ignoreCase = true)
@@ -0,0 +1,22 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
/**
* Single source of truth for the opt-in "keep the gateway chat connection
* alive in the background" preference. Off by default.
*
* Shared by [com.hermesandroid.relay.viewmodel.ConnectionViewModel] (the
* StateFlow + setter that drive the foreground service and the client's
* no-background-close flag) and
* [com.hermesandroid.relay.network.upstream.GatewayKeepAliveService]'s Stop notification
* action, so both read/write the same key.
*/
val KEY_GATEWAY_KEEP_ALIVE = booleanPreferencesKey("gateway_keep_alive_background")
/** Persist the keep-alive preference. Used by the FGS Stop action. */
suspend fun Context.setGatewayKeepAlive(enabled: Boolean) {
relayDataStore.edit { it[KEY_GATEWAY_KEEP_ALIVE] = enabled }
}
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
/**
* A rich content card emitted inline in an assistant message via the
* `CARD:{json}` line marker. Parsed by
* [com.hermesandroid.relay.network.handlers.ChatHandler] and rendered by
* [com.hermesandroid.relay.network.upstream.ChatHandler] and rendered by
* [com.hermesandroid.relay.ui.components.HermesCardBubble].
*
* The marker lives in the text stream alongside `MEDIA:...` for the same
@@ -53,8 +53,22 @@ data class HermesCard(
* which action (if any) has been dispatched, so the same card reloaded
* from session history doesn't re-prompt. Falls back to the card's
* position in the message when null.
*
* For the gateway ask types this is the ask's `request_id` (or
* `approval-<sid>-<ts>` for approval, which has no request id) — the
* dispatch tracker keys answer-once semantics off it.
*/
val id: String? = null,
/**
* Interactive input slot rendered between [fields] and [actions] —
* the answer surface for the gateway ask cards (`ask.clarify` choice
* chips + free text, `ask.secret` masked field, `ask.sudo`
* hold-to-confirm). Null for every plain card. Submissions flow
* through the renderer's `onInputSubmit(cardKey, value)` callback and
* collapse the card via the same [HermesCardDispatch] list as button
* actions.
*/
val input: HermesCardInput? = null,
) {
object BuiltInTypes {
const val SKILL_RESULT = "skill_result"
@@ -62,6 +76,14 @@ data class HermesCard(
const val LINK_PREVIEW = "link_preview"
const val CALENDAR_EVENT = "calendar_event"
const val WEATHER = "weather"
// Gateway interactive asks (desktop-parity wave). Locally built
// from clarify/approval/sudo/secret request events — never parsed
// out of the text stream.
const val ASK_APPROVAL = "ask.approval"
const val ASK_CLARIFY = "ask.clarify"
const val ASK_SUDO = "ask.sudo"
const val ASK_SECRET = "ask.secret"
}
object Accents {
@@ -72,6 +94,67 @@ data class HermesCard(
}
}
/**
* Interactive input slot on a [HermesCard]. The flags compose rather than
* branch — a sudo ask can be `masked + holdToConfirm` (password field whose
* submit is the 650ms press-fill button), while clarify is
* `choices + allowFreeText` and secret is `masked` alone.
*
* Security contract: when [masked] is true the submitted value is a secret.
* It must never be echoed into chat content, logged, or synced via
* CardDispatchSyncBuilder — record [SECRET_PROVIDED_STAMP] as the dispatch's
* actionValue instead of the real value. The renderer masks the collapse
* stamp for masked inputs regardless, but the dispatch record itself is
* persisted and synced, so the caller must not put the secret there.
*/
@Serializable
data class HermesCardInput(
/**
* Input kind — one of [Kinds]. Drives which composite the renderer
* builds; unknown kinds degrade to a plain free-text field so newer
* asks still get an answer surface.
*/
val kind: String,
/** Quick-answer chips (clarify). Empty = no chip row. */
val choices: List<String> = emptyList(),
/** Render the inline free-text mini field under the chips. */
val allowFreeText: Boolean = false,
/** Password-style field: masked glyphs + reveal toggle (secret/sudo). */
val masked: Boolean = false,
/** Submit is a 650ms hold-to-confirm press-fill instead of a tap (sudo). */
val holdToConfirm: Boolean = false,
/**
* Wall-clock expiry for timed asks (sudo 120s, clarify/secret 300s).
* The renderer shows a countdown footer (Amber under 30s) and
* self-collapses to "Expired — not granted" past it. Null = no timeout
* (approval is session-scoped).
*/
val expiresAtMillis: Long? = null,
) {
object Kinds {
const val CHOICE = "choice"
const val TEXT = "text"
const val SECRET = "secret"
const val CONFIRM = "confirm"
}
companion object {
/**
* Sentinel recorded as [HermesCardDispatch.actionValue] when a
* [masked] input is submitted. The real secret value goes only to
* the ask-respond RPC — never into the dispatch record, chat
* content, or session sync.
*/
const val SECRET_PROVIDED_STAMP = "secret-provided"
/**
* Value submitted by a bare hold-to-confirm (no text field) — the
* sudo/approval "yes" that carries no payload of its own.
*/
const val CONFIRM_VALUE = "confirm"
}
}
/**
* A label/value row inside a card. [value] is rendered as markdown so the
* agent can embed emphasis, inline code, or links.
@@ -117,6 +200,16 @@ data class HermesCardAction(
const val SEND_TEXT = "send_text"
const val SLASH_COMMAND = "slash_command"
const val OPEN_URL = "open_url"
/**
* Ask-card answer: dispatch [value] straight to the gateway
* ask-respond RPC (clarify/sudo/secret/approval.respond), never
* as chat text. Dispatches in this mode are EXCLUDED from
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder] —
* the server already absorbed the answer through the blocking
* ask, and for secrets the value must not enter session memory.
*/
const val SUBMIT_ASK = "submit_ask"
}
}
@@ -133,7 +226,7 @@ data class HermesCardAction(
* (with structured `tool_calls`) + `tool` message pairs under a synthetic
* `hermes_card_action` tool name, splicing them into the session history
* the LLM sees. After the API client takes ownership of the request,
* [com.hermesandroid.relay.network.handlers.ChatHandler.markCardDispatchesSynced]
* [com.hermesandroid.relay.network.upstream.ChatHandler.markCardDispatchesSynced]
* flips [syncedToServer] so subsequent turns don't re-send the same
* trace.
*/
@@ -145,7 +238,7 @@ data class HermesCardDispatch(
/**
* Idempotency guard for the server-side session sync path.
* Flipped to true by
* [com.hermesandroid.relay.network.handlers.ChatHandler.markCardDispatchesSynced]
* [com.hermesandroid.relay.network.upstream.ChatHandler.markCardDispatchesSynced]
* once the API client has accepted the request that carried this
* dispatch's synthetic message pair. Once true, the dispatch is
* excluded from future
@@ -4,9 +4,26 @@ import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* How aggressively inbound media is blurred behind a "tap to reveal" gate.
*
* - [OFF] never blur — show everything immediately.
* - [FLAGGED] blur only media the agent flagged sensitive (the model-emitted
* `X-Media-Sensitive` bit; see
* `docs/plans/2026-06-18-attachment-experience.md` §C). This is
* the product default: zero blur when nothing is flagged.
* - [ALL_IMAGES] blur every inbound image regardless of source. Works on the
* pure standard path with no server support at all.
*
* Persisted by [Enum.name] so adding cases later is forward-safe; an unknown
* stored value decodes back to the default rather than throwing.
*/
enum class BlurMode { OFF, FLAGGED, ALL_IMAGES }
/**
* User-tunable limits for inbound media attachments fetched from the relay.
*
@@ -20,12 +37,17 @@ import kotlinx.coroutines.flow.map
* - [autoFetchOnCellular] master switch: when false, the cellular-network
* case always inserts a manual-download placeholder.
* - [cachedMediaCapMb] LRU cap on the `hermes-media/` cache directory.
* - [blurSensitive] whether (and which) inbound images render behind a
* tap-to-reveal blur — see [BlurMode]. Unlike the four knobs above this one
* also applies on the standard (no-Relay) path, since [BlurMode.ALL_IMAGES]
* needs no server cooperation.
*/
data class MediaSettings(
val maxInboundSizeMb: Int = 25,
val autoFetchThresholdMb: Int = 2,
val autoFetchOnCellular: Boolean = false,
val cachedMediaCapMb: Int = 200
val cachedMediaCapMb: Int = 200,
val blurSensitive: BlurMode = BlurMode.FLAGGED
)
/**
@@ -39,11 +61,18 @@ class MediaSettingsRepository(private val context: Context) {
private val KEY_AUTO_FETCH_THRESHOLD_MB = intPreferencesKey("media_auto_fetch_threshold_mb")
private val KEY_AUTO_FETCH_ON_CELLULAR = booleanPreferencesKey("media_auto_fetch_on_cellular")
private val KEY_CACHED_MEDIA_CAP_MB = intPreferencesKey("media_cached_cap_mb")
private val KEY_BLUR_SENSITIVE = stringPreferencesKey("media_blur_sensitive")
const val DEFAULT_MAX_INBOUND_MB = 25
const val DEFAULT_AUTO_FETCH_THRESHOLD_MB = 2
const val DEFAULT_AUTO_FETCH_ON_CELLULAR = false
const val DEFAULT_CACHED_MEDIA_CAP_MB = 200
val DEFAULT_BLUR_SENSITIVE = BlurMode.FLAGGED
/** Decode a persisted [BlurMode] name, falling back to the default. */
private fun parseBlurMode(raw: String?): BlurMode =
raw?.let { name -> BlurMode.entries.firstOrNull { it.name == name } }
?: DEFAULT_BLUR_SENSITIVE
}
val settings: Flow<MediaSettings> = context.relayDataStore.data.map { prefs ->
@@ -51,10 +80,21 @@ class MediaSettingsRepository(private val context: Context) {
maxInboundSizeMb = prefs[KEY_MAX_INBOUND_MB] ?: DEFAULT_MAX_INBOUND_MB,
autoFetchThresholdMb = prefs[KEY_AUTO_FETCH_THRESHOLD_MB] ?: DEFAULT_AUTO_FETCH_THRESHOLD_MB,
autoFetchOnCellular = prefs[KEY_AUTO_FETCH_ON_CELLULAR] ?: DEFAULT_AUTO_FETCH_ON_CELLULAR,
cachedMediaCapMb = prefs[KEY_CACHED_MEDIA_CAP_MB] ?: DEFAULT_CACHED_MEDIA_CAP_MB
cachedMediaCapMb = prefs[KEY_CACHED_MEDIA_CAP_MB] ?: DEFAULT_CACHED_MEDIA_CAP_MB,
blurSensitive = parseBlurMode(prefs[KEY_BLUR_SENSITIVE])
)
}
/**
* Just the blur knob — a standalone flow so per-bubble UI can observe it
* without collecting (and recomposing on) the whole [MediaSettings].
* Built here (outside composition) on purpose so callers can
* `collectAsState()` it without tripping `FlowOperatorInvokedInComposition`.
*/
val blurMode: Flow<BlurMode> = context.relayDataStore.data.map { prefs ->
parseBlurMode(prefs[KEY_BLUR_SENSITIVE])
}
suspend fun setMaxInboundSize(mb: Int) {
context.relayDataStore.edit { it[KEY_MAX_INBOUND_MB] = mb.coerceAtLeast(1) }
}
@@ -70,4 +110,8 @@ class MediaSettingsRepository(private val context: Context) {
suspend fun setCachedMediaCap(mb: Int) {
context.relayDataStore.edit { it[KEY_CACHED_MEDIA_CAP_MB] = mb.coerceAtLeast(10) }
}
suspend fun setBlurSensitive(mode: BlurMode) {
context.relayDataStore.edit { it[KEY_BLUR_SENSITIVE] = mode.name }
}
}
@@ -0,0 +1,68 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Local-only display aliases for agent profiles.
*
* These names are phone UI labels. They are never sent to Hermes and are keyed
* by connection + profile context so the server-default agent can be called
* something different on each configured Hermes host.
*/
class ProfileDisplayAliasStore(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.profileDisplayAliasesDataStore)
companion object {
private const val PREFIX = "profile_alias__"
private fun keyName(connectionId: String, profileName: String?): String =
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
private fun keyFor(connectionId: String, profileName: String?) =
stringPreferencesKey(keyName(connectionId, profileName))
private fun connectionPrefix(connectionId: String): String =
"$PREFIX${connectionId}__"
}
suspend fun setAlias(connectionId: String, profileName: String?, alias: String?) {
dataStore.edit { prefs ->
val key = keyFor(connectionId, profileName)
if (alias.isNullOrBlank()) {
prefs.remove(key)
} else {
prefs[key] = alias
}
}
}
fun aliasFlow(connectionId: String, profileName: String?): Flow<String?> {
val key = keyFor(connectionId, profileName)
return dataStore.data.map { prefs -> prefs[key] }
}
suspend fun clearConnection(connectionId: String) {
val prefix = connectionPrefix(connectionId)
dataStore.edit { prefs ->
prefs.asMap().keys
.filter { it.name.startsWith(prefix) }
.forEach { prefs.remove(it) }
}
}
suspend fun clearAll() {
dataStore.edit { prefs -> prefs.clear() }
}
}
internal val Context.profileDisplayAliasesDataStore: DataStore<Preferences>
by preferencesDataStore(name = "profile_display_aliases")
@@ -0,0 +1,70 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Local-only per-profile agent icons — the visual twin of [ProfileDisplayAliasStore].
*
* Stores a **file path** to an image that was copied into app storage (not a SAF
* content URI, so it survives without a persistable-permission grant). Like the
* name alias, these are phone-UI labels only: never sent to Hermes, and keyed by
* connection + profile context so the same server-default agent can wear a
* different face on each configured host.
*/
class ProfileIconStore(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.profileIconsDataStore)
companion object {
private const val PREFIX = "profile_icon__"
private fun keyName(connectionId: String, profileName: String?): String =
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
private fun keyFor(connectionId: String, profileName: String?) =
stringPreferencesKey(keyName(connectionId, profileName))
private fun connectionPrefix(connectionId: String): String =
"$PREFIX${connectionId}__"
}
suspend fun setIcon(connectionId: String, profileName: String?, path: String?) {
dataStore.edit { prefs ->
val key = keyFor(connectionId, profileName)
if (path.isNullOrBlank()) {
prefs.remove(key)
} else {
prefs[key] = path
}
}
}
fun iconFlow(connectionId: String, profileName: String?): Flow<String?> {
val key = keyFor(connectionId, profileName)
return dataStore.data.map { prefs -> prefs[key] }
}
suspend fun clearConnection(connectionId: String) {
val prefix = connectionPrefix(connectionId)
dataStore.edit { prefs ->
prefs.asMap().keys
.filter { it.name.startsWith(prefix) }
.forEach { prefs.remove(it) }
}
}
suspend fun clearAll() {
dataStore.edit { prefs -> prefs.clear() }
}
}
internal val Context.profileIconsDataStore: DataStore<Preferences>
by preferencesDataStore(name = "profile_icons")
@@ -0,0 +1,95 @@
package com.hermesandroid.relay.data
import android.content.Context
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
/**
* Per-connection persisted "profile lock" — pins the app to ONE Hermes
* profile so the profile pickers/switchers across the app collapse to a
* single locked state. A dedicated Settings control is the only surface that
* still lists every profile (to choose the lock target or unlock).
*
* Twin of [ProfileSelectionStore]: this deliberately rides the SAME
* [profileSelectionsDataStore] ("profile_selections") so the lock and the
* selection clear and migrate together — a per-connection wipe or a wholesale
* reset takes out both, and there is no second DataStore file to keep in sync.
*
* Value semantics (distinct from "selection", which is just a name or absent):
* - **absent key** → unlocked. The flow emits `null`. This is distinct from
* "locked to Server default", so we can tell "no lock" apart from "lock to
* the server's own default profile".
* - [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY] sentinel → locked to **Server
* default** (the null-profile context). Reusing the existing sentinel keeps
* the server-default identity consistent with [AgentDisplay.profileSessionKey].
* - any other string → locked to that profile `name`.
*
* The caller ([com.hermesandroid.relay.viewmodel.connection.ProfileController])
* resolves the locked name against the current server-advertised profile list;
* if the locked profile no longer exists it HOLDS (selection null) and surfaces
* a banner rather than silently switching.
*/
class ProfileLockStore(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.profileSelectionsDataStore)
companion object {
/**
* Preference-key factory. Per-connection so every connection gets its
* own lock slot — profiles are server-scoped, so a lock pinned on one
* server must not leak onto another.
*/
private fun keyFor(connectionId: String) =
stringPreferencesKey("locked_profile_$connectionId")
}
/**
* Persist the lock for [connectionId].
* - `null` → **unlock**: removes the key (converges with fresh-install
* "no key" state).
* - any non-null [profileName] → lock to that profile name. Callers lock
* to Server default by passing [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY].
*/
suspend fun setLockedProfile(connectionId: String, profileName: String?) {
dataStore.edit { prefs ->
val key = keyFor(connectionId)
if (profileName == null) {
prefs.remove(key)
} else {
prefs[key] = profileName
}
}
}
/**
* Emits the locked profile name for [connectionId], or `null` when no lock
* is stored (unlocked). The sentinel
* [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY] means "locked to Server default".
*/
fun lockedProfileFlow(connectionId: String): Flow<String?> {
val key = keyFor(connectionId)
return dataStore.data.map { prefs -> prefs[key] }
}
/**
* Remove the persisted lock for [connectionId]. Called from the connection
* removal path alongside the selection clear so a removed connection's lock
* pointer goes with it.
*/
suspend fun clear(connectionId: String) {
dataStore.edit { prefs ->
prefs.remove(keyFor(connectionId))
}
}
suspend fun clearAll() {
dataStore.edit { prefs ->
prefs.clear()
}
}
}
@@ -10,12 +10,62 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Per-connection, per-Hermes-profile last active chat session.
* Which chat transport created (and can resume) a stored session.
*
* The two chat transports do NOT share session storage, so their ids are not
* interchangeable on a non-default profile:
* - [GATEWAY] — the `/api/ws` tui_gateway path. `session.create`/`session.resume`
* bind the profile's own HERMES_HOME, so sessions live in that profile's
* `state.db`. Ids look like `YYYYMMDD_HHMMSS_<hex>`.
* - [SSE] — the api_server chat path (`/api/sessions/.../chat/stream`,
* `/v1/runs`). The api_server has no per-request profile scoping; it always
* persists to its launch `state.db`. Ids look like `api_<unixsecs>_<hex>`.
*
* Resuming an [SSE] id over the [GATEWAY] (which opens the profile DB) — or vice
* versa — fails with "session not found" and silently forks a new session. So
* each transport gets its own persisted slot, and a stored id is only ever
* restored for the transport that can actually resume it.
*/
enum class SessionTransport(val key: String) {
GATEWAY("gw"),
SSE("sse");
companion object {
/**
* Bucket a stored session id by the subsystem that created it — the
* id's namespace is the server's own ground truth about which transport
* can resume it, more reliable than re-deriving the resolved endpoint
* (a turn can fall back from gateway to SSE per-turn).
*/
fun forSessionId(sessionId: String): SessionTransport =
if (sessionId.startsWith("api_")) SSE else GATEWAY
/**
* Bucket a resolved streaming endpoint. Only `"gateway"` resumes from
* the per-profile DB; every SSE-family member (`"sessions"` /
* `"completions"` / `"runs"`) rides the api_server's launch DB.
*/
fun forEndpoint(resolvedEndpoint: String): SessionTransport =
if (resolvedEndpoint == "gateway") GATEWAY else SSE
}
}
/**
* Per-connection, per-Hermes-profile, per-transport last active chat session.
*
* This is intentionally separate from [ProfileSelectionStore]. Selection says
* which agent is active; this store says which chat session belongs to that
* agent on that connection. Null profile name is the explicit Server default
* context.
*
* **Transport dimension (v1.0.0).** The slot is keyed by [SessionTransport] too,
* because a gateway session and an api_server (SSE) session are stored in
* different databases and cannot be cross-resumed on a non-default profile.
* Keying by transport keeps the two from clobbering one slot and guarantees a
* restored id is always resumable by the transport asking for it. The key shape
* changed in this release, so pre-existing (untransported) slots are not read —
* a one-time drop of the "last session" pointer that also clears the exact stale
* cross-transport ids that caused mid-conversation forks.
*/
class ProfileSessionStore(
private val dataStore: DataStore<Preferences>,
@@ -25,11 +75,18 @@ class ProfileSessionStore(
companion object {
private const val PREFIX = "profile_session__"
private fun keyName(connectionId: String, profileName: String?): String =
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
private fun keyName(
connectionId: String,
profileName: String?,
transport: SessionTransport,
): String =
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}__${transport.key}"
private fun keyFor(connectionId: String, profileName: String?) =
stringPreferencesKey(keyName(connectionId, profileName))
private fun keyFor(
connectionId: String,
profileName: String?,
transport: SessionTransport,
) = stringPreferencesKey(keyName(connectionId, profileName, transport))
private fun connectionPrefix(connectionId: String): String =
"$PREFIX${connectionId}__"
@@ -38,10 +95,11 @@ class ProfileSessionStore(
suspend fun setSessionId(
connectionId: String,
profileName: String?,
transport: SessionTransport,
sessionId: String?,
) {
dataStore.edit { prefs ->
val key = keyFor(connectionId, profileName)
val key = keyFor(connectionId, profileName, transport)
if (sessionId.isNullOrBlank()) {
prefs.remove(key)
} else {
@@ -50,8 +108,12 @@ class ProfileSessionStore(
}
}
fun sessionIdFlow(connectionId: String, profileName: String?): Flow<String?> {
val key = keyFor(connectionId, profileName)
fun sessionIdFlow(
connectionId: String,
profileName: String?,
transport: SessionTransport,
): Flow<String?> {
val key = keyFor(connectionId, profileName, transport)
return dataStore.data.map { prefs -> prefs[key] }
}
@@ -8,8 +8,11 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* User-tunable voice mode preferences.
@@ -37,8 +40,60 @@ data class VoiceSettings(
* docs/plans/2026-05-24-realtime-persistent-session.md.
*/
val realtimePersistentSession: Boolean = true,
/**
* Enhanced-voice overrides for the relay TTS path, mapped onto the active
* provider (Gemini / xAI). Empty string / false means "use the server's
* saved config" — the relay only applies a field when it is set. Surfaced
* in Voice Settings only when the relay advertises an enhanced provider
* (`/voice/config` `tts.enhanced.supported`). Field meaning is generic:
* `enhancedVoice` → Gemini voice / xAI voice_id; `enhancedAudioTags` →
* Gemini audio_tags / xAI auto_speech_tags; `enhancedPersona` is Gemini-only
* and `enhancedLanguage` is xAI-only.
*/
val enhancedVoice: String = "",
val enhancedModel: String = "",
val enhancedAudioTags: Boolean = false,
val enhancedPersona: String = "",
val enhancedLanguage: String = "",
)
/**
* Per-request enhanced-voice overrides forwarded to the relay's
* `/voice/synthesize`. Mirrors the generic fields recognized by
* `plugin/relay/voice.py:_extract_voice_overrides`; the relay maps them onto
* the active provider's config.
*/
data class EnhancedVoiceOverrides(
val voice: String? = null,
val model: String? = null,
val audioTags: Boolean? = null,
val personaPrompt: String? = null,
val language: String? = null,
) {
val isEmpty: Boolean
get() = voice == null && model == null && audioTags == null &&
personaPrompt == null && language == null
companion object {
/**
* Build overrides from persisted settings, or null when nothing is set
* (so the relay falls back to the server's saved config). The audio-tags
* toggle only sends `true` — leaving it off defers to the server default
* rather than forcing it off.
*/
fun fromSettings(s: VoiceSettings): EnhancedVoiceOverrides? {
val overrides = EnhancedVoiceOverrides(
voice = s.enhancedVoice.takeIf { it.isNotBlank() },
model = s.enhancedModel.takeIf { it.isNotBlank() },
audioTags = true.takeIf { s.enhancedAudioTags },
personaPrompt = s.enhancedPersona.takeIf { it.isNotBlank() },
language = s.enhancedLanguage.takeIf { it.isNotBlank() },
)
return overrides.takeUnless { it.isEmpty }
}
}
}
enum class VoiceEngineMode(val storageValue: String) {
HermesVoiceOutput("hermes_voice_output"),
RealtimeAgent("realtime_agent");
@@ -60,13 +115,63 @@ enum class VoiceAudioRoute(val storageValue: String) {
}
}
/**
* Active scope for per-profile voice prefs.
*
* Mirrors [ProfileSelectionStore]'s `_<connectionId>` keying and extends it to
* `_<connectionId>_<profile>` so per-profile voice picks don't leak across
* profiles (or across connections that expose a same-named profile).
*
* A null/blank [profileName] is the "default / launch profile" and resolves to
* the un-namespaced global keys — i.e. the default profile *is* the base layer
* that named profiles override. A null/blank [connectionId] degrades to
* profile-only namespacing, which still isolates profiles within one
* connection; it just can't disambiguate two connections with a same-named
* profile. See [VoicePreferencesRepository.setActiveScope].
*/
data class VoiceProfileScope(
val connectionId: String? = null,
val profileName: String? = null,
) {
companion object {
val Global = VoiceProfileScope()
}
}
class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>) {
constructor(context: Context) : this(context.relayDataStore)
companion object {
private val KEY_ENGINE_MODE = stringPreferencesKey("voice_engine_mode")
private val KEY_AUDIO_ROUTE = stringPreferencesKey("voice_audio_route")
// --- Per-profile keys (override map; namespaced by active scope) -----
// These are stored as base NAME strings (not typed Key<>s) so the
// scoped key can be built per (connectionId, profile) at read/write
// time. Resolution layers a per-profile value over the global value
// over the hard default — see [scopedName] / [resolveString].
//
// Why these are per-profile: engine mode, audio route, and the
// enhanced-voice overrides describe *which voice the agent speaks
// with*, which is a property of the profile (the relay already
// persists `voice_output:`/`realtime_voice:` per profile and
// `RelayVoiceClient` already sends `?profile=`). Keeping them global
// leaked one profile's voice onto every other profile.
private const val KEY_ENGINE_MODE = "voice_engine_mode"
private const val KEY_AUDIO_ROUTE = "voice_audio_route"
private const val KEY_ENH_VOICE = "voice_enh_voice"
private const val KEY_ENH_MODEL = "voice_enh_model"
private const val KEY_ENH_AUDIO_TAGS = "voice_enh_audio_tags"
private const val KEY_ENH_PERSONA = "voice_enh_persona"
private const val KEY_ENH_LANGUAGE = "voice_enh_language"
// --- Global keys (shared across profiles; never namespaced) ----------
// Why these stay global: interaction-mode and silence-threshold are
// ergonomic input preferences about *how the user drives the mic*, not
// about the agent's voice — a user wants the same tap/hold/continuous
// habit regardless of which profile is active. auto-tts and the STT
// language hint are dead/experimental controls today, and the two
// realtime diagnostic toggles (trace details, persistent session) are
// engine-behaviour switches that aren't profile-specific. Keeping them
// un-namespaced means switching profiles never churns these.
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
private val KEY_AUTO_TTS = booleanPreferencesKey("voice_auto_tts")
@@ -83,37 +188,149 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
const val DEFAULT_LANGUAGE = ""
const val DEFAULT_REALTIME_TRACE_DETAILS = false
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
/**
* Build the storage name for a per-profile [base] key under [scope].
*
* - null/blank profile → returns [base] verbatim (the global base
* layer; the default profile reads/writes the un-namespaced key).
* - profile set, no connection → `<base>_<profile>`.
* - profile + connection set → `<base>_<connectionId>_<profile>`,
* matching [ProfileSelectionStore]'s connection-first ordering.
*/
internal fun scopedName(base: String, scope: VoiceProfileScope): String {
val profile = scope.profileName?.trim()?.takeIf { it.isNotEmpty() } ?: return base
val conn = scope.connectionId?.trim()?.takeIf { it.isNotEmpty() }
return if (conn != null) "${base}_${conn}_$profile" else "${base}_$profile"
}
}
val settings: Flow<VoiceSettings> = dataStore.data
.map { prefs ->
VoiceSettings(
engineMode = VoiceEngineMode.fromStorage(
prefs[KEY_ENGINE_MODE] ?: DEFAULT_ENGINE_MODE,
).storageValue,
audioRoute = VoiceAudioRoute.fromStorage(
prefs[KEY_AUDIO_ROUTE] ?: DEFAULT_AUDIO_ROUTE,
).storageValue,
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
?: DEFAULT_REALTIME_TRACE_DETAILS,
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
?: DEFAULT_REALTIME_PERSISTENT_SESSION,
)
// In-memory active scope. Defaults to global so un-scoped consumers (and
// every existing call site) behave exactly as before until a scope is set.
private val _scope = MutableStateFlow(VoiceProfileScope.Global)
/** The active per-profile scope. Set via [setActiveScope]. */
val activeScope: StateFlow<VoiceProfileScope> = _scope.asStateFlow()
/**
* Point the repository at a (connection, profile) scope. Per-profile reads
* and writes (engine/route/enhanced) re-target the namespaced keys for that
* profile; global prefs are unaffected. Passing a null/blank profile name
* reverts per-profile reads/writes to the global base layer (the default
* profile). Idempotent — a no-op when the normalized scope is unchanged.
*/
fun setActiveScope(connectionId: String?, profileName: String?) {
val next = VoiceProfileScope(
connectionId = connectionId?.trim()?.takeIf { it.isNotEmpty() },
profileName = profileName?.trim()?.takeIf { it.isNotEmpty() },
)
if (_scope.value != next) {
_scope.value = next
}
.distinctUntilChanged()
}
/**
* Emits the resolved [VoiceSettings] for the [activeScope]. Re-emits when
* either the underlying DataStore or the active scope changes. Per-profile
* fields are resolved as: per-profile key → global key → hard default.
*/
val settings: Flow<VoiceSettings> = combine(_scope, dataStore.data) { scope, prefs ->
VoiceSettings(
// --- per-profile (override map) ---
engineMode = VoiceEngineMode.fromStorage(
resolveString(prefs, KEY_ENGINE_MODE, scope, DEFAULT_ENGINE_MODE),
).storageValue,
audioRoute = VoiceAudioRoute.fromStorage(
resolveString(prefs, KEY_AUDIO_ROUTE, scope, DEFAULT_AUDIO_ROUTE),
).storageValue,
enhancedVoice = resolveString(prefs, KEY_ENH_VOICE, scope, ""),
enhancedModel = resolveString(prefs, KEY_ENH_MODEL, scope, ""),
enhancedAudioTags = resolveBoolean(prefs, KEY_ENH_AUDIO_TAGS, scope, false),
enhancedPersona = resolveString(prefs, KEY_ENH_PERSONA, scope, ""),
enhancedLanguage = resolveString(prefs, KEY_ENH_LANGUAGE, scope, ""),
// --- global (shared across profiles) ---
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
?: DEFAULT_REALTIME_TRACE_DETAILS,
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
?: DEFAULT_REALTIME_PERSISTENT_SESSION,
)
}.distinctUntilChanged()
// --- per-profile resolution (per-profile key → global key → default) -----
private fun resolveString(
prefs: Preferences,
base: String,
scope: VoiceProfileScope,
default: String,
): String {
val scopedName = scopedName(base, scope)
if (scopedName != base) {
prefs[stringPreferencesKey(scopedName)]?.let { return it }
}
return prefs[stringPreferencesKey(base)] ?: default
}
private fun resolveBoolean(
prefs: Preferences,
base: String,
scope: VoiceProfileScope,
default: Boolean,
): Boolean {
val scopedName = scopedName(base, scope)
if (scopedName != base) {
prefs[booleanPreferencesKey(scopedName)]?.let { return it }
}
return prefs[booleanPreferencesKey(base)] ?: default
}
// --- per-profile setters (write the namespaced key for the active scope) -
suspend fun setEngineMode(mode: VoiceEngineMode) {
dataStore.edit { it[KEY_ENGINE_MODE] = mode.storageValue }
val key = stringPreferencesKey(scopedName(KEY_ENGINE_MODE, _scope.value))
dataStore.edit { it[key] = mode.storageValue }
}
suspend fun setAudioRoute(route: VoiceAudioRoute) {
dataStore.edit { it[KEY_AUDIO_ROUTE] = route.storageValue }
val key = stringPreferencesKey(scopedName(KEY_AUDIO_ROUTE, _scope.value))
dataStore.edit { it[key] = route.storageValue }
}
/** "" clears the override (relay falls back to the server's saved voice). */
suspend fun setEnhancedVoice(voice: String) {
val key = stringPreferencesKey(scopedName(KEY_ENH_VOICE, _scope.value))
dataStore.edit { it[key] = voice.trim() }
}
/** "" clears the override (relay falls back to the server's saved model). */
suspend fun setEnhancedModel(model: String) {
val key = stringPreferencesKey(scopedName(KEY_ENH_MODEL, _scope.value))
dataStore.edit { it[key] = model.trim() }
}
suspend fun setEnhancedAudioTags(enabled: Boolean) {
val key = booleanPreferencesKey(scopedName(KEY_ENH_AUDIO_TAGS, _scope.value))
dataStore.edit { it[key] = enabled }
}
/** "" clears the inline persona/style direction (Gemini). */
suspend fun setEnhancedPersona(persona: String) {
val key = stringPreferencesKey(scopedName(KEY_ENH_PERSONA, _scope.value))
dataStore.edit { it[key] = persona }
}
/** "" clears the language override (xAI). */
suspend fun setEnhancedLanguage(language: String) {
val key = stringPreferencesKey(scopedName(KEY_ENH_LANGUAGE, _scope.value))
dataStore.edit { it[key] = language.trim() }
}
// --- global setters (always the un-namespaced key) -----------------------
suspend fun setInteractionMode(mode: String) {
dataStore.edit { it[KEY_INTERACTION_MODE] = mode }
}
@@ -28,12 +28,50 @@ data class DiagnosticLogEntry(
val endpointRole: String? = null,
val url: String? = null,
val elapsedMs: Long? = null,
/**
* Full (multi-KB) redacted stacktrace for the detail page. Kept OUT of the
* 180-char [detail] truncation — the list still shows the short title/detail,
* the detail view shows this. Null for non-error / manually-recorded entries.
*/
val stacktrace: String? = null,
)
/**
* Current health of a single subsystem on the Diagnostics status timeline.
*
* Distinct from [DiagnosticSeverity], which classifies a *logged event* after
* the fact. A [CheckStatus] is the *live* state of a subsystem, derived
* read-only from connection state + the recent [DiagnosticsLog]. [Unknown] is
* a first-class, honest state — "not checked / not applicable" — never an
* implied pass or fail.
*/
enum class CheckStatus { Pass, Warn, Fail, Unknown }
/**
* One row on the Diagnostics status timeline: a named subsystem check with its
* current [status] and, when not [CheckStatus.Pass], a human [reason] — the
* whole point of the screen is answering "why is this failing?".
*
* [category] links the check back to a [DiagnosticCategory]; when [timestampMs]
* is non-null the reason came from a concrete [DiagnosticLogEntry], so the row
* is tappable and the UI can open that entry's full detail.
*/
data class StatusCheck(
val name: String,
val status: CheckStatus,
val reason: String? = null,
val category: DiagnosticCategory? = null,
val timestampMs: Long? = null,
val durationMs: Long? = null,
)
object DiagnosticsLog {
private const val MAX_ENTRIES = 200
private const val MAX_TEXT_LENGTH = 180
/** Cap for the full stacktrace kept on an error entry — a few KB is plenty. */
private const val MAX_TRACE_LENGTH = 8000
private val lock = Any()
private val _entries = MutableStateFlow<List<DiagnosticLogEntry>>(emptyList())
val entries: StateFlow<List<DiagnosticLogEntry>> = _entries.asStateFlow()
@@ -46,6 +84,7 @@ object DiagnosticsLog {
endpointRole: String? = null,
url: String? = null,
elapsedMs: Long? = null,
stacktrace: String? = null,
) {
val entry = DiagnosticLogEntry(
timestampMs = System.currentTimeMillis(),
@@ -56,12 +95,51 @@ object DiagnosticsLog {
endpointRole = clean(endpointRole),
url = sanitizeUrl(url),
elapsedMs = elapsedMs,
stacktrace = redactTrace(stacktrace),
)
synchronized(lock) {
_entries.value = (_entries.value + entry).takeLast(MAX_ENTRIES)
}
}
/**
* Record an [DiagnosticSeverity.Error] entry from a classified failure. The
* list keeps showing the clean [title] (+ short [detail]); the detail page
* shows the full redacted stacktrace.
*
* Called centrally from [com.hermesandroid.relay.util.classifyError] as a
* side effect, so every classified error lands here with no per-call-site
* churn. The flow is one-way (classify -> record); nothing here re-enters
* the classifier, so there is no recursion.
*
* @param title clean, human title (e.g. [com.hermesandroid.relay.util.HumanError.title]).
* @param detail short one-line summary shown in the list row (truncated to 180).
* @param throwable source error — its stacktrace is captured, redacted, and capped.
*/
fun recordError(
category: DiagnosticCategory,
title: String,
detail: String? = null,
throwable: Throwable? = null,
endpointRole: String? = null,
url: String? = null,
elapsedMs: Long? = null,
) {
record(
category = category,
severity = DiagnosticSeverity.Error,
title = title,
detail = detail ?: throwable?.message,
endpointRole = endpointRole,
url = url,
elapsedMs = elapsedMs,
stacktrace = throwable?.let { stackTraceText(it) },
)
}
private fun stackTraceText(t: Throwable): String =
java.io.StringWriter().also { t.printStackTrace(java.io.PrintWriter(it)) }.toString().trim()
fun recent(
categories: Set<DiagnosticCategory>? = null,
limit: Int = 30,
@@ -101,10 +179,26 @@ object DiagnosticsLog {
private fun clean(value: String?): String? {
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
return trimmed
.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
"${it.groupValues[1]}=[hidden]"
}
.take(MAX_TEXT_LENGTH)
return redact(trimmed).take(MAX_TEXT_LENGTH)
}
/**
* Same secret redaction as [clean] but WITHOUT the 180-char list truncation —
* for the full stacktrace shown on the detail page. Still capped at
* [MAX_TRACE_LENGTH] so a runaway trace can't bloat the ring.
*/
private fun redactTrace(value: String?): String? {
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
val redacted = redact(trimmed)
return if (redacted.length > MAX_TRACE_LENGTH) {
redacted.take(MAX_TRACE_LENGTH) + "\n… (truncated)"
} else {
redacted
}
}
private fun redact(value: String): String =
value.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
"${it.groupValues[1]}=[hidden]"
}
}
@@ -0,0 +1,29 @@
package com.hermesandroid.relay.network
import android.os.Looper
/**
* Run an OkHttp teardown [block] without ever performing a network write on
* the main thread.
*
* [okhttp3.ConnectionPool.evictAll] closes pooled sockets synchronously. For
* a live `https`/`wss` keep-alive connection that close drains the SSL output
* queue — a real network write (`SSLOutputStream.writeInternal`) — which trips
* StrictMode's [android.os.NetworkOnMainThreadException]. Reported as a hard
* crash on connect over TLS/Tailscale (issues #70 / #118 / #124): a
* `viewModelScope` (i.e. `Dispatchers.Main.immediate`) coroutine resumes on the
* main thread and shuts a dashboard/API client down in a `finally` block.
*
* Client shutdown is fire-and-forget cleanup, so when the caller is on the main
* thread we hand [block] to a short-lived daemon thread. Off the main thread
* (already on `Dispatchers.IO` or a background thread) we run it inline so
* callers that deliberately moved off main keep their ordering and any blocking
* `awaitTermination` waits stay where the caller put them.
*/
internal fun shutdownOffMainThread(threadName: String, block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
Thread({ runCatching(block) }, threadName).apply { isDaemon = true }.start()
} else {
block()
}
}
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network.handlers
package com.hermesandroid.relay.network.relay
import android.content.ActivityNotFoundException
import android.content.ClipData
@@ -23,9 +23,10 @@ import kotlinx.serialization.json.booleanOrNull
// === PHASE3-tier-C: flavor gate for sideload-only tools ===
import com.hermesandroid.relay.data.BuildFlavor
// === END PHASE3-tier-C ===
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.RelayHttpClient
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.LocalDispatchResult
import com.hermesandroid.relay.util.MediaCacheWriter
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
@@ -538,7 +539,7 @@ class BridgeCommandHandler(
put(
"error",
"Device Control is not included in the Google Play build " +
"of Hermes Relay. This build keeps Hermes Bridge Core " +
"of Hermes-Relay. This build keeps Hermes Bridge Core " +
"features such as chat, voice, terminal, media, " +
"notifications, and relay status, but it does not " +
"ship AccessibilityService, screen reading, taps, " +
@@ -562,7 +563,7 @@ class BridgeCommandHandler(
"Hermes accessibility service is not enabled. " +
"The phone IS paired and connected — this is " +
"NOT a pairing problem. The user must enable " +
"the Hermes Relay accessibility service in " +
"the Hermes-Relay accessibility service in " +
"Android Settings > Accessibility > " +
"Installed services before the bridge can " +
"dispatch phone-control commands.",
@@ -570,7 +571,7 @@ class BridgeCommandHandler(
put("error_code", "service_unavailable")
put(
"required_action",
"User enables Hermes Relay in Android Accessibility Settings",
"User enables Hermes-Relay in Android Accessibility Settings",
)
}
)
@@ -813,7 +814,7 @@ class BridgeCommandHandler(
}
// === PHASE3-return-to-hermes ===
// Bring the Hermes Relay app back to foreground. Used by the
// Bring the Hermes-Relay app back to foreground. Used by the
// server-side agent as the final step of any multi-app task
// (e.g. after driving Messages to send an SMS) so the user
// sees the agent's reply in-context without manually switching
@@ -1219,7 +1220,7 @@ class BridgeCommandHandler(
respond(
requestId, 403,
buildJsonObject {
put("error", "android_location is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
put("error", "android_location is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
@@ -1234,7 +1235,7 @@ class BridgeCommandHandler(
respond(
requestId, 403,
buildJsonObject {
put("error", "android_search_contacts is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
put("error", "android_search_contacts is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
@@ -1258,7 +1259,7 @@ class BridgeCommandHandler(
respond(
requestId, 403,
buildJsonObject {
put("error", "android_call auto-dial is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
put("error", "android_call auto-dial is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
@@ -1312,7 +1313,7 @@ class BridgeCommandHandler(
respond(
requestId, 403,
buildJsonObject {
put("error", "android_send_sms is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
put("error", "android_send_sms is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
@@ -1370,7 +1371,7 @@ class BridgeCommandHandler(
respond(
requestId, 403,
buildJsonObject {
put("error", "$path is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
put("error", "$path is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
@@ -1411,9 +1412,9 @@ class BridgeCommandHandler(
val target = if (to.isBlank()) "the selected recipient" else to
"Send MMS compose to $target with $attachmentCount attachment(s)?"
} else if (attachmentCount > 0) {
"Share $attachmentCount attachment(s) from Hermes Relay?"
"Share $attachmentCount attachment(s) from Hermes-Relay?"
} else {
"Share text from Hermes Relay?"
"Share text from Hermes-Relay?"
}
val allowed = safetyManager.awaitConfirmation(path, confirmText)
if (!allowed) {
@@ -2418,33 +2419,9 @@ class BridgeCommandHandler(
}
}
/**
* Captured outcome of a local bridge dispatch. Voice mode reads this to
* emit follow-up chat traces showing the real success/failure state of
* an action after the safety modal resolves and the underlying
* [ActionExecutor] method returns. The fields mirror what the LLM path
* would see on a `bridge.response` envelope:
*
* - [status] — HTTP-style status: 200 success, 400 client error,
* 403 user denial / bridge disabled, 500 executor error
* - [errorMessage] — free-text error from the response payload, or null
* on success. Safe to speak / display verbatim to the user.
* - [errorCode] — structured classification (e.g. `permission_denied`,
* `bridge_disabled`, `user_denied`) when `respondFromResult` or a
* direct respond call includes one. Null for errors we haven't
* classified yet.
* - [resultJson] — the raw result object, for callers that need
* action-specific fields (e.g. the resolved phone number from
* /search_contacts). Optional.
*/
data class LocalDispatchResult(
val status: Int,
val errorMessage: String?,
val errorCode: String?,
val resultJson: JsonObject?,
) {
val isSuccess: Boolean get() = status in 200..299
}
// LocalDispatchResult moved to network.shared (ADR 34 fence): it is a passive
// DTO shared with the upstream chat path (ChatHandler), so it cannot live in
// this relay-package file without creating an upstream -> relay import.
/**
* Coroutine context marker installed by [BridgeCommandHandler.handleLocalCommand]
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.models.Envelope
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import android.content.Context
import android.net.ConnectivityManager
@@ -12,7 +12,9 @@ import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.EndpointResolver
import com.hermesandroid.relay.network.shutdownOffMainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -196,6 +198,18 @@ class ConnectionManager(
@Volatile
private var networkResolveJob: kotlinx.coroutines.Job? = null
/** Deferred reaction to a network loss — cancelled if a network returns within the grace. */
private var networkLossJob: kotlinx.coroutines.Job? = null
/**
* Set when a network loss outlives [NETWORK_LOSS_GRACE_MS] — only then may
* a re-resolve switch DOWN to a lower-priority endpoint. Prevents a
* transient probe miss (Wi-Fi settling) from switching routes and
* cancelling an in-flight turn. Cleared once a resolution is published.
*/
@Volatile
private var sustainedLossDeclared = false
init {
// Register at construction, not on first connect(). Standard
// (no-Relay) connections never open the WSS socket, but their HTTP
@@ -214,6 +228,15 @@ class ConnectionManager(
// enough to coalesce the onAvailable burst of a handoff, short
// enough that a route swap still feels immediate.
private const val NETWORK_RESOLVE_DEBOUNCE_MS = 300L
/**
* Grace before reacting to a network loss. A transient blip (Wi-Fi
* power-save/roam, a brief drop, the OS swapping radios) recovers
* within this window and must NOT mark the active endpoint unreachable
* or switch routes — doing so rebuilds the chat client and cancels an
* in-flight turn. Only a loss sustained past the grace switches.
*/
private const val NETWORK_LOSS_GRACE_MS = 6_000L
// Matches plugin.relay.auth._BLOCK_SECONDS (5 min). If we see 429
// on the WSS upgrade, we're IP-banned server-side — retrying at
// our normal 1-30s cadence re-fills the ban bucket and keeps us
@@ -540,6 +563,23 @@ class ConnectionManager(
}
return@launch
}
// Endpoint hysteresis: a transient blip can make the active
// (higher-priority) endpoint's health probe miss, so the resolver
// falls through to a LOWER-priority fallback. Switching on that
// transient miss rebuilds the chat client and CANCELS an in-flight
// turn. Don't switch DOWN in priority unless a sustained loss was
// actually declared (the onLost grace elapsed). Same/upgrade
// winners always publish.
val active = _activeEndpoint.value
if (active != null && resolved.priority > active.priority && !sustainedLossDeclared) {
Log.i(
TAG,
"re-resolve picked lower-priority ${resolved.role}(p${resolved.priority}) over " +
"active ${active.role}(p${active.priority}) not confirmed dead — keeping active",
)
return@launch
}
sustainedLossDeclared = false
_activeEndpoint.value = resolved
if (current == null) return@launch
// After an explicit disconnect() the route still publishes above
@@ -569,15 +609,33 @@ class ConnectionManager(
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
// A network returned — cancel any pending loss reaction: the
// drop was transient, so don't switch routes / rebuild the chat
// client / cancel an in-flight turn. Re-resolve to pick the best
// route (usually the same one); the rebuild only fires if the
// URL actually moved.
networkLossJob?.cancel()
endpointResolver?.clearCache()
scheduleNetworkReResolve("Network change — switching endpoint")
}
override fun onLost(network: Network) {
Log.i(TAG, "network onLost — marking active endpoint unreachable and resolving fallback")
endpointResolver?.clearCache()
markActiveEndpointUnreachable("network lost")
scheduleNetworkReResolve("Network lost — switching endpoint")
// Defer the reaction: a transient blip recovers within the grace
// (onAvailable cancels this job). Reacting immediately — marking
// the active endpoint unreachable + re-resolving to a fallback —
// switches routes mid-blip, which rebuilds the chat client and
// CANCELS the in-flight turn. The gateway client already handles
// its own socket reconnect across the blip.
Log.i(TAG, "network onLost — deferring fallback re-resolve by ${NETWORK_LOSS_GRACE_MS}ms")
networkLossJob?.cancel()
networkLossJob = scope.launch {
delay(NETWORK_LOSS_GRACE_MS)
Log.i(TAG, "network loss sustained past grace — marking active endpoint unreachable and resolving fallback")
sustainedLossDeclared = true
endpointResolver?.clearCache()
markActiveEndpointUnreachable("network lost (sustained)")
scheduleNetworkReResolve("Network lost — switching endpoint")
}
}
}
try {
@@ -594,6 +652,8 @@ class ConnectionManager(
}
private fun unregisterNetworkCallback() {
networkLossJob?.cancel()
networkLossJob = null
val ctx = context ?: return
val cb = networkCallback ?: return
try {
@@ -646,8 +706,12 @@ class ConnectionManager(
disconnect()
unregisterNetworkCallback()
supervisorJob.cancel()
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
// evictAll() closes live wss sockets synchronously; on a TLS keep-alive
// that close is a network write, so keep it off the main thread.
shutdownOffMainThread("ConnectionManager-shutdown") {
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}
fun send(envelope: Envelope) {
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import android.util.Log
import com.hermesandroid.relay.auth.PairedDeviceInfo
@@ -7,6 +7,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
@@ -22,7 +23,7 @@ import java.io.IOException
*
* The chat SSE stream can emit tool output containing a marker of the form
* `MEDIA:hermes-relay://<opaque-token>`
* [ChatHandler][com.hermesandroid.relay.network.handlers.ChatHandler] parses
* [ChatHandler][com.hermesandroid.relay.network.upstream.ChatHandler] parses
* the marker, and [ChatViewModel][com.hermesandroid.relay.viewmodel.ChatViewModel]
* calls [fetchMedia] to pull the actual bytes over plain HTTP(S). The relay
* base URL is the WSS relay URL with `ws`/`wss` swapped for `http`/`https`.
@@ -40,7 +41,11 @@ import java.io.IOException
class RelayHttpClient(
private val okHttpClient: OkHttpClient,
private val relayUrlProvider: () -> String?,
private val sessionTokenProvider: suspend () -> String?
private val sessionTokenProvider: suspend () -> String?,
/** Synchronous snapshot of the paired session token (null when not currently
* paired). Lets [mediaUrlConfigured] check fetch-readiness without
* suspending; mirrors what [sessionTokenProvider] resolves. */
private val pairedTokenSnapshot: () -> String? = { null },
) {
companion object {
@@ -53,6 +58,19 @@ class RelayHttpClient(
}
}
/**
* True when relay media is actually FETCHABLE right now: a non-blank relay
* URL AND a current paired session token. Synchronous. The token check
* matters because the relay's SessionManager is in-memory and wiped on
* restart, so a configured relay URL can outlive the pairing — gating on URL
* alone made the media-capability badge read "available" while every
* `/media/by-path` fetch failed for a missing token. Now the badge (and the
* SSE media hint) agree with what the fetch can do, and self-correct on
* re-pair.
*/
fun mediaUrlConfigured(): Boolean =
!relayUrlProvider().isNullOrBlank() && !pairedTokenSnapshot().isNullOrBlank()
/**
* The result of a successful [fetchMedia] call.
*
@@ -61,28 +79,53 @@ class RelayHttpClient(
* @property bytes raw response body.
* @property fileName best-effort filename parsed from
* `Content-Disposition: inline; filename="..."`, or null.
* @property sensitive model-emitted sensitivity hint, read from the
* relay's `X-Media-Sensitive` response header (`"1"`/`"true"`
* → true). The relay never classifies media — it transports
* whatever the producing tool/agent declared. Absent header →
* false. Consumed by `ChatViewModel` to blur per the user's
* setting.
*/
data class FetchedMedia(
val contentType: String,
val bytes: ByteArray,
val fileName: String?
val fileName: String?,
val sensitive: Boolean = false
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is FetchedMedia) return false
return contentType == other.contentType &&
bytes.contentEquals(other.bytes) &&
fileName == other.fileName
fileName == other.fileName &&
sensitive == other.sensitive
}
override fun hashCode(): Int {
var result = contentType.hashCode()
result = 31 * result + bytes.contentHashCode()
result = 31 * result + (fileName?.hashCode() ?: 0)
result = 31 * result + sensitive.hashCode()
return result
}
}
/**
* Server-side relay context that would be injected into the next agent turn.
* Mirrors `GET /context/injected`; Android treats it as audit-only state.
*/
@Serializable
data class InjectedContextAudit(
val enabled: Boolean = false,
val blocks: List<InjectedContextBlock> = emptyList(),
)
@Serializable
data class InjectedContextBlock(
val name: String,
val text: String,
)
/**
* Fetch `GET /media/<token>` from the relay over HTTP(S). Returns a
* [Result] — success carries a [FetchedMedia], failure wraps the
@@ -141,12 +184,16 @@ class RelayHttpClient(
response.header("Content-Disposition")
)
val sensitive = parseSensitiveHeader(
response.header("X-Media-Sensitive")
)
val body = response.body
if (body == null) {
return@withContext Result.failure(IOException("Empty response body"))
}
val bytes = body.bytes()
Result.success(FetchedMedia(contentType, bytes, fileName))
Result.success(FetchedMedia(contentType, bytes, fileName, sensitive))
}
} catch (e: IOException) {
Log.w(TAG, "fetchMedia failed for $token: ${e.message}")
@@ -248,12 +295,16 @@ class RelayHttpClient(
response.header("Content-Disposition")
)
val sensitive = parseSensitiveHeader(
response.header("X-Media-Sensitive")
)
val body = response.body
if (body == null) {
return@withContext Result.failure(IOException("Empty response body"))
}
val bytes = body.bytes()
Result.success(FetchedMedia(contentType, bytes, fileName))
Result.success(FetchedMedia(contentType, bytes, fileName, sensitive))
}
} catch (e: IOException) {
Log.w(TAG, "fetchMediaByPath failed for $path: ${e.message}")
@@ -264,6 +315,85 @@ class RelayHttpClient(
}
}
/**
* Fetch the relay's server-side injected-context audit. This endpoint is
* optional and fail-open: old/plugin-absent relays return an empty disabled
* audit rather than breaking the client-side context sheet.
*/
suspend fun fetchInjectedContext(): Result<InjectedContextAudit> = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
return@withContext Result.failure(
IllegalStateException("Relay URL not configured")
)
}
val sessionToken = sessionTokenProvider()
if (sessionToken.isNullOrBlank()) {
return@withContext Result.failure(
IllegalStateException("Relay not paired — session token missing")
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val url = try {
"$httpBase/context/injected".toHttpUrl()
} catch (e: IllegalArgumentException) {
return@withContext Result.failure(
IOException("Invalid relay URL: ${e.message}")
)
}
val request = Request.Builder()
.url(url)
.get()
.header("Authorization", "Bearer $sessionToken")
.header("Accept", "application/json")
.build()
val auditClient = okHttpClient.newBuilder()
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.build()
try {
auditClient.newCall(request).execute().use { response ->
if (response.code == 404) {
return@withContext Result.success(InjectedContextAudit())
}
if (!response.isSuccessful) {
val reason = when (response.code) {
401, 403 -> "Unauthorized — re-pair with the relay"
in 500..599 -> "Relay error (HTTP ${response.code})"
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
}
return@withContext Result.failure(IOException(reason))
}
val body = response.body?.string().orEmpty()
if (body.isBlank()) {
return@withContext Result.failure(IOException("Empty response body"))
}
Result.success(
sessionsJson.decodeFromString(
InjectedContextAudit.serializer(),
body,
)
)
}
} catch (e: IOException) {
Log.w(TAG, "fetchInjectedContext failed: ${e.message}")
Result.failure(e)
} catch (e: Exception) {
Log.w(TAG, "fetchInjectedContext parse error: ${e.message}")
Result.failure(e)
}
}
// ------------------------------------------------------------------
// Paired-device management (2026-04-11 security overhaul)
// ------------------------------------------------------------------
@@ -777,4 +907,16 @@ class RelayHttpClient(
val match = Regex("""filename\s*=\s*"?([^";]+)"?""", RegexOption.IGNORE_CASE).find(header)
return match?.groupValues?.get(1)?.trim()?.ifBlank { null }
}
/**
* Parse the relay's `X-Media-Sensitive` response header into a bool.
*
* The relay emits the header only when the media was flagged sensitive,
* with value `"1"` (and tolerates `"true"`). Any other value — or an
* absent header — means "not sensitive", so when in doubt we don't blur.
*/
private fun parseSensitiveHeader(header: String?): Boolean {
val value = header?.trim()?.lowercase() ?: return false
return value == "1" || value == "true"
}
}
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import android.util.Log
import com.hermesandroid.relay.data.ProfileConfigResponse
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import java.net.URI
@@ -0,0 +1,24 @@
package com.hermesandroid.relay.network.relay
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.network.shared.VoiceAudioClient
import java.io.File
/**
* Adapts the relay-only [RelayVoiceClient] (same package) to the neutral
* [VoiceAudioClient] routing seam in `network.shared`. Relay → shared is an
* allowed dependency direction under the ADR 34 package fence.
*/
class RelayVoiceAudioClientAdapter(
private val relayVoiceClient: RelayVoiceClient,
private val enhancedOverridesProvider: () -> EnhancedVoiceOverrides? = { null },
) : VoiceAudioClient {
override val route: VoiceAudioRoute = VoiceAudioRoute.Relay
override suspend fun transcribe(audioFile: File): Result<String> =
relayVoiceClient.transcribe(audioFile)
override suspend fun synthesize(text: String): Result<File> =
relayVoiceClient.synthesize(text, enhancedOverridesProvider())
}
@@ -1,7 +1,8 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import android.content.Context
import android.util.Log
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import kotlinx.coroutines.CompletableDeferred
@@ -208,7 +209,10 @@ class RelayVoiceClient(
* when done (typical pattern: keep the last N mp3s in the cache dir and
* let the OS reclaim on cache pressure).
*/
suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
suspend fun synthesize(
text: String,
enhanced: EnhancedVoiceOverrides? = null,
): Result<File> = withContext(Dispatchers.IO) {
val httpBase = resolveHttpBase()
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
val token = resolveBearerToken()
@@ -223,6 +227,16 @@ class RelayVoiceClient(
val bodyJson = buildJsonObject {
put("text", JsonPrimitive(text))
// Per-request enhanced-voice overrides. The relay maps these generic
// fields onto the active provider (Gemini/xAI) and ignores them for
// others — see voice.py:_extract_voice_overrides.
enhanced?.let { ov ->
ov.voice?.let { put("voice", JsonPrimitive(it)) }
ov.model?.let { put("model", JsonPrimitive(it)) }
ov.audioTags?.let { put("audio_tags", JsonPrimitive(it)) }
ov.personaPrompt?.let { put("persona_prompt", JsonPrimitive(it)) }
ov.language?.let { put("language", JsonPrimitive(it)) }
}
putProfile()
}.toString()
@@ -724,6 +738,7 @@ class RelayVoiceClient(
codec: String? = null,
optimizeStreamingLatency: Int? = null,
textNormalization: Boolean? = null,
autoSpeechTags: Boolean? = null,
fallbackEnabled: Boolean? = null,
): Result<VoiceOutputConfig> = withContext(Dispatchers.IO) {
val httpBase = resolveHttpBase()
@@ -755,6 +770,7 @@ class RelayVoiceClient(
put("optimize_streaming_latency", JsonPrimitive(it))
}
textNormalization?.let { put("text_normalization", JsonPrimitive(it)) }
autoSpeechTags?.let { put("auto_speech_tags", JsonPrimitive(it)) }
fallbackEnabled?.let { put("fallback_enabled", JsonPrimitive(it)) }
}
@@ -1254,6 +1270,13 @@ class RelayVoiceClient(
// True while a turn is awaiting its response. In persistent mode the idle
// guard only applies while a turn is active; between-turn idle is normal.
val activeTurn = AtomicBoolean(true)
// W3: set true once a turn is known to be a long/background Hermes run
// (e.g. `hermes.run.promoted`). The relay can legitimately go quiet for
// minutes while such a run executes, so the 90s idle guard would kill an
// otherwise-healthy turn. When set, the idle check is paused the same way
// persistent between-turn idle is — REALTIME_AGENT_MAX_TURN_MS remains
// the absolute backstop. Reset at every turn boundary.
val longRunningTurn = AtomicBoolean(false)
val inputChunks = buildList {
var offset = 0
var chunkId = 1L
@@ -1290,6 +1313,7 @@ class RelayVoiceClient(
turnStartedAtMs.set(System.currentTimeMillis())
lastEventAtMs.set(System.currentTimeMillis())
activeTurn.set(true)
longRunningTurn.set(false)
}
fun activateSocket(webSocket: WebSocket, generation: Long): Boolean {
while (true) {
@@ -1424,6 +1448,13 @@ class RelayVoiceClient(
lastPlayedAudioEventId.updateAndGet { current -> maxOf(current, playedAudioEventId) }
}
onEvent(event, control)
// W3: a promoted (background) Hermes run can legitimately
// leave the socket quiet for minutes. Flag the turn so the
// idle guard relaxes; MAX_TURN_MS still bounds it.
if (event.type == "hermes.run.promoted") {
longRunningTurn.set(true)
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
}
if (event.isAudioDelta) {
audioChunks += 1
val byteCount = event.byteCount ?: 0
@@ -1452,6 +1483,7 @@ class RelayVoiceClient(
// Turn boundary, not session boundary: keep the socket
// open for the next utterance.
activeTurn.set(false)
longRunningTurn.set(false)
onTurnComplete(summary)
} else {
if (completed.compareAndSet(false, true)) {
@@ -1572,14 +1604,26 @@ class RelayVoiceClient(
if (turnElapsedMs >= REALTIME_AGENT_MAX_TURN_MS) {
throw IOException("Realtime agent exceeded the turn limit")
}
if (idleElapsedMs >= REALTIME_AGENT_IDLE_TIMEOUT_MS) {
// W3: for a known long/background run the relay can go quiet
// for minutes — pause the idle guard the same way persistent
// between-turn idle is paused, keeping only the MAX_TURN_MS
// backstop above.
val idleGuardActive = !longRunningTurn.get()
if (idleGuardActive && idleElapsedMs >= REALTIME_AGENT_IDLE_TIMEOUT_MS) {
throw IOException("Realtime agent stalled waiting for relay events")
}
val waitMs = minOf(
REALTIME_AGENT_WAIT_SLICE_MS,
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
REALTIME_AGENT_IDLE_TIMEOUT_MS - idleElapsedMs,
).coerceAtLeast(1L)
val waitMs = if (idleGuardActive) {
minOf(
REALTIME_AGENT_WAIT_SLICE_MS,
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
REALTIME_AGENT_IDLE_TIMEOUT_MS - idleElapsedMs,
).coerceAtLeast(1L)
} else {
minOf(
REALTIME_AGENT_WAIT_SLICE_MS,
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
).coerceAtLeast(1L)
}
withTimeoutOrNull(waitMs) {
finished.await()
}?.let { return it }
@@ -1610,6 +1654,7 @@ class RelayVoiceClient(
turnStartedAtMs.set(System.currentTimeMillis())
lastEventAtMs.set(System.currentTimeMillis())
activeTurn.set(true)
longRunningTurn.set(false)
} else {
sendTurnPcm(ws, turn.inputPcm, turn.sampleRate)
}
@@ -2304,11 +2349,44 @@ data class VoiceProviderInfo(
val voiceId: String? = null,
val enabled: Boolean = false,
val available: Boolean = true,
/**
* Provider-specific enhanced-voice capability hint. Present (non-null) only
* for the TTS block when the relay's active provider supports per-request
* enhanced control (today: Gemini and xAI).
*/
val enhanced: EnhancedVoiceCapabilities? = null,
) {
val displayVoice: String? get() = voice ?: voiceId
val isEnabled: Boolean get() = enabled || (!provider.isNullOrBlank() && available)
}
/**
* Wire shape of the `tts.enhanced` block on `GET /voice/config` — the relay's
* provider-aware enhanced-voice capability advertisement. Mirrors
* `plugin/relay/voice.py:_enhanced_voice_block`. `voices`/`models` may be empty
* (e.g. xAI uses a free-text voice field); the UI renders from the flags.
*/
@Serializable
data class EnhancedVoiceCapabilities(
val provider: String? = null,
val supported: Boolean = false,
val voices: List<String> = emptyList(),
val models: List<String> = emptyList(),
@SerialName("audio_tag_models")
val audioTagModels: List<String> = emptyList(),
@SerialName("audio_tags_enabled")
val audioTagsEnabled: Boolean = false,
@SerialName("audio_tags_label")
val audioTagsLabel: String = "Expressive tone tags",
@SerialName("supports_persona")
val supportsPersona: Boolean = false,
@SerialName("supports_language")
val supportsLanguage: Boolean = false,
@SerialName("persona_prompt_file")
val personaPromptFile: String? = null,
val overrides: List<String> = emptyList(),
)
@Serializable
data class RealtimeVoiceConfig(
val success: Boolean = false,
@@ -2481,6 +2559,8 @@ data class VoiceOutputConfig(
val codec: String = "pcm",
val optimize_streaming_latency: Int = 1,
val text_normalization: Boolean = false,
/** xAI expressive speech tags on the streaming renderer (xai_tts only). */
val auto_speech_tags: Boolean = false,
val fallback_enabled: Boolean = true,
val fallback_provider: String? = null,
val providers: List<RealtimeProviderInfo> = emptyList(),
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network.models
package com.hermesandroid.relay.network.relay.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.shared
import android.content.Context
import android.net.ConnectivityManager
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.shared
import android.util.Log
import com.hermesandroid.relay.data.EndpointCandidate
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.shared
import android.content.Context
import android.net.ConnectivityManager
@@ -0,0 +1,31 @@
package com.hermesandroid.relay.network.shared
import kotlinx.serialization.json.JsonObject
/**
* Transport-neutral result of a phone-control dispatch.
*
* Shared vocabulary between the relay bridge path
* ([com.hermesandroid.relay.network.relay.BridgeCommandHandler], which produces
* it) and the upstream chat path
* ([com.hermesandroid.relay.network.upstream.ChatHandler], which renders a
* phone-action bubble from it). It is a passive DTO — not a client — so it
* lives in `network.shared` to keep the upstream↔relay package fence intact
* (ADR 34); neither side depends on the other to speak it.
*
* - [status] — HTTP-style status of the dispatch (200 = ok).
* - [errorMessage] — human-readable failure text, or null on success.
* - [errorCode] — machine error code when the dispatch failed and was
* classified, or null.
* - [resultJson] — the raw result object, for callers that need
* action-specific fields (e.g. the resolved phone number from
* /search_contacts). Optional.
*/
data class LocalDispatchResult(
val status: Int,
val errorMessage: String?,
val errorCode: String?,
val resultJson: JsonObject?,
) {
val isSuccess: Boolean get() = status in 200..299
}
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.shared
import java.net.URI
@@ -0,0 +1,117 @@
package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.VoiceAudioRoute
import java.io.File
/**
* Transport-neutral STT/TTS contract. The routing seam between the Standard
* (dashboard) and Relay voice clients — implementations live in `network.upstream`
* (`StandardHermesVoiceClient`) and `network.relay` (`RelayVoiceAudioClientAdapter`),
* while this interface and the [AutoVoiceAudioClient] router stay dependency-neutral
* so neither voice backend leaks across the upstream/relay package fence (ADR 34).
*/
interface VoiceAudioClient {
val route: VoiceAudioRoute
/**
* The route a call would ACTUALLY use right now. For a concrete backend this
* equals [route]; for the [AutoVoiceAudioClient] router it resolves `Auto`
* against live readiness (relay-first). Callers that need to reason about
* the backend's capabilities (e.g. "is standard global-TTS in play?") must
* use this, not the configured preference.
*/
val effectiveRoute: VoiceAudioRoute
get() = route
suspend fun transcribe(audioFile: File): Result<String>
suspend fun synthesize(text: String): Result<File>
}
/**
* Routes each STT/TTS call to the Standard (dashboard) or Relay voice client.
*
* Auto preference order is **Relay first, then Standard**: a paired Relay is
* the purpose-built mobile facade — profile-aware voice config, no dashboard
* sign-in dependency — so users who installed the plugin keep the richer
* path. Standard is the zero-plugin route for vanilla Hermes installs and is
* used whenever Relay isn't configured/paired (or fails mid-call). Power
* users can force either route in Voice Settings.
*
* Depends only on the [VoiceAudioClient] abstraction (both backends are passed
* in as the interface), so this router carries no upstream or relay imports.
*/
class AutoVoiceAudioClient(
private val standardClient: VoiceAudioClient,
private val relayClient: VoiceAudioClient,
private val routeProvider: () -> VoiceAudioRoute,
private val standardReadyProvider: () -> Boolean,
private val relayReadyProvider: () -> Boolean,
) : VoiceAudioClient {
override val route: VoiceAudioRoute
get() = routeProvider()
/**
* Resolve the configured preference to the backend a call would land on:
* `Standard`/`Relay` are honored verbatim; `Auto` prefers Relay when it's
* ready (matching [runAuto]) and falls back to Standard otherwise. Used to
* decide whether standard-only limitations (global TTS) currently apply.
*/
override val effectiveRoute: VoiceAudioRoute
get() = when (routeProvider()) {
VoiceAudioRoute.Standard -> VoiceAudioRoute.Standard
VoiceAudioRoute.Relay -> VoiceAudioRoute.Relay
VoiceAudioRoute.Auto ->
if (relayReadyProvider()) VoiceAudioRoute.Relay else VoiceAudioRoute.Standard
}
override suspend fun transcribe(audioFile: File): Result<String> =
runWithSelectedRoute { it.transcribe(audioFile) }
override suspend fun synthesize(text: String): Result<File> =
runWithSelectedRoute { it.synthesize(text) }
private suspend fun <T> runWithSelectedRoute(
block: suspend (VoiceAudioClient) -> Result<T>,
): Result<T> {
return when (routeProvider()) {
VoiceAudioRoute.Standard -> {
if (!standardReadyProvider()) {
Result.failure(
IllegalStateException(
"Vanilla Hermes voice is not available — check dashboard sign-in in Manage",
),
)
} else {
block(standardClient)
}
}
VoiceAudioRoute.Relay -> {
if (!relayReadyProvider()) {
Result.failure(IllegalStateException("Relay voice is not available"))
} else {
block(relayClient)
}
}
VoiceAudioRoute.Auto -> runAuto(block)
}
}
private suspend fun <T> runAuto(
block: suspend (VoiceAudioClient) -> Result<T>,
): Result<T> {
var relayFailure: Result<T>? = null
if (relayReadyProvider()) {
val result = block(relayClient)
if (result.isSuccess || !standardReadyProvider()) return result
relayFailure = result
}
if (standardReadyProvider()) {
val result = block(standardClient)
if (result.isSuccess) return result
return relayFailure ?: result
}
return relayFailure ?: Result.failure(
IllegalStateException("Voice needs a reachable Hermes dashboard or Relay voice route"),
)
}
}

Some files were not shown because too many files have changed in this diff Show More