Compare commits

...
Author SHA1 Message Date
Bailey DixonandClaude Fable 5 1ebf628304 fix(android): app-start UI freeze from Keystore/Tink global-lock contention
Cold starts froze the UI for up to ~11s (Skipped 1386 frames, Davey!
duration=11596ms). Logcat showed the main thread waiting 4s+ inside
AndroidKeysetManager$Builder.build() behind DefaultDispatcher workers:
EncryptedDashboardCookieStore built its Keystore-backed prefs EAGERLY
in its constructor - a 1-4s operation on StrongBox devices that
serializes through a process-global Tink lock - and several paths
(Manage per-fetch client factory, connection validation probe, session
clear, and the new Manage pre-warm at 8 instances per sweep) each
constructed their own copies, stacking seconds-long lock holds that
main-thread keystore users queued behind.

- EncryptedDashboardCookieStore: keystore-backed store is now built
  lazily on first cookie access (always an OkHttp/IO thread);
  construction is free on any thread.
- ConnectionViewModel.dashboardCookieStoreFor(connectionId): ONE cached
  store per connection, now used by Manage, the validation probe,
  session clear, standard voice, and the pre-warm - one keyset build
  per connection per process instead of one per consumer.
- prewarmDashboardManage: takes the shared store and builds ONE
  DashboardApiClient for the whole sweep (core extracted to
  fetchDashboardSectionStateWith); NonCancellable client shutdown.
- DashboardOAuthSignInDialog cookieStoreFactory widened to the
  DashboardCookieStore interface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:02:38 -04:00
Bailey DixonandClaude Fable 5 b945038a44 Merge feature/manage-loading-polish: Manage payload cache + pre-warm + overview polish
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:43:51 -04:00
Bailey DixonandClaude Fable 5 bd6f3b1aea feat(android): Manage payload cache + pre-warm, skeleton and overview polish
Every entry to Manage was a cold load: the payload cache lived in
remember{} and died with the screen, and the skeleton stacked four
progress bars with fake narrative labels that read like three different
failures. The KPI glyphs (ok/.../!) needed decoding and the status
banner crammed five facts into one line that two trailing buttons (one
a duplicate "Connection" link) kept truncating.

- DashboardPayloadCache: process-lifetime singleton keyed
  connection|dashboardUrl|section; Loaded.fetchedAtMillis drives a 30s
  stale-while-revalidate window (fresh -> no fetch; stale -> cached
  content stays up, thin refresh bar only). Sign-in/out clear as before.
- App-start pre-warm: fetch core extracted to
  fetchDashboardSectionState(); prewarmDashboardManage() fills cold
  keys only, aborts on first unreachable/auth failure, never marks
  Loading so it cannot fight the open screen. RelayApp fires it
  (1.5s debounce) when the persisted dashboard snapshot says reachable
  and signed-in/auth-free, and again after a route handoff.
- Skeleton: one LinearProgressIndicator + three pulsing content-shaped
  ghost cards; no per-card spinners, no fake labels.
- KPI strip: section count / tone-colored dashboard state word
  (ready / sign-in / offline / error) / server version. RelayMetricCard
  gains an optional valueColor.
- Status banner: two-line layout (state + identity + Sign out, then
  URL - route - checked time); duplicate "Connection" button removed -
  the Connections tile is rendered directly below it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:43:51 -04:00
Bailey DixonandClaude Fable 5 e8b007f0ec Merge feature/manage-route-visibility: Manage dashboard target line + per-route sign-in hint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:22:51 -04:00
Bailey DixonandClaude Fable 5 fcbbb85713 feat(android): Manage names its dashboard target + per-route sign-in hint
Manage over a roamed route failed opaquely: the dashboard (:9119) is a
separate server from the API (:8642), sessions are host-scoped cookies,
and an explicit dashboard URL override pins the surface - but the tab
never said which URL it was hitting or why a home sign-in did not carry
over to the Tailscale host.

- Persistent "Dashboard: <url> - <route> route" target line under the
  Manage mode strip (route suffix only when the resolver has moved the
  dashboard off the persisted URL).
- "Dashboard unavailable" card names the exact URL that failed.
- Sign-in card explains per-host cookies when the route has moved:
  sign in once here, the app keeps both sessions.
- Overview connection banner gains the route label.
- New ConnectionViewModel.dashboardRouteMovedHint; the existing
  standardVoiceSignInRouteHint refactored to reuse it (semantics
  unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:22:51 -04:00
Bailey DixonandClaude Fable 5 22d3ee3d6f Merge feature/standard-voice-dashboard-surface: standard voice dashboard surface, route switching + probe visibility
Standard (no-plugin) voice retargeted at the dashboard surface with
Manage parity (models/keys/profiles/skills hub/SOUL editor); standard-
route network auto-switch (LAN <-> Tailscale roaming without Relay) with
escalation + route-candidate preservation; Routes editor (add/edit/
remove fallback routes); remote-access discoverability across setup and
status; visible route-probe outcomes ("Probe now"/"Use now" no longer
fail silently) and bare-host URL forgiveness with port guidance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:02:02 -04:00
Bailey DixonandClaude Fable 5 e7b6f698e0 docs: changelog + devlog for route-probe visibility; URL and ADB-over-Tailscale guidance
- CHANGELOG [Unreleased]: per-route reachability verdicts, bare-host
  URL forgiveness + port copy, silent Re-check/Use-now fix.
- DEVLOG: field-report diagnosis (remote phone on tailnet, route never
  switched, "Resolving" over the internal relay URL) and the fix set.
- user-docs remote-access: new "Which URL Do I Enter?" section - API
  port 8642 vs dashboard 9119 vs relay 8767; raw 100.x Tailscale IP
  needs http:// (and an API server bound beyond loopback) while a
  *.ts.net hostname behind tailscale serve is https-only-by-name.
- user-docs troubleshooting: pairing Android Studio wireless debugging
  to a phone over its Tailscale IP (adb pair vs adb connect ports).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:01:28 -04:00
Bailey DixonandClaude Fable 5 6d180e4c67 fix(android): visible route-probe outcomes + bare-host URL forgiveness
"Probe now"/"Use now" failed silently when every saved route lost its
health probe: probeAndReconnect() early-returned without publishing on
the standard (no relay socket) path, leaving the Routes card on
"Current: Resolving" over the connection's relay URL with no feedback,
and probeNow()'s fixed 100ms delay always lost the race against a real
resolve (4s+ when LAN must time out), pointing the follow-up health
checks at the stale route.

- ConnectionManager: awaitable probeAndReconnectNow() that always
  publishes the resolve outcome (live-socket transient-miss guard
  preserved); probeAndReconnect() is now a launch wrapper.
- EndpointResolver: per-route RouteProbeOutcome map (reachable / human
  failure reason, survives clearCache) with the TLS case spelled out -
  an https route against the plain-HTTP API server fails every probe
  and was previously indistinguishable from "server down".
- ConnectionViewModel: RouteProbeStatus (Idle/Probing/Done(winner));
  probeNow() awaits the resolve, rebuilds the API client on route
  change, queues a re-run when tapped mid-probe; save/remove route end
  in a visible probe cycle.
- Routes UI: "Checking..." progress on Re-check, per-row full URL
  (scheme visible) + last verdict, explicit "No route reachable -
  using saved URL ..." instead of eternal "Resolving".
- URL forgiveness: Connection.normalizeApiUrlInput() defaults bare
  hosts to http:// + the surface's port (API 8642, dashboard 9119);
  explicitly-schemed URLs pass verbatim. Applied across the wizard,
  route editor, and updateApiServerUrl; field copy names the ports;
  route editor previews "Will save: ..." live.

Tests: resolver outcome verdicts, probeAndReconnectNow publish-on-
failure regression, 10 normalizeApiUrlInput cases incl. the bare
Tailscale IP end-to-end journey. Lint + unit suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:01:13 -04:00
Bailey DixonandClaude Fable 5 307f8389f4 docs: refresh README and Play listing around the standard-first story
README: one Quick Start mirroring the app's capability card (Chat /
Manage / Voice / Remote / Relay), voice no longer described as
relay-only, Manage + remote access promoted to headline features,
desktop CLI trimmed behind an explicit alpha banner stating the
planned refocus into a remote hands connector, stale CI badge /
broken anchors / version-pinned what's-new section removed.

Play listing: end-user-first short description (76/80), 3-step quick
start, Manage + Works Away From Home feature blocks, corrected
no-plugin voice story, v0.8.1 release notes (464/500). Compliance
sections kept verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:53:41 -04:00
Bailey DixonandClaude Fable 5 6eadec3d5c docs: changelog + devlog for remote-access discoverability
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:42:38 -04:00
Bailey DixonandClaude Fable 5 fc9ff2b249 feat(android): make remote access discoverable across setup and status
A user following the wizard's happy path (scan LAN, pick server, connect)
ended up with a LAN-only connection and learned remote access existed
only when stranded on "Hermes API unreachable" away from home. Four
nudges, each at a moment the user is actually paying attention:

- Standard setup: the Tailscale URL field moves out of the collapsed
  Advanced expander into the main form as "Remote access - Tailscale URL
  (optional)", with a "Tailscale detected on this phone" hint when the
  detector fires.
- Setup result card: new "Remote" readiness line - green when a fallback
  route exists, neutral "LAN only - add a Tailscale or public route"
  otherwise. StandardApiSetupResult gains remoteRouteConfigured.
- Status pill: "Hermes API unreachable" now diagnoses instead of just
  reporting - single-route connections get "Away from the server's
  network? Add a Tailscale or public route" (sharpened when the phone is
  on Tailscale); multi-route connections get "none of the N routes
  responded, fallbacks retried automatically".
- Connections card: when the phone is on Tailscale but the connection
  has no Tailscale route, an "Add Tailscale route" shortcut opens the
  route editor directly (editor state hoisted out of the routes expander
  so the nudge works while the list is collapsed).

user-docs: remote-access guide documents the on-phone route editor, the
LAN-only callouts, and the one-sign-in-per-route cookie behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:42:01 -04:00
Bailey DixonandClaude Fable 5 8b5698b4b3 docs: changelog + devlog for pre-release polish and routes editor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:30:10 -04:00
Bailey DixonandClaude Fable 5 f8755108a8 feat(android): add/edit/remove fallback routes from the Connections Routes card
The Relay path provisions multi-route candidates via the v3 pairing QR's
endpoints array, but the standard (no-Relay) path had only the wizard's
optional Tailscale field at setup time - no way to add a remote route
after the fact, and no way to edit or remove one. The Routes card was
read-only (prefer / probe / view pin).

- EndpointsCard: "Add route" action, Edit/Remove menu items on fallback
  rows (priority > 0; the primary row mirrors the connection's API URL
  and stays protected), remove confirmation, and a RouteEditorDialog
  with Tailscale/Public/Custom role chips + URL validation. Empty-state
  copy now offers manual add alongside the QR path.
- ConnectionViewModel.saveExtraRoute / removeExtraRoute: persist to
  Connection.routeCandidates, seed from legacy sources first (per-device
  PairingPreferences, or a primary synthesized from saved URLs) so an
  edit never hides routes the card was showing, guard host:port
  collisions, clear a stale preferred-route override on remove, and kick
  a cache-cleared re-resolve so the new route takes effect immediately.
- Wizard's Tailscale field now mentions routes are editable later under
  Settings -> Connections -> Routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:29:12 -04:00
Bailey DixonandClaude Fable 5 98f2e549cc fix(android): pre-release polish for route switching
- Gate network-change socket actions on shouldReconnect: a network event
  whose resolved winner differed from the last URL could resurrect a
  relay socket the user explicitly disconnected (pre-existing hole the
  switchover refactor preserved). Routes still publish for HTTP surfaces.
- refreshActiveEndpoint keeps the live route on a transient probe miss
  while the relay socket is Connected, mirroring the network-callback
  guard, instead of downgrading every HTTP surface to the saved URL.
- Sign-in route hint now uses the endpoint display label (Tailscale, not
  tailscale) and the chat mic toast is route-aware too.
- Reset the unreachable-escalation counter while no API client exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:19:15 -04:00
Bailey DixonandClaude Fable 5 237d38affd docs: changelog + devlog for standard-route network auto-switch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:55:12 -04:00
Bailey DixonandClaude Fable 5 b3402a976e fix(android): route-resolve escalation, per-route sign-in hint, route-candidate preservation
Follow-ups to the standard-route network switchover fix:

- Periodic API health loop now escalates two consecutive Unreachable
  probes into a cache-cleared route re-resolve - the safety net for
  network changes the NetworkCallback missed (e.g. always-on VPN keeping
  "internet available" true through a Wi-Fi -> cell handoff). Client
  rebuild stays reactive via the effectiveApiServerUrl collector.
- Voice Settings explains the per-host dashboard cookie gate when the
  resolver has moved the dashboard off the persisted route: new
  standardVoiceSignInRouteHint flow + route-aware sign-in copy, plus a
  Diagnostics entry from the availability probe.
- URL edits no longer wipe stored fallback routes: new
  Connection.mergeRouteCandidates preserves priority>0 extras (wizard
  Tailscale URL, pairing-payload endpoints) verbatim across
  updateApiServerUrl / updateRelayUrl / connectRelay /
  testRelayReachable / saveApiAndProbeVoice / saveStandardApiConnection.
- Drop the duplicate networkStatus -> revalidate() collector left
  behind by the rechrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:53:26 -04:00
Bailey Dixon efcab7875e Merge fix/standard-route-network-switchover: standard-route network auto-switch (items 1-3) 2026-06-11 08:41:35 -04:00
Bailey DixonandClaude Fable 5 75b51c75de fix(android): network-aware route switching for standard (no-Relay) connections
ConnectionManager's ADR 24 NetworkCallback only registered inside
connect(), and its onAvailable/onLost handlers bailed without a relay
socket URL - so standard connections never re-resolved LAN/Tailscale
routes on network change, and the only recovery was backgrounding the
app (ON_RESUME -> revalidate()).

- Register the NetworkCallback at construction; no-op without context.
- Unify onAvailable/onLost into a debounced re-resolve that publishes
  activeEndpoint even with no socket (HTTP surfaces follow via
  effectiveApiServerUrl / effectiveDashboardUrl); socket swap/reconnect
  behavior for the relay path is preserved.
- refreshActiveEndpoint(clearProbeCache) + revalidate() now clear the
  resolver's probe cache so a just-died route can't win the resolve for
  the rest of the 60s positive TTL.
- activeDashboardUrl() now delegates to effectiveDashboardUrl, so
  standard voice + its availability probe follow the resolved route
  instead of pinning to the persisted LAN dashboard URL.

Robolectric coverage: callback registration/unregistration lifecycle,
socketless onAvailable publishing activeEndpoint, and stale-cache vs
clearProbeCache resolve behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:41:18 -04:00
Bailey DixonandClaude Fable 5 7adb146763 docs: changelog + devlog for chat polish and quick-start docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:25:50 -04:00
Bailey DixonandClaude Fable 5 603f0e8f24 docs(user-docs): two-minute Quick Start + Manage and voice refresh
- New guide/quick-start.md leads the sidebar: install -> connect ->
  capability card -> talk, with power tools in a collapsed details
  block. Detail stays on Installation & Setup.
- features/dashboard.md Android Manage section now lists the real
  per-section capabilities (skills hub browse/preview/install, model
  picker with cost confirm, Keys set/reveal/clear, profile create/
  describe/SOUL editing) and fixes the stale claim that SOUL editing
  needs the paired inspector.
- features/voice.md Requirements split standard-route (dashboard audio,
  one Manage sign-in) from relay-route requirements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:25:50 -04:00
Bailey DixonandClaude Fable 5 1fa41dacee feat(android): quote-in-reply, share conversation, Manage overflow, ambient tip
- Message long-press now opens a Copy / "Quote in reply" menu when the
  quote handler is wired (quote drops the text into the input as a
  Markdown blockquote); copy-only call sites keep the direct copy.
- Chat top bar gains a Share icon (visible with messages) exporting the
  conversation as Markdown via the system share sheet.
- Manage cards with 5+ actions keep three inline and fold the rest
  behind a "More" dropdown - profile cards no longer wrap two rows.
- Settings -> Appearance documents the ambient long-press/tap gesture,
  keeping the hidden entry discoverable incl. via screen readers.

Audit note: scroll-to-bottom FAB, session drawer search, not-connected
empty state with Connect CTA, stop-during-streaming, and tappable
suggestion chips already existed - no changes needed there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:25:49 -04:00
Bailey DixonandClaude Fable 5 445fc98be8 docs: lead voice.md with the standard (no-Relay) route + changelog/devlog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:59:33 -04:00
Bailey DixonandClaude Fable 5 92277c7d07 feat(android): floating status pill, gesture ambient mode, media scope label
- RelayStatusStrip becomes an inset rounded capsule floating above the
  gesture area; the previous zero-radius bordered bar read as a hard
  rectangle against rounded display corners.
- Ambient (fullscreen sphere) mode drops its top-bar toggle: long-press
  the conversation background to enter (message bubbles keep their copy
  long-press and consume first), tap or long-press anywhere to return,
  with a transient "tap to return to chat" hint pill on each entry.
- Media settings now state on-screen that they apply only to
  Relay-delivered attachments, not standard connections or chat uploads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:59:33 -04:00
Bailey DixonandClaude Fable 5 1a21601f14 fix(android): unclosed nested KDoc comments + missing import broke compile
Kotlin block comments NEST: writing the glob `/api/audio/*` (or
/v1/audio/*) inside a KDoc opens a nested comment that the KDoc
terminator does not close, swallowing code until a later */ - producing
"Unclosed comment" at EOF and ~1080 cascade unresolved-reference errors
(ConnectionViewModel and StandardHermesVoiceClient never compiled).
Spell the routes without the trailing star in all three block comments;
line comments were unaffected. Also add the missing RoundedCornerShape
import used by the skills-hub and SOUL editor dialogs.

These slipped through because the local gate piped gradlew through
`tail`, which made the pipeline exit 0 regardless of build status.
Verified for real this time (pipefail): compileSideloadDebugKotlin,
compileGooglePlayDebugKotlin, :app:lint, and
testGooglePlayDebugUnitTest all pass with GRADLE_EXIT=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:40:00 -04:00
Bailey DixonandClaude Fable 5 e8661d3439 docs: changelog + devlog for capability card, quiet standard UX, hub featured
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:22:00 -04:00
Bailey DixonandClaude Fable 5 7f1b1ffcab feat(android): concrete feature rows on onboarding Chat/Manage/Power pages
The cockpit refresh reworked Welcome and the Connect wizard but left the
middle pages as icon + one sentence. Each now carries three feature rows
in the Welcome page's row style: Chat = streaming / profiles / voice
(no extra install); Manage = control / skills hub / one sign-in unlocks
voice; Power = terminal / bridge / realtime. Copy leads standard-first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:21:59 -04:00
Bailey DixonandClaude Fable 5 a1a55c2cef feat(android): voice readiness line on the connection wizard result card
StandardSetupResultCard already scored Chat / Manage / Relay; complete
the capability card with Voice. StandardApiSetupResult gains
voiceAvailability, settled in the same setup probe (dashboard status ->
auth -> audio-route HEAD) and mirrored into the live availability flow,
so the card and the mic gate are accurate the moment setup completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:21:59 -04:00
Bailey DixonandClaude Fable 5 ceab6a1184 fix(android): no relay warnings or mislabeled errors on standard-only voice
Voice Settings fetched three relay configs on open and snackbar-ed every
failure, so a standard-only user got "Relay unreachable" snackbars for a
route they do not use. Gate the fetches on relayVoiceReady and replace
the relay-backed sections (Fallback TTS / Voice Output editor / Realtime
config) with a quiet "Voice Providers" note: speech uses the server-
configured TTS/STT; pair Relay to pick providers from the phone. The
STT section and Test Current Engine stop showing permanent "loading...".

RelayErrorClassifier: preserve IllegalStateException messages (voice
routing throws actionable copy like "needs dashboard sign-in - open
Manage" that was being rewritten into relay advice), and neutralize
connect/timeout/unknown-host bodies to say "server" since those
exceptions also surface from API/dashboard routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:21:59 -04:00
Bailey DixonandClaude Fable 5 23ed1190b4 feat(android): skills hub featured view on dialog open
GET /api/skills/hub/sources populates the browse dialog before the first
search: a "Sources: Official (Nous), skills.sh, ..." line plus the
centralized index's featured skills, marked installed via the same lock
map. Best-effort - failures stay silent and search still works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:21:59 -04:00
Bailey DixonandClaude Fable 5 c7666d7b93 docs: changelog entries for voice/manage work + session log follow-up
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:26:25 -04:00
Bailey DixonandClaude Fable 5 333e369a77 style(android): re-scope blue softening to the active connection card
Feedback: the brand Electric blue was right everywhere except as a
full-card fill. Revert Electric to #111DFF (cockpit selected panels,
pills, light-theme primary keep the vivid blue) and add ElectricMuted
(#4F5BD5), applied only to the active connection card as a 0.42-alpha
wash in place of the full-opacity primaryContainer fill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:26:24 -04:00
Bailey DixonandClaude Fable 5 dcc7be5ed2 feat(android): skills hub browse/install and SOUL editing in Manage
- Skills tab gains "Browse hub" (multi-source search via
  /api/skills/hub/search with installed-state marking, SKILL.md preview
  before install, install/uninstall) and "Update installed". Hub
  mutations are async server-side spawns ({ok, pid}) - the UI reports
  "started" and keeps install rows disabled to prevent double-fires;
  dashboard client read timeout raised to 45s so the server's 30s
  search fan-out can't die client-side at the edge.
- Profiles gain "Edit SOUL": fetches the full SOUL.md (dashboard GET is
  untruncated, safe round-trip), monospace full-file editor dialog,
  PUT on save; creates the file when absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:26:24 -04:00
Bailey DixonandClaude Fable 5 45df538f29 docs: record dashboard voice/audio surface and session log
CLAUDE.md dashboard web-server paragraph now lists the audio, model,
env, and profile routes plus the standard-voice cookie-auth model and
the api_server audio_api:false status. DEVLOG entry for 2026-06-10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:03:57 -04:00
Bailey DixonandClaude Fable 5 703ef8a30b style(android): soften Electric brand blue to indigo
#111DFF (near-pure RGB blue) was too saturated against the muted
navy/periwinkle palette and too dark under Paper text on the selected
connections card. #4F5BD5 stays on the Relay/Purple hue axis, roughly
doubles luminance, and keeps Paper text above WCAG AA. Drives
relaySelectedPanel, dark primaryContainer, and light primary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:03:57 -04:00
Bailey DixonandClaude Fable 5 b91509a331 feat(android): add Manage model, keys, and profile parity actions
Close the phone-vs-hermes-desktop capability gap on the dashboard surface:

- Models tab: "Change main model" opens an /api/model/options picker
  (unauthenticated providers visible but unselectable, pointing at Keys);
  POST /api/model/set with the upstream expensive-model confirm_required
  round-trip surfaced as a confirmation dialog.
- New Keys tab over GET /api/env: Set (write-only, password-masked),
  Reveal (POST /api/env/reveal, server rate-limited), Clear (DELETE with
  JSON body). Channel-managed vars stay visible, tagged "channel", since
  the app has no Channels page to defer to.
- Profiles tab: New profile (POST /api/profiles, clone-from-default
  checkbox), Describe (PUT .../description, blank clears), per-profile
  Model via the shared picker (PUT .../model).
- Overview gains Models + Keys tiles; input-backed action kinds route to
  dialogs instead of firing immediately; successful dashboard sign-in now
  refreshes standard-voice availability so the mic unlocks without
  waiting for the next health tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:03:57 -04:00
Bailey DixonandClaude Fable 5 a3eebbc4b4 fix(android): route standard voice through dashboard surface with cookie auth
StandardHermesVoiceClient implemented the hermes-desktop /api/audio/*
contract but aimed it at the API server (:8642) with a bearer header.
Verified against upstream/main (d1383a6b1, 2026-06-10): api_server has no
audio routes (capabilities advertise audio_api: false; PR #8199 unmerged) -
the routes live on the dashboard web server behind cookie-session auth.
Standard-only users got an enabled mic and a 404 every turn; Auto-route
relay users uploaded full base64 audio to a 404 before each fallback.

- StandardHermesVoiceClient: dashboardUrlProvider + per-connection
  encrypted cookie jar shared with Manage sign-in (new
  DynamicDashboardCookieJar resolves the store per request so connection
  switches stay correct); bearer dropped; 401/404 copy points at Manage
  sign-in / server update.
- New StandardVoiceAvailability (Ready/SignInRequired/Unreachable/
  Unsupported/Unknown) fed by probeStandardVoice(): /api/status ->
  /api/auth/me when gated -> HEAD route-existence check (405 = present).
  Replaces HermesApiClient.probeAudioApi(); re-probes after dashboard
  sign-in/out via refreshStandardVoice().
- AutoVoiceAudioClient Auto order flipped to Relay-first: paired Relay is
  profile-aware and needs no dashboard sign-in; Standard is the
  zero-plugin path for vanilla installs.
- Voice Settings: per-route live status lines, "Sign in via Manage" CTA,
  unsupported-build hint; Realtime Agent labelled relay-required with an
  inline error + guidance when selected without one. Chat mic toast is
  availability-aware.
- DashboardApiClient grows the model/env/profile write methods consumed by
  the Manage parity commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:03:32 -04:00
Bailey Dixon 8a9e0327e1 fix(android): gate startup chrome behind sphere 2026-06-10 18:58:47 -04:00
Bailey Dixon ecefc8b908 fix(android): smooth bridge return and manage loading 2026-06-10 18:28:38 -04:00
Bailey Dixon c9fe4c40a8 fix(android): refine manage and bridge return chrome 2026-06-10 17:15:39 -04:00
Bailey Dixon bda0beec4f fix(android): streamline manage detail layout 2026-06-10 16:55:11 -04:00
Bailey Dixon 55a4227e4d feat(android): apply relay cockpit refresh 2026-06-09 22:29:25 -04:00
Bailey Dixon 22083d4f28 merge standard dashboard power tools split 2026-06-07 19:23:56 -04:00
Bailey Dixon 7d56289f7c feat(android): add standard dashboard power tools split 2026-06-07 19:23:28 -04:00
Bailey Dixon 7d667b9096 feat(android): prefer upstream skills endpoint (#63) 2026-06-04 21:03:59 -04:00
Bailey Dixon f7edffcd81 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
#	RELEASE_NOTES.md
2026-05-26 21:36:26 -04:00
Bailey Dixon ed9dafe571 Merge pull request #62 from Codename-11/fix/voice-barge-in-crash-0.8.1
release(android): android-v0.8.1 — voice barge-in crash hotfix
2026-05-26 21:33:23 -04:00
Bailey Dixon 851dfc7f25 Merge remote-tracking branch 'origin/main' into fix/voice-barge-in-crash-0.8.1 2026-05-26 21:23:32 -04:00
Bailey DixonandClaude Opus 4.7 b0df2c83f5 release(android): android-v0.8.1
Patch release: fixes the voice-mode barge-in crash on the legacy TTS
playback path (ExoPlayer audioSessionId read off-main). versionCode
10 -> 11. No new features — ADR 33 / persistent-session work stays on
dev for the next minor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:22:00 -04:00
Bailey DixonandClaude Opus 4.7 a8a4bc7514 fix(android): read ExoPlayer audio session id on main thread
Voice chat crashed the instant Hermes began replying when barge-in was
enabled and audio used the legacy /voice/synthesize (Media3) path:

  IllegalStateException: Player is accessed on the wrong thread.
  Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'

BargeInListener runs its mic reader on Dispatchers.IO and, to attach
AcousticEchoCanceler, polls an audioSessionIdProvider lambda. On the
legacy path that provider read exoPlayer.audioSessionId directly.
ExoPlayer is thread-confined — its getAudioSessionId() getter calls
verifyApplicationThread() and throws off-main. (The realtime PCM path
was immune: it provides an AudioTrack session id, which is thread-safe.)

VoicePlayer.audioSessionId now serves a @Volatile cache populated from
main-thread Media3 callbacks (AnalyticsListener.onAudioSessionIdChanged
plus a belt-and-braces read in onIsPlayingChanged), so it is safe to
read from any thread.

Adds VoicePlayerTest coverage: the getter reflects the cached id, never
re-invokes the thread-confined getter, and defaults to 0 before the
audio track is allocated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:20:43 -04:00
Bailey DixonandClaude Opus 4.7 b53df95bbf docs: correct stale VoicePlayer "MediaPlayer" references to Media3 ExoPlayer
VoicePlayer was migrated to a single Media3 ExoPlayer (gapless TTS queue)
in the V5 voice-quality pass, but two current-state descriptions still
called it a MediaPlayer — the CLAUDE.md Key Files row and the decisions.md
voice references. The CLAUDE.md drift actively misled a crash diagnosis.
Also note audioSessionId is now a thread-safe @Volatile cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:00:06 -04:00
Bailey Dixon f879fbbd51 Merge pull request #60 from Codename-11/fix/voice-barge-in-wrong-thread
fix(android): read ExoPlayer audio session id on main thread
2026-05-26 20:41:28 -04:00
Bailey DixonandClaude Opus 4.7 a586f3dd60 fix(android): read ExoPlayer audio session id on main thread
Voice chat crashed the instant Hermes began replying when barge-in was
enabled and audio used the legacy /voice/synthesize (Media3) path:

  IllegalStateException: Player is accessed on the wrong thread.
  Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'

BargeInListener runs its mic reader on Dispatchers.IO and, to attach
AcousticEchoCanceler, polls an audioSessionIdProvider lambda. On the
legacy path that provider read exoPlayer.audioSessionId directly.
ExoPlayer is thread-confined — its getAudioSessionId() getter calls
verifyApplicationThread() and throws off-main. (The realtime PCM path
was immune: it provides an AudioTrack session id, which is thread-safe.)

VoicePlayer.audioSessionId now serves a @Volatile cache populated from
main-thread Media3 callbacks (AnalyticsListener.onAudioSessionIdChanged
plus a belt-and-braces read in onIsPlayingChanged), so it is safe to
read from any thread.

Adds VoicePlayerTest coverage: the getter reflects the cached id, never
re-invokes the thread-confined getter, and defaults to 0 before the
audio track is allocated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:31:23 -04:00
Bailey Dixon 65db2e60d4 docs: add relay architecture spec page 2026-05-26 19:30:54 -04:00
Bailey Dixon d41e1b659b docs: add relay architecture spec page 2026-05-26 19:22:06 -04:00
Bailey Dixon de9988322b Merge pull request #59 from Codename-11/fix/realtime-voice-error-display
fix(voice): classify realtime voice.error for a clear, actionable message
2026-05-24 17:51:59 -04:00
Bailey DixonandClaude Opus 4.7 ff09c6116b fix(voice): classify realtime voice.error instead of showing raw provider string
The in-stream voice.error handler set uiState.error to the raw relay message
(e.g. 'xAI Realtime auth is not configured ...'). Route it through surfaceError
-> classifyError('voice_config') so provider-auth and other relay failures show
a clear, actionable banner ('Realtime provider auth unavailable ...') plus a
one-shot errorEvents snackbar with a Voice settings action, matching how the
result-failure path already surfaces errors. Raw detail is still recorded to the
Diagnostics log.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:42:26 -04:00
Bailey Dixon 7c5b7729c1 Merge pull request #58 from Codename-11/feature/realtime-persistent-session
feat(voice): persistent Realtime Agent conversation (one socket across turns)
2026-05-24 16:13:35 -04:00
Bailey DixonandClaude Opus 4.7 304d8e26c8 feat(voice): persistent realtime-agent session (VoiceViewModel wiring)
Wire the persistent session end to end. Realtime Agent voice now opens one
provider session/socket on the first turn (runRealtimeAgent persistent mode in
realtimeSessionJob) and feeds subsequent utterances on realtimeTurnChannel, so
the provider keeps the live conversation across turns.

- Per-turn event state hoisted to fields so the session-lived callback serves
  every turn; submitRealtimeTurn / the open path reset it per turn.
- onRealtimeTurnComplete finalizes each spoken turn (re-arms continuous listen);
  closeRealtimeSession tears down on exit / engine switch / onCleared / error.
- VoicePreferences.realtimePersistentSession (default true) + a Voice Settings ->
  Realtime Agent -> Persistent session toggle fall back to the one-shot path.

Compiles clean (compileSideloadDebugKotlin). Needs on-device validation
(multi-turn follow-ups, barge-in, background promotion mid-conversation,
exit/re-enter) — flag lets you fall back without a rebuild.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:03:02 -04:00
Bailey DixonandClaude Opus 4.7 742d899042 feat(voice): persistent realtime-agent session foundation (client)
Add opt-in persistent mode to RelayVoiceClient.runRealtimeAgent: when a
turnInputs ReceiveChannel is supplied, the WebSocket stays open across turns
(voice.response.done fires onTurnComplete instead of closing), subsequent
RealtimeTurnInputs are sent on the same socket with monotonic chunk ids, the
idle/turn guards scope to an active turn only, and the call ends when the channel
closes. One-shot path (turnInputs=null) is byte-for-byte unchanged.

Relay needs no change — _handle_provider_native_ws already loops over
input_audio/commit/response on one socket. Plan: docs/plans/2026-05-24-realtime-persistent-session.md.

VoiceViewModel wiring follows in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:27:48 -04:00
Bailey Dixon 783fc34e01 Merge pull request #57 from Codename-11/feature/realtime-voice-loop-and-logging
feat(voice): log realtime Hermes run lifecycle (ADR 33 observability)
2026-05-24 15:18:31 -04:00
Bailey DixonandClaude Opus 4.7 ac51a95842 feat(voice): log realtime Hermes run lifecycle (ADR 33 observability)
The hermes.run.* event handlers mutated UI state silently, so a promoted
background run was invisible in logcat even though it ran. Add Log.i for
run started / progress (tier/floor/status) / promoted / background_completed /
cancelled so the background-task lifecycle is traceable on-device.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 14:23:40 -04:00
Bailey Dixon 697610d92c Merge pull request #56 from Codename-11/feature/realtime-background-hermes-runs
feat(realtime): ADR 33 — background Hermes runs in Realtime Agent voice
2026-05-24 13:14:00 -04:00
Bailey DixonandClaude Opus 4.7 f300531054 docs(realtime): close ADR 33 Phase 0 — both verdicts hold-floor-ok
Record unconditional per-provider verdicts and mark Phase 0 done:
- OpenAI: hold-floor-ok (empirical 10/20/30s idle probe).
- xAI: hold-floor-ok — not conditional. The shipping Realtime Agent already
  holds xai_realtime sessions open across between-turn idle (turn_detection:None
  + resume TTL) with no idle-close reports; a relay-host probe is a regression
  check, not a precondition.

Also records that the spike's premise was superseded: Tier B closes the pending
provider call with an interim ack rather than holding an open response, so the
socket only sees the normal between-turns idle gap. No provider needs the
must-reopen fallback; default-on is unblocked.

Updates realtime-voice-poc.md findings, the plan's Phase 0 acceptance (Status:
DONE), and ADR 33's Phase 0 line (RESOLVED).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:21:30 -04:00
Bailey DixonandClaude Opus 4.7 0c7c21964d docs(realtime): ADR 33 Phase 0 verdict — OpenAI idle hold-floor-ok (empirical)
Ran scripts/realtime-provider-idle-probe.py against the live OpenAI realtime API
(VOICE_TOOLS_OPENAI_KEY): the session survived 10s/20s/30s quiescent idle windows
and returned clean audio on every post-idle turn -> verdict hold-floor-ok.
xAI recorded analytically as hold-floor-ok (no dev-box creds; same
turn_detection:None multi-turn model + the promotion path closes the pending
call rather than holding an open response) pending relay-host confirmation.

Fills the docs/realtime-voice-poc.md Idle tolerance findings table, satisfying
the Phase 0 acceptance (a documented per-provider verdict). Logs an incidental
OpenAI session.audio.output.format.rate schema-drift follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:19:22 -04:00
Bailey DixonandClaude Opus 4.7 ab1ffdd2aa docs(devlog): ADR 33 background Hermes runs session entry
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:14:03 -04:00
Bailey DixonandClaude Opus 4.7 ab10097f55 feat(realtime): ADR 33 Phase 3 (Android + docs) — promotion UI + event handling
Android:
- RealtimeVoiceEvent gains tier/floor; parse hermes.run.promoted +
  hermes.run.background_completed in VoiceViewModel, surfaced as a
  BackgroundRunState 'working on it' chip in VoiceModeOverlay (cleared on
  background_completed / cancel).
- RealtimeVoiceConfig gains a promotion block; new
  RelayVoiceClient.updateRealtimeAgentPromotion() PATCH.
- Voice Settings → Realtime Agent → Background tasks: promote toggle, spoken
  handoff toggle, and result-delivery segmented control, persisted to the relay.

Docs:
- CHANGELOG [Unreleased], relay-protocol.md (ADR 33 background-runs section),
  user-docs/features/voice.md (Background tasks).

Kotlin compiles clean under ./gradlew lint (the only 2 lint errors are in the
gitignored local.properties, absent in CI). Python realtime suite 58 tests green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:13:04 -04:00
Bailey DixonandClaude Opus 4.7 e294a571a4 feat(realtime): ADR 33 Phase 3 (relay) — default-on, Tier C durable, config surface
- Flip realtime_voice_promotion_enabled default to true. Safe because the
  promotion path closes the pending provider call with an interim ack rather
  than holding an open response, so the socket only sees the normal between-turns
  idle gap. Phase 0 probe still recommended to confirm per-provider survival.
- Tier C: hermes_run_task(mode='background') detaches immediately (tier=durable),
  even when grace-period promotion is off. Schema 'mode' enum gains 'background'.
- Expose promotion settings in /voice/realtime-agent config GET (promotion block)
  and accept them in PATCH (_validate_config_updates) so Android can read/write.

test_realtime_promotion gains the Tier C immediate-detach case (58 tests green).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:51:22 -04:00
Bailey DixonandClaude Opus 4.7 35893e239f feat(realtime): ADR 33 Phase 2 — Tier B grace-period promotion (default off)
Long Hermes runs in Realtime Agent no longer block the provider event pump.
_run_brokered_tool now shields the run task and waits promote_after_ms; if the
run is still in flight, it detaches to the background (tier=promoted) and returns
control to the pump. _deliver_background_result awaits the task, emits
hermes.run.background_completed, waits for the floor to clear, then speaks the
result once via the existing forced-summary path.

- New events: hermes.run.promoted, hermes.run.background_completed (models.py)
- New realtime_voice settings (config.py + profile_voice.py): promotion_enabled
  (default false), promote_after_ms (6000), background_default_mode, spoken_handoff,
  progress_spoken_after_ms, progress_repeat_ms, result_delivery, max_background_runs
- Provider-tool-call path closes the pending call with an interim background ack
  so the socket isn't left awaiting output; forced path speaks a handoff line
- Cancel (response.cancel / hermes_cancel) stops the background task; background
  delivery task cancelled on session close
- Completion replays through the event ring on resume (detach-safe)

test_realtime_promotion: promote+pump-responsive, short=no-promote, cancel,
detach-resume-replays. Full realtime suite (53 tests) green; pre-existing
unrelated xAI-OAuth-pool test failure noted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:48:14 -04:00
Bailey DixonandClaude Opus 4.7 06332f8f4e feat(realtime): ADR 33 Phase 1 — relay audio floor owner
Add plugin/relay/realtime_agent/floor.py: a pure, single-owner audio floor
(provider | relay_tts | android_filler mouths; idle/provider_speaking/
hermes_filler/result_pending labels) that makes explicit the serialization the
blocking await provided implicitly. Wire it into the broker behavior-
preservingly:

- acquire/release PROVIDER on AUDIO_DELTA/AUDIO_DONE (+ RESPONSE_DONE safety net)
- acquire/release RELAY_TTS around _render_provider_audio
- gate spoken filler by floor.can_speak(ANDROID_FILLER); stamp floor + tier on
  hermes.run.progress

Adds session fields hermes_run_tier + floor. No audible change (today's flow has
no contention); invariants proven in test_realtime_floor (background result never
barges, filler suppressed while provider speaks, relay-TTS only when owned).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:37:25 -04:00
Bailey DixonandClaude Opus 4.7 60623c1751 feat(realtime): ADR 33 Phase 0 provider idle-tolerance probe + docs
Add scripts/realtime-provider-idle-probe.py and the Idle tolerance section in
docs/realtime-voice-poc.md. The probe holds an xAI/OpenAI realtime socket
quiescent across idle windows and reports a per-provider verdict
(hold-floor-ok | needs-keepalive | must-reopen) that selects each provider's
Tier B strategy. Verdict gates ADR 33 default-on promotion.

Also lands ADR 33 and the companion implementation plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:33:12 -04:00
Bailey DixonandClaude Opus 4.7 024ee6c1db docs(release): scrub signing-cert identity from v0.8.0 release notes
The v0.8.0 notes' Verification section published the signing cert CN
(a personal name) in the live GitHub Release. Prior releases never
listed the cert identity — generalize to 'release-signed with the
production upload keystore' to match the house style. Live release body
already updated via gh release edit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:30:49 -04:00
140 changed files with 18834 additions and 2189 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"mobile-mcp": {
"command": "npx",
"args": ["-y", "@mobilenext/mobile-mcp@latest"]
}
}
}
+86
View File
@@ -6,6 +6,92 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### 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.
### Changed
- **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 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.
### 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.
- **"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.
- **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.
- **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`.
- **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).
- **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.
- **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.
- **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.
## [0.8.1] - 2026-05-26
### Fixed
- **Voice mode crash with barge-in on legacy TTS playback.** When barge-in was enabled and the relay served audio over the legacy `/voice/synthesize` (Media3) path, the first agent sentence played for ~2 syllables and then the app crashed with `IllegalStateException: Player is accessed on the wrong thread`. The barge-in listener's `Dispatchers.IO` reader was reading `ExoPlayer.getAudioSessionId()` (a thread-confined accessor) to attach the echo canceller. `VoicePlayer.audioSessionId` now serves a `@Volatile` cache populated from main-thread Media3 callbacks, so it is safe to read from any thread.
## [0.8.0] - 2026-05-23
### Added
+34 -26
View File
@@ -29,45 +29,53 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
| `GET /v1/models` | List available models | — |
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
| `GET /health` | Health check | — |
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
**Non-standard endpoints (provided by fork OR by plugin bootstrap):**
**Compatibility endpoints (not all native upstream API-server routes):**
These endpoints are not in stock upstream `gateway/platforms/api_server.py`. There are three ways a hermes-agent install can serve them:
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. **Codename-11 fork** (`feat/session-api` branch, deployed on the `axiom` branch) — adds them natively. Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) *"feat(api-server): add session management API for frontend clients"* — scope is broader than the title: sessions CRUD + session chat/stream + memory + skills + config + available-models.
2. **Bootstrap injection** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file. Does NOT inject `/api/sessions/{id}/chat/stream` — use `/v1/runs` for chat.
3. **Upstream-merged** (post PR #8556) — bootstrap auto-detects and no-ops.
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.
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
| Endpoint | Purpose | Provided by |
|----------|---------|-------------|
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Fork OR bootstrap OR upstream-merged |
| `GET /api/sessions/{id}/messages` | Conversation history | Fork OR bootstrap OR upstream-merged |
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap OR upstream-merged |
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Fork OR upstream-merged ONLY (NOT bootstrap) |
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap OR upstream-merged |
| `GET /api/skills`, `/{name}` | Skill discovery (list + detail) | Fork OR bootstrap OR upstream-merged |
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; mirrored into bootstrap |
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap OR upstream-merged |
| `GET /api/available-models` | Provider model list | Fork OR bootstrap OR upstream-merged |
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions` or `runs` based on the capability snapshot.
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions`, `completions`, or `runs` based on the capability snapshot.
**Dashboard web server (separate surface — loopback-only):**
**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/logs`, `/api/analytics/usage`. Auth is a page-injected `window.__HERMES_SESSION_TOKEN__` — loopback-only, no external issuance. **Do not proxy this surface over the relay.** Phone consumes the narrower, fork/bootstrap `api_server.py` surface or relay-native profile-scoped endpoints.
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.
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.**
**Tool call rendering paths:**
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
2. **Sessions API** — No structured tool events during streaming; reloads message history on stream complete ("session_end reload" pattern).
2. **Sessions API** — Native upstream emits structured SSE (`run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.started/completed/failed`, `assistant.completed`, `run.completed`, `done`). `run.completed.messages` can reconcile authoritative per-turn transcript.
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
## Key Instructions
- **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:** Remove `hermes_relay_bootstrap/` in one PR once PR #8556 merges. It's no-op-compatible, so leaving it in place during rollout is harmless.
- **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.
## Repository Layout
@@ -107,7 +115,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 patch for vanilla upstream; removable after PR #8556
├── hermes_relay_bootstrap/ ← Runtime compatibility patch; retire per surface as upstream replaces it
├── 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
@@ -197,7 +205,7 @@ hermes-android/
| **App — Voice** | |
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
| `audio/VoicePlayer.kt` | MediaPlayer + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine |
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
@@ -230,7 +238,7 @@ hermes-android/
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
| `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 patch for vanilla upstream; no-op on fork/upstream-merged; remove after PR #8556 |
| `hermes_relay_bootstrap/` | Runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
| **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 |
@@ -374,10 +382,10 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
| Surface | Endpoint | Notes |
|---------|----------|-------|
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; preferred |
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | No live tool events; reloads history on stream complete |
| 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` | Non-standard; bootstrap or fork |
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
| 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` |
@@ -390,7 +398,7 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
| Capabilities | `HEAD /api/sessions`, `HEAD /v1/runs`, etc. | HEAD avoids CORS 403 on OPTIONS preflight |
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
+132
View File
@@ -1,5 +1,137 @@
# Hermes-Relay — Dev Log
## 2026-06-11 — Standard-route network auto-switch (LAN ↔ Tailscale roaming without Relay)
**Context.** Bailey reported that away from home, a configured + signed-in standard connection never switched over to its Tailscale route — chat stayed dead and voice stayed gated, while the old Relay-paired path used to hand off fine (chat catch-up, voice, realtime). Audit traced it to the rechrome: every downstream piece (route candidates stored by the wizard, `effectiveApiServerUrl`/`effectiveDashboardUrl` flows, client-rebuild collectors, chat session refresh on client swap) was sound; nothing upstream ever *triggered* re-resolution on the standard path, and standard voice ignored the resolved route entirely.
**Root causes (4).** (1) `ConnectionManager`'s ADR 24 NetworkCallback registered only inside `connect()` — never on socketless standard connections — and its handlers early-returned without a socket URL. (2) The fallback trigger (`networkStatus` StateFlow → `revalidate()`) is value-deduped over 3 coarse states; with Tailscale's always-on VPN keeping "internet available" true through any handoff, the value never leaves `Available`, so the collector never fires — the exact user this feature targets is the one it can't see. (3) `activeDashboardUrl()` (standard voice client + availability probe) read the **persisted** dashboard URL, not `effectiveDashboardUrl`. (4) The resolver's 60s positive probe cache wasn't cleared on resume/network-change re-resolution, so a just-died LAN route kept winning.
**What changed (branch `fix/standard-route-network-switchover`, merged --no-ff; follow-ups direct on `feature/standard-voice-dashboard-surface`).**
- **ConnectionManager** — NetworkCallback registers at construction (no-op without context); `onAvailable`/`onLost` unify into a debounced (300ms) `scheduleNetworkReResolve()` that publishes `activeEndpoint` even with no socket, preserving the socket-swap/reconnect semantics when one exists. `refreshActiveEndpoint(clearProbeCache)` clears the resolver cache; `revalidate()` passes true.
- **Standard voice** — `activeDashboardUrl()` now delegates to `effectiveDashboardUrl`, so the voice client and `probeStandardVoice()` follow the resolved route; `rebuildApiClient()` → `probeStandardVoice()` re-gates the mic automatically after a route swap.
- **Escalation safety net** — the 30s API health loop turns two consecutive Unreachable probes into a cache-cleared re-resolve, covering handoffs Android never surfaces as connectivity events. Client rebuild stays reactive via the `effectiveApiServerUrl` collector (single rebuild path for all triggers).
- **Route-candidate preservation** — new `Connection.mergeRouteCandidates(rebuilt, existing)`: URL edits (`updateApiServerUrl`, `updateRelayUrl`, `connectRelay`, `testRelayReachable`, `saveApiAndProbeVoice`, `saveStandardApiConnection` fallback) rebuild only the touched route and keep stored priority>0 extras verbatim — previously any URL save collapsed the list to one candidate and silently killed roaming. The wizard doesn't pre-fill its Tailscale field, so blank-on-rerun means "unchanged", not "remove".
- **Per-route sign-in UX** — dashboard cookies are host-scoped, so a LAN sign-in doesn't authenticate the Tailscale host (the store holds both; it's a one-time sign-in per host). New `standardVoiceSignInRouteHint` flow + route-aware copy in Voice Settings' SignInRequired block + a Diagnostics entry from the probe.
- **Cleanup** — removed the duplicate `networkStatus → revalidate()` collector left by the rechrome.
**Tests.** New `ConnectionManagerRouteTest` (Robolectric + MockWebServer): callback registration/unregistration at construction, socketless `onAvailable` publishing `activeEndpoint`, stale-cache vs `clearProbeCache` resolve. New `ConnectionRouteCandidateMergeTest`: extras preserved, payload relay URLs verbatim, host:port collision defers to rebuilt, primary replacement, no-extras passthrough. `:app:lint` + targeted unit suites green locally.
**On-device verification needed (Bailey, via Studio):** standard connection with a Tailscale route → leave Wi-Fi → chat should re-route within ~30–60s worst case (network callback usually immediate); Voice Settings should show the per-route sign-in hint until Manage sign-in on the Tailscale host; return home → routes flip back to LAN (priority 0). Also worth re-checking the Relay-paired handoff path for regressions since onAvailable/onLost were unified.
**Pre-release polish (same day).** Review pass before handing to Studio: (1) gated network-change socket actions on `shouldReconnect` — the swap path force-set it true, so a network event could resurrect a socket the user explicitly disconnected (pre-existing hole the refactor preserved; routes still publish for HTTP surfaces); (2) `refreshActiveEndpoint` keeps the live route on a transient probe miss while the WSS is Connected (mirrors the callback's guard — a resume-time probe blip no longer downgrades every HTTP surface to the saved LAN URL); (3) sign-in route hint upgraded to `displayLabel()` ("Tailscale") and the chat mic toast made route-aware; (4) escalation counter resets while no API client exists.
**Remote-access discoverability (same day, Bailey's ask).** UX audit of the onboarding → remote journey found the mechanics worked but nothing *led* users to them: the wizard's happy path (scan LAN → connect) produced a LAN-only connection silently (the Tailscale field hid inside the collapsed Advanced expander), the setup result card had no remote line, "Hermes API unreachable" didn't distinguish "server down" from "you're remote with no fallback route", and `TailscaleDetector` powered only an informational chip. Shipped four nudges, each at a moment of real user attention: (1) the Tailscale field lifted into the main setup form as "Remote access — Tailscale URL (optional)" with a detected-on-this-phone hint; (2) a "Remote" readiness line on the setup result card (`StandardApiSetupResult.remoteRouteConfigured`); (3) the unreachable status pill diagnoses by route count — single-route gets "add a Tailscale or public route" (sharpened when the phone is on Tailscale), multi-route gets "none of the N routes responded, fallbacks retried automatically"; (4) an "Add Tailscale route" shortcut on the Connections card when the phone is on Tailscale but the connection lacks a tailscale route (route-editor state hoisted out of the expander so it opens with the list collapsed). Deliberately skipped: auto-deriving the server's MagicDNS URL (needs server-side support vanilla hermes-agent doesn't have) and QR-for-standard (no upstream payload generator). user-docs `remote-access.md` gains an "Add or Edit Routes on the Phone" section + per-route sign-in tip.
**Routes editor (same day, Bailey's ask).** Two gaps confirmed: the standard path's only multi-route provisioning was the wizard's buried optional Tailscale field (single route, setup-time only, not pre-filled on re-runs — and no QR equivalent exists because vanilla hermes-agent has no payload generator; the QR comes from the Relay plugin), and the Routes card was read-only. Closed both: `EndpointsCard` gains **Add route** + per-row **Edit/Remove** (fallback rows only — the priority-0 primary mirrors the connection's API URL and is edited there; remove confirms first), backed by a `RouteEditorDialog` (Tailscale/Public/Custom role chips, URL field, inline validation errors from the save callback). `ConnectionViewModel.saveExtraRoute`/`removeExtraRoute` persist to `Connection.routeCandidates`, seeding from the same fallback chain `observeDeviceEndpoints` displays (per-device PairingPreferences → synthesized primary) so an edit never hides QR-provisioned routes; host:port collisions are rejected with a pointed message; removing a route clears a preferred-route override that pointed at it; both finish with a cache-cleared `refreshActiveEndpoint` so the change takes effect immediately. Relay URLs for manual routes are derived (`:8767` convention) — QR remains the path for custom relay URLs. Wizard helper text now points at Settings → Connections → Routes.
**Route probe visibility + URL forgiveness (same day, Bailey remote-debugging).** Field report from the road: Tailscale route added (`100.71.8.56`, port auto-appended), phone on the tailnet, but "Probe now"/"Use now" left the card on "Current: Resolving" showing the internal URL, with no probing indicator anywhere. Diagnosis found one real bug plus a UX black hole:
- **The bug** — `probeAndReconnect()` early-returned (`resolved?.relay?.url ?: current ?: return@launch`) when every probe failed on the standard (no-socket) path: nothing was published, nothing was shown, and the only record went to DiagnosticsLog. Replaced by `probeAndReconnectNow(): EndpointCandidate?` (awaitable; `probeAndReconnect()` is now a launch wrapper) which **always publishes the outcome** — with the Connected-socket transient-miss guard preserved. `probeNow()`'s 100ms-delay-then-health-check hack (a real resolve takes 4s+ when LAN must time out) now awaits the resolve, rebuilds the API client on route change, then health-probes.
- **The black hole** — no probe feedback at any layer. New `RouteProbeOutcome` map on `EndpointResolver` (per-candidate verdict + human reason; survives `clearCache()` — caching ≠ verdict history) with the TLS case spelled out ("TLS failed — server may be http://, not https://"), `RouteProbeStatus` (Idle/Probing/Done(winner)) on ConnectionViewModel, and UI: Re-check buttons show "Checking…", route rows show Reachable/Unreachable-with-reason, the Current line states "No route reachable — using saved URL <api url>" in error color instead of eternal "Resolving" over the **relay** URL fallback (the "internal url" Bailey was seeing — ConnectionsSettingsScreen printed `connection.relayUrl` under the resolving label).
- **Likely root cause of the field failure** — the route row only showed `host:port`; the scheme (the `tls` flag) was invisible, and the editor's placeholder even suggested `https://`. An https route against the plain-HTTP API server TLS-fails every probe. Rows now show the **full URL including scheme**; the editor previews "Will save: http://100.71.8.56:8642" live.
- **URL forgiveness** — new `Connection.normalizeApiUrlInput(raw, defaultPort=8642)`: bare hosts/IPs get `http://` + the surface's default port (dashboard field uses 9119); explicitly-schemed URLs pass **verbatim** (an `https://host` may be a reverse proxy on 443 — never force-append 8642). Applied in `saveExtraRoute`, `saveStandardApiConnection` (API + Tailscale + dashboard fields), and `updateApiServerUrl` (save-time only — its single call site is `applyManualPair`, not per-keystroke). Wizard validators soften to accept bare hosts; field copy now names the ports (API 8642 vs dashboard 9119, relay 8767 derived). `saveExtraRoute`/`removeExtraRoute` end in a full `probeNow()` so a just-saved route shows its verdict immediately.
Tests: +4 resolver outcome tests, +2 ConnectionManager `probeAndReconnectNow` publish tests (winner published socketless; null published when all routes die — the old early-return regression case), +10 `normalizeApiUrlInput` cases incl. the bare-Tailscale-IP end-to-end journey. All green; lint green. user-docs `remote-access.md` gains "Which URL Do I Enter?" (raw `100.x` IP → `http://` + API server must listen beyond loopback; `*.ts.net` behind `tailscale serve` → `https://`, cert is name-only). **On-device verification (Bailey):** with the Tailscale route's scheme visible, check whether it was saved as https — edit to http if so; Re-check should now show per-route verdicts either way.
**README + Play listing audit (same day, Bailey's ask).** Audit found both documents lagging the standard-first pivot by two release cycles: README said "What's new in v0.6.0" (app at 0.8.1), the CI badge pointed at deleted `ci.yml`, two in-page anchors were broken (renamed headings), voice was described as relay-only in three places (surfaces table, features bullet, How It Works diagram) despite the dashboard-surface change, and neither document mentioned Manage parity or remote access at all. Rewrote both: README restructured around the setup card's Chat/Manage/Voice/Remote/Relay framing (one Quick Start instead of three overlapping sections — Quick Start / What It Does / Getting Started), operational detail (sideload steps, update-banner mechanics, paste-workflow demo, uninstall flags) compressed to one-liners with docs-site links, desktop section trimmed to install + 4 commands behind an explicit alpha banner stating the planned refocus into a remote "hands" connector now that hermes-desktop owns desktop chat/management (Bailey's direction). Play listing rewritten end-user-first: new short description ("Your self-hosted Hermes AI agent, in your pocket — chat, voice, and control.", 76/80 chars), a 3-step QUICK START block, Manage + Works Away From Home feature sections, voice corrected to the no-plugin story, "TUI" jargon dropped, v0.8.1 release notes drafted (464/500 chars). Compliance-sensitive GOOGLE PLAY BUILD / SECURITY & PRIVACY sections kept verbatim.
**Field follow-up: http fixed chat; Manage stayed dark over Tailscale (same day).** Confirmed on-device that flipping the route to `http://` made chat roam. Manage not working over the same route has three candidate causes, all by-design rather than app bugs: (1) the dashboard (`:9119`, `hermes_cli/web_server.py`) is a separate server from the API (`:8642`) — tailnet reachability of one proves nothing about the other (bind/port-mapping/ACL; note `hermes-relay-tailscale enable` fronts only 8767 + 8642, never 9119); (2) dashboard sessions are host-scoped cookies, so the home sign-in doesn't authenticate `100.x.y.z:9119` — one sign-in per route, the app keeps both; (3) an explicit dashboard URL override pins Manage to that host (only auto-managed URLs roam — deliberate, since overrides usually mean a reverse proxy; the wizard's LAN scan can store one silently when detected ≠ derived). Audit confirmed the app already targets `effectiveDashboardUrl` everywhere (client factory, sign-in dialog, payload cache keys) — what was missing was *visibility*. Manage now: shows a persistent "Dashboard: <url> · <route> route" target line under the mode strip; names the failing URL in the "Dashboard unavailable" card; and explains per-host sign-in in the sign-in card when the route has moved. Plumbing: new `ConnectionViewModel.dashboardRouteMovedHint` (route label when effective ≠ persisted dashboard URL); `standardVoiceSignInRouteHint` refactored to reuse it (semantics unchanged).
**Server-side root cause + fix (same evening, via SSH per Bailey).** `ss -tlnp` on docker-server showed the real story: API (`:8642`) and relay (`:8767`) on `0.0.0.0`, but `hermes-dashboard.service` ran with `--host 172.16.24.250` — LAN interface only, so `100.71.8.56:9119` was connection-refused (not 401, hence no sign-in card; the "existing login" Bailey saw was last-known persisted state). Rebound to `--host 0.0.0.0` + restart (authorized via prompt), verified `/api/status` on both IPs, updated the server's `~/SYSTEM.md` services table. Phone (adb) confirmed end-to-end: Manage's new target line showed `100.71.8.56:9119 · Tailscale route`, banner flipped to "sign-in required", sign-in card rendered with the route strip. Two learnings recorded: the app's `DashboardCookieJar` is per-connection, NOT host-scoped (sends the stored session cookie to whichever host the route resolves to — sessions normally roam; the restart wiping in-memory dashboard sessions is what forced re-sign-in), and the sign-in strip's "per host" wording could be tightened later.
**Manage loading/overview pass (same day, Bailey's ask).** Three complaints: the cold-load skeleton stacked four progress bars with fake narrative labels; every re-entry to Manage was a cold load; the KPI glyphs (`ok/…/!`) and the one-line status banner (truncated by two trailing buttons, one a duplicate "Connection" link) were weak. Shipped: (1) **process-lifetime payload cache** — `DashboardPayloadCache` singleton replaces the `remember{}` maps, keyed `connection|dashboardUrl|section` so connection switches and route handoffs stay partitioned; `Loaded.fetchedAtMillis` drives a 30s stale-while-revalidate window (fresh → no fetch; stale → cached content + thin refresh bar); sign-in/out clears as before. (2) **App-start pre-warm** — section fetch core extracted to `fetchDashboardSectionState()`; `prewarmDashboardManage()` (internal, same file) fills cold keys only, aborts the sweep on first unreachable/auth failure, never marks Loading so it can't fight the open screen; RelayApp fires it (1.5s debounce) when the persisted snapshot says reachable + signed-in/auth-free, re-firing on route handoff. (3) **Skeleton** — one LinearProgressIndicator + three pulsing content-shaped ghost cards. (4) **KPI strip** — count / tone-colored dashboard state word (ready/sign-in/offline/error) / server version (`RelayMetricCard` gains optional `valueColor`). (5) **Status banner** — two-line layout (state+identity+Sign out / URL·route·checked), duplicate "Connection" button removed (Connections tile is directly below).
**Frozen-sphere incident: Keystore/Tink global-lock contention (same day, found via adb after Bailey reported ~3s startup freeze).** Cold-start logcat showed `Skipped 45 frames` at first draw (pre-existing VM-init cost), then `Long monitor contention … AndroidKeysetManager$Builder.build() … owner DefaultDispatcher-worker-5 … for 4.095s` **on the main thread**, ending in `Skipped 1386 frames` / `Davey! duration=11596ms`. Mechanics: `EncryptedDashboardCookieStore` built its Keystore-backed prefs **eagerly in its constructor** — 1–4s per build on Samsung StrongBox, serialized through Tink's process-global `AndroidKeysetManager.Builder.build()` lock — and the new Manage pre-warm constructed one per section (8×, worker-5 = the lock owner in every contention event), while other paths (connection validation probe, Manage's per-fetch client factory, session clear) constructed yet more instances, one of them on the main thread. The pre-warm didn't create the main-thread keystore work, but it multiplied the stall by keeping the lock hot. Fix (three layers): (1) `EncryptedDashboardCookieStore.store` is now `by lazy` — construction free on any thread, the build lands on first cookie access, which is always an OkHttp/IO thread; (2) new `ConnectionViewModel.dashboardCookieStoreFor(connectionId)` — ONE cached instance per connection, now used by Manage's client factory, the validation probe, session clear, standard voice, and the pre-warm (previously each had private instances = N keyset builds for the same prefs file); (3) `prewarmDashboardManage` takes the shared store + builds ONE `DashboardApiClient` for the whole sweep (`fetchDashboardSectionStateWith(client, …)` core extracted; per-section client/store construction removed; `NonCancellable` shutdown in finally). `DashboardOAuthSignInDialog.cookieStoreFactory` widened to the `DashboardCookieStore` interface. Net keyset builds at cold start: was ~10+ serialized seconds-long holds; now ≤3 (two AuthManagers + one cookie store), all off-main. Honest correction recorded: my earlier "pre-warm can't delay the UI" claim missed the keystore-lock dimension — network was off-main, but lock contention is transitive.
## 2026-06-10 — Standard voice retargeted at the dashboard surface + Manage parity (model/keys/profiles) + softened brand blue
**Context.** Release verification found the just-landed `StandardHermesVoiceClient` implemented the right upstream contract (`/api/audio/transcribe` + `/api/audio/speak`, base64 data-url — hermes-desktop's voice path) but aimed it at the **API server** (:8642) with a bearer header. Verified against upstream/main (tip `d1383a6b1`, fetched 2026-06-10 into `hermes-agent-pr-prep`): `api_server.py` has **no audio routes** (`/v1/capabilities` says `audio_api: false`; PR #8199 unmerged) — the routes live on the **dashboard web server** (`hermes_cli/web_server.py:1877/2012`) behind its cookie-session auth gate. Net effect: standard-only users got an enabled mic and a guaranteed 404 per turn; relay users silently paid a full base64 upload to a 404 before each fallback.
**What changed (branch `feature/standard-voice-dashboard-surface`).**
- **Voice retarget.** `StandardHermesVoiceClient` now takes a `dashboardUrlProvider` (`Connection.resolvedDashboardUrl`, :9119 derived) and an OkHttpClient carrying the **same per-connection encrypted cookie jar Manage signs in with** (new `DynamicDashboardCookieJar` resolves the store per-request so connection switches stay correct). Bearer header dropped — meaningless on this surface. 401/404 error copy now points at Manage sign-in / server update.
- **Availability model.** New `StandardVoiceAvailability` (Unknown/Ready/SignInRequired/Unreachable/Unsupported) in ConnectionViewModel, fed by `probeStandardVoice()`: `GET /api/status` (public) → `GET /api/auth/me` when gated → HEAD existence check on the audio route (405 = present, 404 = old build). Replaces `HermesApiClient.probeAudioApi()` (deleted). Probe runs in the health cycle + `rebuildApiClient()`, refreshes the persisted dashboard snapshot only on material change, and re-runs immediately after Manage sign-in/sign-out (`refreshStandardVoice()`).
- **Route preference.** `AutoVoiceAudioClient` Auto order is now **Relay first, then Standard**: paired Relay is profile-aware and needs no dashboard sign-in; Standard is the zero-plugin path for vanilla installs. Power users can force either in Voice Settings.
- **Voice Settings UX.** Stable STT/TTS Route section shows live per-route status (Standard: Ready / sign-in required / unreachable / unsupported; Relay: ready / not configured; Auto: which route it would use) with a "Sign in via Manage" CTA (navigates to the Manage tab) and an "update hermes-agent or pair Relay" hint. Realtime Agent engine now states "Requires a paired Relay" and shows an inline error + guidance when selected without one. Chat mic toast is availability-aware.
- **Manage parity with hermes-desktop.** New `DashboardApiClient` methods + UI: **Models** tab gets "Change main model" (`/api/model/options` picker → `POST /api/model/set`, with the upstream expensive-model `confirm_required` round-trip); new **Keys** tab (`GET /api/env` inventory → Set (write-only, password-masked) / Reveal (`POST /api/env/reveal`, server rate-limited) / Clear (`DELETE /api/env` with JSON body)); **Profiles** tab gets New profile (`POST /api/profiles`, clone-from-default), Describe (`PUT .../description`), and per-profile Model (`PUT .../model`, shared picker). Overview gains Models + Keys tiles. Channel-managed env vars stay visible (tagged `channel`) since the app has no Channels page to defer to.
- **Theme.** `RelayRefresh.Electric` softened `#111DFF` → `#4F5BD5` (user feedback: connections card too saturated/"blue" vs text + palette). Drives `relaySelectedPanel`, dark `primaryContainer`, light `primary`.
- **Docs.** CLAUDE.md dashboard-surface paragraph now lists the audio/model/env/profile routes and the standard-voice auth model.
**Verified.** `:app:compileSideloadDebugKotlin`, `:app:lint`, and `:app:testGooglePlayDebugUnitTest` all green locally. On-device verification needed: Manage sign-in → standard voice turn on an API-only connection; Auto fallback with relay paired; model picker payload shape against the live dashboard (`parseModelOptions` is tolerant but unverified against real `build_models_payload` output).
**Follow-up (same day).** Closed the deferred parity items + re-scoped the blue:
- **Skills hub** — "Browse hub" on the Skills tab: multi-source search (`GET /api/skills/hub/search`, results marked installed via the lock-file map), SKILL.md **preview before install** (`/api/skills/hub/preview` → detail dialog), install (`POST .../install {identifier}`) / uninstall (`POST .../uninstall {name}`) / "Update installed" (`POST .../update`). All three mutations are **async spawns server-side** (`{ok, pid}`) — UI messages say "started — refresh Skills shortly" and install rows stay disabled to prevent double-fires. Dashboard client read timeout raised 30s→45s so the server's 30s search fan-out can't die client-side at the edge.
- **SOUL editor** — "Edit SOUL" profile action fetches the **full** file (`GET /api/profiles/{name}/soul` is untruncated upstream, unlike the relay Inspector's 200KB cap), opens a monospace full-file editor dialog, `PUT {content}` on save; creates the file when absent.
- **Blue re-scoped** — Bailey liked the original Electric elsewhere; reverted `Electric` to `#111DFF` and added `ElectricMuted` (`#4F5BD5`), applied only to the **active connection card** (was a full-opacity `primaryContainer` fill — the actual complaint) as a 0.42-alpha wash. Cockpit selected panels, pills, and light-theme primary keep the vivid brand blue.
- **CHANGELOG** — `[Unreleased]` entries added (missed in the first commit batch; the dev-branch convention expects per-PR appends).
**Follow-up 2 (same day) — capability card, quiet standard-path UX, hub featured, onboarding copy.**
- **Capability card** — discovered the wizard's `StandardSetupResultCard` already renders Chat/Manage/Relay readiness lines; completed it with a **Voice** line instead of inventing a new surface. `StandardApiSetupResult` gains `voiceAvailability`, settled in the same setup probe (dashboard status → auth → audio-route HEAD) so the card and the mic gate are correct the moment setup finishes. Wording: Ready → "Speech ready via your Hermes server"; SignInRequired → "Unlocks with dashboard sign-in" (the existing Manage CTA covers it); Unsupported → "update or pair Relay".
- **No spurious relay warnings on the standard path** (audit per Bailey). Verified `runVoiceRelayPreflight` only fires on the Realtime engine (correctly relay-gated). The real offender was **Voice Settings**: it fetched three relay configs on open and snackbar'd every failure — a standard-only user got two "Relay unreachable" snackbars for a route they don't use. Now gated on `relayVoiceReady`: fetches skipped entirely, relay-backed sections (Fallback TTS / Voice Output / Realtime config) replaced by one quiet "Voice Providers" card ("speaks through your Hermes server's configured TTS/STT — pair Relay to pick providers from the phone"), STT section shows a quiet line instead of permanent "loading...", and Test Current Engine labels the route honestly.
- **Hub featured view** — `GET /api/skills/hub/sources` on dialog open: "Sources: Official (Nous), skills.sh, ..." line + featured skills (from the centralized index) listed before the first search, marked installed via the same lock map. Best-effort: silent on failure.
- **Onboarding** — the rechrome (55a4227) reworked Welcome (sphere hero + Standard/Advanced paths) and the Connect step (shared ConnectionWizard), but Chat/Manage/Power remained icon + one sentence. They now carry three concrete feature rows each (reusing the Welcome page's row style): Chat = streaming/profiles/voice-no-install; Manage = control/skills-hub/one-sign-in; Power = terminal/bridge/realtime. Copy emphasizes the standard-first story ("no extra install", "signing in once also unlocks voice").
**Follow-up 3 (same day) — release polish: floating status pill, gesture ambient mode, media-settings scoping, voice.md standard route.**
- `RelayStatusStrip` is now a floating capsule (insets → 14dp side / 8dp bottom margins → pill clip) instead of a zero-radius bordered bar — the full-width rectangle clashed with rounded display corners. Gate caught a real overload error: Compose `padding()` can't mix `horizontal` with `top`/`bottom` — use start/end/top/bottom.
- Ambient (fullscreen sphere) lost its top-bar toggle: long-press the conversation background to enter (bubbles keep their copy long-press — they consume first), tap/long-press anywhere to exit, transient "tap to return to chat" pill on every entry. Note: inside a Box nested in a Column, bare `AnimatedVisibility` resolves to the ColumnScope extension and fails — fully qualify `androidx.compose.animation.AnimatedVisibility`.
- Media settings confirmed **Relay-only** (they govern `MEDIA:hermes-relay://<token>` fetches via MediaSettingsRepository) and now say so on-screen.
- `user-docs/features/voice.md` intro rewritten: standard (no-Relay) voice via the dashboard audio routes is now the lead story, with a two-route tip block (Auto prefers Relay when paired) and Realtime marked relay-required. Remaining docs debt logged below.
**Follow-up 4 (same day) — chat enhancements + release docs.** Audit found most of the chat wishlist already shipped (scroll-to-bottom FAB with `userScrolledAway` auto-follow, session drawer search/pin/archive, not-connected empty state with Connect CTA, stop-during-streaming, tappable suggestion chips). Added the genuinely missing pieces: **Quote in reply** (bubble long-press now opens Copy/Quote menu when a quote handler is wired; copy-only call sites keep direct copy), **Share conversation** (top-bar share → Markdown via ACTION_SEND), Manage card **"More" overflow** for 5+ action rows, and the ambient-gesture tip in Appearance (a11y-discoverable). user-docs: new `guide/quick-start.md` (2-minute standard path, capability-card table, power-tools in a collapsed details block) registered first in the sidebar; `features/dashboard.md` Android Manage section rewritten per-section including hub/Keys/SOUL-editing (and the stale "SOUL editing requires the paired inspector" claim fixed); `features/voice.md` Requirements split standard-route vs relay-route. Release coordination: PR #64 (docs baseline refs → dev) overlaps us on CLAUDE.md/DEVLOG/upstream-contributions/dashboard.md — land #64 first, resolve in our PR. Dependabot PRs target `main` directly (off-policy); add `target-branch: dev` to `.github/dependabot.yml` as a follow-up.
**Docs debt (user-docs) for the release:** `features/dashboard.md` lacks the new Manage surfaces (Keys tab, model picker, profile create/describe/SOUL editor, skills-hub browse/featured); `guide/getting-started.md` (17.5KB) should split into a short Quick Start page + separate Install-options/Advanced pages; `features/voice.md` body still describes relay-era requirements beyond the new intro.
**Next.** When upstream PR #8199 lands `/v1/audio/*` on the API server, add it as the preferred standard route (capabilities already advertise `audio_api`) and demote the dashboard path to fallback. Remaining deferred parity: MCP manual add-server form (catalog install covers onboarding), per-profile cron/skill scoping in Manage.
---
## 2026-06-05 — Prefer upstream `/v1/skills` with legacy fallback
**Context.** Upstream Hermes Agent now has baseline skill/session API surface area, while Axiom's fork still preserves richer Relay-specific `/api/*` compatibility routes. The Android client should begin consuming upstream-compatible skill listings when present without breaking older fork/bootstrap installs.
**What changed.** `HermesApiClient.getSkills()` now tries `/v1/skills` first, then falls back to `/api/skills`. Skill parsing accepts upstream OpenAI-style list envelopes (`{"object":"list","data":[...]}`), legacy fork envelopes (`{"skills":[...]}` / `{"items":[...]}`), and direct arrays.
**Verification.** Added pure Kotlin unit coverage for endpoint order and `/v1/skills` `data` parsing. Verified with `ANDROID_HOME=$HOME/Android/Sdk ./gradlew :app:testGooglePlayDebugUnitTest --tests 'com.hermesandroid.relay.network.HermesApiClientTest'` → BUILD SUCCESSFUL. `git diff --check` passes.
---
## 2026-05-26 — Fix voice-mode crash: ExoPlayer audio session id read off-main (barge-in + legacy TTS)
**Report.** Discord user, sideload latest: voice chat crashes the instant Hermes starts answering — "I hear just 2 letters and it crashes." Stack: `IllegalStateException: Player is accessed on the wrong thread. Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'` with the Media3 `player-accessed-on-wrong-thread` doc link and a `Suppressed: ... Dispatchers.IO`.
**Root cause.** `Dispatchers.IO` threads are named `DefaultDispatcher-worker-N` (IO and Default share one scheduler pool), so the crash is on an IO coroutine. `BargeInListener` runs its mic reader on `Dispatchers.IO` and, to attach `AcousticEchoCanceler`, polls an `audioSessionIdProvider` lambda. On the **legacy `/voice/synthesize` (Media3) playback path**, `VoiceViewModel` wires that provider to `{ player.audioSessionId }` → `exoPlayer.audioSessionId`. ExoPlayer is thread-confined; its `getAudioSessionId()` getter calls `verifyApplicationThread()` and throws when read off-main. The realtime PCM path is unaffected because it wires the provider to an `AudioTrack` session id (thread-safe), which is why the bug only hit legacy/fallback setups. Sequence: first sentence starts → `runPlayWorker.onFileReady` → `startBargeInListenerIfEnabled()` → IO reader → `awaitNonZeroSessionId()` → off-main getter → crash ~2 syllables in.
**Fix.** `VoicePlayer.audioSessionId` now serves a `@Volatile cachedAudioSessionId` instead of the raw thread-confined getter. The cache is populated from main-thread Media3 callbacks: an `AnalyticsListener.onAudioSessionIdChanged` hook (authoritative, fires when Media3 allocates/reallocates the AudioTrack) plus a belt-and-braces read inside the existing `onIsPlayingChanged`. Reads from any thread are now safe.
**Tests.** Added `VoicePlayerTest` coverage: getter reflects the analytics-listener-cached id, never re-invokes `exoPlayer.audioSessionId` (the off-main call), and defaults to 0 before allocation. Captured the `AnalyticsListener` in the MockK harness. Verified locally: `:app:lintGooglePlayDebug` + `:app:testGooglePlayDebugUnitTest --tests VoicePlayerTest` both green (BUILD SUCCESSFUL).
**Next.** Landed on `dev` via PR #60, then shipped as the focused patch release `android-v0.8.1` (cherry-picked off the `android-v0.8.0` tag, PR #62); `main` merged back to `dev`.
---
## 2026-05-24 — Background Hermes runs in Realtime Agent voice (ADR 33)
**Context.** Realtime Agent ran each Hermes turn synchronously *inside* the provider event pump (`_run_brokered_tool` did `return await task`), so a long research/multi-tool/desktop run froze the whole realtime session until it finished. ADR 33 + `docs/plans/2026-05-24-realtime-background-hermes-runs.md` define a three-tier model (foreground / promoted / durable) with the relay as an explicit audio-floor owner. Branch `feature/realtime-background-hermes-runs`.
**What shipped (phased, per the plan):**
- **Phase 0 — idle-tolerance probe + verdict.** `scripts/realtime-provider-idle-probe.py` + an "Idle tolerance" section in `docs/realtime-voice-poc.md`. **Ran live against OpenAI** (`VOICE_TOOLS_OPENAI_KEY` in `~/.hermes/.env`): the session survived 10s/20s/30s quiescent windows and returned clean audio on every post-idle turn → verdict **`hold-floor-ok`**. xAI has no creds on the dev box, so its verdict is recorded analytically as `hold-floor-ok` (same `turn_detection:None` multi-turn model; the implementation closes the pending call rather than holding an open response) — confirm on the relay host. Incidental finding logged: OpenAI now wants `session.audio.output.format.rate` at `session.update` (minor `_session_update` follow-up; session still worked).
- **Phase 1 — floor owner.** New `plugin/relay/realtime_agent/floor.py`: pure, single-owner audio floor (`provider` / `relay_tts` / `android_filler` mouths; `idle/provider_speaking/hermes_filler/result_pending` labels). Wired behavior-preservingly into the broker (acquire/release on AUDIO_DELTA/AUDIO_DONE/RESPONSE_DONE; relay-TTS render holds the floor; filler gated by `can_speak`). Invariants in `test_realtime_floor.py`.
- **Phase 2 — Tier B promotion (was default off).** `_run_brokered_tool` shields the run and waits `promote_after_ms`; if still running it detaches to the background, closes the pending provider call with an interim ack, optionally speaks a handoff, and `_deliver_background_result` speaks the answer once the floor is idle. New events `hermes.run.promoted` / `hermes.run.background_completed` + `tier`/`floor` on progress; 8 new settings. `test_realtime_promotion.py` (promote+pump-responsive, short=no-promote, cancel, detach-resume-replays).
- **Phase 3 — default-on + Tier C + Android + docs.** Flipped `promotion_enabled` default **true** (safe: the path closes the pending call rather than holding an open response, so the socket only sees the normal between-turns idle gap). `hermes_run_task(mode="background")` detaches immediately (`tier:"durable"`). Settings exposed on `GET/PATCH /voice/realtime-agent/config`. Android: parse new events → "working on it" chip; Voice Settings → Realtime Agent → Background tasks (promote toggle, spoken-handoff toggle, result-delivery segmented control) → `RelayVoiceClient.updateRealtimeAgentPromotion()`.
**Why default-on despite the Phase 0 gate.** The implementation closes the pending function call with an interim background ack instead of parking an open provider response, so the worst-case "idle open-response" the gate worried about doesn't occur — the socket sits in the same idle state it does between any two user turns. The probe is retained to confirm per-provider survival; documented in config + ADR.
**Verified.** Python realtime suite **58 tests green** (`test_realtime_floor`, `test_realtime_promotion`, both provider suites, routes, profile-voice-config). `./gradlew lint` — Kotlin compiles clean; the only 2 lint errors are in the gitignored `local.properties` (absent in CI). Pre-existing unrelated `test_reads_hermes_xai_oauth_credential_pool` failure confirmed on `origin/dev` baseline.
**Next.** Confirm the xAI idle verdict on the relay host (where xAI creds live); fix the OpenAI `_session_update` rate field; run the lab smoke on a paired device. Open the PR to `dev`.
---
## 2026-05-23 — Un-defer the voice/audio test suite (issue #32) + barge-in resume bug
**Context.** GitHub issue #32 tracked 5 voice/audio unit tests `@Ignore`'d during the v0.5.1 release because the full `:app:testGooglePlayDebugUnitTest` task "hung indefinitely." Scope had quietly grown to **8** ignored classes (3 of the "pure-logic, should-work" ones got swept in defensively). Branch `fix/voice-test-suite`.
+109 -151
View File
@@ -5,16 +5,16 @@
<h1 align="center">Hermes-Relay</h1>
<p align="center">
<strong>One Hermes agent. Two ways to use it.</strong><br>
A native Android remote-control app for your phone, plus a desktop CLI that lets you<br>
use a server-deployed Hermes from your laptop as if it were running locally.
<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.
</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-orange.svg" alt="Desktop CLI"></a>
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></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://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>
</p>
@@ -35,107 +35,136 @@
| Surface | What | Status |
|---------|------|--------|
| **[Android app](#1a-android-app)** | Native phone control — chat, voice, the agent reads your screen and acts on it (tap, type, swipe), notification companion, multi-Connection. | Available — Google Play (Internal testing) + sideload APK |
| **[Desktop app + CLI](#1b-desktop-app--cli-experimental)** | Use a server-deployed Hermes from your laptop **like it's local**. Windows gets the native tray app first: pair, start/pause the daemon, view devices, task log, settings, overlay status, and emergency stop. The CLI remains the terminal/headless surface and powers macOS/Linux installs. Experimental computer-use tools are opt-in. | **Experimental** — `desktop-v0.3.0-alpha.18` (Windows tray installer + native CLI binaries, no Node required) |
| **[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 `~/.hermes/remote-sessions.json` and the same WSS relay. **Pair once from either, both work.**
Both share the same WSS relay and credentials store. **Pair once from either, both work.**
---
## Quick Start
## Quick Start (Android)
Three steps: pick your surface (or install both), then install the relay plugin on your Hermes server.
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**.
### 1a. Android app
<!-- TODO: Uncomment when Play Store listing is live
<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="80"></a>
-->
### 1. Install the app
- **Google Play** — coming soon (currently on Internal testing)
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and choose the newest Android release (`android-v*`; historical Android releases used bare `v*`)
- **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).
#### Sideload APK (GitHub Releases)
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.
Prefer not to wait for Google Play? Grab the signed APK directly:
### 2. Have Hermes running
1. Download the file ending in **`-sideload-release.apk`** from the newest Android release (`android-v*`; historical Android releases used bare `v*`) on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) — that's the full-featured "Hermes Dev" build. (Skip any `.aab` file — those are the Google Play bundle format and won't install directly.)
2. On your phone: **Settings → Apps → Special app access → Install unknown apps** and allow your browser (first time only).
3. Open the APK from your downloads and tap **Install**.
4. Optionally verify integrity against `SHA256SUMS.txt` from the same release (`sha256sum` on macOS/Linux, `Get-FileHash -Algorithm SHA256` on Windows).
Run upstream Hermes with its API server and dashboard enabled:
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
```bash
hermes setup --portal
**Staying up to date (sideload):** the app checks GitHub for a newer release on cold start (at most once every 6 hours) and shows a dismissable banner when you're behind. Tapping **Update** opens the next APK in your browser so Android's Downloads notification hands it to the system installer — no second app required. You can also trigger a check manually under **Settings → About → Updates**. Google Play installs get auto-updates through the Play Store and don't show this banner.
mkdir -p ~/.hermes
API_SERVER_KEY="$(openssl rand -hex 32)"
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
### 1b. Desktop app + CLI (experimental)
echo "Android API URL: http://<this-computer-ip>:8642"
echo "Android API key: $API_SERVER_KEY"
hermes gateway
```
The desktop surface talks to a server-deployed Hermes over WSS. On Windows, the default installer launches the native tray app with pairing, daemon control, devices, task log, settings, overlay status, pause, and emergency stop. The same release still ships the `hermes-relay` CLI for shell/TUI use, scripting, headless daemon mode, and macOS/Linux.
Windows commands, dashboard auth notes, and upstream links: [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
The remote agent can also reach back through the relay and run `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, etc. **on your machine** while its brain stays on the host. One pair, two surfaces (with the Android app), no `ssh`.
### 3. Connect and talk
**Install tray app** (Windows PowerShell):
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:
| 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.
**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
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 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.)
- **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.
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.
## Desktop CLI (alpha)
> **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.
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.
**Install** (Windows PowerShell / macOS / Linux):
```powershell
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
**Install CLI only** (Windows PowerShell):
```powershell
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
**Install CLI** (macOS / Linux):
```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 # interactive Hermes TUI in tmux
hermes-relay "summarize the last commit" # one-shot
hermes-relay --json "..." | jq # structured events for scripting
hermes-relay daemon # headless tool router (agent reaches you anytime)
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
```
**Native paste workflow** (the killer demo): inside `hermes-relay shell`, hit `Win+Shift+S` to screenshot, then `Ctrl+A v` — the client reads your clipboard, ships the image to the server's inbox, and types `/paste` into the TUI for you. Identical UX to native local-Hermes paste. The same chord set works on macOS (`Cmd+Shift+4` → `Ctrl+A v`) and Linux (Wayland/X11 detected automatically).
- **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)
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
**No Node required** — the Windows tray installer bundles the compiled CLI sidecar; CLI-only installs use Bun-compiled native binaries (~60–110 MB per platform) via curl/irm. Version-aware install (`upgrading X → Y`), collision-safe `hermes` short alias for CLI installs, self-update via `hermes-relay update`. Assets are **unsigned** during the experimental phase — SmartScreen/Gatekeeper warnings are expected. Code signing, multi-client server-side routing, and service installers (sc.exe / systemd / launchd) land with v1.0.
## Features
- **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)
- **AI-agent setup recipe**: `/hermes-relay-desktop-setup` (the agent can run `desktop_terminal` on your machine to diagnose install/pair issues live)
### Android
### 2. Install the server plugin (one-liner)
- **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
On the machine running your Hermes agent:
> 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).
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
```
### Desktop CLI
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your client via either of these equivalent entry points:
- **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
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR + 6-char code inline. Shortest path if you're already chatting with the agent.
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. For Android: read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. For the desktop CLI: just pass it as `hermes-relay pair ABCD12 --remote ws://<host>:8767`. Composes with `--ttl` / `--grants`.
## Install with an AI agent
Scan the QR from the Android app's onboarding screen, OR paste the 6-char code into `hermes-relay pair --remote ws://<host>:8767` on your laptop, and you're connected. One pair configures **both** the direct-chat API server **and** the relay (WSS for terminal / bridge / TUI / desktop tools, HTTP for voice routes) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat from the Android app, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
**Dashboard plugin.** If your hermes-agent install has the Dashboard Plugin System (upstream `axiom` branch), Hermes-Relay ships a plugin at `plugin/dashboard/` that surfaces paired devices, bridge command activity, and active inbound-media tokens in the gateway's web UI. It auto-registers through the same `~/.hermes/plugins/hermes-relay` symlink created by `install.sh` — restart the gateway and a "Relay" tab appears. See [docs/relay-server.md](docs/relay-server.md) and `user-docs/features/dashboard.md` for details.
**Updating:** `hermes-relay-update` (shortest path — installed as part of the one-liner) or re-run the same `curl … | bash` from above. Both are equivalent and fully idempotent: pulls latest main, refreshes the editable install, recreates all three shims, restarts `hermes-relay`, and prompts before restarting `hermes-gateway`. Set `HERMES_RELAY_RESTART_GATEWAY=1` to opt into the gateway restart non-interactively. For routine plugin/skill updates without restarting anything, a plain `cd ~/.hermes/hermes-relay && git pull` is enough — the editable install picks up the new code on next process start.
**Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
**Requirements:** Android 8.0+ (SDK 26) for the Android app · macOS / Linux / Windows for the desktop CLI · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server.
### For AI Agents
If you have an AI assistant (Claude, GPT, etc.) and want it to install or maintain Hermes-Relay for you, paste the block below into the chat. The agent will fetch the canonical setup recipe from this repo and walk you through it — verification, pairing, troubleshooting included.
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:
```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.
@@ -146,109 +175,38 @@ 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`
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair` (Android), 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`)
- 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)
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 have Hermes-Relay installed? The same recipe is auto-loaded as a Hermes skill — invoke it from any chat with `/hermes-relay-self-setup` for re-setup, troubleshooting, or "is everything wired correctly?" checks. Single source, two delivery modes (raw URL pre-install + Hermes skill post-install), no drift.
## What It Does
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — native on Android, native in the terminal, with the agent able to reach back through the relay and act on either surface.
| Surface | Channel | What | Status |
|---------|---------|------|--------|
| Android | **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
| Android | **Voice** | Real-time voice conversation via relay TTS/STT | Available |
| Android | **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
| Android | **Terminal** | Secure remote shell via tmux | Phase 2 |
| Desktop CLI | **Shell** | Full Hermes Ink TUI piped over PTY in tmux on the host. Bare `hermes-relay` drops you in. | Available (experimental) |
| Desktop CLI | **Chat** | Structured-event REPL / one-shot / piped stdin. `--json` for scripting. REPL supports `/paste`, `/screenshot`, `/image <path>`. | Available (experimental) |
| Desktop CLI | **In-shell paste / screenshot** | `Ctrl+A v` (clipboard image → server inbox → `/paste` auto-typed). `/screenshot` is multi-monitor by default. | Available (experimental) |
| Desktop CLI | **Local tool routing** | Agent calls `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` — runs on YOUR machine over the same relay | Available (experimental) |
| Desktop CLI | **Daemon** | Headless tool router — keeps tools advertised even when no shell is open | Available (experimental) |
| Desktop CLI | **Self-update** | `hermes-relay update` polls GitHub Releases, atomic-swaps the binary | Available (experimental) |
## What's new in v0.6.0
- **Connect from anywhere** — multi-endpoint pairing with first-class Tailscale support; plug in any VPN or reverse proxy mode. See [`docs/remote-access.md`](docs/remote-access.md).
- **Multi-Connection support** — pair with multiple Hermes servers (home + work, dev + prod, etc.) and switch in one tap from the Chat top bar. Each Connection keeps its own sessions, personalities, profiles, and relay state; theme and safety preferences stay global. Existing installs migrate transparently.
- **Agent Profiles** — the relay auto-discovers upstream Hermes profiles at `~/.hermes/profiles/*/` and the phone overlays the selected profile's model + `SOUL.md` on chat turns. Ephemeral, chat-only, clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED` (default on).
- **Consolidated agent sheet** — Profile + Personality selection and per-session analytics now live in one scrollable bottom sheet opened from the Chat top-bar agent name.
See the [changelog](CHANGELOG.md) for the full list.
## Features
### Android
- **Streaming chat** — Direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, personality picker, searchable command palette (29+ gateway commands), file attachments, and send-while-streaming message queuing
- **Multi-Connection + agent profiles** — Pair with multiple Hermes servers and switch targets from the top bar; select an upstream-discovered agent profile to overlay model + `SOUL.md` on chat turns. Three-layer model: Connection (server) → Profile (agent directory) → Personality (prompt preset)
- **Voice mode** — Experimental server-mediated voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Hermes owns chat, tool calls, and approvals, while relay voice output defaults to provider-neutral streaming TTS (`xai_tts` first) with realtime voice-agent providers kept as a separate lab mode.
- **Phone control (bridge)** — The agent can read what's on screen and act on it — tap, long-press, drag, swipe, scroll, type, and press system keys — plus take screenshots, read/write the clipboard, and control system-wide media playback. Gesture reliability is hardened for dim/idle screens, and a smarter tap-fallback cascade handles apps where labels sit inside non-clickable wrappers
- **Screen understanding** — Filtered accessibility-tree search, per-node property lookups with stable IDs, cheap screen-hash change detection, and multi-window reads (system overlays, popups, notification shade) so the agent can reason about UI without guessing
- **Workflow automation** — Batched macro execution for multi-step flows, real-time accessibility event streaming for "wait until something happens" waits, and a raw-Intent escape hatch for apps that expose deep-link actions
- **Notification companion** — Opt-in notification access so the agent can triage, summarize, and route incoming notifications
- **Bridge safety rails** — Per-app blocklist (banking, payments, 2FA default-blocked), destructive-verb confirmation modal (send, pay, delete, transfer…), idle auto-disable timer, optional persistent-status overlay, full activity log
- **Security & pairing** — QR-code pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
- **Analytics** — Stats for Nerds with TTFT, token usage, stream health, and peak-time charts
> 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) for the full sideload capability matrix.
### Desktop CLI
- **Shell mode (default)** — bare `hermes-relay` pipes the host's actual `hermes` Ink TUI through a PTY in tmux. Same banner, same skin, same slash commands as a local install. `Ctrl+A .` detaches (preserves tmux), `Ctrl+A k` kills, `Ctrl+A v` pastes a clipboard image, `Ctrl+A ?` re-prints chord help, `Ctrl+A Ctrl+A` literal.
- **Chat mode** — REPL or one-shot or piped stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. REPL slash commands `/paste` (clipboard), `/screenshot` (multi-monitor by default; `primary` / `1` / `2` to narrow), `/image <path>` attach the next message.
- **Local tool routing** — agent calls `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — all run on YOUR machine over the same WSS relay. One-time per-URL consent gate; `--no-tools` kill-switch; non-TTY stdin fails closed; agent-proposed patches render as colored diffs with `y/n/e/r` interactive approval. Experimental `desktop_computer_*` control tools require `--experimental-computer-use` / `HERMES_RELAY_EXPERIMENTAL_COMPUTER_USE=1`, task-scoped grants, and visible local approval.
- **Daemon mode** — `hermes-relay daemon` runs the tool router headless so the agent can reach you even when no shell is open. JSON-line lifecycle logs by default, auto-human on TTY. Fails closed on missing consent.
- **Self-update** — `hermes-relay update` polls GitHub Releases (SemVer-max picker, prerelease-aware), verifies SHA256, atomic-swaps the binary on POSIX (running daemon keeps inode), cooperative `.new.exe` swap on Windows.
- **Multi-endpoint pairing + reconnect-on-drop + TOFU cert pinning** — same as the Android app. One QR carries LAN + Tailscale + public; client races candidates in priority order, re-probes on every network change.
- **Workspace awareness** — on connect, client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay (server-side prompt-context consumption coming).
- **Conversation picker on attach** — without `--conversation` / `--new`, you get a numbered list of recent server-side hermes sessions to resume.
- **One install, one binary, no Node required** — Bun-compiled native binaries via curl/irm one-liners; collision-safe `hermes` short alias auto-installed.
## Getting Started
**Android:**
1. **Install the app** from the [link above](#1a-android-app)
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding, or scan a QR via `/hermes-relay-pair`
3. **Start chatting** — the app connects directly to the Hermes API Server
**Desktop CLI:**
1. **Install the binary** — [PowerShell `irm`](#1b-desktop-cli-experimental) (Windows) / curl (macOS / Linux) one-liner
2. **Pair once** — `hermes-relay pair --remote ws://<host>:8767` (mint code via `hermes-pair` or `/hermes-relay-pair` on the server first)
3. **Drop into the shell** — bare `hermes-relay` opens the full Hermes TUI in tmux on the host
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
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) --> Server (:8767) [voice routes — API key or relay session]
Phone (WSS/HTTP) --> Server (:8767) [terminal, bridge, media, sessions]
Desktop CLI (WSS) --> Server (:8767) [tui, terminal, desktop tools]
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 from the Android app connects directly to the Hermes API Server with the Hermes API key — same pattern used by Open WebUI and other Hermes frontends. Voice calls the relay's `/voice/*` HTTP routes and authenticates with that Hermes API bearer when present, falling back to the relay session token for paired devices. Remote control surfaces such as terminal, bridge, TUI, media/session management, and desktop tools require relay pairing on `:8767`, so one scan can configure both the API route and the relay route without merging their auth models.
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/)** | **Getting started, both surfaces, features, configuration — start here** |
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android-specific install + setup + features |
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — shell/chat, pairing, subcommands, local tool routing |
| **[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*`, and Desktop `desktop-v*`) |
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, Desktop `desktop-v*`) |
---
@@ -307,7 +265,7 @@ hermes-relay/
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
### Server (optional — bridge, terminal, TUI, media, and voice routes)
### Server (optional — bridge, terminal, TUI, media, and relay voice routes)
```bash
hermes relay start --no-ssl # if you installed the plugin
@@ -325,7 +283,7 @@ See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setu
### Hermes Plugin (for contributors)
End users should install via the [one-liner](#2-install-the-server-plugin-one-liner) at the top. For local development from a clone:
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
@@ -333,7 +291,7 @@ cp -r plugin ~/.hermes/plugins/hermes-relay
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
```
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
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.
## Hermes Agent
+25 -66
View File
@@ -1,85 +1,44 @@
# Hermes-Relay-Android v0.8.0
# Unreleased
**Release Date:** May 23, 2026
**Since v0.7.0:** Google Play-safe Bridge Core hardening, provider-native Realtime Agent voice with reliable low-latency playback, a text + mic Voice Lab, connection diagnostics, and clearer Voice Settings.
## Changed
v0.8.0 is the Android release-prep build for resubmitting the enhanced Google Play track. The Play artifact keeps chat, profiles, voice, terminal/TUI relay, media, notification companion, relay sessions, QR pairing, and diagnostics, while leaving AccessibilityService-backed Device Control only in the sideload flavor.
- 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.
---
## Download
v0.8.0 ships in two Android build flavors. APK and AAB filenames are version-tagged:
v0.8.1 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.0-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.0-sideload-release.apk` | Direct-install APK for full Device Control testing. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-0.8.0-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-0.8.0-sideload-release.aab` | Parity/testing artifact. |
| 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. |
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.
---
## Highlights
## Fixed
### Google Play Bridge Core
### Voice mode crash with barge-in on legacy TTS playback
- Google Play now ships Bridge Core without AccessibilityService-backed Device Control.
- The Play artifact keeps pairing, profiles, chat, voice, terminal/TUI relay, media, notification companion, relay sessions, connection status, and diagnostics.
- Sideload remains the only track for screen reading, gestures, screenshots, SMS/call/contact/location helpers, overlays, wake locks, and unattended control.
- User docs, feature matrix, privacy/security copy, and Play listing notes now match that artifact boundary.
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`.
### Provider-native Realtime Agent
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.
- Realtime Agent is now a true provider-native voice engine rather than a render-after-Hermes fallback.
- Android streams mic PCM to the relay; the relay opens a server-side xAI or OpenAI realtime session.
- Hermes remains the only path for tools, memory, research/current-data checks, confirmations, side effects, profile context, and durable transcript state.
- The provider receives compact Hermes function results and speaks natural post-tool summaries instead of reading raw tool output aloud.
### Reliable, low-latency realtime playback
- Fixed silent / choppy first-turn realtime audio: the AudioTrack deep-buffer cold-start was parking the playback head at zero. The streaming buffer was shrunk (4000ms → 700ms), the low-latency prebuffer retuned, and a preroll force-start removed for reliable playback from the first frame.
- Added playback diagnostics — time-to-first-audio, requested-vs-actual buffer logging, a first-frame watchdog, and a drain cross-check — so cold-start and underrun issues surface in the Diagnostics log.
### Voice Lab (text + mic demos)
- The realtime voice test screen offers a **Text demo** (raw provider TTS) and a **Mic demo** (full agent path: real speech recognition, Hermes brokering, spoken reply) with tap-to-record / tap-to-stop capture.
- The Voice Lab waveform now follows the playback cursor (driven by the player's amplitude at the playback position) instead of socket-arrival time, so the visual matches what is heard.
### Voice Settings and diagnostics
- Voice Settings now separates **Voice Engine** from global controls.
- The active engine controls the visible card: **Hermes Chat + Voice Output** or **Realtime Agent**.
- The fallback TTS card is global and remains visible for both engines.
- **Test Current Engine** plays the stable voice-output sample in stable mode and opens a provider-native Realtime Agent test session in Realtime Agent mode.
- Settings now has app-level Diagnostics, and API / Relay / Session detail drawers include sanitized recent activity tails.
- Voice turns preflight relay health and use shorter timeouts so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
---
## Google Play Resubmission Notes
- Upload `hermes-relay-0.8.0-googlePlay-release.aab`.
- Play Console release notes can use the `docs/play-store-listing.md` **Release Notes** section.
- The merged Play manifest should contain no `AccessibilityService`, `BIND_ACCESSIBILITY_SERVICE`, `SYSTEM_ALERT_WINDOW`, `FOREGROUND_SERVICE_SPECIAL_USE`, `WAKE_LOCK`, `SEND_SMS`, `CALL_PHONE`, contacts, or location permissions.
- The Play listing should not claim screen reading, screenshots, phone control, or accessibility automation.
## Verification
- Android version metadata: `0.8.0` / `versionCode 10`.
- Google Play release Kotlin compile passed.
- Google Play release AAB build passed.
- Google Play merged manifest has no forbidden Device Control entries.
- Google Play AAB is release-signed with `CN=Bailey Dixon, OU=Hermes-Relay, O=Codename-11`.
- VitePress user-docs build passed.
- Focused voice engine, realtime playback (buffer policy / amplitude / watchdog), and diagnostics unit tests passed.
## Post-install Smoke
- Install/update the sideload APK over the existing sideload app with `adb install -r`.
- Existing pairing should survive a same-flavor update. Re-pair only if you uninstall app data, switch flavor/applicationId, revoke the device, or intentionally clear the server session store.
- Confirm Settings -> Connections shows API, Relay, Session, and Diagnostics activity.
- Confirm Settings -> Voice shows the selected engine card and Test Current Engine follows the selected path.
- In Voice mode, test Hermes Chat + Voice Output first, then opt into Realtime Agent and ask a current-data question to confirm Hermes is queried instead of the provider guessing.
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.
@@ -4,7 +4,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNode
import androidx.compose.ui.test.performClick
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -0,0 +1,66 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import org.junit.Rule
import org.junit.Test
class PowerFeatureGateUiTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun requiresPairingCard_showsPairToUnlock() {
composeTestRule.setContent {
MaterialTheme {
PowerFeatureGateCard(
title = "Terminal",
summary = "Open a server shell through your paired relay session.",
status = PowerFeatureGateStatus.RequiresPairing,
onPrimaryAction = {},
)
}
}
composeTestRule.onNodeWithText("Requires pairing").assertIsDisplayed()
composeTestRule.onNodeWithText("Pair to unlock").assertIsDisplayed()
composeTestRule.onNodeWithText("This feature uses relay grants", substring = true).assertIsDisplayed()
}
@Test
fun expiredPairingCard_showsPairAgain() {
composeTestRule.setContent {
MaterialTheme {
PowerFeatureGateCard(
title = "Bridge",
summary = "Let Hermes send approved bridge commands to this phone.",
status = PowerFeatureGateStatus.PairingExpired,
onPrimaryAction = {},
)
}
}
composeTestRule.onNodeWithText("Pairing expired").assertIsDisplayed()
composeTestRule.onNodeWithText("Pair again").assertIsDisplayed()
}
@Test
fun dashboardSignInCard_usesDashboardLanguage() {
composeTestRule.setContent {
MaterialTheme {
PowerFeatureGateCard(
title = "Manage",
summary = "Open dashboard-backed management features.",
status = PowerFeatureGateStatus.DashboardSignInRequired,
onPrimaryAction = {},
)
}
}
composeTestRule.onNodeWithText("Dashboard sign-in required").assertIsDisplayed()
composeTestRule.onNodeWithText("Open sign-in").assertIsDisplayed()
}
}
@@ -6,7 +6,6 @@ import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNode
import androidx.compose.ui.test.onNodeWithText
import org.junit.Rule
import org.junit.Test
@@ -1,22 +1,19 @@
package com.hermesandroid.relay.ui.onboarding
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import org.junit.Rule
import org.junit.Test
/**
* Instrumented tests for the onboarding pager flow.
*
* These tests require an Android device or emulator because they use
* Compose UI testing APIs and interact with real Compose components.
* Instrumented tests for the Standard-first onboarding pager.
*/
class OnboardingFlowTest {
@@ -24,270 +21,175 @@ class OnboardingFlowTest {
val composeTestRule = createComposeRule()
private fun setOnboardingContent() {
val app = ApplicationProvider.getApplicationContext<Application>()
val connectionViewModel = ConnectionViewModel(app)
composeTestRule.setContent {
HermesRelayTheme {
OnboardingScreen(
onComplete = { _, _, _ -> }
connectionViewModel = connectionViewModel,
onComplete = {},
)
}
}
}
// --- Page 1: Welcome ---
@Test
fun firstPage_showsHermesRelayTitle() {
fun firstPage_showsHermesForAndroidTitle() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Hermes-Relay")
.onNodeWithText("Hermes-Relay for Android")
.assertIsDisplayed()
}
@Test
fun firstPage_showsWelcomeDescription() {
fun firstPage_showsStandardFirstDescription() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Your AI agent, in your pocket. Chat, control, and connect — all from your phone.")
.onNodeWithText("Chat with Hermes and manage your dashboard from your phone.")
.assertIsDisplayed()
}
// --- Skip button ---
@Test
fun skipButton_isAlwaysVisible_onFirstPage() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Skip")
.onNodeWithText("Standard")
.assertIsDisplayed()
}
// --- Navigation: Next button ---
@Test
fun nextButton_isDisplayed_onFirstPage() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Next")
.onNodeWithText("Advanced")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Setup Guide")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Hermes Docs")
.assertIsDisplayed()
}
@Test
fun nextButton_navigatesForward_toPage2() {
fun nextButton_navigatesForward_toChatPage() {
setOnboardingContent()
// Page 1 -> Page 2
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 2 is "Talk to Your Agent"
composeTestRule
.onNodeWithText("Talk to Your Agent")
.onNodeWithText("Chat")
.assertIsDisplayed()
}
@Test
fun canNavigateForward_throughAllPages() {
fun canNavigateForward_throughStandardAndPowerPages() {
setOnboardingContent()
// Page 1: Hermes-Relay (Welcome)
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 2: Talk to Your Agent (Chat)
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 3: Remote Terminal
composeTestRule.onNodeWithText("Remote Terminal").assertIsDisplayed()
composeTestRule.onNodeWithText("Manage").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 4: Device Bridge
composeTestRule.onNodeWithText("Device Bridge").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.onNodeWithText("Power tools").assertIsDisplayed()
composeTestRule.onNodeWithText("Connect").performClick()
composeTestRule.waitForIdle()
// Page 5: Connect to Hermes
composeTestRule.onNodeWithText("Connect to Hermes").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 6: Relay Server (last page)
composeTestRule.onNodeWithText("Relay Server").assertIsDisplayed()
}
// --- Back button ---
@Test
fun backButton_hiddenOnFirstPage() {
setOnboardingContent()
// On page 1, Back should not exist
composeTestRule
.onNodeWithText("Back")
.assertDoesNotExist()
}
@Test
fun backButton_visibleOnPage2() {
setOnboardingContent()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Back")
.assertIsDisplayed()
}
@Test
fun backButton_navigatesBackward() {
setOnboardingContent()
// Go to page 2
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
// Go back to page 1
composeTestRule.onNodeWithText("Back").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
}
// --- Page 5: Connect page ---
@Test
fun connectPage_hasApiServerUrlField() {
setOnboardingContent()
navigateToPage(4) // 0-indexed, page 5 is index 4
composeTestRule
.onNodeWithText("API Server URL")
.assertIsDisplayed()
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
}
@Test
fun connectPage_hasApiKeyField() {
fun connectPage_showsStandardChoiceFirst() {
setOnboardingContent()
navigateToPage(4)
composeTestRule
.onNodeWithText("API Key (optional)", substring = true)
.onNodeWithText("Standard Hermes")
.assertIsDisplayed()
}
@Test
fun connectPage_whereDoIFindThis_showsHelpDialog() {
fun standardSetup_showsApiFields() {
setOnboardingContent()
navigateToPage(4)
// Tap "Where do I find this?"
composeTestRule
.onNodeWithText("Where do I find this?")
.performClick()
composeTestRule.onNodeWithText("Standard Hermes").performClick()
composeTestRule.waitForIdle()
// Dialog should show
composeTestRule
.onNodeWithText("Do I need an API key?")
.onNodeWithText("API server URL")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("API key")
.assertIsDisplayed()
}
@Test
fun connectPage_helpDialog_canBeDismissed() {
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Where do I find this?").performClick()
composeTestRule.onNodeWithText("Standard Hermes").performClick()
composeTestRule.waitForIdle()
// Dialog is showing
composeTestRule.onNodeWithText("Do I need an API key?").assertIsDisplayed()
// Dismiss it
composeTestRule.onNodeWithText("Got it").performClick()
composeTestRule.waitForIdle()
// Dialog should be gone
composeTestRule
.onNodeWithText("Do I need an API key?")
.assertDoesNotExist()
}
// --- Page 6: Relay page ---
@Test
fun relayPage_showsOptionalMessaging() {
setOnboardingContent()
navigateToPage(5) // Last page
composeTestRule
.onNodeWithText("This is optional", substring = true)
.assertIsDisplayed()
}
@Test
fun relayPage_showsRelayUrlField() {
setOnboardingContent()
navigateToPage(5)
composeTestRule
.onNodeWithText("Relay URL (optional)")
.assertIsDisplayed()
}
// --- Get Started button ---
@Test
fun lastPage_showsGetStartedButton() {
setOnboardingContent()
navigateToPage(5)
composeTestRule
.onNodeWithText("Get Started")
.assertIsDisplayed()
}
@Test
fun lastPage_getStartedButton_isEnabled_withDefaultUrl() {
setOnboardingContent()
navigateToPage(5)
// Default URL is "http://localhost:8642" which is non-blank
composeTestRule
.onNodeWithText("Get Started")
.onNodeWithText("Connect")
.assertIsEnabled()
}
// --- Skip button visibility across pages ---
@Test
fun skipButton_visibleOnAllPages() {
fun connectPage_keepsPairingOptional() {
setOnboardingContent()
navigateToPage(4)
// Check skip on first page
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
// Navigate through all pages and check skip
for (i in 0 until 5) {
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
}
composeTestRule
.onNodeWithText("Pair Relay by code")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Power-user path for Terminal, Bridge, Relay sessions, and grants")
.assertIsDisplayed()
}
// --- Helper ---
@Test
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
setOnboardingContent()
repeat(4) {
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
composeTestRule.waitForIdle()
}
composeTestRule
.onNodeWithText("Skip for now — set up later in Settings")
.assertIsDisplayed()
}
private fun navigateToPage(pageIndex: Int) {
repeat(pageIndex) {
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
composeTestRule.waitForIdle()
}
}
@@ -1,157 +1,52 @@
package com.hermesandroid.relay.ui.screens
import android.app.Application
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.material3.MaterialTheme
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.TerminalViewModel
import org.junit.Rule
import org.junit.Test
/**
* Instrumented tests for Terminal and Bridge empty state screens.
* Instrumented smoke tests for the current Terminal and Bridge surfaces.
*/
class EmptyStateTest {
@get:Rule
val composeTestRule = createComposeRule()
// --- Terminal Screen ---
@Test
fun terminalScreen_showsTitle() {
fun terminalScreen_showsCurrentTopBar() {
val app = ApplicationProvider.getApplicationContext<Application>()
val terminalViewModel = TerminalViewModel(app)
val connectionViewModel = ConnectionViewModel(app)
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
TerminalScreen(
terminalViewModel = terminalViewModel,
connectionViewModel = connectionViewModel,
)
}
}
composeTestRule
.onNodeWithText("Remote Terminal")
.assertIsDisplayed()
composeTestRule.onNodeWithText("Terminal").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Search scrollback").assertIsDisplayed()
}
@Test
fun terminalScreen_showsPhase2Chip() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Coming in Phase 2")
.assertIsDisplayed()
}
@Test
fun terminalScreen_showsDescription() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Secure shell access", substring = true)
.assertIsDisplayed()
}
@Test
fun terminalScreen_showsTopBarTitle() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Terminal")
.assertIsDisplayed()
}
@Test
fun terminalScreen_showsPlannedFeatures() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Full ANSI terminal emulator", substring = true)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("tmux session management", substring = true)
.assertIsDisplayed()
}
// --- Bridge Screen ---
@Test
fun bridgeScreen_showsTitle() {
fun bridgeScreen_showsCurrentTopBar() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Device Bridge")
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsPhase3Chip() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Coming in Phase 3")
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsDescription() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Let your Hermes agent interact with your phone", substring = true)
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsTopBarTitle() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Bridge")
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsPlannedFeatures() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Agent-controlled device interaction", substring = true)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Permission management", substring = true)
.assertIsDisplayed()
composeTestRule.onNodeWithText("Bridge").assertIsDisplayed()
}
}
+1
View File
@@ -24,6 +24,7 @@
android:exported="true"
android:launchMode="singleTask"
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.HermesRelay.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
+5 -23
View File
@@ -1,25 +1,7 @@
v0.8.0 - Play-safe Bridge Core, Realtime Agent, and diagnostics
Google Play
* Google Play keeps chat, profiles, voice, terminal/TUI relay, media,
notification companion, relay sessions, QR pairing, and diagnostics.
* AccessibilityService-backed screen reading, phone control, screenshots,
SMS/calls, contacts/location, overlays, wake locks, and unattended control
remain sideload-only.
v0.8.1 - Voice mode crash fix
Voice
* Voice Settings now separates Voice Engine from global controls.
* Hermes Chat + Voice Output and Realtime Agent show their own focused cards.
* Fallback TTS stays visible as a global safety-net card.
* Test Current Engine now plays saved stable voice or a provider-native Realtime Agent sample.
* Realtime Agent uses provider-native xAI/OpenAI speech while Hermes remains
the tool, memory, profile, confirmation, and current-data authority.
* Realtime voice now plays reliably from the first frame with low latency —
fixed silent/choppy first-turn audio and added playback diagnostics.
* The Voice Lab adds a Text demo (raw provider TTS) and a Mic demo (full agent
path with tap-to-record/stop); the waveform follows the playback cursor.
Diagnostics
* Settings now has app-level diagnostics.
* Connection detail sheets show recent API, relay, session, endpoint, and voice
activity so relay hangs surface quickly.
* 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.
@@ -9,6 +9,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -83,9 +84,33 @@ class VoicePlayer(
private var visualizer: Visualizer? = null
private var visualizerAttached = false
// Thread-safe mirror of [ExoPlayer.getAudioSessionId]. ExoPlayer is
// thread-confined — every accessor (the audioSessionId getter included)
// calls verifyApplicationThread() and throws "Player is accessed on the
// wrong thread" if touched off the player's construction thread. The
// barge-in pipeline reads [audioSessionId] from BargeInListener's
// Dispatchers.IO reader coroutine to attach AcousticEchoCanceler, so we
// can't expose the raw getter. Instead we cache the id from the
// main-thread Media3 callbacks below and serve the getter from this
// @Volatile field. (Fixes the legacy-TTS + barge-in crash where the
// first sentence played for ~2 syllables before the IO read threw.)
@Volatile private var cachedAudioSessionId: Int = 0
private val exoPlayer: ExoPlayer = exoPlayerFactory(context.applicationContext)
init {
// AnalyticsListener callbacks are delivered on the player's
// application (main) thread, so caching the id here is the
// authoritative, thread-correct way to track it as Media3 allocates
// and reallocates the underlying AudioTrack.
exoPlayer.addAnalyticsListener(object : AnalyticsListener {
override fun onAudioSessionIdChanged(
eventTime: AnalyticsListener.EventTime,
audioSessionId: Int,
) {
cachedAudioSessionId = audioSessionId
}
})
exoPlayer.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_isPlaying.value = isPlaying
@@ -94,8 +119,16 @@ class VoicePlayer(
// actually begins — the audio session id is stable from
// player construction on Media3 1.x but some OEM pipelines
// don't allocate the track until playback starts.
if (isPlaying && !visualizerAttached) {
attachVisualizer(exoPlayer.audioSessionId)
if (isPlaying) {
// Belt-and-braces with the analytics listener above: this
// 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).
cachedAudioSessionId = exoPlayer.audioSessionId
if (!visualizerAttached) {
attachVisualizer(cachedAudioSessionId)
}
}
}
@@ -211,13 +244,20 @@ class VoicePlayer(
* poll this property briefly rather than assume it's hot-ready at
* [VoicePlayer] construction time.
*
* Exposed read-only. Internally the same id drives the Visualizer
* attach logic in [attachVisualizer]; B4 reads it via a provider
* lambda so the listener can re-check across the 1 s poll window
* without holding a stale reference.
* **Thread-safe.** Backed by [cachedAudioSessionId] rather than the raw
* `ExoPlayer.getAudioSessionId()` getter, because ExoPlayer is
* thread-confined and [BargeInListener] reads this from its
* `Dispatchers.IO` reader coroutine. Reading the raw getter off-main
* throws `IllegalStateException: Player is accessed on the wrong thread`.
* The cache is populated from main-thread Media3 callbacks (the
* [AnalyticsListener.onAudioSessionIdChanged] hook and `onIsPlayingChanged`).
*
* Exposed read-only. B4 reads it via a provider lambda so the listener
* can re-check across the 1 s poll window without holding a stale
* reference.
*/
val audioSessionId: Int
get() = exoPlayer.audioSessionId
get() = cachedAudioSessionId
/**
* Set the playback volume of the underlying ExoPlayer.
@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -40,6 +41,15 @@ sealed class AuthState {
data class Failed(val reason: String) : AuthState()
}
@Serializable
data class ConnectionAuthSecrets(
val sessionToken: String? = null,
val refreshToken: String? = null,
val deviceId: String? = null,
val apiKey: String? = null,
val pairedSessionMetaJson: String? = null,
)
/**
* Orchestrates pairing + session token lifecycle for the relay channel.
*
@@ -134,6 +144,56 @@ class AuthManager(
}
}
suspend fun exportStoredSecrets(
context: Context,
tokenStoreKey: String,
): ConnectionAuthSecrets = withContext(Dispatchers.IO) {
val store = tokenStoreForBackup(context, tokenStoreKey)
ConnectionAuthSecrets(
sessionToken = store.getString(KEY_SESSION_TOKEN),
refreshToken = store.getString(KEY_REFRESH_TOKEN),
deviceId = store.getString(KEY_DEVICE_ID),
apiKey = store.getString(KEY_API_KEY),
pairedSessionMetaJson = store.getString(KEY_PAIRED_META),
)
}
suspend fun importStoredSecrets(
context: Context,
tokenStoreKey: String,
secrets: ConnectionAuthSecrets,
) {
withContext(Dispatchers.IO) {
val store = tokenStoreForBackup(context, tokenStoreKey)
writeOrRemove(store, KEY_SESSION_TOKEN, secrets.sessionToken)
writeOrRemove(store, KEY_REFRESH_TOKEN, secrets.refreshToken)
writeOrRemove(store, KEY_DEVICE_ID, secrets.deviceId)
writeOrRemove(store, KEY_API_KEY, secrets.apiKey)
writeOrRemove(store, KEY_PAIRED_META, secrets.pairedSessionMetaJson)
}
}
private fun tokenStoreForBackup(
context: Context,
tokenStoreKey: String,
): SessionTokenStore {
val appContext = context.applicationContext
return KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
}
private fun writeOrRemove(
store: SessionTokenStore,
key: String,
value: String?,
) {
if (value == null) {
store.remove(key)
} else {
store.putString(key, value)
}
}
/**
* Parse the `profiles` array from an `auth.ok` payload into a list of
* [Profile] entries. Extracted out of [handleAuthOk] so it's
@@ -3,6 +3,18 @@ package com.hermesandroid.relay.data
import kotlinx.serialization.Serializable
import java.net.URI
@Serializable
data class DashboardConnectionStatus(
val checkedAtMillis: Long? = null,
val reachable: Boolean = false,
val authRequired: Boolean? = null,
val authProviders: List<String> = emptyList(),
val authenticated: Boolean? = null,
val authProvider: String? = null,
val gatewayTicketAvailable: Boolean? = null,
val message: String? = null,
)
/**
* A "connection" = a distinct Hermes server connection the app can switch between.
*
@@ -30,9 +42,9 @@ import java.net.URI
*
* **Terminology note (2026-04-18):** earlier drafts of this feature called the
* concept "Profile". Renamed to [Connection] so that the term "Profile" is
* free to mean what Hermes's server config means by it (agent profiles —
* name + model + description defined under `agent.profiles` in config.yaml).
* A follow-up pass will introduce the new `Profile` concept on top.
* free to mean upstream Hermes profiles: separate host-side Hermes homes
* under `~/.hermes/profiles/<name>/`, each with its own config, SOUL, memory,
* sessions, skills, cron, and provider state.
*/
@Serializable
data class Connection(
@@ -41,6 +53,24 @@ data class Connection(
val apiServerUrl: String,
val relayUrl: String,
val tokenStoreKey: String,
/**
* Hermes dashboard/admin URL. Dashboard management features use this
* separately from the relay pairing channel; a blank/null value means
* "derive from [apiServerUrl] using the conventional same-host :9119".
*/
val dashboardUrl: String? = null,
val dashboardAuthRequired: Boolean? = null,
val dashboardAuthProviders: List<String> = emptyList(),
val dashboardLastStatus: DashboardConnectionStatus? = null,
/**
* Candidate host routes for this saved Hermes server. Standard setup
* stores at least one candidate here so API, dashboard, voice, and Relay
* helpers can follow LAN/Tailscale/public handoff before Relay pairing.
* Older installs and legacy serialized records default to an empty list.
*/
val routeCandidates: List<EndpointCandidate> = emptyList(),
/** Optional user preference such as "lan" or "tailscale"; null means Auto. */
val preferredRouteRole: String? = null,
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
val pairedAt: Long? = null,
val lastActiveSessionId: String? = null,
@@ -48,6 +78,12 @@ data class Connection(
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
val expiresAt: Long? = null,
) {
val resolvedDashboardUrl: String
get() = dashboardUrl
?.trim()
?.takeIf { it.isNotBlank() }
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
companion object {
/**
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
@@ -57,6 +93,8 @@ data class Connection(
*/
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
const val DEFAULT_DASHBOARD_PORT: Int = 9119
/**
* Derive a stable per-connection EncryptedSharedPreferences filename
* from a connection UUID. Trimmed to the first 8 characters of the
@@ -80,5 +118,213 @@ data class Connection(
apiServerUrl
}
}
fun deriveDefaultDashboardUrl(
apiServerUrl: String,
dashboardPort: Int = DEFAULT_DASHBOARD_PORT,
): String? {
val trimmed = apiServerUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
val scheme = when (uri.scheme?.lowercase()) {
"http" -> "http"
"https" -> "https"
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
"[$host]"
} else {
host
}
return "$scheme://$hostPart:$dashboardPort"
}
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
if (trimmed.isEmpty()) return true
val derived = deriveDefaultDashboardUrl(apiServerUrl) ?: return false
return trimmed.equals(derived, ignoreCase = true)
}
fun deriveDefaultRelayUrl(
apiServerUrl: String,
relayPort: Int = 8767,
): String? {
val trimmed = apiServerUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
val scheme = when (uri.scheme?.lowercase()) {
"http" -> "ws"
"https" -> "wss"
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
"[$host]"
} else {
host
}
return "$scheme://$hostPart:$relayPort"
}
fun buildRouteCandidates(
apiServerUrl: String,
relayUrl: String,
extraApiUrls: List<Pair<String, String>> = emptyList(),
): List<EndpointCandidate> {
val routes = buildList {
endpointCandidateFromApiUrl(
role = inferRouteRole(apiServerUrl),
priority = 0,
apiServerUrl = apiServerUrl,
relayUrl = relayUrl.takeIf { it.isNotBlank() }
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
)?.let(::add)
extraApiUrls
.map { it.first.trim() to it.second.trim() }
.filter { (_, url) -> url.isNotBlank() }
.forEachIndexed { index, (role, url) ->
endpointCandidateFromApiUrl(
role = role.ifBlank { inferRouteRole(url) },
priority = index + 1,
apiServerUrl = url,
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
)?.let(::add)
}
}
return routes
.distinctBy {
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
}
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
/**
* Overlay a freshly-rebuilt candidate list onto an existing stored
* one, preserving the stored extras (priority > 0) that the rebuild
* doesn't already cover. URL edits rebuild only the route(s) the
* user actually touched — without this merge, saving an API or
* Relay URL collapsed the stored list to a single candidate,
* silently dropping the setup wizard's Tailscale route (or a
* pairing payload's extra endpoints) and killing LAN/VPN roaming.
*
* Stored extras are preserved **verbatim** (role, priority, relay
* URL) rather than re-derived, so payload-specified relay URLs
* survive. Host:port collisions defer to the rebuilt entry.
*/
fun mergeRouteCandidates(
rebuilt: List<EndpointCandidate>,
existing: List<EndpointCandidate>,
): List<EndpointCandidate> {
val rebuiltHostPorts = rebuilt
.map { "${it.api.host.lowercase()}:${it.api.port}" }
.toSet()
val preserved = existing
.filter { it.priority > 0 }
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
return (rebuilt + preserved)
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
/**
* Normalize hand-typed API-URL input: trim, strip trailing slashes,
* default a missing scheme to `http://`, and default a missing port
* to [defaultPort] — most Hermes API servers speak plain HTTP on
* 8642, and a bare `192.168.1.10` / Tailscale `100.x.y.z` is by far
* the most common thing users type.
*
* URLs that already carry a scheme are preserved **verbatim**
* (including a wrong one like `ws://`, so downstream validators can
* complain precisely): an explicit `https://hermes.example.com` may
* be a reverse proxy on 443, and force-appending :8642 would break
* it. Port-defaulting applies only to scheme-less input, where the
* user is visibly relying on our defaults.
*/
fun normalizeApiUrlInput(raw: String, defaultPort: Int = 8642): String {
val trimmed = raw.trim().trimEnd('/')
if (trimmed.isEmpty()) return trimmed
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
val withScheme = "http://$trimmed"
val uri = runCatching { URI(withScheme) }.getOrNull()
val canAppendPort = uri != null &&
!uri.host.isNullOrBlank() &&
uri.port <= 0 &&
uri.rawPath.isNullOrEmpty() &&
uri.rawQuery == null
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
}
private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*://")
fun endpointCandidateFromApiUrl(
role: String,
priority: Int,
apiServerUrl: String,
relayUrl: String,
): EndpointCandidate? {
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
?: return null
val scheme = uri.scheme?.lowercase()
val tls = when (scheme) {
"http" -> false
"https" -> true
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val port = if (uri.port > 0) uri.port else 8642
val resolvedRelayUrl = relayUrl.trim().takeIf { it.isNotBlank() }
?: deriveDefaultRelayUrl(apiServerUrl)
?: return null
val transportHint = when {
resolvedRelayUrl.startsWith("wss://", ignoreCase = true) -> "wss"
resolvedRelayUrl.startsWith("ws://", ignoreCase = true) -> "ws"
else -> null
}
return EndpointCandidate(
role = role.ifBlank { inferRouteRole(apiServerUrl) },
priority = priority,
api = ApiEndpoint(host = host, port = port, tls = tls),
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
)
}
fun inferRouteRole(apiServerUrl: String): String {
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
.getOrNull()
?.lowercase()
?: return "custom"
return when {
host.endsWith(".ts.net") || isTailscaleIpv4(host) -> "tailscale"
host == "localhost" ||
host == "127.0.0.1" ||
host == "::1" ||
isPrivateLanIpv4(host) -> "lan"
else -> "public"
}
}
private fun isTailscaleIpv4(host: String): Boolean {
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
if (parts.size != 4) return false
return parts[0] == 100 && parts[1] in 64..127
}
private fun isPrivateLanIpv4(host: String): Boolean {
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
if (parts.size != 4) return false
return when {
parts[0] == 10 -> true
parts[0] == 172 && parts[1] in 16..31 -> true
parts[0] == 192 && parts[1] == 168 -> true
parts[0] == 169 && parts[1] == 254 -> true
else -> false
}
}
}
}
@@ -158,7 +158,8 @@ class ConnectionStore private constructor(
// callers shouldn't rely on insertion order of a duplicate
// add, and the alternative (throwing) makes migration code
// more brittle than it needs to be.
val next = current.filterNot { it.id == connection.id } + connection
val normalized = connection.withDashboardDefaults()
val next = current.filterNot { it.id == connection.id } + normalized
prefs[KEY_CONNECTIONS] = encodeConnections(next)
_connections.value = next
}
@@ -177,7 +178,8 @@ class ConnectionStore private constructor(
Log.w(TAG, "updateConnection: no connection with id=${connection.id} — ignored")
return@edit
}
val next = current.map { if (it.id == connection.id) connection else it }
val normalized = connection.withDashboardDefaults()
val next = current.map { if (it.id == connection.id) normalized else it }
prefs[KEY_CONNECTIONS] = encodeConnections(next)
_connections.value = next
}
@@ -212,27 +214,83 @@ class ConnectionStore private constructor(
_activeConnectionId.value = null
}
}
removed?.let { connection ->
context?.let { ctx ->
val storeKeys = buildSet {
add(connection.tokenStoreKey)
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
// Pre-StrongBox fallback path used this file. If
// connection 0 is removed, scrub it alongside the
// hardware-backed legacy filename.
add("hermes_companion_auth")
}
}
for (storeKey in storeKeys) {
try {
ctx.deleteSharedPreferences(storeKey)
} catch (e: Exception) {
Log.w(
TAG,
"deleteSharedPreferences($storeKey) failed: ${e.message}",
)
}
}
removed?.let { deleteTokenStoresFor(it) }
}
}
/**
* Factory-reset helper: clear the persisted connection list, active
* pointer, legacy profile aliases, and every known per-connection auth
* store. Unlike removing one connection, this intentionally does not pick
* a successor; callers are resetting the app back to "no connection".
*/
suspend fun clearAllConnections() {
writeMutex.withLock {
var removed: List<Connection> = emptyList()
dataStore.edit { prefs ->
removed = decodeConnections(prefs[KEY_CONNECTIONS])
prefs.remove(KEY_CONNECTIONS)
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
_connections.value = emptyList()
_activeConnectionId.value = null
}
removed.forEach { deleteTokenStoresFor(it) }
}
}
suspend fun replaceConnections(
connections: List<Connection>,
activeConnectionId: String? = null,
) {
writeMutex.withLock {
var removed: List<Connection> = emptyList()
val normalizedConnections = connections.map { it.withDashboardDefaults() }
val normalizedActiveId = activeConnectionId
?.takeIf { id -> normalizedConnections.any { it.id == id } }
?: normalizedConnections.firstOrNull()?.id
dataStore.edit { prefs ->
removed = decodeConnections(prefs[KEY_CONNECTIONS])
if (normalizedConnections.isEmpty()) {
prefs.remove(KEY_CONNECTIONS)
} else {
prefs[KEY_CONNECTIONS] = encodeConnections(normalizedConnections)
}
if (normalizedActiveId == null) {
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
} else {
prefs[KEY_ACTIVE_CONNECTION_ID] = normalizedActiveId
}
prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
_connections.value = normalizedConnections
_activeConnectionId.value = normalizedActiveId
}
removed.forEach { deleteTokenStoresFor(it) }
}
}
private fun deleteTokenStoresFor(connection: Connection) {
context?.let { ctx ->
val storeKeys = buildSet {
add(connection.tokenStoreKey)
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
// Pre-StrongBox fallback path used this file. If
// connection 0 is removed, scrub it alongside the
// hardware-backed legacy filename.
add("hermes_companion_auth")
}
}
for (storeKey in storeKeys) {
try {
ctx.deleteSharedPreferences(storeKey)
} catch (e: Exception) {
Log.w(
TAG,
"deleteSharedPreferences($storeKey) failed: ${e.message}",
)
}
}
}
@@ -294,6 +352,8 @@ class ConnectionStore private constructor(
pairedAt = pairedAtMillis,
transportHint = transportHint,
expiresAt = expiresAtMillis,
dashboardUrl = target.dashboardUrl
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
)
} else {
it
@@ -340,6 +400,8 @@ class ConnectionStore private constructor(
apiServerUrl = apiUrl,
relayUrl = relayUrl,
tokenStoreKey = Connection.LEGACY_TOKEN_STORE_KEY,
dashboardUrl = Connection.deriveDefaultDashboardUrl(apiUrl),
routeCandidates = Connection.buildRouteCandidates(apiUrl, relayUrl),
pairedAt = null,
lastActiveSessionId = legacyLastSessionId,
transportHint = null,
@@ -355,6 +417,33 @@ class ConnectionStore private constructor(
}
}
suspend fun setDashboardStatus(
connectionId: String,
status: DashboardConnectionStatus,
) {
writeMutex.withLock {
dataStore.edit { prefs ->
val current = decodeConnections(prefs[KEY_CONNECTIONS])
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
val next = current.map {
if (it.id == connectionId) {
target.copy(
dashboardUrl = target.dashboardUrl
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
dashboardAuthRequired = status.authRequired,
dashboardAuthProviders = status.authProviders,
dashboardLastStatus = status,
)
} else {
it
}
}
prefs[KEY_CONNECTIONS] = encodeConnections(next)
_connections.value = next
}
}
}
// --- Encoding helpers ---------------------------------------------------
private fun encodeConnections(list: List<Connection>): String =
@@ -364,12 +453,36 @@ class ConnectionStore private constructor(
if (raw.isNullOrBlank()) return emptyList()
return try {
json.decodeFromString(connectionListSerializer, raw)
.map { it.withDashboardDefaults() }
} catch (e: Exception) {
Log.w(TAG, "decodeConnections failed, returning empty list: ${e.message}")
emptyList()
}
}
private fun Connection.withDashboardDefaults(): Connection {
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
val normalizedRoutes = routeCandidates.ifEmpty {
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
}
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
}
return if (
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
normalizedRoutes != routeCandidates ||
normalizedPreferredRouteRole != preferredRouteRole
) {
copy(
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
routeCandidates = normalizedRoutes,
preferredRouteRole = normalizedPreferredRouteRole,
)
} else {
this
}
}
companion object {
private const val TAG = "ConnectionStore"
@@ -6,6 +6,10 @@ import android.util.Log
import androidx.datastore.preferences.core.booleanPreferencesKey
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -20,8 +24,8 @@ import java.io.File
/**
* Manages app data: backup, restore, and reset.
*
* Backup format is a JSON file containing settings and connection info.
* Tokens are NOT included in backups for security.
* Backup format is a JSON file containing full connection metadata and
* credentials. Treat exported files as sensitive secrets.
*/
class DataManager(
private val context: Context,
@@ -51,8 +55,7 @@ class DataManager(
}
/**
* Backup data model -- only non-sensitive settings.
* Tokens and device IDs are never included.
* Backup data model.
*
* **Schema history:**
* - v1: `serverUrl` only (single endpoint, pre-API-split).
@@ -66,22 +69,50 @@ class DataManager(
* re-mapped to `connections` (see [importSettings]). v1/v2 imports
* get `connections = emptyList()` since the old string list was not
* structurally compatible.
* - v5 (2026-06-08): full connection backups. Adds active connection id
* and `connectionSecrets`, including API keys, relay tokens, device id,
* paired metadata, and dashboard cookies.
*/
@Serializable
data class AppBackup(
val version: Int = 4,
val version: Int = 5,
val serverUrl: String? = null, // legacy (v1 compat)
val apiServerUrl: String? = null,
val relayUrl: String? = null,
val theme: String = "auto",
val onboardingCompleted: Boolean = false,
val connections: List<Connection> = emptyList(),
val exportedAt: Long = System.currentTimeMillis()
val activeConnectionId: String? = null,
val containsSensitiveData: Boolean = true,
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
val exportedAt: Long = System.currentTimeMillis(),
)
@Serializable
data class ConnectionSecretBackup(
val connectionId: String,
val tokenStoreKey: String,
val auth: ConnectionAuthSecrets = ConnectionAuthSecrets(),
val dashboardCookies: List<DashboardCookieBackup> = emptyList(),
)
@Serializable
data class DashboardCookieBackup(
val name: String,
val value: String,
val expiresAt: Long,
val domain: String,
val path: String,
val secure: Boolean,
val httpOnly: Boolean,
val hostOnly: Boolean,
val persistent: Boolean,
)
/**
* Export app settings to a JSON string.
* Does NOT include session tokens or device IDs (security).
* Includes connection credentials. The export UI must warn the user that
* the resulting JSON file is sensitive.
*
* The `sessionLabels` parameter is a legacy dead parameter — it was
* previously sourced from `AuthManager.sessionLabels`, a field removed
@@ -100,7 +131,7 @@ class DataManager(
apiServerUrl: String? = null,
relayUrl: String? = null
): String {
val connectionsSnapshot = connectionStore?.connections?.value
val connectionsSnapshot = connectionStore?.connections?.value.orEmpty()
if (connectionStore == null) {
Log.w(
TAG,
@@ -108,19 +139,56 @@ class DataManager(
"(caller constructed DataManager without the multi-connection ctor arg)",
)
}
val connectionSecrets = connectionsSnapshot.map { connection ->
ConnectionSecretBackup(
connectionId = connection.id,
tokenStoreKey = connection.tokenStoreKey,
auth = AuthManager.exportStoredSecrets(context, connection.tokenStoreKey),
dashboardCookies = EncryptedDashboardCookieStore(
context = context,
connectionId = connection.id,
).load().map { it.toBackup() },
)
}
val backup = AppBackup(
version = 4,
version = 5,
serverUrl = serverUrl, // legacy compat
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
theme = theme,
onboardingCompleted = onboardingCompleted,
connections = connectionsSnapshot ?: emptyList(),
exportedAt = System.currentTimeMillis()
connections = connectionsSnapshot,
activeConnectionId = connectionStore?.activeConnectionId?.value,
containsSensitiveData = true,
connectionSecrets = connectionSecrets,
exportedAt = System.currentTimeMillis(),
)
return json.encodeToString(backup)
}
suspend fun restoreConnectionBackup(backup: AppBackup) {
val store = connectionStore ?: return
deleteSensitivePreferenceFiles()
store.replaceConnections(
connections = backup.connections,
activeConnectionId = backup.activeConnectionId,
)
val connectionsById = backup.connections.associateBy { it.id }
backup.connectionSecrets.forEach { secret ->
val connection = connectionsById[secret.connectionId] ?: return@forEach
AuthManager.importStoredSecrets(
context = context,
tokenStoreKey = connection.tokenStoreKey,
secrets = secret.auth,
)
EncryptedDashboardCookieStore(
context = context,
connectionId = connection.id,
).save(secret.dashboardCookies.map { it.toStoredCookie() })
}
}
/**
* Import settings from a JSON string.
* Returns the parsed backup, or null if invalid.
@@ -221,6 +289,11 @@ class DataManager(
// Preserve onboarding state before clearing
val onboarding = isOnboardingCompleted()
// Multi-connection reset: clear the hot ConnectionStore state and
// delete every per-connection token store before the global
// DataStore is wiped.
connectionStore?.clearAllConnections()
// Clear all DataStore preferences
context.relayDataStore.edit { it.clear() }
@@ -231,15 +304,7 @@ class DataManager(
}
}
// Delete the EncryptedSharedPreferences file for auth tokens
withContext(Dispatchers.IO) {
val prefsDir = File(context.filesDir.parent, "shared_prefs")
val authFile = File(prefsDir, "$AUTH_PREFS_NAME.xml")
if (authFile.exists()) {
authFile.delete()
Log.d(TAG, "Deleted auth preferences file")
}
}
deleteSensitivePreferenceFiles()
// Clear cache directory
withContext(Dispatchers.IO) {
@@ -254,6 +319,61 @@ class DataManager(
}
}
private suspend fun deleteSensitivePreferenceFiles() {
withContext(Dispatchers.IO) {
val prefsDir = File(context.filesDir.parent, "shared_prefs")
val stores = buildSet {
add(AUTH_PREFS_NAME)
add(Connection.LEGACY_TOKEN_STORE_KEY)
prefsDir.listFiles()?.forEach { file ->
if (file.extension == "xml") {
val name = file.nameWithoutExtension
if (
name.startsWith("hermes_auth_") ||
name.startsWith("hermes_dashboard_")
) {
add(name)
}
}
}
}
stores.forEach { storeName ->
try {
context.deleteSharedPreferences(storeName)
Log.d(TAG, "Deleted auth preferences file: $storeName")
} catch (e: Exception) {
Log.w(TAG, "deleteSharedPreferences($storeName) failed: ${e.message}")
}
}
}
}
private fun StoredDashboardCookie.toBackup(): DashboardCookieBackup =
DashboardCookieBackup(
name = name,
value = value,
expiresAt = expiresAt,
domain = domain,
path = path,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
persistent = persistent,
)
private fun DashboardCookieBackup.toStoredCookie(): StoredDashboardCookie =
StoredDashboardCookie(
name = name,
value = value,
expiresAt = expiresAt,
domain = domain,
path = path,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
persistent = persistent,
)
/**
* Reset only the onboarding completion flag.
* Next app launch will show onboarding again.
@@ -12,8 +12,10 @@ import kotlinx.serialization.Serializable
* upstream layout (one directory per profile under `~/.hermes/profiles/`)
* and added [systemMessage], sourced from each profile's `SOUL.md`.
*
* A Profile is a NAMED AGENT CONFIG within a Connection. Switching profile
* changes the active agent identity for the Android chat surface:
* A Profile is an upstream Hermes profile context within a Connection.
* Upstream stores named profiles as separate Hermes homes under
* `~/.hermes/profiles/<name>/`. Switching profile changes the active agent
* identity for the Android chat surface:
* - which profile API server the phone routes chat/session calls to when
* the relay advertises [apiServerUrl];
* - which profile name the phone sends to the server for new sessions and
@@ -87,6 +87,12 @@ class ProfileSelectionStore(
prefs.remove(keyFor(connectionId))
}
}
suspend fun clearAll() {
dataStore.edit { prefs ->
prefs.clear()
}
}
}
/**
@@ -63,6 +63,12 @@ class ProfileSessionStore(
.forEach { prefs.remove(it) }
}
}
suspend fun clearAll() {
dataStore.edit { prefs ->
prefs.clear()
}
}
}
internal val Context.profileSessionsDataStore: DataStore<Preferences>
@@ -24,11 +24,19 @@ import kotlinx.coroutines.flow.map
*/
data class VoiceSettings(
val engineMode: String = VoiceEngineMode.HermesVoiceOutput.storageValue,
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
val interactionMode: String = "tap",
val silenceThresholdMs: Long = 3000L,
val autoTts: Boolean = false,
val language: String = "",
val realtimeTraceDetails: Boolean = false,
/**
* When true (default), Realtime Agent keeps one provider session/socket open
* across turns (persistent conversation). When false, falls back to the
* legacy one-session-per-utterance path. See
* docs/plans/2026-05-24-realtime-persistent-session.md.
*/
val realtimePersistentSession: Boolean = true,
)
enum class VoiceEngineMode(val storageValue: String) {
@@ -41,24 +49,40 @@ enum class VoiceEngineMode(val storageValue: String) {
}
}
enum class VoiceAudioRoute(val storageValue: String) {
Auto("auto"),
Standard("standard"),
Relay("relay");
companion object {
fun fromStorage(value: String?): VoiceAudioRoute =
values().firstOrNull { it.storageValue == value } ?: Auto
}
}
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")
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")
private val KEY_LANGUAGE = stringPreferencesKey("voice_language")
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
private val KEY_REALTIME_PERSISTENT_SESSION =
booleanPreferencesKey("voice_realtime_persistent_session")
const val DEFAULT_ENGINE_MODE = "hermes_voice_output"
const val DEFAULT_AUDIO_ROUTE = "auto"
const val DEFAULT_INTERACTION_MODE = "tap"
const val DEFAULT_SILENCE_THRESHOLD_MS = 3000L
const val DEFAULT_AUTO_TTS = false
const val DEFAULT_LANGUAGE = ""
const val DEFAULT_REALTIME_TRACE_DETAILS = false
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
}
val settings: Flow<VoiceSettings> = dataStore.data
@@ -67,12 +91,17 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
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,
)
}
.distinctUntilChanged()
@@ -81,6 +110,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
dataStore.edit { it[KEY_ENGINE_MODE] = mode.storageValue }
}
suspend fun setAudioRoute(route: VoiceAudioRoute) {
dataStore.edit { it[KEY_AUDIO_ROUTE] = route.storageValue }
}
suspend fun setInteractionMode(mode: String) {
dataStore.edit { it[KEY_INTERACTION_MODE] = mode }
}
@@ -100,4 +133,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
}
suspend fun setRealtimePersistentSession(enabled: Boolean) {
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
}
}
@@ -81,12 +81,21 @@ class ConnectionManager(
private val context: Context? = null,
/**
* ADR 24 multi-endpoint resolver. When provided alongside [context] and
* a non-null [deviceIdProvider], every call to [connect] first consults
* the resolver before opening the WSS; on network changes the resolver
* is re-run and we hot-swap to the new winner. When null the manager
* uses the caller-supplied URL verbatim (pre-ADR-24 behavior).
* either [endpointCandidatesProvider] or a non-null [deviceIdProvider],
* every call to [connect] first consults the resolver before opening the
* WSS; on network changes the resolver is re-run and we hot-swap to the
* new winner. When null the manager uses the caller-supplied URL verbatim
* (pre-ADR-24 behavior).
*/
private val endpointResolver: EndpointResolver? = null,
/**
* Candidate supplier for the active saved connection. This is the
* standard-Hermes route source: it works before Relay pairing, so API,
* dashboard, voice, and future Relay calls can hand off between LAN and
* Tailscale using the same resolver. If it returns an empty list, we fall
* back to the legacy per-device PairingPreferences source below.
*/
private val endpointCandidatesProvider: (suspend () -> List<EndpointCandidate>)? = null,
/**
* Suspending supplier for the active device id. Used to key into
* [PairingPreferences.getDeviceEndpoints] during resolution. `null`
@@ -172,10 +181,34 @@ class ConnectionManager(
private var networkCallback: ConnectivityManager.NetworkCallback? = null
/**
* Debounce job for network-change re-resolution. Android fires one
* onAvailable per satisfying network (Wi-Fi + cell + VPN can land within
* milliseconds of each other, and registration itself replays every
* current network), so each event cancels the previous pending resolve
* and the last one wins after a short settle window.
*/
@Volatile
private var networkResolveJob: kotlinx.coroutines.Job? = null
init {
// Register at construction, not on first connect(). Standard
// (no-Relay) connections never open the WSS socket, but their HTTP
// surfaces (chat, dashboard, voice) still need [activeEndpoint] to
// follow LAN/Tailscale handoffs — leaving registration inside
// connect() left the whole ADR 24 network-aware path dormant for
// exactly those users. No-op when [context] is null (tests).
ensureNetworkCallbackRegistered()
}
companion object {
private const val TAG = "ConnectionManager"
private const val MAX_BACKOFF_MS = 30_000L
private const val BASE_BACKOFF_MS = 1_000L
// Settle window before re-resolving after a network event. Long
// 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
// 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
@@ -331,21 +364,30 @@ class ConnectionManager(
private suspend fun resolveBestEndpointSafe(): EndpointCandidate? {
val resolver = endpointResolver ?: return null
val ctx = context ?: return null
val devicePull = deviceIdProvider ?: return null
val deviceId = try {
withTimeoutOrNull(1_000L) { devicePull() }
} catch (_: Exception) {
null
} ?: return null
val endpoints: List<EndpointCandidate> = try {
val endpoints = try {
withTimeoutOrNull(1_000L) {
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
endpointCandidatesProvider?.invoke()
?.takeIf { it.isNotEmpty() }
}
} catch (_: Exception) {
null
} ?: emptyList()
} ?: run {
val devicePull = deviceIdProvider ?: return null
val deviceId = try {
withTimeoutOrNull(1_000L) { devicePull() }
} catch (_: Exception) {
null
} ?: return null
try {
withTimeoutOrNull(1_000L) {
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
}
} catch (_: Exception) {
null
} ?: emptyList()
}
if (endpoints.isEmpty()) return null
@@ -371,35 +413,58 @@ class ConnectionManager(
/**
* User-triggered re-probe. Forces a fresh resolve + reconnect regardless
* of cache state. Backs the "Probe now" row action in the Endpoints card.
* Fire-and-forget wrapper around [probeAndReconnectNow] for callers that
* don't need the outcome.
*/
fun probeAndReconnect() {
scope.launch { probeAndReconnectNow() }
}
/**
* Awaitable body of [probeAndReconnect]. Returns the resolved winner —
* or null when no candidate answered — so callers (probe-status UI) can
* report the outcome instead of guessing with a fixed delay.
*
* Unlike the pre-2026-06 version this ALWAYS publishes the resolve
* outcome to [activeEndpoint]: a standard (no relay socket) connection
* whose probes all failed used to early-return before publishing,
* leaving the Routes card stuck on "Resolving" with no feedback. The
* only exception is the live-socket transient-miss guard shared with
* [refreshActiveEndpoint].
*/
suspend fun probeAndReconnectNow(): EndpointCandidate? {
endpointResolver?.clearCache()
val current = serverUrl
scope.launch {
val resolved = resolveBestEndpointSafe()
val targetUrl = resolved?.relay?.url ?: current ?: return@launch
val normalizedTarget = normalizeRelayUrl(targetUrl)
_activeEndpoint.value = resolved
// Reconnect when the winner changed, and also when the socket is
// stale/disconnected on the same winner. The latter makes the
// "Use now" route action an actual recovery path after Wi-Fi drop
// instead of a no-op that only updates preference state.
if (current == null) {
if (shouldReconnect && reconnectGate()) {
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
connectToUrlOnMainPath(targetUrl)
}
} else if (normalizedTarget != current) {
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
connectToUrlOnMainPath(targetUrl, "Endpoint re-probe")
} else if (_connectionState.value == ConnectionState.Disconnected &&
shouldReconnect &&
reconnectGate()
) {
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
doConnect(current)
}
val resolved = resolveBestEndpointSafe()
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
// Transient probe miss while the relay socket is demonstrably up
// — keep the live route published rather than downgrading every
// HTTP surface to the saved URL. Mirrors refreshActiveEndpoint.
return _activeEndpoint.value
}
_activeEndpoint.value = resolved
val targetUrl = resolved?.relay?.url ?: current ?: return resolved
val normalizedTarget = normalizeRelayUrl(targetUrl)
// Reconnect when the winner changed, and also when the socket is
// stale/disconnected on the same winner. The latter makes the
// "Use now" route action an actual recovery path after Wi-Fi drop
// instead of a no-op that only updates preference state.
if (current == null) {
if (shouldReconnect && reconnectGate()) {
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
connectToUrlOnMainPath(targetUrl)
}
} else if (normalizedTarget != current) {
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
connectToUrlOnMainPath(targetUrl, "Endpoint re-probe")
} else if (_connectionState.value == ConnectionState.Disconnected &&
shouldReconnect &&
reconnectGate()
) {
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
doConnect(current)
}
return resolved
}
/**
@@ -407,9 +472,22 @@ class ConnectionManager(
* WSS reconnect. Used by HTTP-only surfaces (chat/voice/relay HTTP)
* so they can follow LAN/Tailscale/VPN route changes even when the relay
* socket is currently disconnected or intentionally not paired.
*
* @param clearProbeCache wipe the resolver's probe cache first. Pass
* `true` from "the world may have changed" triggers (app resume,
* network change) — otherwise a route that died within the positive
* cache TTL (60s) can still be returned as the winner.
*/
suspend fun refreshActiveEndpoint(): EndpointCandidate? {
suspend fun refreshActiveEndpoint(clearProbeCache: Boolean = false): EndpointCandidate? {
if (clearProbeCache) endpointResolver?.clearCache()
val resolved = resolveBestEndpointSafe()
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
// Transient probe miss while the relay socket is demonstrably up
// (slow resume, mid-handoff blip) — keep publishing the live
// route instead of downgrading every HTTP surface to the saved
// URL. Mirrors scheduleNetworkReResolve's guard.
return _activeEndpoint.value
}
_activeEndpoint.value = resolved
return resolved
}
@@ -432,26 +510,48 @@ class ConnectionManager(
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
}
private fun resolveAndSwitchIfNeeded(closeReason: String) {
/**
* Debounced network-change re-resolution, shared by both NetworkCallback
* events. Re-runs the resolver and publishes the winner to
* [activeEndpoint] so HTTP-only surfaces (chat, dashboard, standard
* voice) follow the route change even when no relay socket exists. When
* a socket IS up, additionally swaps it to a differing winner, or
* reconnects a disconnected socket on the same winner — preserving the
* pre-refactor relay-path behavior.
*/
private fun scheduleNetworkReResolve(closeReason: String) {
if (endpointResolver == null) return
val current = serverUrl ?: return
scope.launch {
networkResolveJob?.cancel()
networkResolveJob = scope.launch {
delay(NETWORK_RESOLVE_DEBOUNCE_MS)
val current = serverUrl
val resolved = resolveBestEndpointSafe()
if (resolved == null) {
_activeEndpoint.value = null
// Don't clear a live socket's endpoint on a transient probe
// miss — only drop the published route when nothing is
// actually connected.
if (_connectionState.value != ConnectionState.Connected) {
_activeEndpoint.value = null
}
return@launch
}
val newUrl = resolved.relay.url
val normalizedNew = normalizeRelayUrl(newUrl)
_activeEndpoint.value = resolved
if (current == null) return@launch
// After an explicit disconnect() the route still publishes above
// (HTTP surfaces keep roaming), but no socket action: without
// this gate a network event whose winner differs from the last
// URL would resurrect a socket the user deliberately closed.
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
// the swap path never re-checked it.)
if (!shouldReconnect) return@launch
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
if (normalizedNew != current) {
Log.i(TAG, "endpoint fallback: swapping $current → $normalizedNew")
connectToUrlOnMainPath(newUrl, closeReason)
Log.i(TAG, "network change: swapping $current → $normalizedNew")
connectToUrlOnMainPath(resolved.relay.url, closeReason)
} else if (_connectionState.value == ConnectionState.Disconnected &&
shouldReconnect &&
reconnectGate()
) {
Log.i(TAG, "endpoint fallback: same winner is disconnected — reconnecting $current")
Log.i(TAG, "network change: same winner is disconnected — reconnecting $current")
doConnect(current)
}
}
@@ -464,36 +564,15 @@ class ConnectionManager(
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
if (endpointResolver == null) return
val url = serverUrl ?: return
endpointResolver.clearCache()
scope.launch {
val resolved = resolveBestEndpointSafe()
val newUrl = resolved?.relay?.url
if (newUrl == null) {
if (_connectionState.value != ConnectionState.Connected) {
_activeEndpoint.value = null
}
return@launch
}
val normalizedNew = normalizeRelayUrl(newUrl)
_activeEndpoint.value = resolved
// Only swap if the winner actually differs from the
// currently-connected URL. Avoids dropping a healthy
// socket on a no-op network flap (Wi-Fi scan, cell
// handover that ends up on the same route, etc.).
if (normalizedNew != url) {
Log.i(TAG, "network change: swapping $url → $normalizedNew")
connectToUrlOnMainPath(newUrl, "Network change — switching endpoint")
}
}
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")
resolveAndSwitchIfNeeded("Network lost — switching endpoint")
scheduleNetworkReResolve("Network lost — switching endpoint")
}
}
try {
@@ -0,0 +1,901 @@
package com.hermesandroid.relay.network
import android.content.Context
import com.hermesandroid.relay.auth.KeystoreTokenStore
import com.hermesandroid.relay.auth.LegacyEncryptedPrefsTokenStore
import com.hermesandroid.relay.auth.SessionTokenStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.IOException
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
data class DashboardStatus(
val authRequired: Boolean,
val authProviders: List<String> = emptyList(),
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
val version: String? = null,
val message: String? = null,
)
data class DashboardAuthProvider(
val name: String,
val displayName: String? = null,
val supportsPassword: Boolean = false,
) {
val isRedirectProvider: Boolean
get() = !supportsPassword
}
data class DashboardLoginResponse(
val ok: Boolean,
val next: String? = null,
val message: String? = null,
)
data class DashboardAuthSession(
val authenticated: Boolean,
val username: String? = null,
val provider: String? = null,
)
data class DashboardWsTicket(
val ticket: String,
val ttlSeconds: Int? = null,
)
/**
* Native client for the Hermes dashboard/admin server (:9119).
*
* This is deliberately separate from [HermesApiClient] and all relay pairing
* clients. Dashboard cookies authenticate standard admin surfaces such as
* skills/cron/MCP/profile config; relay pairing remains the auth path for
* terminal, bridge, media relay, and profile memory file editing.
*/
class DashboardApiClient(
baseUrl: String,
private val okHttpClient: OkHttpClient = defaultClient(),
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
},
) {
private val baseUrl: String = baseUrl.trim().trimEnd('/')
suspend fun getStatus(): Result<DashboardStatus> = withContext(Dispatchers.IO) {
getJson("/api/status").mapCatching { parseStatus(it) }
}
suspend fun getAuthProviders(): Result<List<DashboardAuthProvider>> = withContext(Dispatchers.IO) {
getJson("/api/auth/providers").mapCatching { root ->
parseProviders(root["providers"])
}
}
suspend fun getJsonObject(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
val normalized = if (path.startsWith("/")) path else "/$path"
getJson(normalized)
}
suspend fun getJsonElement(path: String): Result<JsonElement> = withContext(Dispatchers.IO) {
val normalized = if (path.startsWith("/")) path else "/$path"
val request = Request.Builder()
.url("$baseUrl$normalized")
.get()
.build()
executeJsonElement(request, normalized)
}
suspend fun postJsonObject(
path: String,
payload: JsonObject = JsonObject(emptyMap()),
): Result<JsonObject> = withContext(Dispatchers.IO) {
val normalized = if (path.startsWith("/")) path else "/$path"
val request = Request.Builder()
.url("$baseUrl$normalized")
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.build()
executeJson(request, normalized)
}
suspend fun putJsonObject(
path: String,
payload: JsonObject,
): Result<JsonObject> = withContext(Dispatchers.IO) {
val normalized = if (path.startsWith("/")) path else "/$path"
val request = Request.Builder()
.url("$baseUrl$normalized")
.put(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.build()
executeJson(request, normalized)
}
suspend fun deleteJsonObject(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
val normalized = if (path.startsWith("/")) path else "/$path"
val request = Request.Builder()
.url("$baseUrl$normalized")
.delete()
.build()
executeJson(request, normalized)
}
/** DELETE with a JSON body — upstream's `DELETE /api/env` reads the key from the body. */
suspend fun deleteJsonObjectWithBody(
path: String,
payload: JsonObject,
): Result<JsonObject> = withContext(Dispatchers.IO) {
val normalized = if (path.startsWith("/")) path else "/$path"
val request = Request.Builder()
.url("$baseUrl$normalized")
.delete(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.build()
executeJson(request, normalized)
}
// --- Models (dashboard parity with hermes-desktop Settings → Model) ---
/** Full provider/model universe — REST twin of the TUI's `model.options` RPC. */
suspend fun getModelOptions(): Result<JsonObject> = getJsonObject("/api/model/options")
/**
* Assign the main model in `~/.hermes/config.yaml` (new sessions only).
* Upstream may answer `{ok: false, confirm_required: true, warning: ...}`
* for expensive models — re-call with [confirmExpensive] after the user
* accepts the warning.
*/
suspend fun setMainModel(
provider: String,
model: String,
confirmExpensive: Boolean = false,
): Result<JsonObject> =
postJsonObject(
path = "/api/model/set",
payload = buildJsonObject {
put("scope", "main")
put("provider", provider)
put("model", model)
if (confirmExpensive) put("confirm_expensive_model", true)
},
)
// --- Env / keys (dashboard parity with hermes-desktop Settings → Keys) ---
/** Curated env-var inventory: name → {is_set, redacted_value, description, category, ...}. */
suspend fun getEnvVars(): Result<JsonObject> = getJsonObject("/api/env")
suspend fun setEnvVar(key: String, value: String): Result<JsonObject> =
putJsonObject(
path = "/api/env",
payload = buildJsonObject {
put("key", key)
put("value", value)
},
)
suspend fun deleteEnvVar(key: String): Result<JsonObject> =
deleteJsonObjectWithBody(
path = "/api/env",
payload = buildJsonObject { put("key", key) },
)
/** Server rate-limits reveals (5 per 30s) and audit-logs each one. */
suspend fun revealEnvVar(key: String): Result<JsonObject> =
postJsonObject(
path = "/api/env/reveal",
payload = buildJsonObject { put("key", key) },
)
// --- Skills hub (dashboard parity with hermes-desktop Browse-hub tab) ---
/**
* Parallel multi-source hub search. Response carries `results` (name /
* description / source / identifier / trust_level / repo / tags),
* `source_counts`, `timed_out`, and `installed` (identifier → lock entry)
* so already-installed results can be marked. Server caps limit at 50 and
* fans out with a 30s overall timeout — keep client read timeouts above that.
*/
suspend fun searchSkillsHub(query: String, limit: Int = 20): Result<JsonObject> =
getJsonObject("/api/skills/hub/search?q=${queryValue(query)}&limit=${limit.coerceIn(1, 50)}")
/** SKILL.md + manifest for an identifier WITHOUT installing — read before you trust. */
suspend fun previewSkillsHub(identifier: String): Result<JsonObject> =
getJsonObject("/api/skills/hub/preview?identifier=${queryValue(identifier)}")
/**
* Configured hub sources + featured skills (`{sources, index_available,
* featured, installed}`) — content for the browse dialog before the first
* search. Featured entries share the search-result payload shape.
*/
suspend fun getSkillsHubSources(): Result<JsonObject> =
getJsonObject("/api/skills/hub/sources")
/**
* Spawns `hermes skills install <identifier>` server-side and returns
* `{ok, pid}` immediately — the install completes in the background, so
* callers should message "started" and refresh the skills list later.
*/
suspend fun installSkillsHub(identifier: String): Result<JsonObject> =
postJsonObject(
path = "/api/skills/hub/install",
payload = buildJsonObject { put("identifier", identifier) },
)
/** Async spawn like install; takes the installed skill *name*, not the hub identifier. */
suspend fun uninstallSkillsHub(name: String): Result<JsonObject> =
postJsonObject(
path = "/api/skills/hub/uninstall",
payload = buildJsonObject { put("name", name) },
)
/** Async spawn of `hermes skills update` for all hub-installed skills. */
suspend fun updateSkillsHub(): Result<JsonObject> =
postJsonObject("/api/skills/hub/update")
// --- Profiles (write surface) ---
/** Full SOUL.md text — upstream returns the complete file, safe for round-trip editing. */
suspend fun putProfileSoul(name: String, content: String): Result<JsonObject> =
putJsonObject(
path = "/api/profiles/${pathSegment(name)}/soul",
payload = buildJsonObject { put("content", content) },
)
suspend fun createProfile(
name: String,
cloneFromDefault: Boolean = true,
description: String? = null,
): Result<JsonObject> =
postJsonObject(
path = "/api/profiles",
payload = buildJsonObject {
put("name", name)
put("clone_from_default", cloneFromDefault)
if (!description.isNullOrBlank()) put("description", description)
},
)
suspend fun setProfileDescription(name: String, description: String): Result<JsonObject> =
putJsonObject(
path = "/api/profiles/${pathSegment(name)}/description",
payload = buildJsonObject { put("description", description) },
)
suspend fun setProfileModel(
name: String,
provider: String,
model: String,
): Result<JsonObject> =
putJsonObject(
path = "/api/profiles/${pathSegment(name)}/model",
payload = buildJsonObject {
put("provider", provider)
put("model", model)
},
)
suspend fun toggleSkill(name: String, enabled: Boolean): Result<JsonObject> =
putJsonObject(
path = "/api/skills/toggle",
payload = buildJsonObject {
put("name", name)
put("enabled", enabled)
},
)
suspend fun pauseCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/pause${profileQuery(profile)}")
suspend fun resumeCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/resume${profileQuery(profile)}")
suspend fun triggerCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
postJsonObject("/api/cron/jobs/${pathSegment(jobId)}/trigger${profileQuery(profile)}")
suspend fun getCronJobRuns(
jobId: String,
profile: String? = null,
limit: Int = 20,
): Result<JsonObject> =
getJsonObject("/api/cron/jobs/${pathSegment(jobId)}/runs${profileLimitQuery(profile, limit)}")
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> =
putJsonObject(
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
payload = buildJsonObject { put("enabled", enabled) },
)
suspend fun testMcpServer(name: String): Result<JsonObject> =
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
suspend fun removeMcpServer(name: String): Result<JsonObject> =
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
suspend fun installMcpCatalogEntry(
name: String,
env: Map<String, String> = emptyMap(),
enable: Boolean = true,
): Result<JsonObject> =
postJsonObject(
path = "/api/mcp/catalog/install",
payload = buildJsonObject {
put("name", name)
put(
"env",
buildJsonObject {
env.forEach { (key, value) -> put(key, value) }
},
)
put("enable", enable)
},
)
suspend fun setActiveProfile(name: String): Result<JsonObject> =
postJsonObject(
path = "/api/profiles/active",
payload = buildJsonObject { put("name", name) },
)
suspend fun getProfileSoul(name: String): Result<JsonObject> =
getJsonObject("/api/profiles/${pathSegment(name)}/soul")
suspend fun deleteProfile(name: String): Result<JsonObject> =
deleteJsonObject("/api/profiles/${pathSegment(name)}")
suspend fun loginPassword(
provider: String = "basic",
username: String,
password: String,
next: String = "/",
): Result<DashboardLoginResponse> = withContext(Dispatchers.IO) {
val payload = buildJsonObject {
put("provider", provider)
put("username", username)
put("password", password)
put("next", next)
}
val request = Request.Builder()
.url("$baseUrl/auth/password-login")
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.build()
executeJson(request, "Dashboard sign-in").mapCatching { root ->
DashboardLoginResponse(
ok = root.booleanField("ok") ?: true,
next = root.stringField("next"),
message = root.stringField("message") ?: root.stringField("detail"),
)
}
}
suspend fun currentSession(): Result<DashboardAuthSession> = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$baseUrl/api/auth/me")
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) {
return@withContext Result.success(DashboardAuthSession(authenticated = false))
}
if (!response.isSuccessful) {
return@withContext Result.failure(apiFailure(response, "Dashboard session"))
}
val root = response.readJsonObject(json)
Result.success(parseAuthSession(root))
}
}
/**
* True when this dashboard build exposes the hermes-desktop voice routes
* (`/api/audio/transcribe` + `/api/audio/speak`). HEAD on a POST-only
* FastAPI route returns 405 when the path exists and 404 when it doesn't;
* an auth-gated 401/403 also proves the route is registered.
*/
suspend fun audioRoutesPresent(): Boolean = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$baseUrl/api/audio/transcribe")
.head()
.build()
try {
okHttpClient.newCall(request).execute().use { it.code != 404 }
} catch (_: Exception) {
false
}
}
suspend fun requestWsTicket(): Result<DashboardWsTicket> = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$baseUrl/api/auth/ws-ticket")
.post(ByteArray(0).toRequestBody(null))
.build()
executeJson(request, "Dashboard websocket ticket").mapCatching { root ->
val ticket = root.stringField("ticket")
?: root.stringField("ws_ticket")
?: throw IOException("Dashboard websocket ticket response missing ticket")
DashboardWsTicket(
ticket = ticket,
ttlSeconds = root.intField("ttl_seconds") ?: root.intField("ttl"),
)
}
}
fun authLoginUrl(provider: String, next: String = "/"): String =
authLoginUrl(baseUrl = baseUrl, provider = provider, next = next)
fun gatewayWebSocketUrl(ticket: String, path: String = "/api/ws"): String? =
gatewayWebSocketUrl(baseUrl = baseUrl, ticket = ticket, path = path)
fun shutdown() {
okHttpClient.dispatcher.executorService.shutdown()
okHttpClient.connectionPool.evictAll()
}
private suspend fun getJson(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$baseUrl$path")
.get()
.build()
executeJson(request, path)
}
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
return try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return Result.failure(apiFailure(response, operation))
}
Result.success(response.readJsonObject(json))
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun executeJsonElement(request: Request, operation: String): Result<JsonElement> {
return try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return Result.failure(apiFailure(response, operation))
}
Result.success(response.readJsonElement(json))
}
} catch (e: Exception) {
Result.failure(e)
}
}
companion object {
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
fun pathSegment(value: String): String =
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
private fun queryValue(value: String): String =
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
fun authLoginUrl(baseUrl: String, provider: String, next: String = "/"): String {
val root = baseUrl.trim().trimEnd('/')
return "$root/auth/login?provider=${queryValue(provider)}&next=${queryValue(next)}"
}
fun authLandingPath(baseUrl: String): String {
val httpUrl = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return "/"
val basePath = httpUrl.encodedPath.trimEnd('/')
return when {
basePath.isBlank() || basePath == "/" -> "/"
else -> "$basePath/"
}
}
fun gatewayWebSocketUrl(baseUrl: String, ticket: String, path: String = "/api/ws"): String? {
val httpUrl = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return null
val websocketPrefix = when (httpUrl.scheme) {
"https" -> "wss://"
"http" -> "ws://"
else -> return null
}
val normalizedPath = if (path.startsWith("/")) path else "/$path"
val basePath = httpUrl.encodedPath.trimEnd('/')
val encodedPath = when {
basePath.isBlank() || basePath == "/" -> normalizedPath
else -> "$basePath$normalizedPath"
}
val url = httpUrl.newBuilder()
.encodedPath(encodedPath)
.addQueryParameter("ticket", ticket)
.build()
.toString()
return websocketPrefix + url.substringAfter("://")
}
private fun profileQuery(profile: String?): String {
val trimmed = profile?.trim().orEmpty()
return if (trimmed.isBlank()) "" else "?profile=${pathSegment(trimmed)}"
}
private fun profileLimitQuery(profile: String?, limit: Int): String {
val params = buildList {
val trimmed = profile?.trim().orEmpty()
if (trimmed.isNotBlank()) add("profile=${pathSegment(trimmed)}")
add("limit=${limit.coerceIn(1, 100)}")
}
return params.joinToString(prefix = "?", separator = "&")
}
fun defaultClient(
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
): OkHttpClient = OkHttpClient.Builder()
.cookieJar(DashboardCookieJar(cookieStore))
.connectTimeout(10, TimeUnit.SECONDS)
// Skills-hub search fans out server-side with a 30s overall
// timeout; keep the read window above it so a slow-but-successful
// search doesn't die client-side at the edge.
.readTimeout(45, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
fun parseStatus(root: JsonObject): DashboardStatus {
val authObject = root["auth"] as? JsonObject
val providersElement = root["auth_providers"]
?: root["providers"]
?: authObject?.get("providers")
val providers = parseProviders(providersElement)
return DashboardStatus(
authRequired = root.booleanField("auth_required")
?: authObject.booleanField("required")
?: false,
authProviders = providers.map { it.name },
authProviderDetails = providers,
version = root.stringField("version"),
message = root.stringField("message") ?: root.stringField("detail"),
)
}
fun parseAuthSession(root: JsonObject): DashboardAuthSession {
val user = root["user"] as? JsonObject
val session = root["session"] as? JsonObject
val explicitAuthenticated = root.booleanField("authenticated")
?: root.booleanField("ok")
val flatIdentityPresent =
root.stringField("user_id") != null ||
root.stringField("email") != null ||
root.stringField("display_name") != null ||
root.stringField("provider") != null ||
root["expires_at"] != null
val authenticated = explicitAuthenticated
?: (user != null || session != null || flatIdentityPresent)
return DashboardAuthSession(
authenticated = authenticated,
username = root.stringField("username")
?: root.stringField("display_name")
?: root.stringField("email")
?: root.stringField("user_id")
?: user.stringField("username")
?: user.stringField("name")
?: session.stringField("username"),
provider = root.stringField("provider")
?: session.stringField("provider")
?: user.stringField("provider"),
)
}
fun parseProviders(element: JsonElement?): List<DashboardAuthProvider> {
return when (element) {
is JsonArray -> element.mapNotNull { provider(it) }
is JsonObject -> element.entries.mapNotNull { (key, value) ->
val name = key.trim().takeIf { it.isNotBlank() }
if (name != null && value is JsonObject) {
provider(name, value)
} else {
provider(value) ?: name?.let {
DashboardAuthProvider(name = it, supportsPassword = isPasswordProvider(it))
}
}
}
else -> emptyList()
}.distinctBy { it.name }
}
private fun provider(element: JsonElement?): DashboardAuthProvider? {
return when (element) {
is JsonPrimitive -> element.contentOrNull
?.trim()
?.takeIf { it.isNotBlank() }
?.let { DashboardAuthProvider(name = it, supportsPassword = isPasswordProvider(it)) }
is JsonObject -> {
val name = element.stringField("id")
?: element.stringField("name")
?: element.stringField("provider")
?: element.stringField("type")
name?.let { provider(it, element) }
}
else -> null
}
}
private fun provider(name: String, element: JsonObject): DashboardAuthProvider =
DashboardAuthProvider(
name = name,
displayName = element.stringField("display_name")
?: element.stringField("label")
?: element.stringField("title"),
supportsPassword = element.booleanField("supports_password")
?: isPasswordProvider(name),
)
private fun isPasswordProvider(name: String): Boolean =
name.equals("basic", ignoreCase = true) ||
name.equals("password", ignoreCase = true)
}
}
interface DashboardCookieStore {
fun load(): List<StoredDashboardCookie>
fun save(cookies: List<StoredDashboardCookie>)
fun clear()
}
class InMemoryDashboardCookieStore : DashboardCookieStore {
private val lock = Any()
private var cookies: List<StoredDashboardCookie> = emptyList()
override fun load(): List<StoredDashboardCookie> = synchronized(lock) { cookies }
override fun save(cookies: List<StoredDashboardCookie>) {
synchronized(lock) {
this.cookies = cookies
}
}
override fun clear() {
synchronized(lock) {
cookies = emptyList()
}
}
}
class EncryptedDashboardCookieStore(
context: Context,
connectionId: String,
private val json: Json = Json { ignoreUnknownKeys = true },
) : DashboardCookieStore {
private val serializer = ListSerializer(StoredDashboardCookie.serializer())
private val appContext = context.applicationContext
private val prefsName = prefsName(connectionId)
// DEFERRED on purpose. Building the Keystore-backed prefs takes 1-4s
// on StrongBox devices and serializes through a process-GLOBAL Tink
// lock (AndroidKeysetManager.Builder.build) — eager construction here
// froze the main thread for ~11s at app start when several stores were
// built concurrently (frozen-sphere incident, 2026-06-11). Construction
// is now free on any thread; the expensive build happens on the first
// actual cookie access, which is always an OkHttp/IO thread.
private val store: SessionTokenStore by lazy {
KeystoreTokenStore.tryCreate(appContext, prefsName)
?: LegacyEncryptedPrefsTokenStore(appContext, prefsName)
}
override fun load(): List<StoredDashboardCookie> {
val raw = store.getString(KEY_COOKIES) ?: return emptyList()
return runCatching { json.decodeFromString(serializer, raw) }
.getOrElse { emptyList() }
}
override fun save(cookies: List<StoredDashboardCookie>) {
store.putString(KEY_COOKIES, json.encodeToString(serializer, cookies))
}
override fun clear() {
store.remove(KEY_COOKIES)
}
companion object {
private const val KEY_COOKIES = "dashboard_cookies_json"
fun prefsName(connectionId: String): String =
"hermes_dashboard_${connectionId.take(8)}"
}
}
class DashboardCookieJar(
private val store: DashboardCookieStore,
private val clockMillis: () -> Long = { System.currentTimeMillis() },
) : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val now = clockMillis()
val incoming = cookies.map { StoredDashboardCookie.fromCookie(it) }
.filterNot { it.isExpired(now) }
val retained = store.load()
.filterNot { it.isExpired(now) }
.filterNot { old -> incoming.any { it.key == old.key } }
store.save(retained + incoming)
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val now = clockMillis()
val stored = store.load().filterNot { it.isExpired(now) }
if (stored.size != store.load().size) {
store.save(stored)
}
return stored.mapNotNull { it.toCookie() }
.filter { it.matches(url) }
}
}
/**
* Cookie jar that resolves the backing per-connection store at request time.
*
* Long-lived OkHttpClients (e.g. the standard voice client, remembered once
* per process in RelayApp) can't bind a fixed [DashboardCookieStore] because
* the active Connection — and therefore the encrypted cookie file — changes
* when the user switches connections. A null store (no active connection)
* degrades to an empty jar rather than failing the request.
*/
class DynamicDashboardCookieJar(
private val storeProvider: () -> DashboardCookieStore?,
) : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val store = storeProvider() ?: return
DashboardCookieJar(store).saveFromResponse(url, cookies)
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val store = storeProvider() ?: return emptyList()
return DashboardCookieJar(store).loadForRequest(url)
}
}
fun importDashboardCookieHeader(
store: DashboardCookieStore,
url: String,
cookieHeader: String?,
clockMillis: () -> Long = { System.currentTimeMillis() },
): Int {
val httpUrl = url.toHttpUrlOrNull() ?: return 0
val raw = cookieHeader?.trim().orEmpty()
if (raw.isBlank()) return 0
val now = clockMillis()
// CookieManager.getCookie(url) returns only "name=value" pairs; it does
// not expose the original Set-Cookie Path attribute. Store imported
// WebView auth cookies at root so a cookie observed on /auth/callback is
// still sent to /api/auth/me during native session verification.
val cookiePath = "/"
val imported = raw.split(";")
.mapNotNull { part ->
val index = part.indexOf('=')
if (index <= 0) return@mapNotNull null
val name = part.substring(0, index).trim()
val value = part.substring(index + 1).trim()
if (name.isBlank()) return@mapNotNull null
StoredDashboardCookie(
name = name,
value = value,
expiresAt = Long.MAX_VALUE,
domain = httpUrl.host,
path = cookiePath,
secure = httpUrl.isHttps,
httpOnly = true,
hostOnly = true,
persistent = false,
)
}
.filterNot { it.isExpired(now) }
if (imported.isEmpty()) return 0
val retained = store.load()
.filterNot { it.isExpired(now) }
.filterNot { old -> imported.any { it.key == old.key } }
store.save(retained + imported)
return imported.size
}
@Serializable
data class StoredDashboardCookie(
val name: String,
val value: String,
val expiresAt: Long,
val domain: String,
val path: String,
val secure: Boolean,
val httpOnly: Boolean,
val hostOnly: Boolean,
val persistent: Boolean,
) {
val key: String
get() = "${name.lowercase()}|${domain.lowercase()}|$path"
fun isExpired(nowMillis: Long): Boolean =
persistent && expiresAt <= nowMillis
fun toCookie(): Cookie? {
return runCatching {
val builder = Cookie.Builder()
.name(name)
.value(value)
.path(path)
if (hostOnly) {
builder.hostOnlyDomain(domain)
} else {
builder.domain(domain)
}
if (persistent) {
builder.expiresAt(expiresAt)
}
if (secure) builder.secure()
if (httpOnly) builder.httpOnly()
builder.build()
}.getOrNull()
}
companion object {
fun fromCookie(cookie: Cookie): StoredDashboardCookie =
StoredDashboardCookie(
name = cookie.name,
value = cookie.value,
expiresAt = cookie.expiresAt,
domain = cookie.domain,
path = cookie.path,
secure = cookie.secure,
httpOnly = cookie.httpOnly,
hostOnly = cookie.hostOnly,
persistent = cookie.persistent,
)
}
}
private fun Response.readJsonObject(json: Json): JsonObject {
val raw = body.string()
if (raw.isBlank()) return JsonObject(emptyMap())
return json.parseToJsonElement(raw).jsonObject
}
private fun Response.readJsonElement(json: Json): JsonElement {
val raw = body.string()
if (raw.isBlank()) return JsonObject(emptyMap())
return json.parseToJsonElement(raw)
}
private fun apiFailure(response: Response, operation: String): IOException {
val bodyDetail = runCatching { response.body.string() }.getOrDefault("")
val detail = bodyDetail.take(240).ifBlank { response.message }
return IOException("$operation failed - HTTP ${response.code}: $detail")
}
private fun JsonObject?.stringField(name: String): String? =
((this?.get(name) as? JsonPrimitive)?.contentOrNull)
?.trim()
?.takeIf { it.isNotBlank() }
private fun JsonObject?.booleanField(name: String): Boolean? =
(this?.get(name) as? JsonPrimitive)?.booleanOrNull
private fun JsonObject?.intField(name: String): Int? =
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
@@ -10,13 +10,38 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
/**
* Last observed probe result for a single [EndpointCandidate], keyed by
* [EndpointResolver.cacheKey] in [EndpointResolver.probeOutcomes]. Unlike the
* probe *cache* (a short-TTL "don't re-ask the network" optimization), this is
* a UI-facing record of what actually happened — it survives [EndpointResolver
* .clearCache] so the Routes card can keep showing the most recent
* reachability verdict between probes.
*/
data class RouteProbeOutcome(
val reachable: Boolean,
/** Short human-readable failure reason; null when [reachable]. */
val detail: String? = null,
/** Resolver-clock timestamp of when the probe finished. */
val atMillis: Long,
)
/**
* Picks the highest-priority **reachable** [EndpointCandidate] from a
@@ -70,6 +95,26 @@ class EndpointResolver(
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
/**
* Last probe verdict per candidate, keyed by [cacheKey]. Drives the
* per-row reachability line in the Routes card. Deliberately NOT wiped by
* [clearCache] — the cache controls when we re-ask the network; this
* records what the network last said.
*/
val probeOutcomes: StateFlow<Map<String, RouteProbeOutcome>> = _probeOutcomes.asStateFlow()
private fun recordOutcome(candidate: EndpointCandidate, reachable: Boolean, detail: String?) {
_probeOutcomes.update { outcomes ->
outcomes + (cacheKey(candidate) to RouteProbeOutcome(
reachable = reachable,
detail = detail,
atMillis = clock(),
))
}
}
companion object {
private const val TAG = "EndpointResolver"
/**
@@ -99,6 +144,9 @@ class EndpointResolver(
*/
const val NEGATIVE_CACHE_TTL_MS = 2_000L
/** Shared timeout wording so HEAD-timeout and socket-timeout read the same. */
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
/**
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
* Roles are preserved case-verbatim (HMAC canonicalization contract)
@@ -245,6 +293,7 @@ class EndpointResolver(
endpointRole = candidate.role,
url = candidate.api.url,
)
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
return false
}
val fastClient = httpClient.newBuilder()
@@ -272,6 +321,11 @@ class EndpointResolver(
url = candidate.api.url,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(
candidate,
reachable = ok,
detail = if (ok) null else "HTTP ${resp.code} from /health",
)
ok
}
} ?: run {
@@ -284,6 +338,7 @@ class EndpointResolver(
url = candidate.api.url,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
}
} catch (_: TimeoutCancellationException) {
@@ -296,6 +351,7 @@ class EndpointResolver(
url = candidate.api.url,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
} catch (e: Exception) {
Log.d(TAG, "probe failed role=${candidate.role} " +
@@ -309,11 +365,27 @@ class EndpointResolver(
url = candidate.api.url,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
false
}
}
}
/**
* Map a probe exception to a short, actionable string for the Routes
* card. The TLS case is the headline: a route saved with `https://`
* against a plain-HTTP Hermes API server fails its handshake on every
* probe and previously surfaced as a silent "never switches" mystery.
*/
private fun humanProbeFailure(e: Exception): String = when (e) {
is SSLException -> "TLS failed — server may be http://, not https://"
is ConnectException -> "Connection refused"
is UnknownHostException -> "Host not found"
is SocketTimeoutException -> PROBE_TIMEOUT_DETAIL
is NoRouteToHostException -> "No route to host"
else -> e.javaClass.simpleName
}
/**
* Mark [candidate] unreachable without re-probing. Called from
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
@@ -329,9 +401,16 @@ class EndpointResolver(
expiresAt = clock() + NEGATIVE_CACHE_TTL_MS,
reachable = false,
)
recordOutcome(candidate, reachable = false, detail = "Network changed — assumed offline")
}
/** Test-only: wipe the probe cache so a fresh run starts clean. */
/**
* Wipe the probe cache so the next resolve runs fresh probes. Called on
* "the world changed" triggers — NetworkCallback events, manual "Probe
* now", and [refreshActiveEndpoint][ConnectionManager.refreshActiveEndpoint]
* with `clearProbeCache = true` — where a positive entry for a
* just-died route must not outlive the handoff.
*/
internal fun clearCache() {
probeCache.clear()
}
@@ -23,6 +23,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import okhttp3.MediaType.Companion.toMediaType
@@ -62,9 +63,9 @@ enum class ChatMode {
* returns an SSE stream, while `/v1/runs` may be an async JSON run-start API.
*/
data class ServerCapabilities(
/** `/api/sessions` (CRUD) — true on fork, upstream-merged, OR bootstrap-injected. */
/** `/api/sessions` (CRUD) — true on native upstream, fork, OR bootstrap-injected older builds. */
val sessionsApi: Boolean,
/** `/api/sessions/{id}/chat/stream` (SSE) — true ONLY on fork or upstream-merged. */
/** `/api/sessions/{id}/chat/stream` (SSE) — true on native upstream or legacy fork builds. */
val sessionsChatStream: Boolean,
/** `/v1/runs` (structured-event SSE) — true only when explicitly advertised as SSE-compatible. */
val runs: Boolean,
@@ -99,6 +100,62 @@ data class ServerCapabilities(
}
}
private fun JsonObject.childObject(key: String): JsonObject? = this[key] as? JsonObject
private fun JsonObject.booleanFlag(key: String): Boolean =
(this[key] as? JsonPrimitive)?.booleanOrNull == true
private fun JsonObject.hasEndpoint(key: String): Boolean {
val path = ((this[key] as? JsonObject)?.get("path") as? JsonPrimitive)?.contentOrNull
return !path.isNullOrBlank()
}
internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities? {
val root = try {
json.decodeFromString<JsonObject>(body)
} catch (_: Exception) {
return null
}
val features = root.childObject("features")
val endpoints = root.childObject("endpoints")
if (features == null && endpoints == null) return null
fun feature(name: String): Boolean = features?.booleanFlag(name) == true
fun endpoint(name: String): Boolean = endpoints?.hasEndpoint(name) == true
return ServerCapabilities(
sessionsApi = feature("session_resources") ||
endpoint("sessions") ||
endpoint("session_create"),
sessionsChatStream = feature("session_chat_streaming") ||
endpoint("session_chat_stream"),
runs = feature("run_events_sse") || endpoint("run_events"),
portable = feature("chat_completions_streaming") ||
feature("chat_completions") ||
endpoint("chat_completions"),
healthy = true,
)
}
internal val HERMES_SKILL_ENDPOINTS = listOf("/v1/skills", "/api/skills")
internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
try {
val parsed = json.decodeFromString<SkillListResponse>(body)
val skills = parsed.skills ?: parsed.items ?: parsed.data
if (skills != null) return skills
} catch (_: Exception) {
// Fall through to direct-array compatibility below.
}
try {
return json.decodeFromString<List<SkillInfo>>(body)
} catch (_: Exception) {
return null
}
}
/**
* Direct HTTP/SSE client for the Hermes API Server.
*
@@ -242,7 +299,7 @@ class HermesApiClient(
return@withContext Result.failure(IOException("List sessions returned an empty response"))
}
val parsed = json.decodeFromString<SessionListResponse>(body)
Result.success(parsed.items ?: parsed.sessions ?: emptyList())
Result.success(parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList())
}
} catch (e: Exception) {
Log.w(TAG, "Failed to list sessions: ${e.message}")
@@ -331,7 +388,7 @@ class HermesApiClient(
if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext emptyList()
val parsed = json.decodeFromString<MessageListResponse>(body)
parsed.items ?: parsed.messages ?: emptyList()
parsed.data ?: parsed.items ?: parsed.messages ?: emptyList()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to get messages: ${e.message}")
@@ -342,27 +399,21 @@ class HermesApiClient(
// --- Skills ---
suspend fun getSkills(): List<SkillInfo> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/api/skills").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext emptyList()
// Try structured response: { "skills": [...] } or { "items": [...] }
try {
val parsed = json.decodeFromString<SkillListResponse>(body)
val skills = parsed.skills ?: parsed.items
for (endpoint in HERMES_SKILL_ENDPOINTS) {
try {
val request = authRequest("$baseUrl$endpoint").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use
val body = response.body?.string() ?: return@use
val skills = parseSkillListBody(json, body)
if (skills != null) return@withContext skills
} catch (_: Exception) { /* fall through */ }
// Try direct array: [...]
try {
return@withContext json.decodeFromString<List<SkillInfo>>(body)
} catch (_: Exception) { /* fall through */ }
emptyList()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch skills from $endpoint: ${e.message}")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch skills: ${e.message}")
emptyList()
}
emptyList()
}
// --- Server personalities ---
@@ -1129,14 +1180,15 @@ class HermesApiClient(
*
* Probe order:
* 1. `/health` — if this fails, everything else is moot.
* 2. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork OR
* bootstrap-injected upstream).
* 3. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
* 2. `GET /v1/capabilities` — native upstream feature + endpoint map.
* 3. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork,
* native upstream, OR bootstrap-injected older upstream).
* 4. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
* presence. The handler only accepts POST, so HEAD returns 405
* (Method Not Allowed) when the route is registered. 404 means
* the route doesn't exist at all.
* 4. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
* 5. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
* 5. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
* 6. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
* when the response explicitly advertises event-stream compatibility.
*
* **Why HEAD instead of OPTIONS:** The hermes-agent gateway runs CORS
@@ -1168,6 +1220,20 @@ class HermesApiClient(
}
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
val advertisedCapabilities = try {
val req = authRequest("$baseUrl/v1/capabilities").get().build()
client.newCall(req).execute().use { response ->
if (!response.isSuccessful) {
null
} else {
parseCapabilitiesBody(json, response.body.string())
}
}
} catch (_: Exception) {
null
}
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
// Reusable HEAD probe — returns true if the route is registered
// (any status except 404 + network errors). Already inside the
// Dispatchers.IO context from the outer withContext, so the
@@ -0,0 +1,226 @@
package com.hermesandroid.relay.network
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkAddress
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.Inet4Address
import java.util.concurrent.TimeUnit
data class HermesLanDiscoveryResult(
val host: String,
val apiUrl: String,
val dashboardUrl: String?,
val apiReachable: Boolean,
val dashboardReachable: Boolean,
)
/**
* User-triggered local-network discovery for standard Hermes setup.
*
* This deliberately scans only the active RFC1918/link-local LAN around the
* phone, never broad public or Tailscale ranges. Tailscale/public routes still
* belong in the explicit advanced fields where the user controls the URL.
*/
object HermesLanDiscovery {
private const val TAG = "HermesLanDiscovery"
private const val MAX_HOSTS = 254
private const val MAX_CONCURRENT_PROBES = 32
private const val PROBE_TIMEOUT_MS = 650L
private const val IPV4_MASK = 0xFFFF_FFFFL
suspend fun scan(
context: Context,
apiPort: Int = 8642,
dashboardPort: Int = 9119,
): List<HermesLanDiscoveryResult> = withContext(Dispatchers.IO) {
val hosts = localLanHosts(context.applicationContext)
if (hosts.isEmpty()) return@withContext emptyList()
val client = OkHttpClient.Builder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
.build()
coroutineScope {
val semaphore = Semaphore(MAX_CONCURRENT_PROBES)
hosts.map { host ->
async {
semaphore.withPermit {
probeHost(client, host, apiPort, dashboardPort)
}
}
}.awaitAll()
.filterNotNull()
.sortedWith(
compareByDescending<HermesLanDiscoveryResult> { it.dashboardReachable }
.thenByDescending { it.apiReachable }
.thenBy { it.host },
)
}
}
private fun probeHost(
client: OkHttpClient,
host: String,
apiPort: Int,
dashboardPort: Int,
): HermesLanDiscoveryResult? {
val apiUrl = "http://$host:$apiPort"
val dashboardUrl = "http://$host:$dashboardPort"
val dashboardReachable = probe(
client = client,
url = "$dashboardUrl/api/status",
expectedBody = ::looksLikeDashboardStatus,
)
val apiReachable = probe(
client = client,
url = "$apiUrl/health",
expectedBody = ::looksLikeApiHealth,
)
if (!dashboardReachable && !apiReachable) return null
return HermesLanDiscoveryResult(
host = host,
apiUrl = apiUrl,
dashboardUrl = dashboardUrl.takeIf { dashboardReachable },
apiReachable = apiReachable,
dashboardReachable = dashboardReachable,
)
}
private fun probe(
client: OkHttpClient,
url: String,
expectedBody: (String, String) -> Boolean,
): Boolean {
val httpUrl = url.toHttpUrlOrNull() ?: return false
val request = Request.Builder()
.url(httpUrl)
.get()
.header("Accept", "application/json, text/plain, */*")
.build()
return try {
client.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) {
return true
}
if (!response.isSuccessful) {
return false
}
val contentType = response.header("Content-Type").orEmpty()
val body = response.body.string().take(2_048)
expectedBody(body, contentType)
}
} catch (e: Exception) {
Log.d(TAG, "probe failed url=$url type=${e.javaClass.simpleName}")
false
}
}
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
val lower = body.lowercase()
return contentType.contains("json", ignoreCase = true) && (
lower.contains("auth_required") ||
lower.contains("auth_providers") ||
lower.contains("authenticated") ||
lower.contains("hermes")
)
}
private fun looksLikeApiHealth(body: String, contentType: String): Boolean {
if (contentType.contains("json", ignoreCase = true)) return true
if (contentType.contains("text/plain", ignoreCase = true)) return true
return body.isBlank() || body.trimStart().startsWith("{")
}
private fun localLanHosts(context: Context): List<String> {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
?: return emptyList()
val networks = buildList {
connectivityManager.activeNetwork?.let(::add)
connectivityManager.allNetworks.forEach { network ->
if (!contains(network)) add(network)
}
}
val hosts = linkedSetOf<String>()
for (network in networks) {
val linkProperties = connectivityManager.getLinkProperties(network) ?: continue
for (linkAddress in linkProperties.linkAddresses) {
addHostsForLink(linkAddress, hosts)
if (hosts.size >= MAX_HOSTS) break
}
if (hosts.size >= MAX_HOSTS) break
}
return hosts.take(MAX_HOSTS)
}
private fun addHostsForLink(linkAddress: LinkAddress, hosts: MutableSet<String>) {
val address = linkAddress.address as? Inet4Address ?: return
if (address.isLoopbackAddress || address.isMulticastAddress) return
val local = ipv4ToLong(address)
if (!isScannableLanAddress(local)) return
val scanPrefix = when (linkAddress.prefixLength) {
in 24..30 -> linkAddress.prefixLength
else -> 24
}
val mask = subnetMask(scanPrefix)
val network = local and mask
val broadcast = network or (mask.inv() and IPV4_MASK)
val first = network + 1
val last = broadcast - 1
if (first > last) return
for (candidate in first..last) {
if (candidate == local) continue
hosts.add(longToIpv4(candidate))
if (hosts.size >= MAX_HOSTS) return
}
}
private fun subnetMask(prefixLength: Int): Long {
return (IPV4_MASK shl (32 - prefixLength)) and IPV4_MASK
}
private fun ipv4ToLong(address: Inet4Address): Long {
return address.address.fold(0L) { acc, byte ->
(acc shl 8) or (byte.toInt() and 0xFF).toLong()
} and IPV4_MASK
}
private fun longToIpv4(value: Long): String {
return listOf(
(value shr 24) and 0xFF,
(value shr 16) and 0xFF,
(value shr 8) and 0xFF,
value and 0xFF,
).joinToString(".") { it.toString() }
}
private fun isScannableLanAddress(value: Long): Boolean {
val first = ((value shr 24) and 0xFF).toInt()
val second = ((value shr 16) and 0xFF).toInt()
return when {
first == 10 -> true
first == 172 && second in 16..31 -> true
first == 192 && second == 168 -> true
first == 169 && second == 254 -> true
else -> false
}
}
}
@@ -532,6 +532,64 @@ class RelayVoiceClient(
label = "Realtime agent config update",
)
/**
* PATCH the ADR 33 background-run promotion settings. Only non-null fields
* are sent; the relay echoes back the full [RealtimeVoiceConfig].
*/
suspend fun updateRealtimeAgentPromotion(
promotionEnabled: Boolean? = null,
promoteAfterMs: Int? = null,
spokenHandoff: Boolean? = null,
resultDelivery: String? = null,
backgroundDefaultMode: String? = null,
progressSpokenAfterMs: Int? = null,
progressRepeatMs: Int? = null,
maxBackgroundRuns: Int? = null,
): Result<RealtimeVoiceConfig> = withContext(Dispatchers.IO) {
val httpBase = resolveHttpBase()
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
val token = resolveBearerToken()
if (token.isNullOrBlank()) {
return@withContext Result.failure(missingAuthError())
}
val payload = buildJsonObject {
promotionEnabled?.let { put("promotion_enabled", JsonPrimitive(it)) }
promoteAfterMs?.let { put("promote_after_ms", JsonPrimitive(it)) }
spokenHandoff?.let { put("spoken_handoff", JsonPrimitive(it)) }
resultDelivery?.takeIf { it.isNotBlank() }?.let {
put("result_delivery", JsonPrimitive(it.trim()))
}
backgroundDefaultMode?.takeIf { it.isNotBlank() }?.let {
put("background_default_mode", JsonPrimitive(it.trim()))
}
progressSpokenAfterMs?.let { put("progress_spoken_after_ms", JsonPrimitive(it)) }
progressRepeatMs?.let { put("progress_repeat_ms", JsonPrimitive(it)) }
maxBackgroundRuns?.let { put("max_background_runs", JsonPrimitive(it)) }
}
val request = Request.Builder()
.url(urlWithProfile("$httpBase/voice/realtime-agent/config"))
.patch(payload.toString().toRequestBody(JSON_MEDIA_TYPE))
.header("Authorization", "Bearer $token")
.header("Accept", "application/json")
.build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val body = response.body?.string().orEmpty()
return@withContext Result.failure(
IOException(describeHttpError(response.code, response.message, body))
)
}
val raw = response.body?.string().orEmpty()
Result.success(json.decodeFromString(RealtimeVoiceConfig.serializer(), raw))
}
} catch (e: IOException) {
Result.failure(IOException("Realtime promotion update failed: ${e.message ?: "network error"}"))
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun getVoiceOutputConfig(): Result<VoiceOutputConfig> = withContext(Dispatchers.IO) {
val httpBase = resolveHttpBase()
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
@@ -1133,6 +1191,22 @@ class RelayVoiceClient(
}
}
/**
* Run a Realtime Agent voice session.
*
* **One-shot (default):** with [turnInputs] null, this opens a session, sends
* the single [inputPcm]/[prompt] turn, and completes + closes the socket on
* `voice.response.done` — the historical per-utterance behavior used by the
* Voice Lab and the one-shot fallback.
*
* **Persistent (ADR follow-up, see docs/plans/2026-05-24-realtime-persistent-session.md):**
* with [turnInputs] non-null the socket stays open across turns. The first
* turn is [inputPcm]/[prompt]; each subsequent [RealtimeTurnInput] read from
* the channel is sent on the *same* socket, so the provider keeps a live
* conversation. `voice.response.done` invokes [onTurnComplete] instead of
* closing; the call returns only when the channel closes (voice-mode exit) or
* a fatal socket/provider error occurs.
*/
suspend fun runRealtimeAgent(
prompt: String,
inputPcm: ByteArray,
@@ -1140,8 +1214,11 @@ class RelayVoiceClient(
chatSessionId: String? = null,
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
onHandoff: (VoiceHandoffEvent) -> Unit = {},
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
onEvent: (RealtimeVoiceEvent, RealtimeAgentSessionControl) -> Unit,
): Result<RealtimeVoiceSummary> = withContext(Dispatchers.IO) {
val persistent = turnInputs != null
val httpBase = resolveHttpBase()
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
val wsBase = resolveWebSocketBase()
@@ -1172,8 +1249,11 @@ class RelayVoiceClient(
val lastAudioEventId = AtomicLong(0L)
val lastPlayedAudioEventId = AtomicLong(0L)
val lastInputChunkId = AtomicLong(0L)
val turnStartedAtMs = System.currentTimeMillis()
val lastEventAtMs = AtomicLong(turnStartedAtMs)
val turnStartedAtMs = AtomicLong(System.currentTimeMillis())
val lastEventAtMs = AtomicLong(turnStartedAtMs.get())
// 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)
val inputChunks = buildList {
var offset = 0
var chunkId = 1L
@@ -1184,8 +1264,33 @@ class RelayVoiceClient(
offset = end
}
}
// Highest input chunk id sent on this session. The relay dedups by
// input_chunk_seq, so subsequent turns must continue past this.
val sessionMaxChunkId = AtomicLong(inputChunks.size.toLong())
var audioChunks = 0
var audioBytes = 0
// Persistent-mode: chunk + send one more utterance on the open socket.
fun sendTurnPcm(webSocket: WebSocket, pcm: ByteArray, sampleRate: Int) {
var offset = 0
var sentAny = false
while (offset < pcm.size) {
val end = (offset + REALTIME_INPUT_CHUNK_BYTES).coerceAtMost(pcm.size)
val chunkId = sessionMaxChunkId.incrementAndGet()
val encoded = Base64.getEncoder().encodeToString(pcm.copyOfRange(offset, end))
webSocket.send(
"""{"type":"input_audio.append","chunk_id":$chunkId,"sample_rate":$sampleRate,"audio_base64":"$encoded"}"""
)
sentAny = true
offset = end
}
if (sentAny) {
webSocket.send("""{"type":"input_audio.commit"}""")
}
turnStartedAtMs.set(System.currentTimeMillis())
lastEventAtMs.set(System.currentTimeMillis())
activeTurn.set(true)
}
fun activateSocket(webSocket: WebSocket, generation: Long): Boolean {
while (true) {
val activeGeneration = activeSocketGeneration.get()
@@ -1332,24 +1437,28 @@ class RelayVoiceClient(
inputChunkId = event.inputChunkId,
)
if (event.type == "voice.response.done") {
if (completed.compareAndSet(false, true)) {
finished.complete(
Result.success(
RealtimeVoiceSummary(
provider = event.provider ?: session.provider,
model = event.model ?: session.model,
voice = event.voice ?: session.voice,
sampleRate = session.sampleRate,
audioChunks = audioChunks,
audioBytes = audioBytes,
firstAudioMs = event.firstAudioMs,
responseDoneMs = event.responseDoneMs,
eventLogPath = event.eventLogPath ?: session.eventLogPath,
)
)
)
val summary = RealtimeVoiceSummary(
provider = event.provider ?: session.provider,
model = event.model ?: session.model,
voice = event.voice ?: session.voice,
sampleRate = session.sampleRate,
audioChunks = audioChunks,
audioBytes = audioBytes,
firstAudioMs = event.firstAudioMs,
responseDoneMs = event.responseDoneMs,
eventLogPath = event.eventLogPath ?: session.eventLogPath,
)
if (persistent) {
// Turn boundary, not session boundary: keep the socket
// open for the next utterance.
activeTurn.set(false)
onTurnComplete(summary)
} else {
if (completed.compareAndSet(false, true)) {
finished.complete(Result.success(summary))
}
webSocket.close(1000, "done")
}
webSocket.close(1000, "done")
} else if (event.type == "voice.error" || event.type == "voice.session.resume_failed") {
completeFailure(event.message ?: "Realtime agent error")
webSocket.close(1011, "provider error")
@@ -1452,25 +1561,85 @@ class RelayVoiceClient(
suspend fun awaitRealtimeAgentCompletion(): Result<RealtimeVoiceSummary> {
while (true) {
val now = System.currentTimeMillis()
val turnElapsedMs = now - turnStartedAtMs
val idleElapsedMs = now - lastEventAtMs.get()
if (turnElapsedMs >= REALTIME_AGENT_MAX_TURN_MS) {
throw IOException("Realtime agent exceeded the turn limit")
// In persistent mode the turn/idle guards only apply while a turn
// is actually in flight; between-turn idle is expected and must
// not trip the stall timeout. The session ends when the turn
// channel closes or a fatal error completes `finished`.
val guardActive = !persistent || activeTurn.get()
if (guardActive) {
val turnElapsedMs = now - turnStartedAtMs.get()
val idleElapsedMs = now - lastEventAtMs.get()
if (turnElapsedMs >= REALTIME_AGENT_MAX_TURN_MS) {
throw IOException("Realtime agent exceeded the turn limit")
}
if (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)
withTimeoutOrNull(waitMs) {
finished.await()
}?.let { return it }
} else {
withTimeoutOrNull(REALTIME_AGENT_WAIT_SLICE_MS) {
finished.await()
}?.let { return it }
}
if (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)
withTimeoutOrNull(waitMs) {
finished.await()
}?.let { return it }
}
}
// Persistent mode: drain further utterances and feed each onto the open
// socket as a new turn. Closing the channel (voice-mode exit) ends the
// session by completing `finished`.
val turnReader: Job? = if (turnInputs != null) {
launch {
try {
for (turn in turnInputs) {
val ws = currentSocket.get() ?: continue
if (turn.prompt.isNotBlank() && turn.inputPcm.isEmpty()) {
ws.send(
buildRealtimeResponseCreate(
text = turn.prompt,
toolScaffold = false,
renderMode = "verbatim",
)
)
turnStartedAtMs.set(System.currentTimeMillis())
lastEventAtMs.set(System.currentTimeMillis())
activeTurn.set(true)
} else {
sendTurnPcm(ws, turn.inputPcm, turn.sampleRate)
}
}
} finally {
// Channel closed -> end the persistent session cleanly.
if (completed.compareAndSet(false, true)) {
finished.complete(
Result.success(
RealtimeVoiceSummary(
provider = session.provider,
model = session.model,
voice = session.voice,
sampleRate = session.sampleRate,
audioChunks = audioChunks,
audioBytes = audioBytes,
firstAudioMs = null,
responseDoneMs = null,
eventLogPath = session.eventLogPath,
)
)
)
}
currentSocket.get()?.close(1000, "session ended")
}
}
} else {
null
}
val socket = openSocket(resume = false)
val routeWatcher = startRouteResumeWatcher(
surface = "Realtime agent",
@@ -1491,6 +1660,7 @@ class RelayVoiceClient(
Result.failure(IOException(e.message ?: "Realtime agent timed out", e))
} finally {
routeWatcher?.cancel()
turnReader?.cancel()
}
}
@@ -2080,6 +2250,8 @@ class RelayVoiceClient(
eventLogPath = (obj["event_log_path"] as? JsonPrimitive)?.contentOrNull,
firstAudioMs = (metrics?.get("first_audio_ms") as? JsonPrimitive)?.doubleOrNull,
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
raw = raw,
)
} catch (e: Exception) {
@@ -2154,6 +2326,28 @@ data class RealtimeVoiceConfig(
val configScope: String? = null,
@SerialName("fallback_to_global")
val fallbackToGlobal: Boolean = false,
/** ADR 33 background-run promotion settings. */
val promotion: RealtimeVoicePromotion? = null,
)
/** Wire shape of the `promotion` block on `GET /voice/realtime-agent/config`. */
@Serializable
data class RealtimeVoicePromotion(
val enabled: Boolean = true,
@SerialName("promote_after_ms")
val promoteAfterMs: Int = 6000,
@SerialName("background_default_mode")
val backgroundDefaultMode: String = "promote",
@SerialName("spoken_handoff")
val spokenHandoff: Boolean = true,
@SerialName("progress_spoken_after_ms")
val progressSpokenAfterMs: Int = 15000,
@SerialName("progress_repeat_ms")
val progressRepeatMs: Int = 30000,
@SerialName("result_delivery")
val resultDelivery: String = "speak_when_idle",
@SerialName("max_background_runs")
val maxBackgroundRuns: Int = 1,
)
@Serializable
@@ -2392,6 +2586,9 @@ data class RealtimeVoiceEvent(
val eventLogPath: String? = null,
val firstAudioMs: Double? = null,
val responseDoneMs: Double? = null,
// ADR 33: background-run promotion fields.
val tier: String? = null,
val floor: String? = null,
val raw: String,
) {
val isAudioDelta: Boolean
@@ -2467,6 +2664,33 @@ data class RealtimeVoiceSummary(
val eventLogPath: String?,
)
/**
* One utterance fed into a persistent Realtime Agent session
* (see [RelayVoiceClient.runRealtimeAgent] persistent mode). A blank [inputPcm]
* with a non-blank [prompt] sends a text turn; otherwise the PCM is chunked and
* committed as a spoken turn.
*/
data class RealtimeTurnInput(
val inputPcm: ByteArray,
val sampleRate: Int = 16_000,
val prompt: String = "",
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is RealtimeTurnInput) return false
return sampleRate == other.sampleRate &&
prompt == other.prompt &&
inputPcm.contentEquals(other.inputPcm)
}
override fun hashCode(): Int {
var result = inputPcm.contentHashCode()
result = 31 * result + sampleRate
result = 31 * result + prompt.hashCode()
return result
}
}
data class VoiceOutputSummary(
val provider: String,
val model: String,
@@ -0,0 +1,296 @@
package com.hermesandroid.relay.network
import android.content.Context
import com.hermesandroid.relay.data.VoiceAudioRoute
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.File
import java.io.IOException
import java.util.Base64
import java.util.concurrent.TimeUnit
interface VoiceAudioClient {
val route: VoiceAudioRoute
suspend fun transcribe(audioFile: File): Result<String>
suspend fun synthesize(text: String): Result<File>
}
class RelayVoiceAudioClientAdapter(
private val relayVoiceClient: RelayVoiceClient,
) : 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)
}
/**
* 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.
*/
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()
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(
"Standard 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"),
)
}
}
/**
* Standard (no-plugin) voice client — speaks the upstream **dashboard web
* server** contract that hermes-desktop's voice mode uses:
*
* POST {dashboard}/api/audio/transcribe {data_url, mime_type} → {ok, transcript}
* POST {dashboard}/api/audio/speak {text} → {ok, data_url, mime_type}
*
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
* on the API server (:8642) — current upstream api_server advertises
* `audio_api: false` and registers no audio routes. Auth is the dashboard
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
* same per-connection cookie jar the Manage tab signs in with; an API bearer
* header is meaningless on this surface. Revisit when upstream PR #8199
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md §6).
* (No glob spellings in block comments — Kotlin block comments nest.)
*/
class StandardHermesVoiceClient(
private val context: Context,
private val okHttpClient: OkHttpClient,
private val dashboardUrlProvider: () -> String?,
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
},
) : VoiceAudioClient {
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
private val callClient: OkHttpClient =
okHttpClient.newBuilder()
.callTimeout(90, TimeUnit.SECONDS)
.build()
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
val baseUrl = dashboardBaseUrl()
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
if (!audioFile.exists() || audioFile.length() == 0L) {
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
}
val dataUrl = buildAudioDataUrl(audioFile)
val payload = buildJsonObject {
put("data_url", dataUrl)
put("mime_type", mediaTypeForAudioFile(audioFile))
}
val request = Request.Builder()
.url("$baseUrl/api/audio/transcribe")
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.header("Accept", "application/json")
.build()
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
val transcript = root.stringField("transcript")
?: root.stringField("text")
?: root.stringField("message")
if (transcript.isNullOrBlank()) {
throw IOException("Hermes audio transcribe returned an empty transcript")
}
transcript
}
}
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
val baseUrl = dashboardBaseUrl()
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
val cleanText = text.trim()
if (cleanText.isBlank()) {
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
}
val payload = buildJsonObject { put("text", cleanText) }
val request = Request.Builder()
.url("$baseUrl/api/audio/speak")
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.header("Accept", "application/json")
.build()
executeJson(request, "Hermes audio speak").mapCatching { root ->
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
if (dataUrl.isNullOrBlank()) {
throw IOException("Hermes audio speak returned no audio")
}
val mimeType = root.stringField("mime_type")
?: root.stringField("mimeType")
?: mimeTypeFromDataUrl(dataUrl)
?: "audio/mpeg"
val bytes = decodeDataUrl(dataUrl)
if (bytes.isEmpty()) throw IOException("Hermes audio speak returned empty audio")
val extension = extensionForMimeType(mimeType)
File(context.cacheDir, "hermes_voice_${System.currentTimeMillis()}.$extension")
.also { it.writeBytes(bytes) }
}
}
private fun dashboardBaseUrl(): String? =
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
return try {
callClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return Result.failure(apiFailure(response, operation))
}
val body = response.body.string()
if (body.isBlank()) {
return Result.failure(IOException("$operation returned an empty response"))
}
val root = json.decodeFromString<JsonObject>(body)
val ok = (root["ok"] as? JsonPrimitive)?.contentOrNull
?.toBooleanStrictOrNull()
if (ok == false) {
val message = root.stringField("message")
?: root.stringField("error")
?: "$operation failed"
return Result.failure(IOException(message))
}
Result.success(root)
}
} catch (e: IOException) {
Result.failure(IOException("$operation failed: ${e.message ?: "network error"}", e))
} catch (e: Exception) {
Result.failure(IOException("$operation failed: ${e.message ?: "parse error"}", e))
}
}
private fun apiFailure(response: Response, operation: String): IOException {
val body = runCatching { response.body.string() }.getOrDefault("")
val detail = body.takeIf { it.isNotBlank() } ?: response.message
val message = when (response.code) {
401, 403 -> "$operation needs dashboard sign-in - open Manage to sign in"
404 -> "$operation unavailable on this Hermes build - update hermes-agent or use Relay"
in 500..599 -> "$operation failed - server error HTTP ${response.code}"
else -> "$operation failed - HTTP ${response.code}: $detail"
}
return IOException(message)
}
private fun buildAudioDataUrl(audioFile: File): String {
val mimeType = mediaTypeForAudioFile(audioFile)
val encoded = Base64.getEncoder().encodeToString(audioFile.readBytes())
return "data:$mimeType;base64,$encoded"
}
private fun mediaTypeForAudioFile(file: File): String =
when (file.extension.lowercase()) {
"wav" -> "audio/wav"
"m4a", "mp4" -> "audio/mp4"
"mp3" -> "audio/mpeg"
"ogg" -> "audio/ogg"
"webm" -> "audio/webm"
else -> "application/octet-stream"
}
private fun decodeDataUrl(dataUrl: String): ByteArray {
val comma = dataUrl.indexOf(',')
val payload = if (comma >= 0) dataUrl.substring(comma + 1) else dataUrl
return Base64.getDecoder().decode(payload)
}
private fun mimeTypeFromDataUrl(dataUrl: String): String? {
if (!dataUrl.startsWith("data:", ignoreCase = true)) return null
val semi = dataUrl.indexOf(';')
if (semi <= "data:".length) return null
return dataUrl.substring("data:".length, semi).takeIf { it.isNotBlank() }
}
private fun extensionForMimeType(mimeType: String): String =
when (mimeType.lowercase().substringBefore(';')) {
"audio/wav", "audio/wave", "audio/x-wav" -> "wav"
"audio/mp4", "audio/aac", "audio/m4a" -> "m4a"
"audio/ogg" -> "ogg"
"audio/webm" -> "webm"
else -> "mp3"
}
private fun JsonObject.stringField(name: String): String? =
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
private companion object {
val JSON_MEDIA = "application/json".toMediaType()
}
}
@@ -81,6 +81,7 @@ object FlexibleIdNonNullSerializer : KSerializer<String> {
data class SessionListResponse(
val items: List<SessionItem>? = null,
val sessions: List<SessionItem>? = null, // alternate key
val data: List<SessionItem>? = null, // upstream /api/sessions list envelope
val total: Int? = null
)
@@ -127,6 +128,7 @@ data class RenameSessionRequest(
data class MessageListResponse(
val items: List<MessageItem>? = null,
val messages: List<MessageItem>? = null, // alternate key
val data: List<MessageItem>? = null, // upstream /api/sessions/{id}/messages list envelope
val total: Int? = null
)
@@ -300,5 +302,6 @@ data class SkillInfo(
@Serializable
data class SkillListResponse(
val skills: List<SkillInfo>? = null,
val items: List<SkillInfo>? = null
val items: List<SkillInfo>? = null,
val data: List<SkillInfo>? = null
)
@@ -5,7 +5,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -26,11 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
@@ -44,6 +39,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
@@ -51,14 +47,11 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.purpleGlow
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -69,6 +62,9 @@ import androidx.navigation.navArgument
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.ConnectionStatusBanner
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
import com.hermesandroid.relay.ui.components.RelayStatusStrip
import com.hermesandroid.relay.ui.components.UnattendedGlobalBanner
import com.hermesandroid.relay.ui.components.UpdateBanner
import com.hermesandroid.relay.update.UpdateCheckResult
@@ -78,6 +74,10 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BridgePreferencesRepository
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.data.VoicePreferencesRepository
import com.hermesandroid.relay.data.VoiceSettings
import com.hermesandroid.relay.data.displayLabel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
@@ -94,6 +94,7 @@ import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
// === END PHASE3-safety-rails ===
import com.hermesandroid.relay.ui.screens.ChatScreen
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
import com.hermesandroid.relay.ui.screens.DashboardManagementScreen
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
@@ -104,8 +105,14 @@ import com.hermesandroid.relay.ui.screens.SettingsScreen
import com.hermesandroid.relay.ui.screens.TerminalScreen
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
import com.hermesandroid.relay.ui.screens.VoiceSettingsScreen
import com.hermesandroid.relay.ui.screens.prewarmDashboardManage
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayGridTexture
import com.hermesandroid.relay.network.RelayProfileInspectorClient
import com.hermesandroid.relay.network.AutoVoiceAudioClient
import com.hermesandroid.relay.network.DynamicDashboardCookieJar
import com.hermesandroid.relay.network.RelayVoiceAudioClientAdapter
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
@@ -116,6 +123,7 @@ import com.hermesandroid.relay.audio.VoiceRecorder
import com.hermesandroid.relay.audio.VoiceSfxPlayer
import com.hermesandroid.relay.audio.RealtimePcmPlayer
import com.hermesandroid.relay.network.RelayVoiceClient
import com.hermesandroid.relay.network.StandardHermesVoiceClient
import com.hermesandroid.relay.auth.AuthState
import androidx.lifecycle.viewModelScope
@@ -166,6 +174,7 @@ sealed class Screen(
}
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
// Non-bottom-nav destinations — reached by explicit navigation, not the
@@ -183,11 +192,11 @@ sealed class Screen(
// the ConnectionsSettings "Re-pair" button targets a specific
// connection. The "Add connection" path pre-creates a placeholder
// via `ConnectionViewModel.beginAddConnection()` and routes here
// with that id, so the wizard's applyPairingPayload lands in the
// with that id, so the wizard's standard connect / applyPairingPayload lands in the
// new connection's auth store instead of the outgoing one's.
data object Pair : Screen(
"pair?connectionId={connectionId}&autoStart={autoStart}",
"Pair",
"Connect",
Icons.Filled.Settings,
) {
const val ARG_CONNECTION_ID: String = "connectionId"
@@ -196,8 +205,8 @@ sealed class Screen(
* Currently only `"scan"` is recognised — the "Add connection" FAB
* on the Connections screen passes it so the camera opens
* immediately instead of forcing the user through the Method step.
* Re-pair flows intentionally leave this null so the full chooser
* (Scan / Enter code / Show code) remains available.
* Standard add/re-pair flows leave this null so the full chooser
* remains available.
*/
const val ARG_AUTO_START: String = "autoStart"
fun route(connectionId: String? = null, autoStart: String? = null): String {
@@ -278,13 +287,6 @@ sealed class Screen(
}
}
private val bottomNavScreens = listOf(
Screen.Chat,
Screen.Terminal,
Screen.Bridge,
Screen.Settings
)
@Composable
fun RelayApp() {
val connectionViewModel: ConnectionViewModel = viewModel()
@@ -364,6 +366,15 @@ fun RelayApp() {
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
val mediaContext = androidx.compose.ui.platform.LocalContext.current
val voicePreferences = remember(mediaContext) { VoicePreferencesRepository(mediaContext) }
val voiceSettings by voicePreferences.settings.collectAsState(initial = VoiceSettings())
val selectedAudioRoute = VoiceAudioRoute.fromStorage(voiceSettings.audioRoute)
val selectedAudioRouteState = rememberUpdatedState(selectedAudioRoute)
val standardVoiceReady by connectionViewModel.standardVoiceReady.collectAsState()
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val relayVoiceReady by connectionViewModel.relayVoiceReady.collectAsState()
val standardVoiceReadyState = rememberUpdatedState(standardVoiceReady)
val relayVoiceReadyState = rememberUpdatedState(relayVoiceReady)
// Voice pipeline wiring — mirrors ChatViewModel.initializeMedia (above).
// We build a dedicated OkHttpClient so voice requests don't contend with
@@ -392,6 +403,35 @@ fun RelayApp() {
},
)
}
// Standard voice talks to the dashboard web server (hermes-desktop's
// /api/audio/* contract) and authenticates with the same per-connection
// cookie session Manage signs in with. Dashboard URLs are connection-level
// (no per-profile dashboard exists), so unlike chat this client does not
// route through ProfileApiUrlResolver.
val standardVoiceClient = remember {
StandardHermesVoiceClient(
context = mediaContext,
okHttpClient = okhttp3.OkHttpClient.Builder()
.cookieJar(
DynamicDashboardCookieJar {
connectionViewModel.activeDashboardCookieStore()
},
)
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
.build(),
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
)
}
val voiceAudioClient = remember {
AutoVoiceAudioClient(
standardClient = standardVoiceClient,
relayClient = RelayVoiceAudioClientAdapter(voiceClient),
routeProvider = { selectedAudioRouteState.value },
standardReadyProvider = { standardVoiceReadyState.value },
relayReadyProvider = { relayVoiceReadyState.value },
)
}
// Profile Inspector client. Shares the same lazy relay URL + bearer
// token providers as the voice client so any rotation/re-pair is
@@ -419,6 +459,7 @@ fun RelayApp() {
val player = VoicePlayer(mediaContext)
voiceViewModel.initialize(
voiceClient = voiceClient,
voiceAudioClient = voiceAudioClient,
chatViewModel = chatViewModel,
recorder = recorder,
player = player,
@@ -451,7 +492,7 @@ fun RelayApp() {
// 2026-04-17: persist the interaction-mode preference across
// app restarts. VoicePreferencesRepository is the same repo
// VoiceSettingsScreen reads/writes.
voicePreferences = com.hermesandroid.relay.data.VoicePreferencesRepository(mediaContext),
voicePreferences = voicePreferences,
voiceRelayPreflight = { connectionViewModel.verifyRelayForVoice() },
voiceHandoffReporter = { connectionViewModel.recordVoiceHandoff(it) },
bargeInPreferences = com.hermesandroid.relay.data.BargeInPreferencesRepository(mediaContext),
@@ -608,14 +649,8 @@ fun RelayApp() {
val fontScale by connectionViewModel.fontScale.collectAsState()
HermesRelayTheme(themePreference = themePreference, fontScale = fontScale) {
// Brief sphere intro after system splash fades
var introComplete by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(3000L) // show sphere intro for 3s
introComplete = true
}
val navController = rememberNavController()
var postOnboardingRoute by remember { mutableStateOf<String?>(null) }
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
// Collect navigation requests posted by external launchers (e.g., the
@@ -633,14 +668,67 @@ fun RelayApp() {
}
// === END PHASE3-safety-rails-followup ===
LaunchedEffect(onboardingCompleted, postOnboardingRoute) {
val route = postOnboardingRoute
if (onboardingCompleted && route != null) {
postOnboardingRoute = null
navController.navigate(route) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
launchSingleTop = true
}
}
}
// startDestination uses the route TEMPLATE so it matches the
// composable registered below; optional args default to null/false.
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
val navBackStackEntry by navController.currentBackStackEntryAsState()
val isOnboarding = navBackStackEntry?.destination?.route == Screen.Onboarding.route
val currentRoute = navBackStackEntry?.destination?.route
val isOnboarding = currentRoute == Screen.Onboarding.route
var bridgePrimaryReturnRoute by remember { mutableStateOf<String?>(null) }
var bridgePrimaryReturnLabel by remember { mutableStateOf<String?>(null) }
fun rememberBridgeReturn(route: String, label: String) {
bridgePrimaryReturnRoute = route
bridgePrimaryReturnLabel = label
}
fun clearBridgeReturn() {
bridgePrimaryReturnRoute = null
bridgePrimaryReturnLabel = null
}
val bridgeReturnTitle = bridgePrimaryReturnLabel?.let { "Return to $it" }
val bridgeReturnSubtitle = when (bridgePrimaryReturnLabel) {
"Chat" -> "Back to conversation"
"Manage" -> "Back to management"
else -> "Back to previous tab"
}
val bridgeReturnAction: (() -> Unit)? = bridgePrimaryReturnRoute?.let { route ->
{
clearBridgeReturn()
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
LaunchedEffect(currentRoute) {
if (
currentRoute == Screen.Chat.route ||
currentRoute == Screen.Manage.route ||
currentRoute == Screen.Settings.route ||
currentRoute == Screen.Onboarding.route
) {
clearBridgeReturn()
}
}
val isDarkTheme = isSystemInDarkTheme()
val density = LocalDensity.current
val imeBottom = WindowInsets.ime.getBottom(density)
val isKeyboardVisible = imeBottom > 0
@@ -650,6 +738,81 @@ fun RelayApp() {
// without the Chat/Terminal/Bridge/Settings tabs peeking through below.
val voiceUiState by voiceViewModel.uiState.collectAsState()
val globalConnectionStatus by connectionViewModel.globalConnectionStatus.collectAsState()
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val relayReady by connectionViewModel.relayReady.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
val appReady by connectionViewModel.isReady.collectAsState()
var startupGateMinElapsed by remember { mutableStateOf(false) }
var startupGateTimedOut by remember { mutableStateOf(false) }
var startupGateReleased by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(650L)
startupGateMinElapsed = true
}
LaunchedEffect(Unit) {
delay(5_500L)
startupGateTimedOut = true
}
val hasStartupConnection = activeConnection?.apiServerUrl?.isNotBlank() == true
val startupConnectionResolved = appReady && (
!hasStartupConnection ||
apiReachable ||
apiHealth == ConnectionViewModel.HealthStatus.Reachable ||
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ||
startupGateTimedOut
)
LaunchedEffect(
onboardingCompleted,
startupGateMinElapsed,
startupConnectionResolved,
) {
if (
onboardingCompleted &&
!startupGateReleased &&
startupGateMinElapsed &&
startupConnectionResolved
) {
startupGateReleased = true
}
}
val showStartupSphere =
onboardingCompleted &&
!startupGateReleased &&
!startupGateTimedOut &&
!voiceUiState.voiceMode
// Pre-warm the Manage tab's payload cache when the persisted
// dashboard snapshot says this connection was reachable and signed
// in (or auth-free) — a cold app start then lands on populated
// Manage data instead of skeletons. Keyed on the effective URL so a
// LAN↔Tailscale handoff re-warms the new host's cache; the delay
// debounces resolver flaps during startup (each key change cancels
// the previous run). The pre-warm itself only fills cold keys.
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl) {
val connection = activeConnection ?: return@LaunchedEffect
if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect
val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect
val dashboardUsable = snapshot.reachable &&
(snapshot.authRequired == false || snapshot.authenticated == true)
if (!dashboardUsable) return@LaunchedEffect
delay(1_500L)
// The VM's cached per-connection store — the prewarm must NOT
// construct its own (each instance lazily pays a multi-second
// Keystore keyset build under a process-global Tink lock).
val cookieStore = connectionViewModel.activeDashboardCookieStore()
?: return@LaunchedEffect
prewarmDashboardManage(
cookieStore = cookieStore,
connectionId = connection.id,
dashboardUrl = effectiveDashboardUrl,
)
}
// Single snackbar host for the whole app — exposed via LocalSnackbarHost
// so voice/chat/settings screens can call showHumanError from their
@@ -705,11 +868,24 @@ fun RelayApp() {
masterEnabled &&
unattendedEnabled &&
!isOnboarding &&
!showStartupSphere &&
!voiceUiState.voiceMode
val showConnectionStatusBanner =
globalConnectionStatus != null &&
!isOnboarding &&
!showStartupSphere &&
!voiceUiState.voiceMode
val onConnectionStatusBannerClick: () -> Unit = {
val title = globalConnectionStatus?.title.orEmpty()
val destination = when {
title.contains("No Hermes connection", ignoreCase = true) -> Screen.Pair.route()
title.contains("dashboard", ignoreCase = true) -> Screen.Manage.route
else -> Screen.ConnectionsSettings.route
}
navController.navigate(destination) {
launchSingleTop = true
}
}
// === END v0.4.1 polish ===
// Multi-connection switcher has moved into the AgentInfoSheet's
@@ -779,6 +955,7 @@ fun RelayApp() {
ConnectionStatusBanner(
status = globalConnectionStatus,
includeStatusBarPadding = !showUnattendedBanner && availableUpdate == null,
onClick = onConnectionStatusBannerClick,
)
}
@@ -820,83 +997,48 @@ fun RelayApp() {
contentWindowInsets = WindowInsets(0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (!isOnboarding && !isKeyboardVisible && !voiceUiState.voiceMode) {
NavigationBar(
containerColor = if (isDarkTheme) {
Color(0xFF1A1A2E).copy(alpha = 0.9f)
} else {
MaterialTheme.colorScheme.surface
}
) {
val currentDestination = navBackStackEntry?.destination
bottomNavScreens.forEach { screen ->
val isSelected = currentDestination?.hierarchy?.any {
it.route == screen.route
} == true
NavigationBarItem(
icon = {
Box(
modifier = if (isSelected && isDarkTheme) {
Modifier.purpleGlow(
radius = 18.dp,
alpha = 0.4f,
isDarkTheme = true
)
} else Modifier
) {
Icon(
imageVector = screen.icon,
contentDescription = screen.label
)
}
},
label = { Text(screen.label) },
selected = isSelected,
colors = if (isDarkTheme) {
NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
NavigationBarItemDefaults.colors()
},
onClick = {
// Chat's route is a template with an
// optional `?openAgentSheet` arg — always
// navigate to the concrete bare-"chat"
// URI from bottom nav so we don't leak
// the `{openAgentSheet}` placeholder into
// the destination and so tab-switching
// never re-opens the AgentInfoSheet.
val target = when (screen) {
is Screen.Chat -> Screen.Chat.route(openAgentSheet = false)
else -> screen.route
}
navController.navigate(target) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
if (!isOnboarding && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
val leading = when {
apiReachable -> "api online"
relayReady -> "relay connected"
else -> "offline"
}
val leadingColor = when {
apiReachable -> RelayRefresh.Green
relayReady -> RelayRefresh.Relay
else -> RelayRefresh.Danger
}
val routeLabel = activeEndpoint?.displayLabel()
?: activeConnection?.label
?: "no route"
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() } ?: "default"
val modelLabel = serverModelName.takeIf { it.isNotBlank() } ?: "model pending"
val safetyLabel = if (BuildFlavor.isSideload && masterEnabled) {
"safety: ${if (unattendedEnabled) "unattended" else "on"}"
} else {
"profile: $profileLabel"
}
RelayStatusStrip(
leading = "$leading / $routeLabel",
trailing = "$modelLabel / $safetyLabel",
leadingColor = leadingColor,
)
}
}
) { innerPadding ->
CompositionLocalProvider(LocalSnackbarHost provides snackbarHostState) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(innerPadding)
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
composable(Screen.Onboarding.route) {
// The wizard inside OnboardingScreen now owns credential
// application via ConnectionViewModel.applyPairingPayload,
@@ -924,6 +1066,10 @@ fun RelayApp() {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
}
},
onManageSignIn = {
postOnboardingRoute = Screen.Manage.route
connectionViewModel.completeOnboarding()
}
)
}
@@ -973,52 +1119,215 @@ fun RelayApp() {
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
},
onNavigateToConnect = {
navController.navigate(Screen.Pair.route()) {
launchSingleTop = true
}
},
onNavigateToManage = {
navController.navigate(Screen.Manage.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToBridge = {
rememberBridgeReturn(
route = Screen.Chat.route(openAgentSheet = false),
label = "Chat",
)
navController.navigate(Screen.Bridge.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToTerminal = {
navController.navigate(Screen.Terminal.route) {
launchSingleTop = true
}
},
onNavigateToSettings = {
navController.navigate(Screen.Settings.route) {
launchSingleTop = true
}
},
onNavigateToProfileInspector = { profileName ->
navController.navigate(Screen.ProfileInspector.route(profileName)) {
launchSingleTop = true
}
},
)
}
composable(Screen.Manage.route) {
DashboardManagementScreen(
connectionViewModel = connectionViewModel,
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
},
onNavigateToChat = {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToBridge = {
rememberBridgeReturn(
route = Screen.Manage.route,
label = "Manage",
)
navController.navigate(Screen.Bridge.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToTerminal = {
navController.navigate(Screen.Terminal.route) {
launchSingleTop = true
}
},
onNavigateToSettings = {
navController.navigate(Screen.Settings.route) {
launchSingleTop = true
}
},
)
}
composable(Screen.Terminal.route) {
TerminalScreen(
terminalViewModel = terminalViewModel,
connectionViewModel = connectionViewModel
)
}
composable(Screen.Bridge.route) {
if (BuildFlavor.isSideload) {
BridgeScreen(
connectionViewModel = connectionViewModel,
onNavigateToBridgeSafety = {
navController.navigate(Screen.BridgeSafetySettings.route)
},
if (coldStartAuthState is AuthState.Paired) {
TerminalScreen(
terminalViewModel = terminalViewModel,
connectionViewModel = connectionViewModel
)
} else {
BridgeCoreScreen(
connectionViewModel = connectionViewModel,
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
},
onNavigateToTerminal = {
navController.navigate(Screen.Terminal.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToVoiceSettings = {
navController.navigate(Screen.VoiceSettings.route)
},
onNavigateToNotificationCompanion = {
navController.navigate(Screen.NotificationCompanionSettings.route)
},
onNavigateToMediaSettings = {
navController.navigate(Screen.MediaSettings.route)
},
onNavigateToRelaySessions = {
navController.navigate(Screen.PairedDevices.route)
PowerFeatureGateScreen(
title = "Terminal",
summary = "Open a server shell through your paired relay session.",
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
},
)
}
}
composable(Screen.Bridge.route) {
if (coldStartAuthState !is AuthState.Paired) {
PowerFeatureGateScreen(
title = "Bridge",
summary = "Let Hermes send approved bridge commands to this phone.",
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
},
onBack = bridgeReturnAction,
)
} else {
if (BuildFlavor.isSideload) {
BridgeScreen(
connectionViewModel = connectionViewModel,
returnTitle = bridgeReturnTitle,
returnSubtitle = bridgeReturnSubtitle,
returnLabel = bridgePrimaryReturnLabel ?: "Back",
onReturn = bridgeReturnAction,
onNavigateToBridgeSafety = {
navController.navigate(Screen.BridgeSafetySettings.route)
},
onNavigateToChat = {
clearBridgeReturn()
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToManage = {
clearBridgeReturn()
navController.navigate(Screen.Manage.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToSettings = {
navController.navigate(Screen.Settings.route) {
launchSingleTop = true
}
},
)
} else {
BridgeCoreScreen(
connectionViewModel = connectionViewModel,
returnTitle = bridgeReturnTitle,
returnSubtitle = bridgeReturnSubtitle,
returnLabel = bridgePrimaryReturnLabel ?: "Back",
onReturn = bridgeReturnAction,
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
},
onNavigateToChat = {
clearBridgeReturn()
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToManage = {
clearBridgeReturn()
navController.navigate(Screen.Manage.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToTerminal = {
navController.navigate(Screen.Terminal.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToVoiceSettings = {
navController.navigate(Screen.VoiceSettings.route)
},
onNavigateToNotificationCompanion = {
navController.navigate(Screen.NotificationCompanionSettings.route)
},
onNavigateToMediaSettings = {
navController.navigate(Screen.MediaSettings.route)
},
onNavigateToRelaySessions = {
navController.navigate(Screen.PairedDevices.route)
},
onNavigateToSettings = {
navController.navigate(Screen.Settings.route) {
launchSingleTop = true
}
},
)
}
}
}
composable(Screen.Settings.route) {
SettingsScreen(
connectionViewModel = connectionViewModel,
@@ -1032,9 +1341,19 @@ fun RelayApp() {
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
},
onNavigateToManage = {
navController.navigate(Screen.Manage.route)
},
onNavigateToChatSettings = {
navController.navigate(Screen.ChatSettings.route)
},
onNavigateToTerminal = {
navController.navigate(Screen.Terminal.route)
},
onNavigateToBridge = {
clearBridgeReturn()
navController.navigate(Screen.Bridge.route)
},
onNavigateToMediaSettings = {
navController.navigate(Screen.MediaSettings.route)
},
@@ -1072,10 +1391,24 @@ fun RelayApp() {
)
}
composable(Screen.VoiceSettings.route) {
val standardVoiceSignInRouteHint by
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
VoiceSettingsScreen(
voiceViewModel = voiceViewModel,
voiceClient = voiceClient,
selectedProfile = selectedProfile,
standardVoiceAvailability = standardVoiceAvailability,
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
relayVoiceReady = relayVoiceReady,
onOpenManage = {
navController.navigate(Screen.Manage.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onBack = { navController.popBackStack() }
)
}
@@ -1098,6 +1431,24 @@ fun RelayApp() {
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
},
onNavigateToChat = {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToManage = {
navController.navigate(Screen.Manage.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onNavigateToTerminal = {
navController.navigate(Screen.Terminal.route)
},
@@ -1113,21 +1464,35 @@ fun RelayApp() {
onNavigateToRelaySessions = {
navController.navigate(Screen.PairedDevices.route)
},
onNavigateToSettings = {
navController.navigate(Screen.Settings.route) {
launchSingleTop = true
}
},
)
}
}
// === END PHASE3-safety-rails ===
composable(Screen.PairedDevices.route) {
PairedDevicesScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
onRequestRepair = {
// Pop back to Settings so the user lands on the
// "Scan Pairing QR" button rather than getting
// stranded on an empty devices list.
navController.popBackStack(Screen.Settings.route, inclusive = false)
}
)
if (coldStartAuthState is AuthState.Paired) {
PairedDevicesScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
onRequestRepair = {
navController.navigate(Screen.Pair.route())
}
)
} else {
PowerFeatureGateScreen(
title = "Relay sessions",
summary = "Review and revoke devices paired with this relay.",
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
},
onBack = { navController.popBackStack() },
)
}
}
// (The `composable(Screen.ConnectionSettings.route)` block
// that used to live here — hosting the singular, legacy
@@ -1202,18 +1567,23 @@ fun RelayApp() {
onAddConnection = {
connectionSwitchScope.launch {
// Create and switch to the placeholder before
// opening the camera. Otherwise a fast scan can
// save the session token into the outgoing
// opening the wizard. Otherwise a fast scan or
// standard save can write into the outgoing
// connection's auth store.
val id = connectionViewModel.beginAddConnection(
preAllocatedId = java.util.UUID.randomUUID().toString(),
)
navController.navigate(
Screen.Pair.route(connectionId = id, autoStart = "scan")
Screen.Pair.route(connectionId = id)
)
}
},
onBack = { navController.popBackStack() },
onNavigateToManage = {
navController.navigate(Screen.Manage.route) {
launchSingleTop = true
}
},
onNavigateToPairedDevices = {
navController.navigate(Screen.PairedDevices.route)
},
@@ -1260,6 +1630,12 @@ fun RelayApp() {
// beyond popping the backstack.
navController.popBackStack()
},
onManageSignIn = {
navController.popBackStack()
navController.navigate(Screen.Manage.route) {
launchSingleTop = true
}
},
onCancel = {
// If the user bailed out before completing a
// pair, discard the placeholder we pre-created
@@ -1355,6 +1731,18 @@ fun RelayApp() {
val sectionArg = backStackEntry.arguments
?.getString(Screen.ProfileInspector.ARG_SECTION)
?: Screen.ProfileInspector.SECTION_CONFIG
if (coldStartAuthState !is AuthState.Paired) {
PowerFeatureGateScreen(
title = "Profile Inspector",
summary = "Inspect relay-backed profile config, SOUL, memory files, and skills.",
status = PowerFeatureGateStatus.fromRelayAuth(coldStartAuthState),
onPrimaryAction = {
navController.navigate(Screen.Pair.route())
},
onBack = { navController.popBackStack() },
)
return@composable
}
val inspectorViewModel: ProfileInspectorViewModel = viewModel(
viewModelStoreOwner = backStackEntry,
key = "profile-inspector-$profileNameArg",
@@ -1399,6 +1787,7 @@ fun RelayApp() {
)
}
}
} // end bridge-return wrapper column
} // end CompositionLocalProvider
}
} // end Column (wraps banner + Scaffold)
@@ -1410,16 +1799,18 @@ fun RelayApp() {
// ConnectionSwitcherSheet.kt itself is kept so any future programmatic
// callers (deep links, automation) can still invoke it if needed.)
// Sphere intro overlay — fades out after 1.5s to reveal main UI
// Startup connection gate. Keeps transient "connect" prompts hidden
// until the first saved-connection health decision resolves.
AnimatedVisibility(
visible = !introComplete && onboardingCompleted,
visible = showStartupSphere,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(600))
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF1A1A2E)),
.background(RelayRefresh.Background)
.relayGridTexture(alpha = 0.14f),
contentAlignment = Alignment.Center
) {
// Sphere fills background
@@ -1435,13 +1826,13 @@ fun RelayApp() {
Text(
text = "Hermes-Relay",
style = MaterialTheme.typography.headlineMedium,
color = Color.White.copy(alpha = 0.9f)
color = RelayRefresh.Paper.copy(alpha = 0.92f)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "agent interface",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.5f),
color = RelayRefresh.Muted.copy(alpha = 0.72f),
letterSpacing = 2.sp
)
}
@@ -94,30 +94,21 @@ import kotlinx.coroutines.launch
*/
/**
* Three tappable status rows (API / Relay / Session), always visible on
* the active card. Replaces the old "Active Connection" quick-look card
* that used to live at the top of `SettingsScreen` — same information
* density, same tap-for-info-sheet behavior.
*
* Tap on the Relay row while it's [RelayUiState.Stale] fires an immediate
* reconnect + toast; every other row falls through to the info sheet
* target via [onOpenApiInfo] / [onOpenRelayInfo] / [onOpenSessionInfo].
* Standard Hermes status rows (API / Dashboard). Dashboard auth is surfaced
* here so users do not have to open Manage just to discover sign-in is needed.
*/
@Composable
fun ActiveCardStatusSection(
fun ActiveCardStandardStatusSection(
connectionViewModel: ConnectionViewModel,
relayEnabled: Boolean,
onOpenApiInfo: () -> Unit,
onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit,
onOpenDashboard: () -> Unit,
) {
val context = LocalContext.current
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val dashboardStatus = activeConnection?.dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
ConnectionStatusRow(
label = "API Server",
@@ -132,44 +123,74 @@ fun ActiveCardStatusSection(
modifier = Modifier.fillMaxWidth(),
)
if (relayEnabled) {
// ADR 24: relayRowState carries both the phase and the active
// endpoint role. statusText appends " · <Role>" when the
// resolver has picked one, so the chip reads "Connected · LAN"
// etc. without any extra wiring here.
ConnectionStatusRow(
label = "Relay",
state = relayRowState.asBadgeState(),
statusText = relayRowState.statusText(connectedLabel = "Connected"),
onClick = {
if (relayUiState == RelayUiState.Stale) {
connectionViewModel.connectRelay()
Toast.makeText(
context,
"Reconnecting to relay…",
Toast.LENGTH_SHORT,
).show()
} else {
onOpenRelayInfo()
}
},
modifier = Modifier.fillMaxWidth(),
)
ConnectionStatusRow(
label = "Dashboard",
isConnected = dashboardStatus?.reachable == true && !dashboardSignInRequired,
statusText = when {
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Not configured"
dashboardStatus == null -> "Not checked"
!dashboardStatus.reachable -> "Unreachable"
dashboardSignInRequired -> "Sign-in required"
dashboardStatus.authenticated == true -> "Signed in"
dashboardStatus.authRequired == false -> "Available"
else -> "Available"
},
onClick = onOpenDashboard,
modifier = Modifier.fillMaxWidth(),
)
}
ConnectionStatusRow(
label = "Session",
isConnected = authState is AuthState.Paired,
isConnecting = authState is AuthState.Pairing,
statusText = when (authState) {
is AuthState.Paired -> "Paired"
is AuthState.Pairing -> "Pairing..."
is AuthState.Unpaired -> "Unpaired"
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
},
onClick = onOpenSessionInfo,
modifier = Modifier.fillMaxWidth(),
)
}
/**
* Optional Relay status rows (transport / paired session). Kept separate from
* [ActiveCardStandardStatusSection] so API/dashboard setup does not visually
* read as incomplete when Relay is not paired.
*/
@Composable
fun ActiveCardRelayStatusSection(
connectionViewModel: ConnectionViewModel,
onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit,
) {
val context = LocalContext.current
val authState by connectionViewModel.authState.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
// ADR 24: relayRowState carries both the phase and the active endpoint
// role. statusText appends " · <Role>" when the resolver has picked one.
ConnectionStatusRow(
label = "Relay",
state = relayRowState.asBadgeState(),
statusText = relayRowState.statusText(connectedLabel = "Connected"),
onClick = {
if (relayUiState == RelayUiState.Stale) {
connectionViewModel.connectRelay()
Toast.makeText(
context,
"Reconnecting to relay…",
Toast.LENGTH_SHORT,
).show()
} else {
onOpenRelayInfo()
}
},
modifier = Modifier.fillMaxWidth(),
)
ConnectionStatusRow(
label = "Session",
isConnected = authState is AuthState.Paired,
isConnecting = authState is AuthState.Pairing,
statusText = when (authState) {
is AuthState.Paired -> "Paired"
is AuthState.Pairing -> "Pairing..."
is AuthState.Unpaired -> "Unpaired"
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
},
onClick = onOpenSessionInfo,
modifier = Modifier.fillMaxWidth(),
)
}
/**
@@ -326,7 +347,7 @@ private fun ManualUrlSubsection(
) { result ->
isTestingApi = false
apiVoiceSetupResult = result
if (!result.voiceConfigReachable && result.relayAutoDerived) {
if (!result.voiceConfigReachable && result.voiceRoute == "relay" && result.relayAutoDerived) {
relayOverrideVisible = true
result.relayUrl?.let { relayUrlInput = it }
}
@@ -334,9 +355,13 @@ private fun ManualUrlSubsection(
context,
when {
result.apiReachable && result.voiceConfigReachable ->
"API and voice relay reachable"
if (result.voiceRoute == "standard") {
"API and standard voice reachable"
} else {
"API and relay voice reachable"
}
result.apiReachable ->
"API reachable; relay URL needs review"
"API reachable; voice route needs review"
else -> "Cannot reach API server"
},
Toast.LENGTH_SHORT,
@@ -373,7 +398,7 @@ private fun ManualUrlSubsection(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Voice uses the relay's /voice routes. The app derives this from the API host unless a custom route is needed.",
text = "Relay is optional for voice. Standard voice uses the Hermes API; Relay voice uses this route when selected or needed.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -403,7 +428,7 @@ private fun ManualUrlSubsection(
placeholder = { Text("wss://your-server:8767") },
singleLine = true,
supportingText = {
Text("Only needed when Auto cannot reach /voice/config")
Text("Only needed when the optional Relay route cannot be auto-derived")
},
modifier = Modifier.fillMaxWidth(),
)
@@ -417,9 +442,13 @@ private fun ManualUrlSubsection(
}
Text(
text = if (result.voiceConfigReachable) {
"Voice ready via ${result.relayUrl ?: "relay"}"
if (result.voiceRoute == "standard") {
"Voice ready via standard Hermes API"
} else {
"Voice ready via ${result.relayUrl ?: "relay"}"
}
} else {
"Relay URL required: ${result.voiceConfigError ?: "voice config probe failed"}"
"Voice route needs review: ${result.voiceConfigError ?: "voice config probe failed"}"
},
style = MaterialTheme.typography.bodySmall,
color = color,
@@ -574,7 +603,7 @@ private fun InsecureToggleSubsection(
* the QR scanner isn't usable (no camera, headless host, bad lighting).
*
* 1. Copy the phone-generated code (with Refresh to regenerate)
* 2. Run `hermes-pair --register-code <code>` on the host
* 2. Run `hermes pair --register-code <code>` on the host
* 3. Tap Connect — with a 15s auth watcher that surfaces success /
* failure through the global snackbar host
*
@@ -696,7 +725,7 @@ private fun ManualPairingCodeSubsection(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Text(
text = "hermes-pair --register-code $pairingCode",
text = "hermes pair --register-code $pairingCode",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
@@ -705,10 +734,10 @@ private fun ManualPairingCodeSubsection(
)
IconButton(
onClick = {
val cmd = "hermes-pair --register-code $pairingCode"
val cmd = "hermes pair --register-code $pairingCode"
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("hermes-pair command", cmd)),
ClipEntry(ClipData.newPlainText("hermes pair command", cmd)),
)
snackbarHost.showSnackbar("Command copied")
}
@@ -717,7 +746,7 @@ private fun ManualPairingCodeSubsection(
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy hermes-pair command",
contentDescription = "Copy hermes pair command",
modifier = Modifier.size(16.dp),
)
}
@@ -783,9 +812,9 @@ private fun ManualPairingCodeSubsection(
text = "This is a fallback for when you can't scan the pairing QR " +
"— for example, no camera, the host can't render a QR, or you " +
"only have SSH access from a single device. The canonical flow " +
"is the QR scan from `/hermes-relay-pair` or `hermes-pair`.\n\n" +
"is the QR scan from `/hermes-relay-pair` or `hermes pair`.\n\n" +
"How it works: the phone generates a 6-character code locally. " +
"You paste that code into the host's `hermes-pair --register-code` " +
"You paste that code into the host's `hermes pair --register-code` " +
"command, which pre-registers it with the relay. When you tap " +
"Connect here, the phone presents the same code to the relay " +
"and gets a long-lived session token in return.\n\n" +
@@ -44,7 +44,7 @@ import com.hermesandroid.relay.viewmodel.BridgeStatus
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Visual style mirrors the status
* cards in `PairedDevicesScreen`: surfaceVariant background, 16dp padding,
* 10dp row spacing. Uses [ConnectionStatusBadge] for the pulsing status dot
* so the Bridge tab looks visually consistent with the Settings → Connection
* so the Bridge tab looks visually consistent with the Settings → Connections
* section.
*
* The headline switch is `enabled = allowEnable` so users can't flip it on
@@ -28,7 +28,7 @@ import com.hermesandroid.relay.viewmodel.BridgeStatus
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Kept distinct from
* [BridgeMasterToggle] so that Agent safety-rails in Wave 2 can relocate the master
* toggle without losing the status surface (and so we can reuse this card
* in the Settings → Connection section later if desired).
* in the Settings → Connections section later if desired).
*/
@Composable
fun BridgeStatusCard(
@@ -7,6 +7,7 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -59,6 +60,7 @@ fun ConnectionStatusBanner(
status: ConnectionStatusSnapshot?,
modifier: Modifier = Modifier,
includeStatusBarPadding: Boolean = false,
onClick: (() -> Unit)? = null,
) {
val current = status ?: return
val containerColor = when {
@@ -95,6 +97,7 @@ fun ConnectionStatusBanner(
tonalElevation = 0.dp,
modifier = Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.animateContentSize(animationSpec = tween(durationMillis = 180)),
) {
Column(modifier = Modifier.fillMaxWidth()) {
@@ -154,6 +157,15 @@ fun ConnectionStatusBanner(
overflow = TextOverflow.Ellipsis,
)
}
current.actionLabel?.takeIf { it.isNotBlank() }?.let { label ->
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.86f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
val outputLines = current.entries
.takeLast(2)
@@ -627,6 +627,7 @@ fun AgentInfoSheet(
chatViewModel: ChatViewModel,
onDismiss: () -> Unit,
onNavigateToConnections: () -> Unit,
onNavigateToProfileInspector: (String) -> Unit = {},
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -741,7 +742,7 @@ fun AgentInfoSheet(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
SectionLabel(
title = "Profile",
hint = "Overlay an agent's model + SOUL",
hint = "Host-side Hermes contexts",
)
val defaultDotColor = serverDefaultProfile?.let { profile ->
@@ -868,6 +869,13 @@ fun AgentInfoSheet(
append("profile: ")
append(profile.name)
}
if (profile.hasIsolatedApi) {
if (isNotEmpty()) append(" \u2022 ")
append("isolated API")
} else {
if (isNotEmpty()) append(" \u2022 ")
append("compatibility overlay")
}
if (isApparentActive && selectedProfile == null) {
if (isNotEmpty()) append(" \u2022 ")
append("This is the server's active profile")
@@ -884,6 +892,19 @@ fun AgentInfoSheet(
leadingDotContentDescription = dotA11y,
secondaryTrailing = if (profile.hasSoul || profile.skillCount > 0) {
{
ProfileMetadataBadge(
text = if (profile.hasIsolatedApi) "API" else "Overlay",
background = if (profile.hasIsolatedApi) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (profile.hasIsolatedApi) {
MaterialTheme.colorScheme.onTertiaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
if (profile.skillCount > 0) {
ProfileMetadataBadge(
text = "${profile.skillCount} skills",
@@ -904,7 +925,9 @@ fun AgentInfoSheet(
if (selectedProfile?.name != profile.name) {
connectionViewModel.selectProfile(profile)
val display = primaryLabel
val suffix = if (profile.systemMessage?.isNotBlank() == true) {
val suffix = if (profile.hasIsolatedApi) {
" — profile API active"
} else if (profile.systemMessage?.isNotBlank() == true) {
" — model + SOUL applied"
} else {
" — model applied"
@@ -915,6 +938,21 @@ fun AgentInfoSheet(
)
}
val inspectorTarget = selectedProfile
?: serverDefaultProfile
?: selectableProfiles.firstOrNull()
inspectorTarget?.let { profile ->
TextButton(
onClick = {
onDismiss()
onNavigateToProfileInspector(profile.name)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Inspect ${AgentDisplay.profileDisplayName(profile) ?: profile.name}")
}
}
if (profileOverridesPersonality) {
Text(
text = "This profile's system message overrides the personality below.",
@@ -1065,7 +1103,7 @@ fun AgentInfoSheet(
val hostname = com.hermesandroid.relay.data.Connection
.extractDefaultLabel(connection.apiServerUrl)
val statusLine = when {
connection.pairedAt == null -> "$hostname • Not paired"
connection.pairedAt == null -> "$hostname • Standard"
else -> "$hostname • Paired"
}
ProfileRadioRow(
@@ -119,7 +119,7 @@ private fun ConnectionRow(
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val statusLine = if (connection.pairedAt == null) {
"$hostname • Not paired"
"$hostname • Standard"
} else {
"$hostname • Paired"
}
File diff suppressed because it is too large Load Diff
@@ -19,15 +19,19 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.VpnKey
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -42,9 +46,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.data.isKnownRole
import com.hermesandroid.relay.network.RouteProbeOutcome
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
@@ -72,15 +78,40 @@ fun EndpointsCard(
onClearOverride: () -> Unit,
onProbeNow: () -> Unit,
onViewPin: suspend (EndpointCandidate) -> String?,
/** True while a user-triggered route probe is in flight. */
isProbing: Boolean = false,
/**
* Last probe verdict for a route, or null when it has never been
* probed. Lambda (not a map) so the card stays decoupled from the
* resolver's cache-key scheme.
*/
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
/**
* Route management — the standard path's manual equivalent of a v3 QR's
* `endpoints` array. Null callbacks hide the corresponding affordance.
* Edit/Remove only appear on fallback rows (priority > 0); the primary
* row mirrors the connection's API URL and is edited there.
*/
onAddRoute: (() -> Unit)? = null,
onEditRoute: ((EndpointCandidate) -> Unit)? = null,
onRemoveRoute: ((EndpointCandidate) -> Unit)? = null,
) {
if (endpoints.isEmpty()) {
Text(
text = "No route candidates stored for this device yet. " +
"Scan a v3 pairing QR (Hermes 0.4.2+) to enable multi-route " +
"switching — LAN + Tailscale + public URLs.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "No route candidates stored for this connection yet. " +
"Add a remote route (Tailscale, public URL) for automatic " +
"switching when the phone leaves this network — or scan a " +
"v3 pairing QR (Hermes 0.4.2+) if you use Relay.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onAddRoute != null) {
TextButton(onClick = onAddRoute) {
Text("Add route")
}
}
}
return
}
@@ -99,12 +130,25 @@ fun EndpointsCard(
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
activeEndpoint.api.port == candidate.api.port,
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
isProbing = isProbing,
outcome = outcomeFor(candidate),
onPrefer = { onPreferEndpoint(candidate) },
onProbeNow = onProbeNow,
onViewPin = onViewPin,
onEdit = onEditRoute?.takeIf { candidate.priority > 0 }
?.let { edit -> { edit(candidate) } },
onRemove = onRemoveRoute?.takeIf { candidate.priority > 0 }
?.let { remove -> { remove(candidate) } },
)
}
if (onAddRoute != null) {
HorizontalDivider()
TextButton(onClick = onAddRoute, modifier = Modifier.fillMaxWidth()) {
Text("Add route")
}
}
if (preferredRole != null) {
HorizontalDivider()
TextButton(onClick = onClearOverride, modifier = Modifier.fillMaxWidth()) {
@@ -122,12 +166,17 @@ private fun EndpointRow(
candidate: EndpointCandidate,
isActive: Boolean,
isPreferred: Boolean,
isProbing: Boolean = false,
outcome: RouteProbeOutcome? = null,
onPrefer: () -> Unit,
onProbeNow: () -> Unit,
onViewPin: suspend (EndpointCandidate) -> String?,
onEdit: (() -> Unit)? = null,
onRemove: (() -> Unit)? = null,
) {
var menuOpen by remember { mutableStateOf(false) }
var pinDialogText by remember { mutableStateOf<String?>(null) }
var confirmRemove by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxWidth()) {
@@ -174,13 +223,35 @@ private fun EndpointRow(
)
}
}
// Full URL, scheme included: http vs https decides whether
// the health probe TLS-handshakes, so two rows that both
// read "host:8642" can behave completely differently. The
// scheme must be visible to be debuggable.
Text(
text = "${candidate.api.host}:${candidate.api.port}" +
text = candidate.api.url +
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
when {
isProbing -> Text(
text = "Checking…",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
outcome == null -> Unit // never probed — say nothing
outcome.reachable -> Text(
text = "Reachable",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
else -> Text(
text = "Unreachable — ${outcome.detail ?: "no detail"}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
// 3-dot overflow menu — actions per-row so the card stays flat
@@ -228,11 +299,54 @@ private fun EndpointRow(
}
},
)
if (onEdit != null) {
DropdownMenuItem(
text = { Text("Edit route") },
onClick = {
menuOpen = false
onEdit()
},
)
}
if (onRemove != null) {
DropdownMenuItem(
text = { Text("Remove route") },
onClick = {
menuOpen = false
confirmRemove = true
},
)
}
}
}
}
}
if (confirmRemove && onRemove != null) {
AlertDialog(
onDismissRequest = { confirmRemove = false },
title = { Text("Remove ${candidate.displayLabel()} route?") },
text = {
Text(
text = "${candidate.api.host}:${candidate.api.port} will no longer be " +
"probed as a fallback. You can add it back any time.",
style = MaterialTheme.typography.bodySmall,
)
},
confirmButton = {
TextButton(
onClick = {
confirmRemove = false
onRemove()
},
) { Text("Remove") }
},
dismissButton = {
TextButton(onClick = { confirmRemove = false }) { Text("Cancel") }
},
)
}
pinDialogText?.let { body ->
AlertDialog(
onDismissRequest = { pinDialogText = null },
@@ -328,3 +442,154 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
"public" -> Icons.Filled.Public
else -> Icons.Filled.Shield
}
/**
* Add/edit dialog for an extra fallback route — the manual counterpart of a
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
* set up LAN ↔ Tailscale roaming without the plugin.
*
* The relay URL is derived from the API URL (same `:8767` convention the
* wizard uses); routes that need a custom relay URL still come from a QR.
*
* @param original null = add a new route; non-null = edit (pre-fills role +
* URL, keeps the stored priority).
* @param onSave invoked with (role, apiUrl, resultCallback); the callback
* receives a user-facing error string to render inline, or null on
* success (the dialog then closes itself).
*/
@Composable
fun RouteEditorDialog(
original: EndpointCandidate?,
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
onDismiss: () -> Unit,
) {
val knownRoles = listOf("tailscale", "public")
var selectedRole by remember {
mutableStateOf(
when (original?.role?.lowercase()) {
null -> "tailscale"
in knownRoles -> original.role.lowercase()
else -> CUSTOM_ROLE
},
)
}
var customRole by remember {
mutableStateOf(
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
)
}
var url by remember { mutableStateOf(original?.api?.url.orEmpty()) }
var errorText by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
val effectiveRole = if (selectedRole == CUSTOM_ROLE) customRole else selectedRole
val saveEnabled = !saving &&
url.isNotBlank() &&
(selectedRole != CUSTOM_ROLE || customRole.isNotBlank())
AlertDialog(
onDismissRequest = { if (!saving) onDismiss() },
title = { Text(if (original == null) "Add route" else "Edit route") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "A fallback route the phone switches to when the " +
"primary stops answering — e.g. your server's " +
"Tailscale or public URL.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = selectedRole == "tailscale",
onClick = { selectedRole = "tailscale" },
label = { Text("Tailscale") },
)
FilterChip(
selected = selectedRole == "public",
onClick = { selectedRole = "public" },
label = { Text("Public") },
)
FilterChip(
selected = selectedRole == CUSTOM_ROLE,
onClick = { selectedRole = CUSTOM_ROLE },
label = { Text("Custom") },
)
}
if (selectedRole == CUSTOM_ROLE) {
OutlinedTextField(
value = customRole,
onValueChange = { customRole = it },
label = { Text("Route name") },
placeholder = { Text("wireguard-home") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
// Live preview of what will actually be saved — scheme and
// port defaults applied — so "what, which port, http or
// https?" is answered before Save, not after a failed probe.
val previewCandidate = remember(url, effectiveRole) {
url.takeIf { it.isNotBlank() }?.let {
Connection.endpointCandidateFromApiUrl(
role = effectiveRole.ifBlank { "custom" },
priority = original?.priority ?: 1,
apiServerUrl = Connection.normalizeApiUrlInput(it),
relayUrl = "",
)
}
}
OutlinedTextField(
value = url,
onValueChange = {
url = it
errorText = null
},
label = { Text("API server URL or host") },
placeholder = { Text("100.71.8.56 or http://host:8642") },
singleLine = true,
isError = errorText != null,
supportingText = {
Text(
text = errorText ?: when {
url.isBlank() ->
"Use the API server port (8642 by default) — " +
"not the dashboard's 9119. http:// is " +
"assumed unless you type https://."
previewCandidate != null ->
"Will save: ${previewCandidate.api.url} — relay " +
"and dashboard URLs are derived from the host"
else ->
"Enter a host/IP or an http(s):// URL " +
"(API port 8642, not dashboard 9119)"
},
)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = saveEnabled,
onClick = {
saving = true
onSave(effectiveRole, url) { error ->
saving = false
if (error == null) {
onDismiss()
} else {
errorText = error
}
}
},
) { Text(if (saving) "Saving…" else "Save") }
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") }
},
)
}
private const val CUSTOM_ROLE = "__custom__"
@@ -27,8 +27,13 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -56,6 +61,11 @@ fun MessageBubble(
isFirstInGroup: Boolean = true,
isLastInGroup: Boolean = true,
onCopyMessage: (String) -> Unit = {},
/**
* Quote this message into the input field. Null hides the Quote entry in
* the long-press menu, so legacy call sites keep the copy-only behavior.
*/
onQuoteMessage: ((String) -> Unit)? = null,
/**
* Invoked when the user taps a FAILED inbound attachment card.
* `attachmentIndex` is the position in [ChatMessage.attachments] so the
@@ -191,6 +201,31 @@ fun MessageBubble(
.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f))
)
}
// Long-press opens a compact action menu when a quote handler is
// wired; with copy as the only action it stays a direct copy so the
// one-action case doesn't pay a menu tap.
var showMessageActions by remember { mutableStateOf(false) }
if (onQuoteMessage != null) {
DropdownMenu(
expanded = showMessageActions,
onDismissRequest = { showMessageActions = false },
) {
DropdownMenuItem(
text = { Text("Copy") },
onClick = {
showMessageActions = false
onCopyMessage(message.content)
},
)
DropdownMenuItem(
text = { Text("Quote in reply") },
onClick = {
showMessageActions = false
onQuoteMessage(message.content)
},
)
}
}
Surface(
shape = bubbleShape,
color = backgroundColor,
@@ -206,7 +241,13 @@ fun MessageBubble(
)
.combinedClickable(
onClick = {},
onLongClick = { onCopyMessage(message.content) }
onLongClick = {
if (onQuoteMessage != null) {
showMessageActions = true
} else {
onCopyMessage(message.content)
}
}
)
.semantics { contentDescription = a11yDescription }
) {
@@ -0,0 +1,219 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
enum class PowerFeatureGateStatus(
val label: String,
val actionLabel: String,
val explanation: String,
) {
RequiresPairing(
label = "Requires pairing",
actionLabel = "Pair to unlock",
explanation = "This feature uses relay grants and requires a paired device session.",
),
PairingExpired(
label = "Pairing expired",
actionLabel = "Pair again",
explanation = "Your relay session is no longer accepted. Pair again to get a fresh grant.",
),
Unavailable(
label = "Unavailable on this server",
actionLabel = "View connection",
explanation = "This server does not currently expose the relay capability this feature needs.",
),
DashboardSignInRequired(
label = "Dashboard sign-in required",
actionLabel = "Open sign-in",
explanation = "This standard dashboard feature needs a dashboard session before it can load.",
);
companion object {
fun fromRelayAuth(authState: AuthState): PowerFeatureGateStatus {
return when (authState) {
is AuthState.Failed -> {
val reason = authState.reason.lowercase()
if ("expired" in reason || "token" in reason || "session" in reason) {
PairingExpired
} else {
RequiresPairing
}
}
else -> RequiresPairing
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PowerFeatureGateScreen(
title: String,
summary: String,
status: PowerFeatureGateStatus,
onPrimaryAction: () -> Unit,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp, vertical = 20.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
PowerFeatureGateCard(
title = title,
summary = summary,
status = status,
onPrimaryAction = onPrimaryAction,
)
}
}
}
@Composable
fun PowerFeatureGateCard(
title: String,
summary: String,
status: PowerFeatureGateStatus,
onPrimaryAction: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(10.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = status.label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
}
}
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = status.explanation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(2.dp))
Button(
onClick = onPrimaryAction,
modifier = Modifier.fillMaxWidth(),
) {
Text(status.actionLabel)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PowerFeatureGatePreview() {
HermesRelayTheme {
PowerFeatureGateCard(
title = "Terminal",
summary = "Open a server shell through your paired relay session.",
status = PowerFeatureGateStatus.RequiresPairing,
onPrimaryAction = {},
)
}
}
@Preview(showBackground = true)
@Composable
private fun PowerFeatureGateExpiredPreview() {
HermesRelayTheme {
PowerFeatureGateCard(
title = "Bridge",
summary = "Let Hermes send approved bridge commands to this phone.",
status = PowerFeatureGateStatus.PairingExpired,
onPrimaryAction = {},
)
}
}
@@ -68,8 +68,10 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import java.net.URI
import java.util.concurrent.Executors
import kotlin.math.max
@@ -132,7 +134,7 @@ import kotlin.math.max
* }
* ```
*
* The top-level fields configure the direct-chat Hermes API server. The
* The top-level fields configure the direct Hermes API server. The
* optional [relay] block configures the Hermes-Relay WSS connection used by
* the terminal and bridge channels. The [endpoints] list (v3+) carries an
* ordered array of candidate endpoints; the phone picks the highest-priority
@@ -217,12 +219,17 @@ private val json = Json {
}
/**
* Try to parse a scanned string as a Hermes pairing QR payload.
* Try to parse a scanned string as a Hermes connection QR payload.
*
* Accepts v1, v2, and v3 (or anything without a `hermes` field — we default
* to `1`). Returns null when the payload is not valid JSON, has no `host`
* field, or fails strict decoding.
*
* For standard Hermes setup, also accepts generic API-only QRs:
* - a plain `http://host:8642` or `https://host:8642` URL
* - JSON with `api_url`, `apiUrl`, `server_url`, `serverUrl`, or `url`, plus
* optional `api_key`, `apiKey`, or `key`
*
* **Endpoint synthesis (ADR 24):** when the payload has no `endpoints`
* array (v1/v2 QRs), a single priority-0 [EndpointCandidate] is materialized
* from the top-level fields so downstream code can always iterate
@@ -233,6 +240,13 @@ private val json = Json {
* role case, priority order, and unknown roles are all preserved.
*/
fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
val trimmed = raw.trim()
return parseHermesRelayQr(trimmed)
?: parseGenericApiJsonQr(trimmed)
?: parseGenericApiUrlQr(trimmed)
}
private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
return try {
// Quick check: must contain a `host` field and be valid JSON. We no
// longer reject based on the `hermes` version int — future v4+ QRs
@@ -263,6 +277,85 @@ fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
}
}
private fun parseGenericApiJsonQr(raw: String): HermesPairingPayload? {
return try {
val obj = json.decodeFromString<JsonObject>(raw)
val apiUrl = firstString(
obj,
"api_url",
"apiUrl",
"server_url",
"serverUrl",
"url",
) ?: return null
val apiKey = firstString(obj, "api_key", "apiKey", "key").orEmpty()
payloadFromApiUrl(apiUrl, apiKey)
} catch (_: Exception) {
null
}
}
private fun parseGenericApiUrlQr(raw: String): HermesPairingPayload? {
return payloadFromApiUrl(raw, apiKey = "")
}
private fun firstString(obj: JsonObject, vararg names: String): String? {
return names.firstNotNullOfOrNull { name ->
obj[name]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
}
}
private fun payloadFromApiUrl(apiUrl: String, apiKey: String): HermesPairingPayload? {
val uri = runCatching { URI(apiUrl.trim().trimEnd('/')) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase()
val tls = when (scheme) {
"http" -> false
"https" -> true
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val payload = HermesPairingPayload(
host = host,
port = if (uri.port > 0) uri.port else 8642,
key = apiKey.trim(),
tls = tls,
relay = null,
)
return payload.copy(endpoints = listOf(synthesizeGenericEndpoint(payload)))
}
private fun synthesizeGenericEndpoint(payload: HermesPairingPayload): EndpointCandidate {
val host = payload.host.lowercase()
val role = when {
host.endsWith(".ts.net") || host.startsWith("100.") -> "tailscale"
isPrivateLanHost(host) -> "lan"
else -> "public"
}
return EndpointCandidate(
role = role,
priority = 0,
api = ApiEndpoint(
host = payload.host,
port = payload.port,
tls = payload.tls,
),
relay = RelayEndpoint(url = "", transportHint = null),
)
}
private fun isPrivateLanHost(host: String): Boolean {
if (host == "localhost" || host == "127.0.0.1" || host == "::1") return true
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
if (parts.size != 4) return false
return when {
parts[0] == 10 -> true
parts[0] == 172 && parts[1] in 16..31 -> true
parts[0] == 192 && parts[1] == 168 -> true
parts[0] == 169 && parts[1] == 254 -> true
else -> false
}
}
/**
* Build a single priority-0 [EndpointCandidate] from a v1/v2 pairing payload
* that lacked an `endpoints` array. Preserves the top-level API coordinates
@@ -635,7 +728,7 @@ fun QrPairingScanner(
// Instructions
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(horizontal = 32.dp)
) {
Icon(
@@ -645,14 +738,14 @@ fun QrPairingScanner(
modifier = Modifier.size(32.dp)
)
Text(
text = "Point at a Hermes pairing QR code",
style = MaterialTheme.typography.bodyLarge,
text = "Scan a Hermes setup QR",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Text(
text = "Generate one on your server with: hermes-pair",
style = MaterialTheme.typography.bodySmall,
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Hermes-Relay plugin.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
@@ -0,0 +1,436 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.RelayDottedOverlay
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
import com.hermesandroid.relay.ui.theme.relayPanel
import com.hermesandroid.relay.ui.theme.relaySelectedPanel
enum class RelayPrimaryMode(val label: String) {
Chat("Chat"),
Manage("Manage"),
Bridge("Bridge"),
}
@Composable
fun RelayModeStrip(
selected: RelayPrimaryMode,
onModeSelected: (RelayPrimaryMode) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
RelayPrimaryMode.entries.forEach { mode ->
val active = mode == selected
Box(
modifier = Modifier
.weight(1f)
.height(34.dp)
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
.then(
if (active) {
Modifier.relaySelectedPanel()
} else {
Modifier.relayPanel(
background = RelayRefresh.Background.copy(alpha = 0.45f),
)
},
)
.clickable { onModeSelected(mode) },
contentAlignment = Alignment.Center,
) {
Text(
text = mode.label,
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraBold),
color = if (active) RelayRefresh.Paper else RelayRefresh.Muted,
maxLines = 1,
)
}
}
}
}
@Composable
fun RelayStatusStrip(
leading: String,
trailing: String,
modifier: Modifier = Modifier,
leadingColor: Color = RelayRefresh.Green,
) {
// Floating capsule, not an edge-to-edge bar: a full-width bordered
// rectangle clashes with rounded display corners and reads as a hard
// shelf. Insets are applied BEFORE the margins so the pill floats above
// the gesture area with the app background showing around it.
Column(
modifier = modifier
.fillMaxWidth()
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
),
)
.padding(start = 14.dp, end = 14.dp, top = 2.dp, bottom = 8.dp)
.clip(RoundedCornerShape(999.dp))
.relayPanel(
shape = RoundedCornerShape(999.dp),
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
borderColor = RelayRefresh.Line,
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(22.dp)
.padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = leading,
style = relayMetadataStyle(),
color = leadingColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = trailing,
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
fun RelayReturnStrip(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
label: String = "Back",
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
.relaySelectedPanel()
.clickable(onClick = onClick)
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Surface(
modifier = Modifier.size(32.dp),
shape = RoundedCornerShape(7.dp),
color = RelayRefresh.Background.copy(alpha = 0.58f),
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = RelayRefresh.Paper,
modifier = Modifier.size(18.dp),
)
}
}
Surface(
modifier = Modifier.size(32.dp),
shape = RoundedCornerShape(7.dp),
color = RelayRefresh.Navy3.copy(alpha = 0.72f),
border = BorderStroke(1.dp, RelayRefresh.Line),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = null,
tint = RelayRefresh.Relay,
modifier = Modifier.size(17.dp),
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraBold),
color = RelayRefresh.Paper,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = label,
style = relayMetadataStyle(),
color = RelayRefresh.Relay,
maxLines = 1,
)
}
}
@Composable
fun RelayHeroPanel(
title: String,
subtitle: String,
modifier: Modifier = Modifier,
accent: Color = RelayRefresh.Relay,
action: @Composable (() -> Unit)? = null,
) {
Box(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
.relaySelectedPanel(),
) {
RelayDottedOverlay(alpha = 0.18f)
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
color = RelayRefresh.Paper,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = RelayRefresh.Ink.copy(alpha = 0.86f),
)
action?.invoke()
}
}
}
@Composable
fun RelayMetricCard(
value: String,
label: String,
modifier: Modifier = Modifier,
valueColor: Color = RelayRefresh.Paper,
) {
Column(
modifier = modifier
.relayPanel()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = valueColor,
maxLines = 1,
)
Text(
text = label,
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
)
}
}
@Composable
fun RelayNavTile(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
selected: Boolean = false,
enabled: Boolean = true,
trailing: @Composable (() -> Unit)? = null,
) {
val base = if (selected) {
Modifier.relaySelectedPanel()
} else {
Modifier.relayPanel(background = RelayRefresh.Navy2.copy(alpha = 0.72f))
}
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(RelayRefresh.CardRadius))
.then(base)
.clickable(enabled = enabled, onClick = onClick)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Surface(
modifier = Modifier.size(34.dp),
shape = RoundedCornerShape(7.dp),
color = RelayRefresh.Navy3.copy(alpha = if (enabled) 0.86f else 0.38f),
border = BorderStroke(1.dp, RelayRefresh.Line),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (enabled) RelayRefresh.Relay else RelayRefresh.Dim,
modifier = Modifier.size(18.dp),
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraBold),
color = if (enabled) RelayRefresh.Paper else RelayRefresh.Dim,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = if (enabled) RelayRefresh.Muted else RelayRefresh.Dim,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (trailing != null) {
trailing()
} else {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = if (enabled) RelayRefresh.Muted else RelayRefresh.Dim,
modifier = Modifier.size(18.dp),
)
}
}
}
@Composable
fun RelaySectionCaption(
title: String,
meta: String? = null,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.ExtraBold),
color = RelayRefresh.Paper,
)
meta?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
fun RelayStatusPill(
text: String,
active: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.relayPanel(
background = if (active) RelayRefresh.Green.copy(alpha = 0.12f) else RelayRefresh.Navy3.copy(alpha = 0.7f),
borderColor = if (active) RelayRefresh.Green.copy(alpha = 0.36f) else RelayRefresh.Line,
)
.padding(horizontal = 8.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Icon(
imageVector = if (active) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
contentDescription = null,
tint = if (active) RelayRefresh.Green else RelayRefresh.Muted,
modifier = Modifier.size(13.dp),
)
Text(
text = text,
style = relayMetadataStyle(),
color = if (active) RelayRefresh.Green else RelayRefresh.Muted,
maxLines = 1,
)
}
}
@Composable
fun RelayChromeIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.size(38.dp),
shape = RoundedCornerShape(RelayRefresh.CardRadius),
color = RelayRefresh.Background.copy(alpha = 0.52f),
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
) {
IconButton(onClick = onClick) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = RelayRefresh.Paper,
modifier = Modifier.size(19.dp),
)
}
}
}
@@ -14,10 +14,14 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -36,10 +40,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private enum class SessionDrawerFilter(val label: String) {
All("All"),
Pinned("Pinned"),
Archive("Archive"),
}
@Composable
fun SessionDrawerContent(
sessions: List<ChatSession>,
@@ -53,8 +65,35 @@ fun SessionDrawerContent(
) {
var renameDialogSession by remember { mutableStateOf<ChatSession?>(null) }
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
var query by remember { mutableStateOf("") }
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
var pinnedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var archivedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
val visibleSessions = sessions
.asSequence()
.filter { session ->
when (filter) {
SessionDrawerFilter.All -> session.sessionId !in archivedSessionIds
SessionDrawerFilter.Pinned ->
session.sessionId in pinnedSessionIds &&
session.sessionId !in archivedSessionIds
SessionDrawerFilter.Archive -> session.sessionId in archivedSessionIds
}
}
.filter { session ->
val needle = query.trim()
needle.isBlank() ||
session.sessionId.contains(needle, ignoreCase = true) ||
session.title.orEmpty().contains(needle, ignoreCase = true) ||
session.model.orEmpty().contains(needle, ignoreCase = true)
}
.toList()
ModalDrawerSheet(modifier = Modifier.width(300.dp)) {
ModalDrawerSheet(
modifier = Modifier.width(320.dp),
drawerContainerColor = RelayRefresh.Background,
drawerContentColor = RelayRefresh.Ink,
) {
Column(modifier = Modifier.padding(16.dp)) {
// Header
Text(
@@ -84,12 +123,41 @@ fun SessionDrawerContent(
Text("New Chat")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
placeholder = { Text("Search sessions or id...") },
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
SessionDrawerFilter.entries.forEach { item ->
FilterChip(
selected = filter == item,
onClick = { filter = item },
label = {
Text(
text = item.label,
style = relayMetadataStyle(),
)
},
)
}
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
}
if (sessions.isEmpty()) {
if (visibleSessions.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -97,7 +165,7 @@ fun SessionDrawerContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No sessions yet",
text = if (sessions.isEmpty()) "No sessions yet" else "No matching sessions",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -109,11 +177,27 @@ fun SessionDrawerContent(
}
} else {
LazyColumn {
items(sessions, key = { it.sessionId }) { session ->
items(visibleSessions, key = { it.sessionId }) { session ->
SessionItem(
session = session,
isActive = session.sessionId == currentSessionId,
pinned = session.sessionId in pinnedSessionIds,
archived = session.sessionId in archivedSessionIds,
onClick = { onSelectSession(session.sessionId) },
onTogglePinned = {
pinnedSessionIds = if (session.sessionId in pinnedSessionIds) {
pinnedSessionIds - session.sessionId
} else {
pinnedSessionIds + session.sessionId
}
},
onToggleArchived = {
archivedSessionIds = if (session.sessionId in archivedSessionIds) {
archivedSessionIds - session.sessionId
} else {
archivedSessionIds + session.sessionId
}
},
onRename = { renameDialogSession = session },
onDelete = { deleteDialogSession = session }
)
@@ -184,7 +268,11 @@ fun SessionDrawerContent(
private fun SessionItem(
session: ChatSession,
isActive: Boolean,
pinned: Boolean,
archived: Boolean,
onClick: () -> Unit,
onTogglePinned: () -> Unit,
onToggleArchived: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit
) {
@@ -234,6 +322,20 @@ private fun SessionItem(
}
}
IconButton(onClick = onTogglePinned, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Filled.Star,
contentDescription = if (pinned) "Unpin session" else "Pin session",
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onToggleArchived, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Filled.Archive,
contentDescription = if (archived) "Restore session" else "Archive session",
tint = if (archived) RelayRefresh.Relay else MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onRename, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Filled.Edit,
@@ -209,7 +209,7 @@ private fun defaultOptionIndex(options: List<TtlOption>): Int =
/**
* Compute the default TTL for a new pair based on:
* - QR payload's `ttlSeconds` (operator intent via `hermes-pair --ttl`)
* - QR payload's `ttlSeconds` (operator intent via `hermes pair --ttl`)
* - Transport hint (`"wss"` → 30d, `"ws"` → 7d)
* - Tailscale detected → 30d
* - Fallback → 30d
@@ -301,6 +301,28 @@ fun VoiceModeOverlay(
)
}
AnimatedVisibility(
visible = uiState.backgroundRun != null,
enter = fadeIn(tween(140)),
exit = fadeOut(tween(180)),
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
) {
Text(
text = uiState.backgroundRun?.message
?: "Working on it in the background…",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
}
Spacer(Modifier.height(8.dp))
DestructiveCountdownRow(
@@ -27,6 +27,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -40,6 +41,7 @@ fun OnboardingPage(
title: String,
description: String,
modifier: Modifier = Modifier,
transparentHero: Boolean = false,
heroContent: @Composable BoxScope.() -> Unit = {
FeatureHero(
icon = icon,
@@ -82,14 +84,14 @@ fun OnboardingPage(
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
shape = heroShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
)
) {
val heroModifier = Modifier
.fillMaxWidth()
.height(232.dp)
Box(
modifier = Modifier
.fillMaxWidth()
.height(232.dp)
.background(heroBrush),
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
contentAlignment = Alignment.Center
) {
heroContent()
@@ -24,8 +24,8 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.MenuBook
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.PhonelinkSetup
import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.material3.AlertDialog
@@ -48,13 +48,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.collectAsState
import androidx.lifecycle.viewmodel.compose.viewModel
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.SphereState
import com.hermesandroid.relay.ui.components.ConnectionWizard
@@ -63,18 +62,16 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
/** Page identifiers for dynamic onboarding flow. */
private enum class OnboardingPage { Welcome, Chat, Terminal, Bridge, Connect }
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect }
/**
* Three-stage onboarding:
* Standard-first onboarding:
*
* 1. **Feature pages** (Welcome / Chat / Terminal / Bridge) — informational
* swipe-able introduction. Bridge / Terminal pages only show when the
* relay feature is enabled (Developer Options).
* 1. **Feature pages** (Welcome / Chat / Manage / Power tools) — standard
* Hermes API/dashboard features first, Relay-only power tools second.
* 2. **Connect page** — embeds the shared [ConnectionWizard] so onboarding
* uses the exact same scan → confirm → verify flow as Settings → Connection.
* The wizard owns credential application; on success / skip it calls back
* into [onComplete] to finish onboarding and navigate to chat.
* uses the exact same Standard API/dashboard and optional Relay pairing
* flow as Settings → Connections.
*
* The previous separate "ConnectPage" + "RelayPage" pair has been removed —
* it discarded the QR's relay block, never applied per-channel grants, never
@@ -102,19 +99,14 @@ fun OnboardingScreen(
// lands on the right VM and survives the Onboarding→Chat transition.
connectionViewModel: ConnectionViewModel,
onComplete: () -> Unit,
onManageSignIn: () -> Unit = onComplete,
) {
val context = LocalContext.current
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
// Build page list dynamically based on feature flags
val pages = remember(relayEnabled) {
val pages = remember {
buildList {
add(OnboardingPage.Welcome)
add(OnboardingPage.Chat)
if (relayEnabled) {
add(OnboardingPage.Terminal)
add(OnboardingPage.Bridge)
}
add(OnboardingPage.Manage)
add(OnboardingPage.Power)
add(OnboardingPage.Connect)
}
}
@@ -134,7 +126,11 @@ fun OnboardingScreen(
onDismissRequest = { showSkipConfirm = false },
title = { Text("Skip setup?") },
text = {
Text("You can configure your server connection later in Settings → Connection. Without pairing, chat and voice features won't work yet.")
Text(
"You can configure your Hermes connection later in Settings → Connections. " +
"Without a connection, Chat and Manage won't load. Relay pairing can " +
"be added later for power tools."
)
},
confirmButton = {
TextButton(onClick = {
@@ -185,11 +181,12 @@ fun OnboardingScreen(
when (pages[pageIndex]) {
OnboardingPage.Welcome -> WelcomePage()
OnboardingPage.Chat -> ChatPage()
OnboardingPage.Terminal -> TerminalPage()
OnboardingPage.Bridge -> BridgePage()
OnboardingPage.Manage -> ManagePage()
OnboardingPage.Power -> PowerToolsPage()
OnboardingPage.Connect -> ConnectPage(
connectionViewModel = connectionViewModel,
onComplete = onComplete,
onManageSignIn = onManageSignIn,
onSkip = { showSkipConfirm = true },
)
}
@@ -260,14 +257,15 @@ private fun WelcomePage() {
val context = LocalContext.current
OnboardingPage(
icon = Icons.Outlined.RocketLaunch,
title = "Hermes-Relay",
description = "Your Hermes agent, in your pocket.",
title = "Hermes-Relay for Android",
description = "Chat with Hermes and manage your dashboard from your phone.",
transparentHero = true,
heroContent = {
Box(modifier = Modifier.fillMaxSize()) {
MorphingSphere(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 6.dp),
.padding(horizontal = 4.dp, vertical = 2.dp),
state = SphereState.Idle,
intensity = 0.12f,
)
@@ -287,27 +285,48 @@ private fun WelcomePage() {
)
}
Box(
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 18.dp)
.size(60.dp)
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.86f))
.padding(start = 7.dp, end = 12.dp, top = 5.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = "Hermes-Relay logo",
modifier = Modifier.size(42.dp)
contentDescription = "Hermes logo",
modifier = Modifier.size(30.dp)
)
Text(
text = "Hermes-Relay",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Standard",
description = "Connect to your running Hermes dashboard and API. No Relay install or pairing required.",
)
SetupPathSummary(
label = "Advanced",
description = "Add Hermes-Relay for Terminal, Bridge, relay sessions, and channel grants.",
)
}
Text(
text = "Read the app guide, browse the repo, or jump to Hermes Agent docs while you finish server setup.",
style = MaterialTheme.typography.bodyMedium,
text = "The setup guide has copy/paste commands when you need to start Hermes on a computer or server.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -318,7 +337,7 @@ private fun WelcomePage() {
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/"))
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/guide/getting-started"))
)
},
modifier = Modifier.weight(1f)
@@ -329,71 +348,158 @@ private fun WelcomePage() {
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("User Guide")
Text("Setup Guide")
}
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay"))
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(R.drawable.ic_github),
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("GitHub")
Text("Hermes Docs")
}
}
Text(
text = "hermes-agent.nousresearch.com",
text = "Hermes API server docs",
style = MaterialTheme.typography.bodySmall.copy(
textDecoration = TextDecoration.Underline
),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com")))
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server")))
}
)
}
}
@Composable
private fun SetupPathSummary(
label: String,
description: String,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(78.dp),
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun ChatPage() {
OnboardingPage(
icon = Icons.Outlined.Forum,
title = "Chat",
description = "Talk to any Hermes agent profile with real-time streaming responses, tool progress, and full markdown."
)
description = "Your Hermes agent, streaming in real time.",
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Streaming",
description = "Live responses with tool progress, markdown, and rich cards as the agent works.",
)
SetupPathSummary(
label = "Profiles",
description = "Switch agent profiles mid-flow — each keeps its own sessions, model, and persona.",
)
SetupPathSummary(
label = "Voice",
description = "Tap the mic to talk. Speech runs through your Hermes server — no extra install.",
)
}
}
}
@Composable
private fun TerminalPage() {
private fun ManagePage() {
OnboardingPage(
icon = Icons.Filled.Settings,
title = "Manage",
description = "Your Hermes dashboard, pocket-sized.",
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Control",
description = "Profiles, skills, automations, MCP servers, models, and provider keys.",
)
SetupPathSummary(
label = "Skills hub",
description = "Search the hub, read a skill before installing, and install from your phone.",
)
SetupPathSummary(
label = "One sign-in",
description = "Signing into the dashboard once also unlocks voice for this connection.",
)
}
}
}
@Composable
private fun PowerToolsPage() {
OnboardingPage(
icon = Icons.Outlined.Terminal,
title = "Terminal",
description = "Secure remote shell access to your server via tmux. Coming soon."
)
}
@Composable
private fun BridgePage() {
OnboardingPage(
icon = Icons.Outlined.PhonelinkSetup,
title = "Bridge",
description = "Let your agent control your device — taps, typing, screenshots, and automation. Coming soon."
)
title = "Power tools",
description = "Pair the optional Relay when you want more than standard.",
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
label = "Terminal",
description = "A real tmux session on your server, in your pocket.",
)
SetupPathSummary(
label = "Bridge",
description = "Let the agent operate this phone — with safety rails (sideload builds).",
)
SetupPathSummary(
label = "Realtime",
description = "Provider-native realtime voice agent and profile-aware voice providers.",
)
}
}
}
@Composable
private fun ConnectPage(
connectionViewModel: ConnectionViewModel,
onComplete: () -> Unit,
onManageSignIn: () -> Unit,
onSkip: () -> Unit,
) {
Box(
@@ -406,6 +512,7 @@ private fun ConnectPage(
connectionViewModel = connectionViewModel,
onComplete = onComplete,
onCancel = onSkip,
onManageSignIn = onManageSignIn,
showSkip = true,
)
}
@@ -251,6 +251,18 @@ fun AppearanceSettingsScreen(
enabled = animEnabled
)
}
// The ambient-mode entry is a gesture with no visible
// control — this line is its discoverable documentation
// (including for screen-reader users browsing settings).
if (animEnabled) {
Text(
text = "Tip: long-press the chat background for a fullscreen " +
"ambient sphere; tap anywhere to return.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -14,6 +15,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Devices
@@ -22,6 +24,7 @@ import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -40,7 +43,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
import com.hermesandroid.relay.ui.components.RelayHeroPanel
import com.hermesandroid.relay.ui.components.RelayModeStrip
import com.hermesandroid.relay.ui.components.RelayNavTile
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
import com.hermesandroid.relay.ui.components.RelayReturnStrip
import com.hermesandroid.relay.ui.components.RelaySectionCaption
import com.hermesandroid.relay.ui.components.RelayStatusPill
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayGridTexture
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.statusText
/**
@@ -55,20 +69,42 @@ import com.hermesandroid.relay.viewmodel.statusText
fun BridgeCoreScreen(
connectionViewModel: ConnectionViewModel,
onNavigateToConnections: () -> Unit,
onNavigateToChat: () -> Unit = {},
onNavigateToManage: () -> Unit = {},
onNavigateToTerminal: () -> Unit,
onNavigateToVoiceSettings: () -> Unit,
onNavigateToNotificationCompanion: () -> Unit,
onNavigateToMediaSettings: () -> Unit,
onNavigateToRelaySessions: () -> Unit,
onNavigateToSettings: () -> Unit = {},
returnTitle: String? = null,
returnSubtitle: String = "",
returnLabel: String = "Back",
onReturn: (() -> Unit)? = null,
) {
val relayState by connectionViewModel.relayUiState.collectAsState()
val relayConnected = relayState == RelayUiState.Connected
Scaffold(
topBar = {
TopAppBar(
title = { Text("Bridge") },
actions = {
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = "Terminal",
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
RelayChromeIconButton(
icon = Icons.Filled.Tune,
contentDescription = "Settings",
onClick = onNavigateToSettings,
modifier = Modifier.padding(end = 4.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
containerColor = RelayRefresh.Background.copy(alpha = 0.96f),
),
)
},
@@ -77,10 +113,52 @@ fun BridgeCoreScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(RelayRefresh.Background)
.relayGridTexture(alpha = 0.12f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
RelayModeStrip(
selected = RelayPrimaryMode.Bridge,
onModeSelected = { mode ->
when (mode) {
RelayPrimaryMode.Chat -> onNavigateToChat()
RelayPrimaryMode.Manage -> onNavigateToManage()
RelayPrimaryMode.Bridge -> Unit
}
},
modifier = Modifier.padding(horizontal = 0.dp, vertical = 0.dp),
)
if (returnTitle != null && onReturn != null) {
RelayReturnStrip(
icon = Icons.AutoMirrored.Filled.ArrowBack,
title = returnTitle,
subtitle = returnSubtitle,
label = returnLabel,
onClick = onReturn,
)
}
RelayHeroPanel(
title = if (relayConnected) "Phone bridge is paired" else "Bridge Core is waiting",
subtitle = if (relayConnected) {
"Terminal, voice, notification, media, and relay-session controls share this grant."
} else {
"Pair Relay to use Terminal and phone bridge tools. Chat and Manage continue over standard Hermes API."
},
action = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RelayStatusPill(
text = relayState.statusText("connected").lowercase(),
active = relayConnected,
)
RelayStatusPill(
text = "safety on",
active = true,
)
}
},
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
@@ -161,59 +239,47 @@ fun BridgeCoreScreen(
}
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Bridge Features",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
BridgeCoreRow(
icon = Icons.Filled.Link,
title = "Connections",
subtitle = "Pair, switch, and verify relay routes",
onClick = onNavigateToConnections,
)
BridgeCoreRow(
icon = Icons.Filled.Code,
title = "Terminal",
subtitle = "Attach to your Hermes relay terminal",
onClick = onNavigateToTerminal,
)
BridgeCoreRow(
icon = Icons.Filled.GraphicEq,
title = "Voice",
subtitle = "Configure STT, TTS, provider, and voice mode",
onClick = onNavigateToVoiceSettings,
)
BridgeCoreRow(
icon = Icons.Filled.Notifications,
title = "Notification companion",
subtitle = "Forward notifications you grant Android access to share",
onClick = onNavigateToNotificationCompanion,
)
BridgeCoreRow(
icon = Icons.Filled.Image,
title = "Media",
subtitle = "Manage inbound attachments and cache behavior",
onClick = onNavigateToMediaSettings,
)
BridgeCoreRow(
icon = Icons.Filled.Devices,
title = "Relay sessions",
subtitle = "Review active grants for this server",
onClick = onNavigateToRelaySessions,
)
}
}
RelaySectionCaption(
title = "Bridge Surface",
meta = "not hidden in settings",
)
RelayNavTile(
icon = Icons.Filled.Link,
title = "Connections",
subtitle = "Pair, switch, and verify relay routes",
onClick = onNavigateToConnections,
)
RelayNavTile(
icon = Icons.Filled.Code,
title = "Terminal",
subtitle = "Attach to your Hermes relay terminal",
onClick = onNavigateToTerminal,
selected = relayConnected,
)
RelayNavTile(
icon = Icons.Filled.GraphicEq,
title = "Voice",
subtitle = "Provider, model, output voice",
onClick = onNavigateToVoiceSettings,
)
RelayNavTile(
icon = Icons.Filled.Notifications,
title = "Notifications",
subtitle = "Shared app notifications",
onClick = onNavigateToNotificationCompanion,
)
RelayNavTile(
icon = Icons.Filled.Image,
title = "Media",
subtitle = "Inbound attachments and cache behavior",
onClick = onNavigateToMediaSettings,
)
RelayNavTile(
icon = Icons.Filled.Devices,
title = "Relay sessions",
subtitle = "Review active grants for this server",
onClick = onNavigateToRelaySessions,
)
Spacer(modifier = Modifier.height(16.dp))
}
@@ -10,6 +10,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat
import com.hermesandroid.relay.data.BuildFlavor
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -24,6 +25,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -62,9 +65,17 @@ import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.components.BridgeActivityLog
import com.hermesandroid.relay.ui.components.BridgeMasterToggle
import com.hermesandroid.relay.ui.components.BridgePermissionChecklist
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
import com.hermesandroid.relay.ui.components.RelayHeroPanel
import com.hermesandroid.relay.ui.components.RelayModeStrip
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
import com.hermesandroid.relay.ui.components.RelayReturnStrip
import com.hermesandroid.relay.ui.components.RelayStatusPill
// === v0.4.1 unattended-access ===
import com.hermesandroid.relay.ui.components.UnattendedAccessRow
// === END v0.4.1 unattended-access ===
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayGridTexture
import com.hermesandroid.relay.viewmodel.BridgeViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
@@ -105,6 +116,13 @@ fun BridgeScreen(
// === PHASE3-safety-rails: safety summary card ===
onNavigateToBridgeSafety: () -> Unit = {},
// === END PHASE3-safety-rails ===
onNavigateToChat: () -> Unit = {},
onNavigateToManage: () -> Unit = {},
onNavigateToSettings: () -> Unit = {},
returnTitle: String? = null,
returnSubtitle: String = "",
returnLabel: String = "Back",
onReturn: (() -> Unit)? = null,
) {
val masterToggle by viewModel.masterToggle.collectAsState()
val permissionStatus by viewModel.permissionStatus.collectAsState()
@@ -197,8 +215,16 @@ fun BridgeScreen(
topBar = {
TopAppBar(
title = { Text("Bridge") },
actions = {
RelayChromeIconButton(
icon = Icons.Filled.Tune,
contentDescription = "Settings",
onClick = onNavigateToSettings,
modifier = Modifier.padding(end = 4.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
)
)
}
@@ -207,10 +233,45 @@ fun BridgeScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(RelayRefresh.Background)
.relayGridTexture(alpha = 0.12f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
RelayModeStrip(
selected = RelayPrimaryMode.Bridge,
onModeSelected = { mode ->
when (mode) {
RelayPrimaryMode.Chat -> onNavigateToChat()
RelayPrimaryMode.Manage -> onNavigateToManage()
RelayPrimaryMode.Bridge -> Unit
}
},
)
if (returnTitle != null && onReturn != null) {
RelayReturnStrip(
icon = Icons.AutoMirrored.Filled.ArrowBack,
title = returnTitle,
subtitle = returnSubtitle,
label = returnLabel,
onClick = onReturn,
)
}
RelayHeroPanel(
title = if (relayReady) "Phone bridge is paired" else "Bridge controls are staged",
subtitle = if (relayReady) {
"Terminal, voice, notification, media, and advanced phone controls share this grant."
} else {
"Pair Relay to receive bridge commands. You can still configure permissions and safety before pairing."
},
action = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RelayStatusPill("relay", relayReady)
RelayStatusPill("safety", true)
}
},
)
// Relay-not-connected banner. Bridge commands arrive over the
// relay's WSS — when relay is Unpaired / Disconnected / URL
// blank, the AccessibilityService + foreground service will
@@ -251,7 +312,7 @@ fun BridgeScreen(
)
Text(
text = "Bridge commands travel over the relay. " +
"Pair a relay in Settings → Connection for " +
"Pair a relay in Settings → Connections for " +
"the bridge to actually do anything.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
@@ -30,17 +30,23 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
@@ -128,6 +134,9 @@ import com.hermesandroid.relay.ui.components.CompactToolCall
import com.hermesandroid.relay.ui.components.InlineAutocomplete
import com.hermesandroid.relay.ui.components.MessageBubble
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
import com.hermesandroid.relay.ui.components.RelayModeStrip
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
import com.hermesandroid.relay.ui.components.SphereState
import com.hermesandroid.relay.ui.components.SessionDrawerContent
import com.hermesandroid.relay.ui.components.SlashCommand
@@ -136,6 +145,8 @@ import com.hermesandroid.relay.ui.components.ToolProgressCard
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayGridTexture
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.VoiceViewModel
@@ -187,6 +198,12 @@ fun ChatScreen(
// screen. Default no-op preserves existing test/preview call sites that
// don't wire navigation.
onNavigateToConnections: () -> Unit = {},
onNavigateToConnect: () -> Unit = onNavigateToConnections,
onNavigateToManage: () -> Unit = {},
onNavigateToBridge: () -> Unit = {},
onNavigateToTerminal: () -> Unit = {},
onNavigateToSettings: () -> Unit = {},
onNavigateToProfileInspector: (String) -> Unit = {},
) {
val voiceUiState by voiceViewModel.uiState.collectAsState()
var voiceCompactMode by remember { mutableStateOf(false) }
@@ -249,11 +266,13 @@ fun ChatScreen(
var voiceOutputConfig by remember { mutableStateOf<VoiceOutputConfig?>(null) }
var realtimeAgentConfig by remember { mutableStateOf<RealtimeVoiceConfig?>(null) }
val chatReady by connectionViewModel.chatReady.collectAsState()
// Voice mode's /voice/transcribe and /voice/synthesize calls both go
// over the relay, but voice can authenticate with the saved Hermes API
// key or a paired Relay session. Gate the Mic button on voiceReady
// so chat+voice-only setups don't need the full pairing flow.
// Stable voice can use the standard Hermes dashboard audio routes or the
// optional Relay voice routes. Gate the mic on either route being usable;
// availability picks the actionable toast when neither is.
val voiceReady by connectionViewModel.voiceReady.collectAsState()
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val standardVoiceSignInRouteHint by
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val chatMode by connectionViewModel.chatMode.collectAsState()
val error by chatViewModel.error.collectAsState()
@@ -835,7 +854,8 @@ fun ChatScreen(
Column(
modifier = Modifier
.fillMaxSize()
.radialNavyBackground(isDarkTheme = isDarkTheme)
.background(RelayRefresh.Background)
.relayGridTexture(alpha = 0.14f)
.imePadding()
.alpha(chatAlpha)
) {
@@ -992,7 +1012,7 @@ fun ChatScreen(
activeEndpoint?.let { ep ->
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
color = RelayRefresh.Navy3.copy(alpha = 0.78f),
modifier = Modifier
.padding(end = 4.dp)
.clickable { onNavigateToConnections() },
@@ -1000,7 +1020,7 @@ fun ChatScreen(
Text(
text = ep.displayLabel(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
color = RelayRefresh.Relay,
modifier = Modifier.padding(
horizontal = 8.dp,
vertical = 4.dp,
@@ -1008,25 +1028,45 @@ fun ChatScreen(
)
}
}
// Ambient mode toggle (show/hide sphere visualization).
// Profile + personality pickers moved into the agent sheet
// that opens on title tap — the top bar no longer owns
// those chips, reclaiming the horizontal space for a
// cleaner title block.
if (animationEnabled) {
IconButton(onClick = { ambientMode = !ambientMode }) {
Icon(
imageVector = if (ambientMode) Icons.Filled.ChatBubble else Icons.Filled.AutoAwesome,
contentDescription = if (ambientMode) "Show chat" else "Ambient mode",
tint = if (ambientMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (messages.isNotEmpty()) {
RelayChromeIconButton(
icon = Icons.Filled.Share,
contentDescription = "Share conversation",
onClick = { shareConversation(context, messages) },
modifier = Modifier.padding(end = 4.dp),
)
}
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = "Terminal",
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
RelayChromeIconButton(
icon = Icons.Filled.Tune,
contentDescription = "Settings",
onClick = onNavigateToSettings,
modifier = Modifier.padding(end = 4.dp),
)
// Ambient mode (fullscreen sphere) has no top-bar toggle —
// it's a quiet gesture: long-press the conversation
// background to enter, tap anywhere to return. A hint pill
// on entry teaches the way back.
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
)
)
RelayModeStrip(
selected = RelayPrimaryMode.Chat,
onModeSelected = { mode ->
when (mode) {
RelayPrimaryMode.Chat -> Unit
RelayPrimaryMode.Manage -> onNavigateToManage()
RelayPrimaryMode.Bridge -> onNavigateToBridge()
}
},
)
// Error banner with retry
AnimatedVisibility(visible = error != null) {
@@ -1073,12 +1113,25 @@ fun ChatScreen(
}
}
// Ambient mode: fullscreen sphere visualization
// Ambient mode: fullscreen sphere visualization. Tap (or
// long-press) anywhere to return; a transient hint pill teaches
// the exit on every entry.
if (ambientMode && animationEnabled) {
var showAmbientHint by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(2_800)
showAmbientHint = false
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onTap = { ambientMode = false },
onLongPress = { ambientMode = false },
)
},
contentAlignment = Alignment.Center
) {
MorphingSphere(
@@ -1087,6 +1140,24 @@ fun ChatScreen(
intensity = streamingIntensity,
toolCallBurst = toolCallBurst
)
androidx.compose.animation.AnimatedVisibility(
visible = showAmbientHint,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 18.dp),
enter = fadeIn(),
exit = fadeOut(),
) {
Text(
text = "tap to return to chat",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.78f))
.padding(horizontal = 12.dp, vertical = 5.dp),
)
}
}
}
// Message list or empty state
@@ -1129,42 +1200,61 @@ fun ChatScreen(
}
Text(
text = "Start a conversation",
text = if (chatReady) "Start a conversation" else "Connect to Hermes",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
if (!chatReady) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Configure API server in Settings",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(20.dp))
// Suggestion chips
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
suggestions.forEach { suggestion ->
AssistChip(
onClick = { inputText = suggestion },
label = {
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall
)
},
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
labelColor = MaterialTheme.colorScheme.onSurfaceVariant
Spacer(modifier = Modifier.height(12.dp))
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.86f),
),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Chat needs a Standard Hermes API connection.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
)
Button(
onClick = onNavigateToConnect,
modifier = Modifier.fillMaxWidth(),
) {
Text("Connect Standard Hermes")
}
}
}
} else {
Spacer(modifier = Modifier.height(20.dp))
// Suggestion chips
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
suggestions.forEach { suggestion ->
AssistChip(
onClick = { inputText = suggestion },
label = {
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall
)
},
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
labelColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
}
}
@@ -1176,6 +1266,17 @@ fun ChatScreen(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
// Quiet entry to ambient mode: long-press the
// conversation background. Bubbles keep their own
// long-press (copy) — they consume the gesture first,
// so only presses on empty space land here.
.pointerInput(animationEnabled) {
detectTapGestures(
onLongPress = {
if (animationEnabled) ambientMode = true
},
)
}
) {
// Ambient sphere behind messages
if (animationEnabled && animationBehindChat && !ambientMode) {
@@ -1247,6 +1348,18 @@ fun ChatScreen(
chatViewModel.dispatchCardAction(msgId, cardKey, action)
}
},
onQuoteMessage = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val quoted = text.take(600)
.trim()
.lines()
.joinToString("\n") { line -> "> $line" }
inputText = if (inputText.isBlank()) {
"$quoted\n\n"
} else {
"$inputText\n$quoted\n\n"
}
},
onCopyMessage = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
// The new Clipboard API is suspend-based, so the
@@ -1350,6 +1463,7 @@ fun ChatScreen(
inputText = if (cmd.command.contains(" ")) cmd.command + " " else "$base "
},
modifier = Modifier.padding(horizontal = 16.dp)
)
}
@@ -1556,10 +1670,6 @@ fun ChatScreen(
)
}
} else {
// Gate on voiceReady — voice mode needs a relay
// route, but the app can derive it from the API URL
// and authenticate with the saved Hermes API key or
// a paired Relay session.
IconButton(
onClick = {
if (voiceReady) {
@@ -1567,7 +1677,16 @@ fun ChatScreen(
} else {
android.widget.Toast.makeText(
context,
"Voice needs API key or pairing, plus a reachable relay route",
when (standardVoiceAvailability) {
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
standardVoiceSignInRouteHint?.let { route ->
"Voice needs a one-time sign-in on the $route route — open Manage"
} ?: "Voice needs dashboard sign-in — open Manage to sign in"
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Unsupported ->
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
else ->
"Voice needs a reachable Hermes dashboard or Relay voice route"
},
android.widget.Toast.LENGTH_SHORT,
).show()
}
@@ -1734,6 +1853,7 @@ fun ChatScreen(
chatViewModel = chatViewModel,
onDismiss = { showAgentInfo = false },
onNavigateToConnections = onNavigateToConnections,
onNavigateToProfileInspector = onNavigateToProfileInspector,
)
}
}
@@ -1789,3 +1909,37 @@ private fun DateSeparator(timestamp: Long) {
}
}
}
/**
* Share the visible conversation as Markdown via the system share sheet.
* Role names are matched as strings so this helper stays decoupled from the
* MessageRole enum's package.
*/
private fun shareConversation(
context: android.content.Context,
messages: List<com.hermesandroid.relay.data.ChatMessage>,
) {
val body = buildString {
appendLine("# Hermes conversation")
appendLine()
messages.forEach { message ->
if (message.content.isBlank()) return@forEach
val speaker = when {
message.role.name.equals("user", ignoreCase = true) -> "**You:**"
message.role.name.equals("assistant", ignoreCase = true) -> "**Hermes:**"
else -> "**System:**"
}
appendLine(speaker)
appendLine(message.content.trim())
appendLine()
}
}
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(android.content.Intent.EXTRA_TEXT, body)
putExtra(android.content.Intent.EXTRA_SUBJECT, "Hermes conversation")
}
context.startActivity(
android.content.Intent.createChooser(intent, "Share conversation"),
)
}
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -21,6 +22,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -53,12 +55,16 @@ import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
import com.hermesandroid.relay.ui.components.ActiveCardSecurityPosture
import com.hermesandroid.relay.ui.components.ActiveCardStatusSection
import com.hermesandroid.relay.ui.components.ActiveCardRelayStatusSection
import com.hermesandroid.relay.ui.components.ActiveCardStandardStatusSection
import com.hermesandroid.relay.ui.components.ApiServerInfoSheet
import com.hermesandroid.relay.ui.components.EndpointsCard
import com.hermesandroid.relay.ui.components.RouteEditorDialog
import com.hermesandroid.relay.ui.components.InsecureConnectionAckDialog
import com.hermesandroid.relay.ui.components.RelayInfoSheet
import com.hermesandroid.relay.ui.components.SessionInfoSheet
import com.hermesandroid.relay.network.RelayUrlDeriver
import com.hermesandroid.relay.network.RouteProbeOutcome
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.statusText
@@ -108,6 +114,7 @@ fun ConnectionsSettingsScreen(
onRemoveConnection: (id: String) -> Unit,
onAddConnection: () -> Unit,
onBack: () -> Unit,
onNavigateToManage: () -> Unit,
// Opens `PairedDevicesScreen` for the server-side session list. Wired
// via the "Relay sessions" row inside the active card's security
// posture strip. Must not be null — the row is always rendered.
@@ -126,6 +133,12 @@ fun ConnectionsSettingsScreen(
// (HTTP-only) is unaffected.
val relayEnabled by FeatureFlags.relayEnabled(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
val activeRelayConfigured: Boolean = if (connectionViewModel != null) {
val configured by connectionViewModel.relayConfigured.collectAsState()
configured
} else {
false
}
// Kick a WSS reconnect on screen entry in case the user landed here
// from a Stale chip. Moved here from the deleted singular
@@ -190,7 +203,7 @@ fun ConnectionsSettingsScreen(
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "Tap Add connection to pair with a Hermes server.",
text = "Tap Add connection to connect to Standard Hermes.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -219,6 +232,11 @@ fun ConnectionsSettingsScreen(
// + action row) and don't collect any VM flows.
activeConnectionViewModel = if (isActive) connectionViewModel else null,
relayEnabled = relayEnabled,
relayConfigured = if (isActive) {
activeRelayConfigured
} else {
connection.hasConfiguredRelay()
},
isDarkTheme = isDarkTheme,
onReconnect = onReconnectActive,
onRename = { newLabel -> onRenameConnection(connection.id, newLabel) },
@@ -226,6 +244,7 @@ fun ConnectionsSettingsScreen(
onRevoke = { onRevokeConnection(connection.id) },
onRemove = { onRemoveConnection(connection.id) },
onOpenApiInfo = { showApiInfoSheet = true },
onOpenDashboard = onNavigateToManage,
onOpenRelayInfo = { showRelayInfoSheet = true },
onOpenSessionInfo = { showSessionInfoSheet = true },
onInsecureAckRequested = { showInsecureAckDialog = true },
@@ -297,6 +316,7 @@ private fun ConnectionCard(
liveState: RelayUiState?,
activeConnectionViewModel: ConnectionViewModel?,
relayEnabled: Boolean,
relayConfigured: Boolean,
isDarkTheme: Boolean,
onReconnect: () -> Unit,
onRename: (String) -> Unit,
@@ -304,6 +324,7 @@ private fun ConnectionCard(
onRevoke: () -> Unit,
onRemove: () -> Unit,
onOpenApiInfo: () -> Unit,
onOpenDashboard: () -> Unit,
onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit,
onInsecureAckRequested: () -> Unit,
@@ -315,8 +336,11 @@ private fun ConnectionCard(
var showRemoveConfirm by remember { mutableStateOf(false) }
var endpointsExpanded by remember { mutableStateOf(false) }
// Active card: muted indigo wash instead of the full-strength Electric
// primaryContainer — a card-sized fill of the brand blue overwhelmed the
// body text (2026-06-10 feedback); small accents keep the vivid blue.
val containerColor = if (isActive) {
MaterialTheme.colorScheme.primaryContainer
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.42f)
} else {
MaterialTheme.colorScheme.surfaceVariant
}
@@ -344,6 +368,20 @@ private fun ConnectionCard(
} else {
false
}
val routeProbeStatus: ConnectionViewModel.RouteProbeStatus =
if (activeConnectionViewModel != null) {
val status by activeConnectionViewModel.routeProbeStatus.collectAsState()
status
} else {
ConnectionViewModel.RouteProbeStatus.Idle
}
val routeProbeOutcomes: Map<String, RouteProbeOutcome> =
if (activeConnectionViewModel != null) {
val outcomes by activeConnectionViewModel.routeProbeOutcomes.collectAsState()
outcomes
} else {
emptyMap()
}
Card(
modifier = Modifier.fillMaxWidth(),
@@ -378,9 +416,13 @@ private fun ConnectionCard(
// ── Subtitle: hostname + status + endpoints roles ──────────
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val hasStandardApi = connection.apiServerUrl.isNotBlank()
val pairedStatus = when {
liveState != null -> liveState.statusText(connectedLabel = "Connected")
liveState != null &&
(connection.pairedAt != null || liveState != RelayUiState.NotConfigured) ->
liveState.statusText(connectedLabel = "Connected")
connection.pairedAt != null -> formatPairedRelative(connection.pairedAt)
hasStandardApi -> "Standard · Relay not paired"
else -> "Not paired"
}
// ADR 24 — active-only endpoint role summary.
@@ -406,8 +448,16 @@ private fun ConnectionCard(
overflow = TextOverflow.Ellipsis,
)
ConnectionSurfaceSummary(
connection = connection,
isActive = isActive,
liveState = liveState,
activeConnectionViewModel = activeConnectionViewModel,
relayConfigured = relayConfigured,
)
// ── Single-endpoint nudge (active only) ──────────────────────
if (isActive && endpoints.size == 1) {
if (isActive && connection.pairedAt != null && endpoints.size == 1) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp),
@@ -449,8 +499,12 @@ private fun ConnectionCard(
TextButton(onClick = onReconnect) { Text("Reconnect") }
}
TextButton(onClick = { showRenameDialog = true }) { Text("Rename") }
TextButton(onClick = onRepair) { Text("Re-pair") }
TextButton(onClick = { showRevokeConfirm = true }) { Text("Revoke") }
TextButton(onClick = onRepair) {
Text(if (connection.pairedAt == null) "Pair Relay" else "Re-pair")
}
if (connection.pairedAt != null) {
TextButton(onClick = { showRevokeConfirm = true }) { Text("Revoke") }
}
TextButton(onClick = { showRemoveConfirm = true }) {
Text(text = "Remove", color = MaterialTheme.colorScheme.error)
}
@@ -463,21 +517,29 @@ private fun ConnectionCard(
if (isActive && activeConnectionViewModel != null) {
HorizontalDivider()
// ── Connection health section ────────────────────────────
SectionHeader(text = "Connection health")
SectionCaption(text = "Tap any row for details.")
// ── Standard section ─────────────────────────────────────
SectionHeader(text = "Standard")
SectionCaption(text = "API and dashboard setup for Chat and Manage.")
// Status section (3 tappable rows → info sheets). Always
// visible on the active card — the "health dashboard"
// replacing the old Settings-top quick-look card.
ActiveCardStatusSection(
ActiveCardStandardStatusSection(
connectionViewModel = activeConnectionViewModel,
relayEnabled = relayEnabled,
onOpenApiInfo = onOpenApiInfo,
onOpenRelayInfo = onOpenRelayInfo,
onOpenSessionInfo = onOpenSessionInfo,
onOpenDashboard = onOpenDashboard,
)
if (relayEnabled) {
HorizontalDivider()
SectionHeader(text = "Relay")
SectionCaption(
text = "Optional power tools: Terminal, Bridge, relay sessions, and grants.",
)
ActiveCardRelayStatusSection(
connectionViewModel = activeConnectionViewModel,
onOpenRelayInfo = onOpenRelayInfo,
onOpenSessionInfo = onOpenSessionInfo,
)
}
// ── Routes section (conditional on having endpoints) ─────
// ADR 24 behavior preserved verbatim from pre-refactor;
// user-facing copy now reads "Routes" instead of
@@ -493,6 +555,10 @@ private fun ConnectionCard(
var preferredRole by remember(connection.id) {
mutableStateOf(activeConnectionViewModel.getPreferredEndpointRole())
}
var routeEditorOpen by remember(connection.id) { mutableStateOf(false) }
var routeEditorOriginal by remember(connection.id) {
mutableStateOf<EndpointCandidate?>(null)
}
val hasTailscaleRoute = endpoints.any {
it.role.equals("tailscale", ignoreCase = true)
}
@@ -507,16 +573,50 @@ private fun ConnectionCard(
val tailscaleLaunchIntent = remember(context) {
context.packageManager.getLaunchIntentForPackage("com.tailscale.ipn")
}
val activeRouteLabel = activeEndpoint?.displayLabel() ?: "Resolving"
val activeRouteHost = activeEndpoint?.let {
"${it.api.host}:${it.api.port}"
} ?: connection.relayUrl
val isRouteProbing =
routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Probing
// Last user-triggered probe finished with NO winner: say
// so explicitly. The old UI sat on "Resolving" forever
// and showed the (internal) relay URL underneath, which
// read as "stuck on the internal route".
val probeCameUpEmpty = activeEndpoint == null &&
routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Done &&
routeProbeStatus.winner == null
val activeRouteLabel = when {
activeEndpoint != null -> activeEndpoint.displayLabel()
isRouteProbing -> "Checking routes…"
probeCameUpEmpty -> "No route reachable"
else -> "Resolving"
}
// Full URL (scheme included) — http vs https is the
// difference between a working route and a TLS-failing
// one, so never hide it. With no resolved route, show the
// saved API URL the app is actually falling back to.
val activeRouteHost = activeEndpoint?.api?.url
?: "Using saved URL: ${
connection.apiServerUrl.ifBlank { connection.relayUrl }
}"
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = "Current: $activeRouteLabel",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Current: $activeRouteLabel",
style = MaterialTheme.typography.bodyMedium,
color = if (probeCameUpEmpty) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
},
)
if (isRouteProbing) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
}
}
Text(
text = activeRouteHost,
style = MaterialTheme.typography.bodySmall,
@@ -524,6 +624,15 @@ private fun ConnectionCard(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (probeCameUpEmpty) {
Text(
text = "None of the saved routes answered a health " +
"probe. Expand the routes below for per-route " +
"reasons.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
if (showTailscaleUnavailableHint) {
Surface(
@@ -566,23 +675,69 @@ private fun ConnectionCard(
}
TextButton(
onClick = { activeConnectionViewModel.probeNow() },
enabled = !isRouteProbing,
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
horizontal = 0.dp,
),
) {
Text("Re-check")
Text(if (isRouteProbing) "Checking…" else "Re-check")
}
}
}
}
}
if (isTailscaleDetected && !hasTailscaleRoute) {
// Inverse of the hint above: the phone is on Tailscale
// but this connection has nothing to roam to. This is
// the strongest signal a user wants remote access and
// simply never configured it — offer the route editor
// directly instead of hoping they find Show routes.
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "Phone is on Tailscale — no Tailscale route yet",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text = "Add your server's Tailscale URL so Hermes " +
"keeps working when this phone leaves the " +
"server's network.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
TextButton(
onClick = {
routeEditorOriginal = null
routeEditorOpen = true
},
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
horizontal = 0.dp,
),
) {
Text("Add Tailscale route")
}
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = { activeConnectionViewModel.probeNow() }) {
Text("Re-check")
TextButton(
onClick = { activeConnectionViewModel.probeNow() },
enabled = !isRouteProbing,
) {
Text(if (isRouteProbing) "Checking…" else "Re-check")
}
if (preferredRole != null) {
TextButton(
@@ -621,6 +776,10 @@ private fun ConnectionCard(
EndpointsCard(
endpoints = endpoints,
activeEndpoint = activeEndpoint,
isProbing = isRouteProbing,
outcomeFor = { candidate ->
routeProbeOutcomes[activeConnectionViewModel.routeOutcomeKey(candidate)]
},
preferredRole = preferredRole,
onPreferEndpoint = { candidate ->
activeConnectionViewModel.setPreferredEndpointRole(candidate.role)
@@ -634,6 +793,34 @@ private fun ConnectionCard(
onViewPin = { candidate ->
activeConnectionViewModel.lookupEndpointPin(candidate)
},
onAddRoute = {
routeEditorOriginal = null
routeEditorOpen = true
},
onEditRoute = { candidate ->
routeEditorOriginal = candidate
routeEditorOpen = true
},
onRemoveRoute = { candidate ->
activeConnectionViewModel.removeExtraRoute(candidate)
},
)
}
// Rendered outside the routes expander so the "Add
// Tailscale route" nudge above can open it while the
// routes list is collapsed.
if (routeEditorOpen) {
RouteEditorDialog(
original = routeEditorOriginal,
onSave = { role, apiUrl, onResult ->
activeConnectionViewModel.saveExtraRoute(
role = role,
apiUrl = apiUrl,
original = routeEditorOriginal,
onResult = onResult,
)
},
onDismiss = { routeEditorOpen = false },
)
}
}
@@ -643,11 +830,11 @@ private fun ConnectionCard(
// ── Advanced section ─────────────────────────────────────
// Header + caption above the collapsed Advanced card so
// users understand this branch is a power-user surface,
// not something they're expected to touch after QR pairing.
// not something they're expected to touch after Standard setup.
SectionHeader(text = "Advanced")
SectionCaption(
text = "Manual setup — most people don't need this " +
"after QR pairing.",
"after Standard Hermes setup.",
)
// Advanced expander: manual URL config + insecure toggle
@@ -739,6 +926,158 @@ private fun ConnectionCard(
}
}
@Composable
private fun ConnectionSurfaceSummary(
connection: Connection,
isActive: Boolean,
liveState: RelayUiState?,
activeConnectionViewModel: ConnectionViewModel?,
relayConfigured: Boolean,
) {
val activeApiReachable: Boolean? = if (activeConnectionViewModel != null) {
val reachable by activeConnectionViewModel.apiServerReachable.collectAsState()
reachable
} else {
null
}
val activeApiHealth: ConnectionViewModel.HealthStatus? = if (activeConnectionViewModel != null) {
val health by activeConnectionViewModel.apiServerHealth.collectAsState()
health
} else {
null
}
val activeConnection: Connection? = if (activeConnectionViewModel != null) {
val current by activeConnectionViewModel.activeConnection.collectAsState()
current
} else {
null
}
val dashboardStatus = (activeConnection ?: connection).dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val apiText = when {
connection.apiServerUrl.isBlank() -> "Missing"
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking"
activeApiReachable == true -> "Ready"
isActive && activeApiReachable == false -> "Offline"
else -> "Configured"
}
val apiTone = when (apiText) {
"Ready" -> SummaryTone.Good
"Offline", "Missing" -> SummaryTone.Warning
else -> SummaryTone.Neutral
}
val dashboardText = when {
connection.resolvedDashboardUrl.isBlank() -> "Missing"
dashboardStatus == null -> "Unchecked"
!dashboardStatus.reachable -> "Offline"
dashboardSignInRequired -> "Sign in"
dashboardStatus.authenticated == true -> "Signed in"
else -> "Available"
}
val dashboardTone = when (dashboardText) {
"Signed in", "Available" -> SummaryTone.Good
"Sign in" -> SummaryTone.Info
"Offline", "Missing" -> SummaryTone.Warning
else -> SummaryTone.Neutral
}
val relayText = when {
!relayConfigured -> "Optional"
liveState != null -> liveState.statusText(connectedLabel = "Ready")
connection.pairedAt != null -> "Paired"
connection.relayUrl.isNotBlank() -> "Configured"
else -> "Configure"
}
val relayTone = when {
!relayConfigured -> SummaryTone.Neutral
liveState == RelayUiState.Connected -> SummaryTone.Good
liveState == RelayUiState.Stale || liveState == RelayUiState.Disconnected -> SummaryTone.Warning
else -> SummaryTone.Info
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ConnectionSurfacePill(
label = "API",
value = apiText,
tone = apiTone,
modifier = Modifier.weight(1f),
)
ConnectionSurfacePill(
label = "Dashboard",
value = dashboardText,
tone = dashboardTone,
modifier = Modifier.weight(1f),
)
ConnectionSurfacePill(
label = "Relay",
value = relayText,
tone = relayTone,
modifier = Modifier.weight(1f),
)
}
}
private enum class SummaryTone { Neutral, Good, Info, Warning }
@Composable
private fun ConnectionSurfacePill(
label: String,
value: String,
tone: SummaryTone,
modifier: Modifier = Modifier,
) {
val container = when (tone) {
SummaryTone.Good -> MaterialTheme.colorScheme.primaryContainer
SummaryTone.Info -> MaterialTheme.colorScheme.tertiaryContainer
SummaryTone.Warning -> MaterialTheme.colorScheme.errorContainer
SummaryTone.Neutral -> MaterialTheme.colorScheme.surface
}
val content = when (tone) {
SummaryTone.Good -> MaterialTheme.colorScheme.onPrimaryContainer
SummaryTone.Info -> MaterialTheme.colorScheme.onTertiaryContainer
SummaryTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
modifier = modifier,
color = container,
shape = RoundedCornerShape(8.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = content,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
color = content,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private fun Connection.hasConfiguredRelay(): Boolean {
val trimmedRelayUrl = relayUrl.trim()
return pairedAt != null ||
trimmedRelayUrl.isNotBlank() &&
!RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl)
}
@Composable
private fun RenameConnectionDialog(
initialLabel: String,
@@ -73,6 +73,8 @@ fun DeveloperSettingsScreen(
// Data management local state — unfolded from the private
// DataManagementSection helper in the old SettingsScreen.
var showResetDialog by remember { mutableStateOf(false) }
var showExportDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
var backupJson by remember { mutableStateOf<String?>(null) }
// SAF file picker for export
@@ -196,17 +198,12 @@ fun DeveloperSettingsScreen(
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Save settings to a file (no tokens or API keys)",
text = "Full backup with API keys, tokens, and dashboard cookies",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = {
connectionViewModel.exportSettings { json ->
backupJson = json
exportLauncher.launch("hermes-relay-backup.json")
}
}) {
IconButton(onClick = { showExportDialog = true }) {
Icon(
imageVector = Icons.Filled.FileDownload,
contentDescription = "Export settings"
@@ -226,14 +223,12 @@ fun DeveloperSettingsScreen(
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Restore settings from a backup file",
text = "Restore full backup and replace saved connections",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = {
importLauncher.launch(arrayOf("application/json"))
}) {
IconButton(onClick = { showImportDialog = true }) {
Icon(
imageVector = Icons.Filled.FileUpload,
contentDescription = "Import settings"
@@ -400,12 +395,73 @@ fun DeveloperSettingsScreen(
}
}
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
title = { Text("Export sensitive backup?") },
text = {
Text(
"This backup includes saved connections, API keys, relay session tokens, device IDs, and dashboard cookies. Anyone with the file may be able to access your Hermes server."
)
},
confirmButton = {
TextButton(
onClick = {
showExportDialog = false
connectionViewModel.exportSettings { json ->
backupJson = json
exportLauncher.launch("hermes-relay-sensitive-backup.json")
}
}
) {
Text("Export")
}
},
dismissButton = {
TextButton(onClick = { showExportDialog = false }) {
Text("Cancel")
}
}
)
}
if (showImportDialog) {
AlertDialog(
onDismissRequest = { showImportDialog = false },
title = { Text("Import backup?") },
text = {
Text(
"Importing a backup can restore API keys, relay tokens, device IDs, and dashboard cookies. It replaces the saved connection list on this device."
)
},
confirmButton = {
TextButton(
onClick = {
showImportDialog = false
importLauncher.launch(arrayOf("application/json"))
}
) {
Text("Choose file")
}
},
dismissButton = {
TextButton(onClick = { showImportDialog = false }) {
Text("Cancel")
}
}
)
}
// Confirmation dialog for data reset
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = { Text("Reset All Data?") },
text = { Text("This will clear all settings, API keys, authentication tokens, and cached data. You'll need to reconfigure your API server and re-pair with your relay. This cannot be undone.") },
title = { Text("Reset all app data?") },
text = {
Text(
"This clears saved connections, API keys, Relay tokens, dashboard cookies, device IDs, settings, and cached data. Use dashboard sign out or Relay pairing controls when you only need to clear one connection path. This cannot be undone."
)
},
confirmButton = {
TextButton(
onClick = {
@@ -122,10 +122,17 @@ fun MediaSettingsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Controls how the app handles files sent by tool results (screenshots, PDFs, etc.) over the relay.",
text = "Controls how the app handles files sent by tool results " +
"(screenshots, PDFs, etc.) over the relay.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Relay only — these settings don't affect images you attach " +
"in chat or anything on a standard (no-Relay) connection.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Max inbound attachment size — 5..100 MB in 5 MB steps
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -21,19 +21,17 @@ import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
/**
* Full-screen pairing route. Wraps [ConnectionWizard] in a real Scaffold so
* Full-screen connection route. Wraps [ConnectionWizard] in a real Scaffold so
* the chooser tiles, manual-entry forms, and camera viewport all get the
* actual window — not a Compose Dialog that leaked the Settings cards
* underneath. Reached via Settings → Connection → Pair (or any "Re-pair"
* underneath. Reached via Settings → Connections → Add/Pair Relay (or any "Re-pair"
* button), and pops back to wherever it came from on complete or cancel.
*
* [autoStart] lets the caller deep-link into a specific pair method. When
* set to `"scan"`, the wizard jumps straight to camera-permission-request
* → scanner on first composition. Null (default) shows the full Method
* chooser so users can pick Scan / Enter code / Show code. The "Add
* connection" FAB sets this to `"scan"` because there's exactly one
* obvious next step after "I want a new connection"; re-pair flows
* intentionally leave it null.
* chooser so users can pick Standard API/dashboard setup or a Relay pairing
* method.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -41,6 +39,7 @@ fun PairScreen(
connectionViewModel: ConnectionViewModel,
onComplete: () -> Unit,
onCancel: () -> Unit,
onManageSignIn: (() -> Unit)? = null,
autoStart: String? = null,
) {
val context = LocalContext.current
@@ -56,7 +55,7 @@ fun PairScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Pair with your server") },
title = { Text("Connect to Hermes") },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(
@@ -77,10 +76,11 @@ fun PairScreen(
ConnectionWizard(
connectionViewModel = connectionViewModel,
onComplete = {
Toast.makeText(context, "Paired successfully", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "Connection updated", Toast.LENGTH_SHORT).show()
onComplete()
},
onCancel = onCancel,
onManageSignIn = onManageSignIn,
showSkip = false,
autoStart = autoStart,
)
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.Security
// === END PHASE3-safety-rails ===
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -109,7 +110,10 @@ fun SettingsScreen(
// + manual URL + insecure toggle + manual pairing code surface via
// expandable sections, so there's nothing left to link to twice.
onNavigateToConnections: () -> Unit,
onNavigateToManage: () -> Unit,
onNavigateToChatSettings: () -> Unit,
onNavigateToTerminal: () -> Unit,
onNavigateToBridge: () -> Unit,
onNavigateToMediaSettings: () -> Unit,
onNavigateToAppearanceSettings: () -> Unit,
onNavigateToAnalytics: () -> Unit,
@@ -255,6 +259,14 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Link,
title = "Hermes management",
subtitle = "Skills, cron, MCP, profiles, models, config",
onClick = onNavigateToManage,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.AutoMirrored.Filled.Chat,
title = "Chat",
@@ -281,18 +293,6 @@ fun SettingsScreen(
)
// === END PHASE3-notif-listener-followup ===
if (BuildFlavor.isSideload) {
// === PHASE3-safety-rails: bridge safety entry-point ===
SettingsCategoryRow(
icon = Icons.Filled.Security,
title = "Bridge safety",
subtitle = "Blocklist, destructive-verb confirmation, auto-disable",
onClick = onNavigateToBridgeSafety,
isDarkTheme = isDarkTheme,
)
// === END PHASE3-safety-rails ===
}
SettingsCategoryRow(
icon = Icons.Filled.Image,
title = "Media",
@@ -309,6 +309,24 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
SettingsSectionHeader("Power tools")
SettingsCategoryRow(
icon = Icons.Filled.Code,
title = "Terminal",
subtitle = "Server shell access through a paired relay session",
onClick = onNavigateToTerminal,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.PhoneAndroid,
title = "Bridge",
subtitle = "Relay-granted phone bridge controls",
onClick = onNavigateToBridge,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Devices,
title = "Relay sessions",
@@ -317,6 +335,18 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
if (BuildFlavor.isSideload) {
// === PHASE3-safety-rails: bridge safety entry-point ===
SettingsCategoryRow(
icon = Icons.Filled.Security,
title = "Bridge safety",
subtitle = "Blocklist, destructive-verb confirmation, auto-disable",
onClick = onNavigateToBridgeSafety,
isDarkTheme = isDarkTheme,
)
// === END PHASE3-safety-rails ===
}
SettingsCategoryRow(
icon = Icons.Filled.Analytics,
title = "Analytics",
@@ -366,6 +396,7 @@ fun SettingsScreen(
chatViewModel = chatViewModel,
onDismiss = { showAgentSheet = false },
onNavigateToConnections = onNavigateToConnections,
onNavigateToProfileInspector = onNavigateToProfileInspector,
)
}
@@ -503,6 +534,18 @@ private fun ActiveAgentCard(
}
}
@Composable
private fun SettingsSectionHeader(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 2.dp),
)
}
/**
* One row in the root Settings category list. Matches the visual style of
* the existing Voice navigation row that was previously inline in the
@@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.hermesandroid.relay.data.BargeInSensitivity
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.data.VoiceEngineMode
import com.hermesandroid.relay.data.VoicePreferencesRepository
import com.hermesandroid.relay.data.VoiceSettings
@@ -75,6 +76,7 @@ import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.util.classifyError
import com.hermesandroid.relay.viewmodel.InteractionMode
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
import com.hermesandroid.relay.viewmodel.VoiceSettingsViewModel
import com.hermesandroid.relay.viewmodel.VoiceViewModel
import kotlinx.coroutines.launch
@@ -97,6 +99,15 @@ fun VoiceSettingsScreen(
voiceViewModel: VoiceViewModel,
voiceClient: RelayVoiceClient?,
selectedProfile: Profile? = null,
standardVoiceAvailability: StandardVoiceAvailability = StandardVoiceAvailability.Unknown,
/**
* Non-null endpoint display label (e.g. "Tailscale") when the sign-in
* gate is up because the resolver moved the dashboard to a route the
* user hasn't signed in on yet — dashboard cookies are per-host.
*/
standardVoiceSignInRouteHint: String? = null,
relayVoiceReady: Boolean = false,
onOpenManage: (() -> Unit)? = null,
onBack: () -> Unit,
settingsViewModel: VoiceSettingsViewModel = viewModel(),
) {
@@ -106,6 +117,7 @@ fun VoiceSettingsScreen(
val prefsRepo = remember { VoicePreferencesRepository(context) }
val voiceSettings by prefsRepo.settings.collectAsState(initial = VoiceSettings())
val currentEngine = VoiceEngineMode.fromStorage(voiceSettings.engineMode)
val currentAudioRoute = VoiceAudioRoute.fromStorage(voiceSettings.audioRoute)
val bargeInPrefs by settingsViewModel.bargeInPrefs.collectAsState()
val aecAvailable = settingsViewModel.aecAvailable
@@ -150,8 +162,21 @@ fun VoiceSettingsScreen(
// shown as snackbars here as well as the inline "unavailable" label below.
val snackbarHost = LocalSnackbarHost.current
LaunchedEffect(voiceClient, selectedProfile?.name) {
LaunchedEffect(voiceClient, selectedProfile?.name, relayVoiceReady) {
val client = voiceClient ?: return@LaunchedEffect
if (!relayVoiceReady) {
// Standard-only connection: there is no Relay voice surface to
// query. Fetching anyway would only manufacture error snackbars
// for a route the user isn't using — stay quiet and let the
// relay-backed sections render their "not configured" line.
voiceConfig = null
voiceConfigError = null
voiceOutputConfig = null
voiceOutputConfigError = null
realtimeConfig = null
realtimeConfigError = null
return@LaunchedEffect
}
val voiceResult = client.getVoiceConfig()
if (voiceResult.isSuccess) {
voiceConfig = voiceResult.getOrNull()
@@ -365,12 +390,13 @@ fun VoiceSettingsScreen(
listOf(
VoiceEngineMode.HermesVoiceOutput to Triple(
"Hermes Chat + Voice Output",
"Hermes handles chat, tools, memory, and renders speech through the relay.",
"Hermes handles chat, tools, and memory; speech runs over the standard " +
"Hermes dashboard or Relay — whichever the STT/TTS route below picks.",
false,
),
VoiceEngineMode.RealtimeAgent to Triple(
"Realtime Agent",
"Provider-native realtime speech with Hermes-brokered tools.",
"Provider-native realtime speech with Hermes-brokered tools. Requires a paired Relay.",
true,
),
).forEach { (engine, copy) ->
@@ -408,6 +434,150 @@ fun VoiceSettingsScreen(
}
}
}
if (currentEngine == VoiceEngineMode.RealtimeAgent && !relayVoiceReady) {
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.size(8.dp))
Text(
text = "No Relay is configured for this connection, so the Realtime " +
"Agent can't start. Pair Relay in Settings → Connections, or " +
"switch back to Hermes Chat + Voice Output.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
if (currentEngine == VoiceEngineMode.HermesVoiceOutput) {
SectionCard(title = "Stable STT/TTS Route") {
val standardStatus = when (standardVoiceAvailability) {
StandardVoiceAvailability.Ready -> "Ready"
StandardVoiceAvailability.SignInRequired -> "Dashboard sign-in required"
StandardVoiceAvailability.Unreachable -> "Dashboard unreachable"
StandardVoiceAvailability.Unsupported -> "Not available on this Hermes build"
StandardVoiceAvailability.Unknown -> "Checking..."
}
val standardOk = standardVoiceAvailability == StandardVoiceAvailability.Ready
val relayStatus = if (relayVoiceReady) "Ready" else "Relay not configured"
val autoStatus = when {
relayVoiceReady -> "Ready — using Relay"
standardOk -> "Ready — using standard Hermes"
else -> "No route available yet"
}
listOf(
RouteOption(
route = VoiceAudioRoute.Auto,
label = "Auto",
detail = "Relay when paired; otherwise the standard Hermes dashboard. Recommended.",
status = autoStatus,
statusOk = relayVoiceReady || standardOk,
),
RouteOption(
route = VoiceAudioRoute.Standard,
label = "Standard Hermes",
detail = "The dashboard audio path Hermes Desktop uses — works on a " +
"vanilla Hermes install, no Relay plugin required.",
status = standardStatus,
statusOk = standardOk,
),
RouteOption(
route = VoiceAudioRoute.Relay,
label = "Relay",
detail = "Relay plugin voice — profile-aware providers and streaming voice output.",
status = relayStatus,
statusOk = relayVoiceReady,
badge = "Optional",
),
).forEach { option ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = currentAudioRoute == option.route,
onClick = {
scope.launch { prefsRepo.setAudioRoute(option.route) }
},
)
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = currentAudioRoute == option.route,
onClick = null,
)
Spacer(Modifier.size(8.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(option.label, style = MaterialTheme.typography.bodyMedium)
option.badge?.let { ExperimentalBadge(it) }
}
Text(
text = option.detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = option.status,
style = MaterialTheme.typography.labelSmall,
color = if (option.statusOk) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
when (standardVoiceAvailability) {
StandardVoiceAvailability.SignInRequired -> {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = if (standardVoiceSignInRouteHint != null) {
"You're connected over the $standardVoiceSignInRouteHint " +
"route, and dashboard sign-ins are per-host — a sign-in " +
"from your home network doesn't carry over. Sign in once " +
"in Manage while on this route to unlock voice here too."
} else {
"Your Hermes dashboard requires sign-in before standard " +
"voice can transcribe or speak. Signing in once in Manage " +
"unlocks it for this connection."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onOpenManage != null) {
TextButton(onClick = onOpenManage) {
Text("Sign in via Manage")
}
}
}
StandardVoiceAvailability.Unsupported -> {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "This Hermes server build doesn't expose the dashboard " +
"audio routes yet. Update hermes-agent on the server, or " +
"pair Relay to use Relay voice.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> Unit
}
}
}
// --- Global Voice Controls ---
@@ -503,7 +673,20 @@ fun VoiceSettingsScreen(
}
// --- Global Fallback Text-to-Speech ---
SectionCard(title = "Global Fallback Text-to-Speech") {
if (!relayVoiceReady) {
SectionCard(title = "Voice Providers") {
Text(
text = "This connection speaks through your Hermes server's " +
"configured TTS and STT (config.yaml on the server, or the " +
"dashboard's Audio settings). Pair Relay to pick providers, " +
"models, and voices from the phone.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (relayVoiceReady) SectionCard(title = "Global Fallback Text-to-Speech") {
Text(
text = "Always available as the stable speech safety net when provider-native voice is unavailable.",
style = MaterialTheme.typography.bodySmall,
@@ -543,8 +726,10 @@ fun VoiceSettingsScreen(
}
}
if (currentEngine == VoiceEngineMode.HermesVoiceOutput) {
if (currentEngine == VoiceEngineMode.HermesVoiceOutput && relayVoiceReady) {
// --- Hermes Chat + Voice Output ---
// Relay-backed provider editing; standard-only connections get
// the quiet "Voice Providers" card above instead.
SectionCard(title = "Hermes Chat + Voice Output") {
ProviderRow(
label = "Status",
@@ -912,8 +1097,11 @@ fun VoiceSettingsScreen(
}
}
if (currentEngine == VoiceEngineMode.RealtimeAgent) {
if (currentEngine == VoiceEngineMode.RealtimeAgent && relayVoiceReady) {
// --- Realtime Agent ---
// Relay-only engine; without Relay the engine picker above
// already shows the requirement, so skip the config card
// rather than rendering permanent "loading..." rows.
SectionCard(title = "Realtime Agent", badge = "Experimental") {
Text(
text = "Hermes still owns tools and confirmations. Realtime mode may fall back to stable voice if the provider disconnects.",
@@ -941,6 +1129,26 @@ fun VoiceSettingsScreen(
},
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Persistent session", style = MaterialTheme.typography.bodyLarge)
Text(
text = "Keep one provider conversation open across turns. Turn off to fall back to a fresh session per utterance.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = voiceSettings.realtimePersistentSession,
onCheckedChange = { enabled ->
scope.launch { prefsRepo.setRealtimePersistentSession(enabled) }
},
)
}
Spacer(Modifier.height(4.dp))
ProviderRow(
label = "Status",
@@ -975,6 +1183,109 @@ fun VoiceSettingsScreen(
ProviderRow(label = "Auth", value = realtimeAuthLabel(config))
}
realtimeConfig?.promotion?.let { promo ->
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text("Background tasks", style = MaterialTheme.typography.titleSmall)
Text(
text = "Long Hermes tasks keep running in the background so the conversation stays responsive; the answer is spoken when it's ready.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Promote long tasks", style = MaterialTheme.typography.bodyLarge)
Text(
text = "Detach a slow run after ${promo.promoteAfterMs} ms instead of waiting silently",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = promo.enabled,
onCheckedChange = { enabled ->
scope.launch {
val client = voiceClient ?: return@launch
val result = client.updateRealtimeAgentPromotion(
promotionEnabled = enabled,
)
if (result.isSuccess) realtimeConfig = result.getOrNull()
}
},
)
}
if (promo.enabled) {
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Spoken handoff", style = MaterialTheme.typography.bodyLarge)
Text(
text = "Say a short \"I'm on it\" when a task moves to the background",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = promo.spokenHandoff,
onCheckedChange = { enabled ->
scope.launch {
val client = voiceClient ?: return@launch
val result = client.updateRealtimeAgentPromotion(
spokenHandoff = enabled,
)
if (result.isSuccess) realtimeConfig = result.getOrNull()
}
},
)
}
Spacer(Modifier.height(8.dp))
Text(
"When the answer is ready",
style = MaterialTheme.typography.labelMedium,
)
Spacer(Modifier.height(4.dp))
val deliveryOptions = listOf(
"speak_when_idle",
"notify_then_speak",
"visual_only",
)
val deliveryLabels = listOf("Speak", "Notify", "Show only")
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
deliveryOptions.forEachIndexed { index, option ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = deliveryOptions.size,
),
onClick = {
scope.launch {
val client = voiceClient ?: return@launch
val result = client.updateRealtimeAgentPromotion(
resultDelivery = option,
)
if (result.isSuccess) realtimeConfig = result.getOrNull()
}
},
selected = option == promo.resultDelivery,
) {
Text(
deliveryLabels[index],
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Row(
@@ -1313,25 +1624,34 @@ fun VoiceSettingsScreen(
// --- Speech-to-Text ---
SectionCard(title = "Speech-to-Text") {
ProviderRow(
label = "Provider",
value = voiceConfig?.stt?.provider ?: (voiceConfigError?.let { "unavailable" } ?: "loading..."),
)
voiceConfig?.stt?.let { stt ->
ProviderRow(label = "Enabled", value = if (stt.isEnabled) "yes" else "no")
}
voiceConfig?.stt?.model?.let { model ->
ProviderRow(label = "Model", value = model)
}
voiceConfig?.let { config ->
ProviderRow(
label = "Profile",
value = voiceProfileLabel(config.profile, selectedProfile),
if (!relayVoiceReady) {
Text(
text = "Transcription runs on your Hermes server with its " +
"configured STT provider.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ProviderRow(
label = "Scope",
value = voiceScopeLabel(config.configScope, config.fallbackToGlobal),
label = "Provider",
value = voiceConfig?.stt?.provider ?: (voiceConfigError?.let { "unavailable" } ?: "loading..."),
)
voiceConfig?.stt?.let { stt ->
ProviderRow(label = "Enabled", value = if (stt.isEnabled) "yes" else "no")
}
voiceConfig?.stt?.model?.let { model ->
ProviderRow(label = "Model", value = model)
}
voiceConfig?.let { config ->
ProviderRow(
label = "Profile",
value = voiceProfileLabel(config.profile, selectedProfile),
)
ProviderRow(
label = "Scope",
value = voiceScopeLabel(config.configScope, config.fallbackToGlobal),
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
@@ -1387,23 +1707,28 @@ fun VoiceSettingsScreen(
},
)
if (currentEngine == VoiceEngineMode.HermesVoiceOutput) {
ProviderRow(
label = "Profile",
value = voiceOutputConfig?.let { config ->
voiceProfileLabel(config.profile, selectedProfile)
} ?: voiceProfileLabel(null, selectedProfile),
)
ProviderRow(
label = "Voice",
value = listOfNotNull(
voiceOutputConfig?.default_provider,
voiceOutputConfig?.default_model,
voiceOutputConfig?.default_voice,
).joinToString(" / ").ifBlank { "loading..." },
)
if (relayVoiceReady) {
ProviderRow(
label = "Profile",
value = voiceOutputConfig?.let { config ->
voiceProfileLabel(config.profile, selectedProfile)
} ?: voiceProfileLabel(null, selectedProfile),
)
ProviderRow(
label = "Voice",
value = listOfNotNull(
voiceOutputConfig?.default_provider,
voiceOutputConfig?.default_model,
voiceOutputConfig?.default_voice,
).joinToString(" / ").ifBlank { "loading..." },
)
} else {
ProviderRow(label = "Route", value = "standard Hermes")
ProviderRow(label = "Voice", value = "server-configured TTS")
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Play the saved Voice Output renderer for the active profile.",
text = "Play a short sample through the active voice route.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1542,6 +1867,15 @@ private fun voiceScopeLabel(scope: String?, fallbackToGlobal: Boolean): String {
return if (fallbackToGlobal) "$base fallback" else base
}
private data class RouteOption(
val route: VoiceAudioRoute,
val label: String,
val detail: String,
val status: String,
val statusOk: Boolean,
val badge: String? = null,
)
private data class VoiceChoice(
val value: String,
val label: String = value,
@@ -0,0 +1,162 @@
package com.hermesandroid.relay.ui.theme
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
object RelayRefresh {
val Ink = Color(0xFFF7F6F0)
val Paper = Color(0xFFF7F3EA)
val Muted = Color(0xFFA7A4B7)
val Dim = Color(0xFF68647D)
val Background = Color(0xFF08090D)
val Navy = Color(0xFF121426)
val Navy2 = Color(0xFF191B31)
val Navy3 = Color(0xFF22243C)
val Relay = Color(0xFFAEBFFF)
val Purple = Color(0xFF8C5CFF)
val Electric = Color(0xFF111DFF)
/**
* Softened Electric for large filled surfaces (e.g. the active
* connection card). Full-strength Electric stays for small accents and
* the alpha-blended selected panels, where the saturation reads as brand
* rather than glare — 2026-06-10/11 feedback: the blue was right
* everywhere except as a full-card fill against body text.
*/
val ElectricMuted = Color(0xFF4F5BD5)
val Cyan = Color(0xFF6BDCFF)
val Green = Color(0xFF58D36F)
val Amber = Color(0xFFF2B14B)
val Danger = Color(0xFFFF6B78)
val Line = Color(0x24F7F6F0)
val LineStrong = Color(0x47F7F6F0)
val CardRadius = 8.dp
val Mono = FontFamily.Monospace
}
fun Modifier.relayPanel(
shape: Shape = RoundedCornerShape(RelayRefresh.CardRadius),
background: Color = RelayRefresh.Navy2.copy(alpha = 0.78f),
borderColor: Color = RelayRefresh.Line,
): Modifier = this
.background(background, shape)
.border(1.dp, borderColor, shape)
fun Modifier.relaySelectedPanel(
shape: Shape = RoundedCornerShape(RelayRefresh.CardRadius),
): Modifier = this
.background(
Brush.linearGradient(
listOf(
RelayRefresh.Electric.copy(alpha = 0.52f),
RelayRefresh.Purple.copy(alpha = 0.18f),
),
),
shape,
)
.border(1.dp, RelayRefresh.Electric.copy(alpha = 0.72f), shape)
fun Modifier.relayGridTexture(
grid: Dp = 42.dp,
dot: Dp = 10.dp,
alpha: Float = 0.18f,
): Modifier = drawBehind {
val gridPx = grid.toPx().coerceAtLeast(1f)
val dotPx = dot.toPx().coerceAtLeast(1f)
var x = 0f
while (x <= size.width) {
drawLine(
color = Color.White.copy(alpha = 0.018f * alpha * 5f),
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = 1f,
)
x += gridPx
}
var y = 0f
while (y <= size.height) {
drawLine(
color = Color.White.copy(alpha = 0.026f * alpha * 5f),
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1f,
)
y += gridPx
}
var dy = 0f
while (dy <= size.height) {
var dx = 0f
while (dx <= size.width) {
drawCircle(
color = RelayRefresh.Relay.copy(alpha = 0.16f * alpha * 5f),
radius = 1.15f,
center = Offset(dx + 1f, dy + 1f),
)
dx += dotPx
}
dy += dotPx
}
}
@Composable
fun RelayTextureBox(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
Box(
modifier = modifier
.background(RelayRefresh.Background)
.relayGridTexture(alpha = 0.18f),
content = content,
)
}
@Composable
fun RelayDottedOverlay(
modifier: Modifier = Modifier,
alpha: Float = 0.16f,
) {
Canvas(modifier = modifier.fillMaxSize()) {
val step = 10.dp.toPx()
var y = 0f
while (y <= size.height) {
var x = 0f
while (x <= size.width) {
drawCircle(
color = RelayRefresh.Relay.copy(alpha = alpha),
radius = 1.1f,
center = Offset(x + 1f, y + 1f),
)
x += step
}
y += step
}
}
}
@Composable
fun relayMetadataStyle(): TextStyle =
MaterialTheme.typography.labelSmall.copy(
fontFamily = RelayRefresh.Mono,
fontWeight = FontWeight.Medium,
letterSpacing = 0.sp,
)
@@ -1,81 +1,77 @@
package com.hermesandroid.relay.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
// Brand palette — derived from assets/logo.svg
private val HermesPrimary = Color(0xFF6B35E8) // Logo primary purple
private val HermesPrimaryLight = Color(0xFF9B6BF0) // Logo accent purple
private val HermesPrimaryDark = Color(0xFF4A1DB8) // Deeper variant for containers
private val HermesNavy = Color(0xFF1A1A2E) // Logo background navy
private val HermesNavySurface = Color(0xFF1E1E34) // Slightly lifted surface
private val HermesNavyVariant = Color(0xFF2A2A44) // Card/surface variant
private val DarkColorScheme = darkColorScheme(
primary = HermesPrimaryLight,
onPrimary = Color(0xFF1A0049),
primaryContainer = HermesPrimary,
onPrimaryContainer = Color(0xFFE8DEFF),
secondary = Color(0xFFB8AACC),
onSecondary = Color(0xFF2B2040),
secondaryContainer = Color(0xFF413558),
onSecondaryContainer = Color(0xFFE8DEFF),
tertiary = Color(0xFF9B6BF0),
onTertiary = Color(0xFF1A0049),
tertiaryContainer = Color(0xFF3D1F8C),
onTertiaryContainer = Color(0xFFE8DEFF),
background = HermesNavy,
onBackground = Color(0xFFE4E1E9),
surface = HermesNavy,
onSurface = Color(0xFFE4E1E9),
surfaceVariant = HermesNavyVariant,
onSurfaceVariant = Color(0xFFC9C3D4),
surfaceContainerLowest = Color(0xFF151524),
surfaceContainerLow = Color(0xFF1C1C30),
surfaceContainer = HermesNavySurface,
surfaceContainerHigh = Color(0xFF24243C),
surfaceContainerHighest = Color(0xFF2E2E48),
outline = Color(0xFF5A5470),
outlineVariant = Color(0xFF3D3854)
primary = RelayRefresh.Relay,
onPrimary = RelayRefresh.Background,
primaryContainer = RelayRefresh.Electric,
onPrimaryContainer = RelayRefresh.Paper,
secondary = RelayRefresh.Purple,
onSecondary = RelayRefresh.Paper,
secondaryContainer = RelayRefresh.Navy3,
onSecondaryContainer = RelayRefresh.Paper,
tertiary = RelayRefresh.Cyan,
onTertiary = RelayRefresh.Background,
tertiaryContainer = RelayRefresh.Purple.copy(alpha = 0.42f),
onTertiaryContainer = RelayRefresh.Paper,
background = RelayRefresh.Background,
onBackground = RelayRefresh.Ink,
surface = RelayRefresh.Background,
onSurface = RelayRefresh.Ink,
surfaceVariant = RelayRefresh.Navy2,
onSurfaceVariant = RelayRefresh.Muted,
surfaceContainerLowest = Color(0xFF05060A),
surfaceContainerLow = Color(0xFF0B0C12),
surfaceContainer = RelayRefresh.Navy,
surfaceContainerHigh = RelayRefresh.Navy2,
surfaceContainerHighest = RelayRefresh.Navy3,
error = RelayRefresh.Danger,
onError = RelayRefresh.Background,
errorContainer = RelayRefresh.Danger.copy(alpha = 0.18f),
onErrorContainer = RelayRefresh.Paper,
outline = RelayRefresh.LineStrong,
outlineVariant = RelayRefresh.Line,
)
private val LightColorScheme = lightColorScheme(
primary = HermesPrimary,
primary = RelayRefresh.Electric,
onPrimary = Color.White,
primaryContainer = Color(0xFFE8DEFF),
onPrimaryContainer = Color(0xFF1A0049),
secondary = Color(0xFF5E5474),
primaryContainer = RelayRefresh.Relay,
onPrimaryContainer = RelayRefresh.Background,
secondary = RelayRefresh.Purple,
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8DEFF),
onSecondaryContainer = Color(0xFF1B1030),
tertiary = HermesPrimaryDark,
onTertiary = Color.White,
tertiaryContainer = Color(0xFFE8DEFF),
onTertiaryContainer = Color(0xFF1A0049),
background = Color(0xFFFCF8FF),
onBackground = Color(0xFF1B1B22),
surface = Color(0xFFFCF8FF),
onSurface = Color(0xFF1B1B22),
surfaceVariant = Color(0xFFEAE4F2),
onSurfaceVariant = Color(0xFF48444E),
secondaryContainer = Color(0xFFE5E7FF),
onSecondaryContainer = RelayRefresh.Background,
tertiary = RelayRefresh.Cyan,
onTertiary = RelayRefresh.Background,
tertiaryContainer = Color(0xFFDDF8FF),
onTertiaryContainer = RelayRefresh.Background,
background = RelayRefresh.Paper,
onBackground = RelayRefresh.Background,
surface = RelayRefresh.Paper,
onSurface = RelayRefresh.Background,
surfaceVariant = Color(0xFFE9E8F1),
onSurfaceVariant = Color(0xFF38384A),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF7F2FC),
surfaceContainer = Color(0xFFF1ECF6),
surfaceContainerHigh = Color(0xFFEBE6F0),
surfaceContainerHighest = Color(0xFFE5E0EA),
outline = Color(0xFF79747E),
outlineVariant = Color(0xFFCBC4D0)
surfaceContainerLow = Color(0xFFF4F2F8),
surfaceContainer = Color(0xFFEDEBF4),
surfaceContainerHigh = Color(0xFFE3E1EC),
surfaceContainerHighest = Color(0xFFDAD7E6),
error = RelayRefresh.Danger,
onError = Color.White,
errorContainer = Color(0xFFFFD9DE),
onErrorContainer = Color(0xFF410006),
outline = Color(0xFF777386),
outlineVariant = Color(0xFFCAC7D8),
)
@Composable
@@ -90,16 +86,7 @@ fun HermesRelayTheme(
else -> isSystemInDarkTheme()
}
val colorScheme = when {
// Dynamic colors available on Android 12+ (API 31)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (useDarkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
useDarkTheme -> DarkColorScheme
else -> LightColorScheme
}
val colorScheme = if (useDarkTheme) DarkColorScheme else LightColorScheme
// Compose-wide font scaling. We multiply the user's chosen scale into the
// current LocalDensity.fontScale (which already reflects the system font
@@ -8,50 +8,52 @@ import androidx.compose.ui.unit.sp
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.ExtraBold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp
lineHeight = 34.sp,
letterSpacing = 0.sp,
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
letterSpacing = 0.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
letterSpacing = 0.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
letterSpacing = 0.sp
)
)
@@ -139,21 +139,24 @@ fun classifyError(t: Throwable?, context: String? = null): HumanError {
// happen to contain HTTP-ish substrings. Only fall through to the
// message scan once we know it's a plain IOException.
return when (t) {
// Bodies say "server", not "relay" — these exceptions also surface
// from the standard API/dashboard routes, where relay wording would
// send users debugging the wrong box.
is UnknownHostException -> HumanError(
title = "Can't reach server",
body = "Check your network and the relay URL in Settings",
body = "Check your network and the server URL in Settings",
retryable = true,
actionLabel = "Retry",
)
is ConnectException -> HumanError(
title = "Connection refused",
body = "The relay isn't accepting connections — make sure it's running",
body = "The server isn't accepting connections — make sure it's running",
retryable = true,
actionLabel = "Retry",
)
is SocketTimeoutException -> HumanError(
title = "Network timeout",
body = "The relay took too long to respond",
body = "The server took too long to respond",
retryable = true,
actionLabel = "Retry",
)
@@ -172,14 +175,18 @@ fun classifyError(t: Throwable?, context: String? = null): HumanError {
)
is IllegalStateException -> HumanError(
title = titlePrefix(context),
body = "Not ready — check that the relay is paired and online",
// Voice routing throws IllegalStateException with actionable copy
// ("needs dashboard sign-in — open Manage"); preserve it instead
// of rewriting every not-ready state into relay advice.
body = t.message?.takeIf { it.isNotBlank() }
?: "Not ready — check that the relay is paired and online",
retryable = true,
)
is IOException -> {
if ("timeout" in msg) {
HumanError(
title = "Network timeout",
body = "The relay took too long to respond",
body = "The server took too long to respond",
retryable = true,
actionLabel = "Retry",
)
File diff suppressed because it is too large Load Diff
@@ -111,6 +111,7 @@ enum class ConnectionStatusTone {
data class ConnectionStatusSnapshot(
val title: String,
val route: String? = null,
val actionLabel: String? = null,
val active: Boolean = false,
val success: Boolean = false,
val tone: ConnectionStatusTone = ConnectionStatusTone.Info,
@@ -122,6 +123,7 @@ fun ConnectionHandoffStatus.asConnectionStatusSnapshot(): ConnectionStatusSnapsh
ConnectionStatusSnapshot(
title = title,
route = route,
actionLabel = null,
active = active,
success = success,
tone = when {
@@ -17,6 +17,7 @@ import com.hermesandroid.relay.data.BargeInPreferencesRepository
import com.hermesandroid.relay.data.BargeInSensitivity
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoiceEngineMode
import com.hermesandroid.relay.data.VoiceIntentTrace
@@ -25,9 +26,13 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.RelayVoiceClient
import com.hermesandroid.relay.network.RelayVoiceAudioClientAdapter
import com.hermesandroid.relay.network.RealtimeAgentSessionControl
import com.hermesandroid.relay.network.RealtimeTurnInput
import com.hermesandroid.relay.network.RealtimeVoiceSummary
import com.hermesandroid.relay.network.RealtimeVoiceEvent
import com.hermesandroid.relay.network.VoiceHandoffEvent
import com.hermesandroid.relay.network.VoiceAudioClient
import com.hermesandroid.relay.network.handlers.LocalDispatchResult
import com.hermesandroid.relay.util.HumanError
import com.hermesandroid.relay.util.classifyError
@@ -63,6 +68,7 @@ import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import com.hermesandroid.relay.data.VoicePreferencesRepository
import com.hermesandroid.relay.data.VoiceAudioRoute
/**
* Where we are in the voice conversation cycle. Used to drive the UI
@@ -131,6 +137,21 @@ data class VoiceUiState(
* fresh turn (mic tap), whichever comes first.
*/
val permissionDeniedCallout: PermissionDeniedCallout? = null,
/**
* ADR 33: non-null while a Hermes run has been promoted to (or started as) a
* background task. Drives a persistent "working on it" chip so the user
* knows a long task is still running after the spoken handoff. Cleared when
* the background run completes, is cancelled, or errors.
*/
val backgroundRun: BackgroundRunState? = null,
)
/** ADR 33 background/promoted Hermes run surface for the voice overlay. */
data class BackgroundRunState(
val runId: String? = null,
/** "promoted" (auto-detached long run) or "durable" (explicit mode=background). */
val tier: String = "promoted",
val message: String = "Working on it in the background…",
)
data class VoiceHandoffStatus(
@@ -238,9 +259,9 @@ data class VoiceStats(
* Idle → Listening (record mic) → Transcribing (upload) → Thinking
* → Speaking (sentence-buffered TTS) → Idle
*
* Requires [VoiceRecorder], [VoicePlayer], [RelayVoiceClient], and a
* Requires [VoiceRecorder], [VoicePlayer], [VoiceAudioClient], and a
* [ChatViewModel] for sending the transcribed text through the normal
* chat pipeline. All four are wired via [initialize] after construction.
* chat pipeline. Realtime Agent still uses [RelayVoiceClient].
*
* ### Sentence-boundary streaming TTS
* The SSE stream emits text one token at a time, but TTS wants whole
@@ -265,10 +286,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private const val TTS_CACHE_CAP = 6 // keep the last N mp3s on disk
private const val MAX_BROKERED_TOOL_STATUS_PER_MESSAGE = 2
private const val STABLE_VOICE_INTERFACE_CONTEXT =
"Hermes Relay interface context for this turn:\n" +
"Hermes Android voice interface context for this turn:\n" +
"- Active voice engine: Hermes chat + voice output (hermes_voice_output).\n" +
"- Active route: Android mic -> relay STT /voice/transcribe -> " +
"normal Hermes chat stream -> relay voice output playback.\n" +
"- Active route: Android mic -> selected Hermes STT route -> " +
"normal Hermes chat stream -> selected Hermes TTS route playback.\n" +
"- This is not Realtime Agent mode. If the user asks which " +
"interface, path, or mode is active, answer from this context."
@@ -373,6 +394,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// --- Dependencies (injected via initialize) --------------------------
private var voiceClient: RelayVoiceClient? = null
private var voiceAudioClient: VoiceAudioClient? = null
private var chatViewModel: ChatViewModel? = null
private var recorder: VoiceRecorder? = null
private var player: VoicePlayer? = null
@@ -386,8 +408,28 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private var voicePreferencesJob: Job? = null
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
private var realtimeTraceDetails: Boolean = false
private var realtimePersistentSession: Boolean = true
private var realtimeAgentControl: RealtimeAgentSessionControl? = null
private var realtimeConfirmationControl: RealtimeAgentSessionControl? = null
// === Persistent realtime-agent session (one socket across turns) ===
// The long-lived call to RelayVoiceClient.runRealtimeAgent(persistent) runs
// in realtimeSessionJob; further utterances are fed on realtimeTurnChannel.
// The per-turn event holders below are hoisted to fields so the single
// session-lived event callback can serve every turn; submitRealtimeTurn /
// the open path reset them at each turn boundary.
private var realtimeSessionJob: Job? = null
private var realtimeTurnChannel: kotlinx.coroutines.channels.Channel<RealtimeTurnInput>? = null
private var rtUserText: String = ""
private var rtAssistantMessageId: String = ""
private var rtConversationContext: List<RealtimeConversationContextMessage> = emptyList()
private val audioSeen = AtomicBoolean(false)
private val audioBytes = AtomicInteger(0)
private val bargeInStarted = AtomicBoolean(false)
private val lastRealtimeAudioEventId = AtomicLong(0L)
private var responseText = StringBuilder()
private var inputTranscript = StringBuilder()
private val spokenStatusKeys = mutableSetOf<String>()
private var voiceRelayPreflight: (suspend () -> Result<Unit>)? = null
// === PHASE3-voice-intents: voice→bridge intent routing ===
@@ -697,6 +739,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
*/
fun initialize(
voiceClient: RelayVoiceClient,
voiceAudioClient: VoiceAudioClient? = null,
chatViewModel: ChatViewModel,
recorder: VoiceRecorder,
player: VoicePlayer,
@@ -734,6 +777,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
voiceHandoffReporter: ((VoiceHandoffEvent) -> Unit)? = null,
) {
this.voiceClient = voiceClient
this.voiceAudioClient = voiceAudioClient ?: RelayVoiceAudioClientAdapter(voiceClient)
this.chatViewModel = chatViewModel
this.recorder = recorder
this.player = player
@@ -811,8 +855,18 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
"realtimeTraceDetails=${settings.realtimeTraceDetails}",
)
}
// Switching engine away from Realtime Agent (or disabling the
// persistent toggle) must drop any open persistent session.
if (
(voiceEngineMode == VoiceEngineMode.RealtimeAgent &&
nextEngineMode != VoiceEngineMode.RealtimeAgent) ||
(realtimePersistentSession && !settings.realtimePersistentSession)
) {
closeRealtimeSession()
}
voiceEngineMode = nextEngineMode
realtimeTraceDetails = settings.realtimeTraceDetails
realtimePersistentSession = settings.realtimePersistentSession
val mode = when (settings.interactionMode.lowercase()) {
"hold" -> InteractionMode.HoldToTalk
"continuous" -> InteractionMode.Continuous
@@ -1068,6 +1122,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// Chime BEFORE teardown — AudioTrack release would cut it off otherwise.
try { sfxPlayer?.playExit() } catch (_: Exception) { /* ignore */ }
cancelRealtimeAgentTurn("exit voice mode")
closeRealtimeSession()
// B4: tear down the barge-in listener + timers before we kill the
// player so AEC doesn't try to track a released audio session.
stopBargeInListener()
@@ -1514,9 +1569,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
onResult: (Result<Unit>) -> Unit = {},
) {
val app = getApplication<Application>()
val client = voiceClient
val audioClient = voiceAudioClient
val relayClient = voiceClient
val p = player
if (client == null || p == null) {
if (audioClient == null || p == null) {
onResult(Result.failure(IllegalStateException("Voice pipeline not initialized")))
Toast.makeText(app, "Voice test failed: pipeline not initialized", Toast.LENGTH_SHORT).show()
setError("Voice pipeline not initialized")
@@ -1524,7 +1580,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
val triggerToast = Toast.makeText(app, "Testing voice…", Toast.LENGTH_SHORT).also { it.show() }
viewModelScope.launch {
val profileAwareResult = testVoiceViaVoiceOutput(client, sample)
val profileAwareResult = if (audioClient.route == VoiceAudioRoute.Relay && relayClient != null) {
testVoiceViaVoiceOutput(relayClient, sample)
} else {
null
}
val result = if (profileAwareResult != null) {
if (profileAwareResult.isSuccess) {
triggerToast.cancel()
@@ -1533,9 +1593,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
return@launch
}
Log.w(TAG, "profile-aware voice test failed; falling back to legacy synthesize: ${profileAwareResult.exceptionOrNull()?.message}")
client.synthesize(sample)
audioClient.synthesize(sample)
} else {
client.synthesize(sample)
audioClient.synthesize(sample)
}
if (result.isFailure) {
triggerToast.cancel()
@@ -1705,9 +1765,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
inputPcm: ByteArray,
inputSampleRate: Int,
) {
val client = voiceClient
val relayClient = voiceClient
val audioClient = voiceAudioClient
val chatVm = chatViewModel
if (client == null || chatVm == null) {
if (audioClient == null || chatVm == null) {
setError("Voice pipeline not initialized")
return
}
@@ -1724,6 +1785,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
)
if (engineModeForTurn == VoiceEngineMode.RealtimeAgent) {
val client = relayClient
if (client == null) {
setError("Realtime Agent needs a Relay voice route")
return
}
Log.i(TAG, "Voice input routed to Realtime Agent")
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
@@ -1740,6 +1806,31 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
responseText = "",
)
}
if (realtimePersistentSession) {
// Persistent conversation: one socket across turns. Open lazily on
// the first turn (the long-lived call runs in realtimeSessionJob),
// then feed further utterances on the channel. Fallback to one-shot
// if the toggle is off (Voice Settings).
if (realtimeSessionJob?.isActive == true && realtimeTurnChannel != null) {
submitRealtimeTurn(chatVm, inputPcm, inputSampleRate)
} else {
closeRealtimeSession()
realtimeTurnChannel = kotlinx.coroutines.channels.Channel(
kotlinx.coroutines.channels.Channel.UNLIMITED,
)
realtimeSessionJob = viewModelScope.launch {
runRealtimeAgentTurn(
client = client,
chatVm = chatVm,
userText = "",
inputPcm = inputPcm,
inputSampleRate = inputSampleRate,
persistentOpen = true,
)
}
}
return
}
runRealtimeAgentTurn(
client = client,
chatVm = chatVm,
@@ -1755,15 +1846,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Info,
title = "Voice turn started",
detail = "Hermes voice output",
detail = "Hermes voice output (${audioClient.route.storageValue})",
)
if (!runVoiceRelayPreflight("Hermes voice output")) return
// Transcribe
_uiState.update { it.copy(state = VoiceState.Transcribing, outputAudioActive = false) }
val sttStartedAtMs = System.currentTimeMillis()
val audioBytes = try { audioFile.length() } catch (_: Exception) { 0L }
val transcribeResult = client.transcribe(audioFile)
val transcribeResult = audioClient.transcribe(audioFile)
val sttLatencyMs = System.currentTimeMillis() - sttStartedAtMs
if (transcribeResult.isFailure) {
val err = transcribeResult.exceptionOrNull()
@@ -2004,6 +2094,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
userText: String,
inputPcm: ByteArray,
inputSampleRate: Int,
persistentOpen: Boolean = false,
) {
providerRealtimeAgentTurnActive.set(true)
streamObserverJob?.cancel()
@@ -2031,19 +2122,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
)
}
val conversationContext = chatVm.realtimeAgentContextMessages()
val assistantMessageId = chatVm.startRealtimeAgentTurn(
// Per-turn event state is hoisted to fields so one session-lived callback
// serves every turn in persistent mode; reset them for this turn.
audioSeen.set(false)
audioBytes.set(0)
bargeInStarted.set(false)
lastRealtimeAudioEventId.set(0L)
responseText = StringBuilder()
inputTranscript = StringBuilder()
spokenStatusKeys.clear()
rtUserText = userText
rtConversationContext = chatVm.realtimeAgentContextMessages()
rtAssistantMessageId = chatVm.startRealtimeAgentTurn(
userText = userText,
chatSessionId = chatVm.currentSessionId.value,
)
val conversationContext = rtConversationContext
val pcmPlayer = realtimePcmPlayer
val audioSeen = AtomicBoolean(false)
val audioBytes = AtomicInteger(0)
val bargeInStarted = AtomicBoolean(false)
val lastRealtimeAudioEventId = AtomicLong(0L)
val responseText = StringBuilder()
val inputTranscript = StringBuilder()
val spokenStatusKeys = mutableSetOf<String>()
fun emitStatus(
key: String,
@@ -2105,10 +2200,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
chatSessionId = chatVm.currentSessionId.value,
conversationContext = conversationContext,
onHandoff = ::recordVoiceHandoff,
turnInputs = if (persistentOpen) realtimeTurnChannel else null,
onTurnComplete = { summary -> onRealtimeTurnComplete(summary) },
) { event, control ->
realtimeAgentControl = control
chatVm.applyRealtimeAgentEvent(
assistantMessageId = assistantMessageId,
assistantMessageId = rtAssistantMessageId,
event = event,
showDetailedTrace = realtimeTraceDetails,
)
@@ -2119,13 +2216,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
it.copy(
state = VoiceState.Listening,
outputAudioActive = false,
transcribedText = inputTranscript.toString().ifBlank { userText },
transcribedText = inputTranscript.toString().ifBlank { rtUserText },
)
}
}
"voice.input_transcript.final" -> {
inputTranscript.clear()
inputTranscript.append(event.text ?: userText)
inputTranscript.append(event.text ?: rtUserText)
_uiState.update {
it.copy(
state = VoiceState.Thinking,
@@ -2135,6 +2232,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
}
"voice.response.started", "hermes.run.started" -> {
if (event.type == "hermes.run.started") {
Log.i(
TAG,
"Realtime Hermes run started run=${event.runId ?: "?"} " +
"session=${event.chatSessionId ?: "?"}",
)
}
_uiState.update {
it.copy(state = VoiceState.Thinking, outputAudioActive = false)
}
@@ -2172,6 +2276,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
"hermes.run.progress" -> {
val line = realtimeHermesProgressLine(event.message)
Log.i(
TAG,
"Realtime Hermes progress tier=${event.tier ?: "?"} " +
"floor=${event.floor ?: "?"} status=${event.statusKey ?: "?"} " +
"shouldSpeak=${event.shouldSpeak} run=${event.runId ?: "?"}",
)
if (event.shouldSpeak) {
emitStatus(
key = "progress-${event.runId ?: "run"}-${event.statusKey ?: line}",
@@ -2188,6 +2298,40 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
)
}
}
"hermes.run.promoted" -> {
// The run detached to the background; the provider speaks the
// handoff. Surface a persistent chip so the user knows a long
// task is still in flight (ADR 33 Tier B/C).
val tier = event.tier ?: "promoted"
Log.i(
TAG,
"Realtime run promoted to background tier=$tier " +
"run=${event.runId ?: "?"} session=${event.chatSessionId ?: "?"}",
)
_uiState.update {
it.copy(
backgroundRun = BackgroundRunState(
runId = event.runId,
tier = tier,
message = if (tier == "durable") {
"Started a background task — I'll report back."
} else {
"This is taking a moment — working on it in the background."
},
),
)
}
}
"hermes.run.background_completed" -> {
// Background run finished; the spoken summary follows via the
// provider's forced-summary turn. Clear the chip.
Log.i(
TAG,
"Realtime background run completed run=${event.runId ?: "?"} " +
"ok=${event.success != false}",
)
_uiState.update { it.copy(backgroundRun = null) }
}
"hermes.confirmation.requested" -> {
val confirmationId = event.confirmationId
if (!confirmationId.isNullOrBlank()) {
@@ -2269,6 +2413,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
}
"hermes.run.cancelled" -> {
Log.i(TAG, "Realtime Hermes run cancelled run=${event.runId ?: "?"}")
realtimeConfirmationControl = null
_uiState.update {
it.copy(
@@ -2276,6 +2421,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
outputAudioActive = false,
responseText = "Cancelled.",
hermesConfirmation = null,
backgroundRun = null,
)
}
}
@@ -2294,19 +2440,22 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
"voice.error" -> {
realtimeConfirmationControl = null
val rawDetail = event.message ?: "Realtime agent failed"
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Error,
title = "Realtime voice error",
detail = event.message ?: "Realtime agent failed",
detail = rawDetail,
)
_uiState.update { it.copy(hermesConfirmation = null) }
// Route the raw relay message through the classifier so
// provider-auth (and other) failures surface a clear,
// actionable message + a Voice-settings snackbar action
// instead of a raw "xAI Realtime auth ..." provider string.
surfaceError(
java.io.IOException(rawDetail),
context = "voice_config",
)
_uiState.update {
it.copy(
state = VoiceState.Error,
error = event.message ?: "Realtime agent failed",
hermesConfirmation = null,
)
}
}
}
}
@@ -2335,9 +2484,78 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
title = "Realtime voice turn failed",
detail = err?.message ?: "Unknown error",
)
// A persistent session ending in error must drop so the next turn opens
// a fresh one rather than submitting into a dead channel.
closeRealtimeSession()
surfaceError(err, context = "voice_config")
}
/**
* Per-turn completion in a persistent realtime session (ADR follow-up). The
* socket stays open; this just finalizes the spoken turn and re-arms
* continuous listening if enabled.
*/
private fun onRealtimeTurnComplete(summary: RealtimeVoiceSummary) {
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Info,
title = "Realtime voice turn complete",
)
Log.i(
TAG,
"Realtime persistent turn complete provider=${summary.provider} " +
"audioChunks=${summary.audioChunks}",
)
pendingInTtsQueue.set(0)
maybeAutoResume()
}
/**
* Submit a follow-up utterance onto the already-open persistent session
* (turns 2+). Mirrors the per-turn reset the open path does for turn 1.
*/
private fun submitRealtimeTurn(chatVm: ChatViewModel, inputPcm: ByteArray, inputSampleRate: Int) {
val channel = realtimeTurnChannel ?: return
drainQueuedLocalTts()
try { player?.stop() } catch (_: Exception) { /* ignore */ }
firstFrameWatchdogJob?.cancel(); firstFrameWatchdogJob = null
clearSpokenChunksState()
audioSeen.set(false)
audioBytes.set(0)
bargeInStarted.set(false)
lastRealtimeAudioEventId.set(0L)
responseText = StringBuilder()
inputTranscript = StringBuilder()
spokenStatusKeys.clear()
rtUserText = ""
rtConversationContext = chatVm.realtimeAgentContextMessages()
rtAssistantMessageId = chatVm.startRealtimeAgentTurn(
userText = "",
chatSessionId = chatVm.currentSessionId.value,
)
providerRealtimeAgentTurnActive.set(true)
_uiState.update {
it.copy(
state = VoiceState.Thinking,
outputAudioActive = false,
transcribedText = null,
responseText = "",
)
}
val sent = channel.trySend(RealtimeTurnInput(inputPcm, inputSampleRate)).isSuccess
Log.i(TAG, "Realtime persistent turn submitted sent=$sent pcmBytes=${inputPcm.size}")
}
/** Tear down the persistent realtime session (voice-mode exit / engine switch). */
private fun closeRealtimeSession() {
val hadSession = realtimeTurnChannel != null || realtimeSessionJob != null
realtimeTurnChannel?.close()
realtimeTurnChannel = null
realtimeSessionJob?.cancel()
realtimeSessionJob = null
if (hadSession) Log.i(TAG, "Realtime persistent session closed")
}
private fun realtimeToolStatusLine(toolName: String?): String {
val normalized = toolName.orEmpty().lowercase()
return when {
@@ -2615,7 +2833,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private fun shouldPreferRealtimeVoice(): Boolean =
voiceOutputAvailable != false &&
realtimePcmPlayer != null &&
voiceClient != null
voiceClient != null &&
voiceAudioClient?.route == VoiceAudioRoute.Relay
private fun drainSentences() {
while (true) {
@@ -2686,12 +2905,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// TTS consumer — two-coroutine pipeline: synth runs ahead of playback
// ---------------------------------------------------------------------
//
// Provider-neutral voice output is now the preferred path. It uses
// /voice/output/* to stream renderer PCM through the relay and writes
// those chunks directly to AudioTrack. The legacy synth/play workers stay
// alive underneath as the fallback path when the relay does not expose the
// output route, provider auth is missing, or a renderer fails before
// audio starts.
// Relay-selected voice output can stream renderer PCM through
// /voice/output/* and write chunks directly to AudioTrack. Standard
// upstream audio and Relay fallback both use the synth/play workers.
private fun startRealtimeTtsConsumer() {
realtimeTtsConsumerJob?.cancel()
@@ -3025,9 +3241,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
synthesize = { sentence ->
val synthStartedAtMs = System.currentTimeMillis()
try {
val client = voiceClient
val client = voiceAudioClient
val result = if (client == null) {
Result.failure(IllegalStateException("voiceClient not initialized"))
Result.failure(IllegalStateException("voice audio client not initialized"))
} else {
client.synthesize(sentence)
}
@@ -3899,6 +4115,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
override fun onCleared() {
super.onCleared()
closeRealtimeSession()
// B4: release the listener + VAD engine native resources before
// anything else. stopBargeInListener is defensive / idempotent.
stopBargeInListener()
+3
View File
@@ -38,6 +38,9 @@
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
@@ -72,6 +73,7 @@ class VoicePlayerTest {
private lateinit var context: Context
private lateinit var exoPlayer: ExoPlayer
private var listener: Player.Listener? = null
private var analyticsListener: AnalyticsListener? = null
// Mirrors the real ExoPlayer's counter so the test's view and the
// VoicePlayer's view agree without having to drive every listener
@@ -99,6 +101,14 @@ class VoicePlayerTest {
listener = listenerSlot.captured
}
// Capture the AnalyticsListener too — VoicePlayer registers one to
// mirror the audio session id onto the main thread (the barge-in
// wrong-thread crash fix).
val analyticsSlot = slot<AnalyticsListener>()
every { exoPlayer.addAnalyticsListener(capture(analyticsSlot)) } answers {
analyticsListener = analyticsSlot.captured
}
// Queue + state inspection read from the fake counters so the
// VoicePlayer sees consistent values whether it reads them in
// play(), stop(), or from the listener callback.
@@ -139,6 +149,7 @@ class VoicePlayerTest {
fun tearDown() {
unmockkAll()
listener = null
analyticsListener = null
fakeMediaItemCount = 0
fakeIsPlaying = false
fakePlaybackState = Player.STATE_IDLE
@@ -227,4 +238,33 @@ class VoicePlayerTest {
verify { exoPlayer.volume = 1.0f }
assertEquals(1.0f, fakeVolume, 0.0001f)
}
@Test
fun `audioSessionId is served from the cached id, not the thread-confined getter`() {
// Regression: ExoPlayer is thread-confined, so reading
// exoPlayer.audioSessionId off the main thread (BargeInListener's
// Dispatchers.IO reader) throws "Player is accessed on the wrong
// thread" and crashed voice mode mid-playback. The getter must read
// a cached value instead — populated from main-thread callbacks.
val voicePlayer = VoicePlayer(context) { exoPlayer }
// Media3 reports a freshly-allocated session id on the main thread.
analyticsListener?.onAudioSessionIdChanged(mockk(relaxed = true), 42)
assertEquals(42, voicePlayer.audioSessionId)
// Reading the property again must not touch the raw ExoPlayer getter
// (that's the off-main call that throws). The only legitimate read of
// exoPlayer.audioSessionId happens inside onIsPlayingChanged on the
// main thread, which this test never triggers.
voicePlayer.audioSessionId
voicePlayer.audioSessionId
verify(exactly = 0) { exoPlayer.audioSessionId }
}
@Test
fun `audioSessionId defaults to zero before any session is allocated`() {
val voicePlayer = VoicePlayer(context) { exoPlayer }
assertEquals(0, voicePlayer.audioSessionId)
}
}
@@ -0,0 +1,116 @@
package com.hermesandroid.relay.data
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionDashboardFieldsTest {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
@Test
fun deriveDefaultDashboardUrl_usesSameHostAndDashboardPort() {
assertEquals(
"http://example.local:9119",
Connection.deriveDefaultDashboardUrl("http://example.local:8642"),
)
}
@Test
fun deriveDefaultDashboardUrl_preservesHttpsScheme() {
assertEquals(
"https://hermes.example.com:9119",
Connection.deriveDefaultDashboardUrl("https://hermes.example.com:8642"),
)
}
@Test
fun deriveDefaultDashboardUrl_wrapsIpv6Host() {
assertEquals(
"http://[::1]:9119",
Connection.deriveDefaultDashboardUrl("http://[::1]:8642"),
)
}
@Test
fun deriveDefaultDashboardUrl_rejectsUnsupportedScheme() {
assertNull(Connection.deriveDefaultDashboardUrl("ws://localhost:8767"))
}
@Test
fun resolvedDashboardUrl_usesExplicitOverride() {
val connection = sampleConnection(
dashboardUrl = "https://dashboard.example.com",
)
assertEquals("https://dashboard.example.com", connection.resolvedDashboardUrl)
}
@Test
fun resolvedDashboardUrl_derivesWhenMissing() {
val connection = sampleConnection(dashboardUrl = null)
assertEquals("http://localhost:9119", connection.resolvedDashboardUrl)
}
@Test
fun legacySerializedConnection_decodesWithDashboardDefaults() {
val legacyJson = """
{
"id": "conn-1",
"label": "local",
"apiServerUrl": "http://localhost:8642",
"relayUrl": "ws://localhost:8767",
"tokenStoreKey": "hermes_auth_conn"
}
""".trimIndent()
val connection = json.decodeFromString<Connection>(legacyJson)
assertNull(connection.dashboardUrl)
assertEquals("http://localhost:9119", connection.resolvedDashboardUrl)
assertTrue(Connection.isAutoManagedDashboardUrl(connection.dashboardUrl, connection.apiServerUrl))
assertEquals(0, connection.routeCandidates.size)
}
@Test
fun buildRouteCandidates_createsLanAndTailscaleRoutes() {
val routes = Connection.buildRouteCandidates(
apiServerUrl = "http://192.168.1.25:8642",
relayUrl = "ws://192.168.1.25:8767",
extraApiUrls = listOf("tailscale" to "https://hermes.tail1234.ts.net:8642"),
)
assertEquals(2, routes.size)
assertEquals("lan", routes[0].role)
assertEquals("192.168.1.25", routes[0].api.host)
assertEquals("ws://192.168.1.25:8767", routes[0].relay.url)
assertEquals("tailscale", routes[1].role)
assertEquals("hermes.tail1234.ts.net", routes[1].api.host)
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay.url)
}
@Test
fun inferRouteRole_detectsTailscaleCgnat() {
assertEquals("tailscale", Connection.inferRouteRole("https://100.75.1.2:8642"))
assertEquals("lan", Connection.inferRouteRole("http://10.0.0.5:8642"))
assertEquals("public", Connection.inferRouteRole("https://hermes.example.com:8642"))
}
private fun sampleConnection(
dashboardUrl: String? = null,
): Connection = Connection(
id = "conn-1",
label = "local",
apiServerUrl = "http://localhost:8642",
relayUrl = "ws://localhost:8767",
tokenStoreKey = "hermes_auth_conn",
dashboardUrl = dashboardUrl,
)
}
@@ -0,0 +1,129 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* [Connection.mergeRouteCandidates] — URL edits must not wipe the stored
* fallback routes. Before this helper, `updateApiServerUrl` /
* `updateRelayUrl` / `saveApiAndProbeVoice` rebuilt `routeCandidates` from
* just the edited URL, silently dropping the setup wizard's Tailscale route
* (or a pairing payload's extra endpoints) — after which LAN↔Tailscale
* roaming had nothing to roam to.
*/
class ConnectionRouteCandidateMergeTest {
private fun candidate(
role: String,
priority: Int,
host: String,
port: Int = 8642,
relayUrl: String = "ws://$host:8767",
): EndpointCandidate = EndpointCandidate(
role = role,
priority = priority,
api = ApiEndpoint(host = host, port = port, tls = false),
relay = RelayEndpoint(url = relayUrl),
)
@Test
fun `preserves stored tailscale extra when primary is rebuilt`() {
val rebuilt = Connection.buildRouteCandidates(
apiServerUrl = "http://192.168.1.50:8642",
relayUrl = "ws://192.168.1.50:8767",
)
val existing = listOf(
candidate(role = "lan", priority = 0, host = "192.168.1.50"),
candidate(role = "tailscale", priority = 1, host = "100.64.0.7"),
)
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
assertEquals(2, merged.size)
assertEquals("lan", merged[0].role)
assertEquals("tailscale", merged[1].role)
assertEquals("100.64.0.7", merged[1].api.host)
}
@Test
fun `stored extras keep their relay url verbatim`() {
val rebuilt = Connection.buildRouteCandidates(
apiServerUrl = "http://192.168.1.50:8642",
relayUrl = "ws://192.168.1.50:8767",
)
// Pairing payloads can carry relay URLs that differ from the
// derive-from-api default (e.g. a reverse-proxied wss path).
val payloadRelay = "wss://hermes.tail1234.ts.net:8767/relay"
val existing = listOf(
candidate(
role = "tailscale",
priority = 1,
host = "hermes.tail1234.ts.net",
relayUrl = payloadRelay,
),
)
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
assertEquals(payloadRelay, merged.first { it.role == "tailscale" }.relay.url)
}
@Test
fun `rebuilt entry wins a host-port collision with a stored extra`() {
val rebuilt = Connection.buildRouteCandidates(
apiServerUrl = "http://192.168.1.50:8642",
relayUrl = "ws://192.168.1.50:8767",
extraApiUrls = listOf("tailscale" to "http://100.64.0.7:8642"),
)
val existing = listOf(
// Same host:port as the rebuilt extra but stale role/relay —
// must NOT survive alongside it.
candidate(
role = "vpn-old",
priority = 2,
host = "100.64.0.7",
relayUrl = "ws://stale.example:8767",
),
)
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
assertEquals(2, merged.size)
val tailscale = merged.first { it.api.host == "100.64.0.7" }
assertEquals("tailscale", tailscale.role)
assertEquals("ws://100.64.0.7:8767", tailscale.relay.url)
}
@Test
fun `stored primary is never preserved - rebuilt primary replaces it`() {
val rebuilt = Connection.buildRouteCandidates(
apiServerUrl = "http://10.0.0.99:8642",
relayUrl = "ws://10.0.0.99:8767",
)
val existing = listOf(
candidate(role = "lan", priority = 0, host = "192.168.1.50"),
candidate(role = "tailscale", priority = 1, host = "100.64.0.7"),
)
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
assertEquals(2, merged.size)
assertTrue(
"old priority-0 host must be replaced by the edited URL",
merged.none { it.api.host == "192.168.1.50" },
)
assertEquals("10.0.0.99", merged.first { it.priority == 0 }.api.host)
assertEquals("100.64.0.7", merged.first { it.priority == 1 }.api.host)
}
@Test
fun `merge with no stored extras returns rebuilt list unchanged`() {
val rebuilt = Connection.buildRouteCandidates(
apiServerUrl = "http://192.168.1.50:8642",
relayUrl = "ws://192.168.1.50:8767",
)
assertEquals(rebuilt, Connection.mergeRouteCandidates(rebuilt, emptyList()))
}
}
@@ -0,0 +1,110 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* [Connection.normalizeApiUrlInput] — hand-typed URL forgiveness added with
* the route-probe visibility work (2026-06-11).
*
* Contract: bare hosts/IPs get `http://` and the surface's default port;
* anything with an explicit scheme passes through verbatim (an
* `https://host` may be a reverse proxy on 443 — appending :8642 would
* break it, and a wrong scheme like `ws://` must survive so the field
* validators can name the actual mistake).
*/
class ConnectionUrlInputNormalizationTest {
@Test
fun bareIp_getsSchemeAndDefaultPort() {
assertEquals(
"http://100.71.8.56:8642",
Connection.normalizeApiUrlInput("100.71.8.56"),
)
}
@Test
fun bareHostWithPort_getsSchemeOnly() {
assertEquals(
"http://my-server:8642",
Connection.normalizeApiUrlInput("my-server:8642"),
)
}
@Test
fun explicitHttpUrl_passesVerbatim() {
assertEquals(
"http://192.168.1.10:8642",
Connection.normalizeApiUrlInput("http://192.168.1.10:8642"),
)
}
@Test
fun explicitHttpsWithoutPort_isNotPortDefaulted() {
// Could be a reverse proxy on 443 — never append :8642 to an
// explicitly-schemed URL.
assertEquals(
"https://hermes.example.com",
Connection.normalizeApiUrlInput("https://hermes.example.com"),
)
}
@Test
fun wrongScheme_passesVerbatimForValidatorsToCatch() {
assertEquals(
"ws://host:8767",
Connection.normalizeApiUrlInput("ws://host:8767"),
)
}
@Test
fun whitespaceAndTrailingSlash_areTrimmed() {
assertEquals(
"http://100.71.8.56:8642",
Connection.normalizeApiUrlInput(" 100.71.8.56/ "),
)
}
@Test
fun blankInput_staysBlank() {
assertEquals("", Connection.normalizeApiUrlInput(" "))
}
@Test
fun bareHostWithPath_getsSchemeButNoPort() {
// A path makes naive ":8642" suffixing wrong — scheme only.
assertEquals(
"http://host/api",
Connection.normalizeApiUrlInput("host/api"),
)
}
@Test
fun dashboardDefaultPort_isHonored() {
assertEquals(
"http://192.168.1.10:9119",
Connection.normalizeApiUrlInput(
"192.168.1.10",
defaultPort = Connection.DEFAULT_DASHBOARD_PORT,
),
)
}
@Test
fun bareTailscaleHost_roundTripsThroughCandidateBuilder() {
// End-to-end: the exact user journey from the bug report — typing a
// bare Tailscale IP must yield a plain-HTTP (tls=false) candidate on
// port 8642 with the tailscale role inferred.
val normalized = Connection.normalizeApiUrlInput("100.71.8.56")
val candidate = Connection.endpointCandidateFromApiUrl(
role = "",
priority = 1,
apiServerUrl = normalized,
relayUrl = "",
)
assertEquals("tailscale", candidate?.role)
assertEquals("100.71.8.56", candidate?.api?.host)
assertEquals(8642, candidate?.api?.port)
assertEquals(false, candidate?.api?.tls)
}
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.data
import com.hermesandroid.relay.auth.ConnectionAuthSecrets
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
@@ -99,45 +100,65 @@ class DataManagerTest {
assertTrue("JSON should contain 'onboardingCompleted'", jsonStr.contains("\"onboardingCompleted\""))
}
// --- Backup does NOT contain sensitive data ---
// --- Sensitive full-backup marker ---
@Test
fun backup_doesNotContainApiKey() {
fun backup_marksSensitiveDataByDefault() {
val backup = DataManager.AppBackup()
assertTrue(backup.containsSensitiveData)
}
@Test
fun backup_connectionSecrets_roundTrip() {
val backup = DataManager.AppBackup(
apiServerUrl = "http://localhost:8642",
theme = "auto"
connections = listOf(
Connection(
id = "id-a",
label = "local",
apiServerUrl = "http://localhost:8642",
relayUrl = "ws://localhost:8767",
tokenStoreKey = "hermes_auth_id-a",
),
),
activeConnectionId = "id-a",
connectionSecrets = listOf(
DataManager.ConnectionSecretBackup(
connectionId = "id-a",
tokenStoreKey = "hermes_auth_id-a",
auth = ConnectionAuthSecrets(
sessionToken = "relay-session-token",
refreshToken = "refresh-token",
deviceId = "device-id",
apiKey = "api-key",
pairedSessionMetaJson = """{"transport_hint":"wss"}""",
),
dashboardCookies = listOf(
DataManager.DashboardCookieBackup(
name = "hermes_session",
value = "cookie-value",
expiresAt = Long.MAX_VALUE,
domain = "localhost",
path = "/",
secure = false,
httpOnly = true,
hostOnly = true,
persistent = false,
),
),
),
),
)
val jsonStr = json.encodeToString(backup)
val restored = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertFalse("Backup should not contain 'apiKey'", jsonStr.contains("\"apiKey\""))
assertFalse("Backup should not contain 'api_key'", jsonStr.contains("\"api_key\""))
}
@Test
fun backup_doesNotContainSessionToken() {
val backup = DataManager.AppBackup()
val jsonStr = json.encodeToString(backup)
assertFalse("Backup should not contain 'session_token'", jsonStr.contains("\"session_token\""))
assertFalse("Backup should not contain 'sessionToken'", jsonStr.contains("\"sessionToken\""))
}
@Test
fun backup_doesNotContainDeviceId() {
val backup = DataManager.AppBackup()
val jsonStr = json.encodeToString(backup)
assertFalse("Backup should not contain 'device_id'", jsonStr.contains("\"device_id\""))
assertFalse("Backup should not contain 'deviceId'", jsonStr.contains("\"deviceId\""))
}
@Test
fun backup_doesNotContainBearerToken() {
val backup = DataManager.AppBackup()
val jsonStr = json.encodeToString(backup)
assertFalse("Backup should not contain 'token'", jsonStr.contains("\"token\""))
assertFalse("Backup should not contain 'bearer'", jsonStr.lowercase().contains("\"bearer\""))
assertTrue(jsonStr.contains("relay-session-token"))
assertTrue(jsonStr.contains("api-key"))
assertEquals("id-a", restored.activeConnectionId)
assertEquals("relay-session-token", restored.connectionSecrets[0].auth.sessionToken)
assertEquals("api-key", restored.connectionSecrets[0].auth.apiKey)
assertEquals("cookie-value", restored.connectionSecrets[0].dashboardCookies[0].value)
}
// --- Serialization round-trip ---
@@ -249,7 +270,7 @@ class DataManagerTest {
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertNotNull(result)
assertEquals(4, result.version)
assertEquals(5, result.version)
assertNull(result.serverUrl)
assertNull(result.apiServerUrl)
assertNull(result.relayUrl)
@@ -280,9 +301,9 @@ class DataManagerTest {
// --- Format version handling ---
@Test
fun backup_defaultVersion_isFour() {
fun backup_defaultVersion_isFive() {
val backup = DataManager.AppBackup()
assertEquals(4, backup.version)
assertEquals(5, backup.version)
}
@Test
@@ -348,6 +369,21 @@ class DataManagerTest {
apiServerUrl = "http://10.0.0.5:8642",
relayUrl = "wss://10.0.0.5:8767",
tokenStoreKey = "hermes_auth_id-b",
routeCandidates = listOf(
EndpointCandidate(
role = "lan",
priority = 0,
api = ApiEndpoint(host = "10.0.0.5", port = 8642, tls = false),
relay = RelayEndpoint(url = "ws://10.0.0.5:8767", transportHint = "ws"),
),
EndpointCandidate(
role = "tailscale",
priority = 1,
api = ApiEndpoint(host = "hermes.ts.net", port = 8642, tls = true),
relay = RelayEndpoint(url = "wss://hermes.ts.net:8767", transportHint = "wss"),
),
),
preferredRouteRole = "tailscale",
pairedAt = 1_700_000_000L,
transportHint = "wss",
expiresAt = 1_700_100_000L,
@@ -359,6 +395,8 @@ class DataManagerTest {
val restored = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertEquals(connections, restored.connections)
assertEquals("tailscale", restored.connections[1].preferredRouteRole)
assertEquals("hermes.ts.net", restored.connections[1].routeCandidates[1].api.host)
}
@Test
@@ -0,0 +1,197 @@
package com.hermesandroid.relay.network
import android.content.Context
import android.net.ConnectivityManager
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowNetwork
/**
* Standard-route (no relay socket) coverage for [ConnectionManager]'s ADR 24
* network-aware switching, added when the machinery was decoupled from the
* WSS connect path:
*
* 1. The [ConnectivityManager.NetworkCallback] registers at construction —
* previously only `connect()` registered it, so standard (no-Relay)
* connections never saw network changes at all.
* 2. A network `onAvailable` with **no relay socket** still re-resolves and
* publishes [ConnectionManager.activeEndpoint], which is what the
* HTTP-only surfaces (chat, dashboard, standard voice) follow.
* 3. `refreshActiveEndpoint(clearProbeCache = true)` forgets a
* cached-reachable route so a just-died endpoint can't win the resolve
* for the remainder of the 60s positive cache TTL.
*
* Runs under Robolectric for ConnectivityManager + a real [MockWebServer]
* for the resolver's `HEAD /health` probes — same probe contract as
* [EndpointResolverTest].
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ConnectionManagerRouteTest {
private lateinit var context: Context
private lateinit var connectivityManager: ConnectivityManager
private lateinit var server: MockWebServer
private val managers = mutableListOf<ConnectionManager>()
@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
connectivityManager = context.getSystemService(ConnectivityManager::class.java)!!
server = MockWebServer()
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
if (request.path?.endsWith("/health") == true) {
MockResponse().setResponseCode(200)
} else {
MockResponse().setResponseCode(404)
}
}
server.start()
}
@After
fun tearDown() {
managers.forEach { runCatching { it.shutdown() } }
managers.clear()
runCatching { server.shutdown() }
}
private fun registeredCallbacks(): Set<ConnectivityManager.NetworkCallback> =
shadowOf(connectivityManager).networkCallbacks.toSet()
private fun candidate(role: String = "tailscale"): EndpointCandidate =
EndpointCandidate(
role = role,
priority = 0,
api = ApiEndpoint(host = server.hostName, port = server.port, tls = false),
relay = RelayEndpoint(url = "ws://${server.hostName}:${server.port}"),
)
private fun buildManager(
candidates: () -> List<EndpointCandidate>,
): ConnectionManager = ConnectionManager(
ChannelMultiplexer(),
context = context,
endpointResolver = EndpointResolver(httpClient = OkHttpClient()),
endpointCandidatesProvider = { candidates() },
).also { managers.add(it) }
@Test
fun `network callback registers at construction without connect`() {
val before = registeredCallbacks()
buildManager { emptyList() }
assertEquals(
"ConnectionManager must register its NetworkCallback at construction " +
"so standard (no-Relay) connections follow network changes",
before.size + 1,
registeredCallbacks().size,
)
}
@Test
fun `shutdown unregisters the construction-time network callback`() {
val before = registeredCallbacks()
val manager = buildManager { emptyList() }
manager.shutdown()
assertEquals(before, registeredCallbacks())
}
@Test
fun `onAvailable with no relay socket re-resolves and publishes activeEndpoint`() {
val before = registeredCallbacks()
val manager = buildManager { listOf(candidate()) }
assertNull("no endpoint should be published before any trigger", manager.activeEndpoint.value)
val callback = (registeredCallbacks() - before).single()
callback.onAvailable(ShadowNetwork.newInstance(101))
val published = runBlocking {
withTimeout(10_000) { manager.activeEndpoint.first { it != null } }
}
assertEquals("tailscale", published!!.role)
}
@Test
fun `refreshActiveEndpoint returns stale cached winner unless clearProbeCache`() {
val manager = buildManager { listOf(candidate()) }
// Prime: server up → candidate resolves and its probe result caches.
val first = runBlocking { manager.refreshActiveEndpoint() }
assertNotNull("expected the live candidate to resolve", first)
// Route dies inside the positive cache TTL.
server.shutdown()
// Without clearing, the 60s positive cache still vouches for it.
val stale = runBlocking { manager.refreshActiveEndpoint() }
assertEquals(
"cached-reachable entry should still win within the TTL",
"tailscale",
stale?.role,
)
// Clearing the cache forces a fresh probe, which now fails.
val fresh = runBlocking { manager.refreshActiveEndpoint(clearProbeCache = true) }
assertNull("fresh probe against the dead route must yield no winner", fresh)
assertNull(manager.activeEndpoint.value)
}
@Test
fun `probeAndReconnectNow publishes the winner on the standard (no socket) path`() {
val manager = buildManager { listOf(candidate()) }
val winner = runBlocking { manager.probeAndReconnectNow() }
assertEquals("tailscale", winner?.role)
assertEquals(
"probeAndReconnectNow must publish activeEndpoint even with no relay socket",
"tailscale",
manager.activeEndpoint.value?.role,
)
}
@Test
fun `probeAndReconnectNow publishes null when every route fails its probe`() {
val manager = buildManager { listOf(candidate()) }
// Prime a winner, then kill the route. The old implementation
// early-returned here without publishing, leaving the Routes card
// stuck on the stale winner / "Resolving" with no feedback.
runBlocking { manager.refreshActiveEndpoint() }
assertNotNull(manager.activeEndpoint.value)
server.shutdown()
val winner = runBlocking { manager.probeAndReconnectNow() }
assertNull("no reachable route → null winner", winner)
assertNull(
"the failed outcome must be published, not silently swallowed",
manager.activeEndpoint.value,
)
}
}
@@ -0,0 +1,431 @@
package com.hermesandroid.relay.network
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonArray
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class DashboardApiClientTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun getStatus_parsesAuthRequiredAndProviders() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""
{
"version": "0.16.0",
"auth_required": true,
"auth_providers": ["basic", "nous"]
}
""".trimIndent(),
),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val status = client.getStatus().getOrThrow()
val request = server.takeRequest()
assertEquals("/api/status", request.path)
assertTrue(status.authRequired)
assertEquals(listOf("basic", "nous"), status.authProviders)
assertEquals("basic", status.authProviderDetails.first().name)
assertEquals("0.16.0", status.version)
}
@Test
fun getStatus_acceptsProviderObjects() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""
{
"auth": {
"required": true,
"providers": [
{"id": "basic", "label": "Username & Password"},
{"type": "oauth", "name": "nous"}
]
}
}
""".trimIndent(),
),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val status = client.getStatus().getOrThrow()
assertTrue(status.authRequired)
assertEquals(listOf("basic", "nous"), status.authProviders)
assertTrue(status.authProviderDetails.first { it.name == "basic" }.supportsPassword)
assertFalse(status.authProviderDetails.first { it.name == "nous" }.supportsPassword)
}
@Test
fun getAuthProviders_parsesProviderMetadata() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""
{
"providers": [
{
"name": "basic",
"display_name": "Username & Password",
"supports_password": true
},
{
"name": "nous",
"display_name": "Nous Research",
"supports_password": false
}
]
}
""".trimIndent(),
),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val providers = client.getAuthProviders().getOrThrow()
assertEquals("/api/auth/providers", server.takeRequest().path)
assertEquals("Username & Password", providers[0].displayName)
assertTrue(providers[0].supportsPassword)
assertEquals("nous", providers[1].name)
assertTrue(providers[1].isRedirectProvider)
}
@Test
fun getAuthProviders_acceptsProviderMapMetadata() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""
{
"providers": {
"basic": {
"display_name": "Username & Password",
"supports_password": true
},
"nous": {
"type": "oauth",
"display_name": "Nous Research"
}
}
}
""".trimIndent(),
),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val providers = client.getAuthProviders().getOrThrow()
assertEquals(listOf("basic", "nous"), providers.map { it.name })
assertTrue(providers.first { it.name == "basic" }.supportsPassword)
assertEquals("Nous Research", providers.first { it.name == "nous" }.displayName)
assertTrue(providers.first { it.name == "nous" }.isRedirectProvider)
}
@Test
fun authUrlAndGatewayWebSocketUrl_preserveReverseProxyPrefix() {
val authUrl = DashboardApiClient.authLoginUrl(
baseUrl = "https://example.com/hermes/",
provider = "nous",
next = "/chat",
)
val wsUrl = DashboardApiClient.gatewayWebSocketUrl(
baseUrl = "https://example.com/hermes/",
ticket = "abc/123",
)
val landingPath = DashboardApiClient.authLandingPath("https://example.com/hermes/")
assertEquals(
"https://example.com/hermes/auth/login?provider=nous&next=%2Fchat",
authUrl,
)
assertEquals(
"wss://example.com/hermes/api/ws?ticket=abc%2F123",
wsUrl,
)
assertEquals("/hermes/", landingPath)
}
@Test
fun importDashboardCookieHeader_storesWebViewCookiesForDashboardClient() {
val store = InMemoryDashboardCookieStore()
val imported = importDashboardCookieHeader(
store = store,
url = "https://example.com/hermes/",
cookieHeader = "hermes_session_at=access; hermes_session_rt=refresh",
)
val client = DashboardCookieJar(store)
val cookies = client.loadForRequest(
"https://example.com/hermes/api/auth/me".toHttpUrl(),
)
assertEquals(2, imported)
assertEquals(listOf("hermes_session_at", "hermes_session_rt"), cookies.map { it.name })
assertTrue(cookies.all { it.secure })
}
@Test
fun importDashboardCookieHeader_callbackPathStillMatchesApiSession() {
val store = InMemoryDashboardCookieStore()
val imported = importDashboardCookieHeader(
store = store,
url = "https://example.com/auth/callback?nous=ok",
cookieHeader = "hermes_session=abc123",
)
val client = DashboardCookieJar(store)
val cookies = client.loadForRequest(
"https://example.com/api/auth/me".toHttpUrl(),
)
assertEquals(1, imported)
assertEquals(listOf("hermes_session"), cookies.map { it.name })
}
@Test
fun getStatus_defaultsMissingAuthFieldsForOlderDashboard() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("""{"version": "legacy"}"""),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val status = client.getStatus().getOrThrow()
assertFalse(status.authRequired)
assertEquals(emptyList<String>(), status.authProviders)
assertEquals("legacy", status.version)
}
@Test
fun passwordLogin_postsExpectedBodyAndPersistsSessionCookie() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.addHeader("Set-Cookie", "hermes_session=abc123; Path=/; HttpOnly")
.setBody("""{"ok": true, "next": "/"}"""),
)
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"authenticated": true, "username": "bailey", "provider": "basic"}"""),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val login = client.loginPassword(
username = "bailey",
password = "secret",
).getOrThrow()
val session = client.currentSession().getOrThrow()
val loginRequest = server.takeRequest()
val sessionRequest = server.takeRequest()
assertEquals("/auth/password-login", loginRequest.path)
val body = loginRequest.body.readUtf8()
assertTrue(body.contains(""""provider":"basic""""))
assertTrue(body.contains(""""username":"bailey""""))
assertTrue(body.contains(""""password":"secret""""))
assertTrue(login.ok)
assertEquals("/", login.next)
assertEquals("/api/auth/me", sessionRequest.path)
assertEquals("hermes_session=abc123", sessionRequest.getHeader("Cookie"))
assertTrue(session.authenticated)
assertEquals("bailey", session.username)
assertEquals("basic", session.provider)
}
@Test
fun currentSession_mapsUnauthorizedToUnauthenticated() = runTest {
server.enqueue(MockResponse().setResponseCode(401))
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val session = client.currentSession().getOrThrow()
assertFalse(session.authenticated)
}
@Test
fun currentSession_acceptsUpstreamFlatDashboardSession() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""
{
"user_id": "user_123",
"email": "bailey@example.com",
"display_name": "Bailey",
"provider": "nous",
"expires_at": 1893456000
}
""".trimIndent(),
),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val session = client.currentSession().getOrThrow()
assertEquals("/api/auth/me", server.takeRequest().path)
assertTrue(session.authenticated)
assertEquals("Bailey", session.username)
assertEquals("nous", session.provider)
}
@Test
fun dashboardRequest_reportsUnsupportedEndpointAsHttpFailure() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(404)
.setBody("""{"detail": "not found"}"""),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val failure = client.getJsonObject("/api/mcp/servers").exceptionOrNull()
assertEquals("/api/mcp/servers", server.takeRequest().path)
assertTrue(failure?.message.orEmpty().contains("/api/mcp/servers failed - HTTP 404"))
}
@Test
fun getJsonElement_acceptsTopLevelArray() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("""[{"name":"default"}]"""),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val root = client.getJsonElement("/api/profiles").getOrThrow()
assertEquals("/api/profiles", server.takeRequest().path)
assertTrue(root is JsonArray)
assertEquals(1, (root as JsonArray).size)
}
@Test
fun toggleSkill_putsExpectedBody() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("""{"ok": true, "name": "research", "enabled": false}"""),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
client.toggleSkill("research", enabled = false).getOrThrow()
val request = server.takeRequest()
assertEquals("PUT", request.method)
assertEquals("/api/skills/toggle", request.path)
val body = request.body.readUtf8()
assertTrue(body.contains(""""name":"research""""))
assertTrue(body.contains(""""enabled":false"""))
}
@Test
fun cronActions_encodeJobIdAndProfile() = runTest {
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"runs": []}"""))
val client = DashboardApiClient(baseUrl = server.url("/").toString())
client.pauseCronJob("daily report", profile = "work profile").getOrThrow()
client.getCronJobRuns("daily report", profile = "work profile", limit = 250).getOrThrow()
val pause = server.takeRequest()
val runs = server.takeRequest()
assertEquals("POST", pause.method)
assertEquals("/api/cron/jobs/daily%20report/pause?profile=work%20profile", pause.path)
assertEquals("GET", runs.method)
assertEquals("/api/cron/jobs/daily%20report/runs?profile=work%20profile&limit=100", runs.path)
}
@Test
fun mcpActions_useEnabledTestAndRemoveRoutes() = runTest {
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true, "tools": []}"""))
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true, "background": false}"""))
val client = DashboardApiClient(baseUrl = server.url("/").toString())
client.setMcpServerEnabled("github tools", enabled = true).getOrThrow()
client.testMcpServer("github tools").getOrThrow()
client.removeMcpServer("github tools").getOrThrow()
client.installMcpCatalogEntry(
name = "linear",
env = mapOf("LINEAR_API_KEY" to "secret"),
enable = false,
).getOrThrow()
val enable = server.takeRequest()
val test = server.takeRequest()
val remove = server.takeRequest()
val install = server.takeRequest()
assertEquals("PUT", enable.method)
assertEquals("/api/mcp/servers/github%20tools/enabled", enable.path)
assertTrue(enable.body.readUtf8().contains(""""enabled":true"""))
assertEquals("POST", test.method)
assertEquals("/api/mcp/servers/github%20tools/test", test.path)
assertEquals("DELETE", remove.method)
assertEquals("/api/mcp/servers/github%20tools", remove.path)
assertEquals("POST", install.method)
assertEquals("/api/mcp/catalog/install", install.path)
val body = install.body.readUtf8()
assertTrue(body.contains(""""name":"linear""""))
assertTrue(body.contains(""""LINEAR_API_KEY":"secret""""))
assertTrue(body.contains(""""enable":false"""))
}
@Test
fun profileActions_useActiveAndDeleteRoutes() = runTest {
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"content": "soul", "exists": true}"""))
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
val client = DashboardApiClient(baseUrl = server.url("/").toString())
client.setActiveProfile("research").getOrThrow()
client.getProfileSoul("research profile").getOrThrow()
client.deleteProfile("old profile").getOrThrow()
val active = server.takeRequest()
val soul = server.takeRequest()
val delete = server.takeRequest()
assertEquals("POST", active.method)
assertEquals("/api/profiles/active", active.path)
assertTrue(active.body.readUtf8().contains(""""name":"research""""))
assertEquals("GET", soul.method)
assertEquals("/api/profiles/research%20profile/soul", soul.path)
assertEquals("DELETE", delete.method)
assertEquals("/api/profiles/old%20profile", delete.path)
}
}
@@ -275,6 +275,79 @@ class EndpointResolverTest {
assertEquals("tailscale", winner!!.role)
}
// ---------------------------------------------------------------
// Test 8 — probeOutcomes records UI-facing verdicts per candidate
// ---------------------------------------------------------------
@Test
fun probeOutcomes_recordsReachableAndUnreachableVerdicts() = runTest {
val resolver = EndpointResolver(fastClient, clock = { clockMillis.get() })
val lan = candidate("lan", priority = 0, server = reachableServer)
reachableServer.dispatcher = healthDispatcher(statusCode = 200)
resolver.resolve(listOf(lan))
val reachableOutcome = resolver.probeOutcomes.value[EndpointResolver.cacheKey(lan)]
assertNotNull("successful probe must record an outcome", reachableOutcome)
assertTrue(reachableOutcome!!.reachable)
assertNull("reachable outcomes carry no failure detail", reachableOutcome.detail)
// Flip to 500 past the positive TTL so a real probe fires again.
reachableServer.dispatcher = healthDispatcher(statusCode = 500)
clockMillis.set(EndpointResolver.CACHE_TTL_MS + 1_000L)
resolver.resolve(listOf(lan))
val failedOutcome = resolver.probeOutcomes.value[EndpointResolver.cacheKey(lan)]
assertNotNull(failedOutcome)
assertTrue("failed probe must flip the outcome", !failedOutcome!!.reachable)
assertEquals("HTTP 500 from /health", failedOutcome.detail)
}
@Test
fun probeOutcomes_recordsConnectionFailureDetail() = runTest {
val dead = MockWebServer().apply { start() }
val deadHost = dead.hostName
val deadPort = dead.port
dead.shutdown()
val resolver = EndpointResolver(fastClient, clock = { clockMillis.get() })
val deadCandidate = EndpointCandidate(
role = "tailscale", priority = 0,
api = ApiEndpoint(deadHost, deadPort, tls = false),
relay = RelayEndpoint("ws://$deadHost:$deadPort", transportHint = "ws"),
)
resolver.resolve(listOf(deadCandidate))
val outcome = resolver.probeOutcomes.value[EndpointResolver.cacheKey(deadCandidate)]
assertNotNull("dead-port probe must record an outcome", outcome)
assertTrue(!outcome!!.reachable)
assertNotNull("failure outcomes must carry a human detail", outcome.detail)
}
@Test
fun probeOutcomes_surviveClearCache() = runTest {
val resolver = EndpointResolver(fastClient, clock = { clockMillis.get() })
val lan = candidate("lan", priority = 0, server = reachableServer)
resolver.resolve(listOf(lan))
assertNotNull(resolver.probeOutcomes.value[EndpointResolver.cacheKey(lan)])
resolver.clearCache()
assertNotNull(
"clearCache resets probe *caching*, not the UI-facing verdict history",
resolver.probeOutcomes.value[EndpointResolver.cacheKey(lan)],
)
}
@Test
fun markUnreachable_recordsOutcome() = runTest {
val resolver = EndpointResolver(fastClient, clock = { clockMillis.get() })
val lan = candidate("lan", priority = 0, server = reachableServer)
resolver.markUnreachable(lan)
val outcome = resolver.probeOutcomes.value[EndpointResolver.cacheKey(lan)]
assertNotNull(outcome)
assertTrue(!outcome!!.reachable)
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
@@ -1,8 +1,11 @@
package com.hermesandroid.relay.network
import com.hermesandroid.relay.network.models.SkillListResponse
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -171,6 +174,47 @@ class HermesApiClientTest {
assertEquals(ChatMode.ENHANCED_HERMES, capabilities.toChatMode())
}
@Test
fun parseCapabilitiesBody_prefersNativeUpstreamSessionFeatures() {
val body = """
{
"object": "hermes.api_server.capabilities",
"features": {
"chat_completions": true,
"run_events_sse": true,
"session_resources": true,
"session_chat_streaming": true,
"skills_api": true
},
"endpoints": {
"chat_completions": {"method": "POST", "path": "/v1/chat/completions"},
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
"sessions": {"method": "GET", "path": "/api/sessions"},
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
"skills": {"method": "GET", "path": "/v1/skills"},
"toolsets": {"method": "GET", "path": "/v1/toolsets"}
}
}
""".trimIndent()
val capabilities = parseCapabilitiesBody(Json { ignoreUnknownKeys = true }, body)
assertEquals(true, capabilities?.sessionsApi)
assertEquals(true, capabilities?.sessionsChatStream)
assertEquals(true, capabilities?.portable)
assertEquals(true, capabilities?.runs)
assertEquals("sessions", capabilities?.preferredChatEndpoint())
}
@Test
fun parseCapabilitiesBody_returnsNullForUnrelatedJson() {
val body = """{"status":"ok"}"""
val capabilities = parseCapabilitiesBody(Json { ignoreUnknownKeys = true }, body)
assertNull(capabilities)
}
// --- URL construction patterns ---
// These verify the string patterns used by authRequest() inside the client.
@@ -220,6 +264,46 @@ class HermesApiClientTest {
assertEquals("http://localhost:8642/v1/models", url)
}
// --- Skills endpoint compatibility ---
@Test
fun skillEndpointOrder_prefersUpstreamV1ThenLegacyApiFallback() {
assertEquals(listOf("/v1/skills", "/api/skills"), HERMES_SKILL_ENDPOINTS)
}
@Test
fun skillListResponse_parsesUpstreamV1DataEnvelope() {
val body = """
{
"object": "list",
"data": [
{"name": "android", "description": "Control phone", "category": "android"}
]
}
""".trimIndent()
val parsed = Json { ignoreUnknownKeys = true }.decodeFromString<SkillListResponse>(body)
assertEquals(1, parsed.data?.size)
assertEquals("android", parsed.data?.first()?.name)
}
@Test
fun parseSkillListBody_acceptsUpstreamV1DataEnvelope() {
val body = """
{
"object": "list",
"data": [
{"name": "android", "description": "Control phone", "category": "android"}
]
}
""".trimIndent()
val parsed = parseSkillListBody(Json { ignoreUnknownKeys = true }, body)
assertEquals(listOf("android"), parsed?.map { it.name })
}
@Test
fun urlConstruction_deleteSession() {
val baseUrl = "http://localhost:8642"
@@ -134,6 +134,24 @@ class SessionModelsTest {
assertEquals("s1", response.sessions!![0].id)
}
@Test
fun sessionListResponse_withUpstreamDataField() {
val jsonStr = """
{
"object": "list",
"data": [
{"id": "s1", "title": "Session 1"}
]
}
""".trimIndent()
val response = json.decodeFromString<SessionListResponse>(jsonStr)
assertNotNull(response.data)
assertEquals(1, response.data!!.size)
assertEquals("s1", response.data!![0].id)
}
@Test
fun sessionListResponse_bothFieldsNull_whenEmpty() {
val jsonStr = """{}"""
@@ -141,6 +159,7 @@ class SessionModelsTest {
assertNull(response.items)
assertNull(response.sessions)
assertNull(response.data)
}
// --- SessionResponse ---
@@ -279,6 +298,25 @@ class SessionModelsTest {
assertEquals(1, response.messages!!.size)
}
@Test
fun messageListResponse_withUpstreamDataField() {
val jsonStr = """
{
"object": "list",
"session_id": "s1",
"data": [
{"role": "user", "content": "Test"}
]
}
""".trimIndent()
val response = json.decodeFromString<MessageListResponse>(jsonStr)
assertNotNull(response.data)
assertEquals(1, response.data!!.size)
assertEquals("Test", response.data!![0].contentText)
}
// --- HermesSseEvent ---
@Test
@@ -270,6 +270,44 @@ class HermesPairingPayloadTest {
assertNull(ep.relay.transportHint)
}
@Test
fun genericApiUrlQr_buildsStandardPayload() {
val payload = parseHermesPairingQr("https://hermes.example.com:8642")
assertNotNull(payload)
val parsed = payload!!
val endpoints = parsed.endpoints.orEmpty()
assertEquals("hermes.example.com", parsed.host)
assertEquals(8642, parsed.port)
assertTrue(parsed.tls)
assertEquals("", parsed.key)
assertNull(parsed.relay)
assertEquals("https://hermes.example.com:8642", parsed.serverUrl)
assertEquals(1, endpoints.size)
assertEquals("public", endpoints[0].role)
}
@Test
fun genericApiJsonQr_acceptsUrlAndApiKeyAliases() {
val raw = """
{
"api_url": "http://192.168.1.50:8642",
"api_key": "dev-key"
}
""".trimIndent()
val payload = parseHermesPairingQr(raw)
assertNotNull(payload)
val parsed = payload!!
assertEquals("192.168.1.50", parsed.host)
assertEquals(8642, parsed.port)
assertFalse(parsed.tls)
assertEquals("dev-key", parsed.key)
assertNull(parsed.relay)
assertEquals("lan", parsed.endpoints.orEmpty()[0].role)
}
@Test
fun missingHost_rejectsPayload() {
// The parser's minimum contract: a payload without `host` is not
@@ -0,0 +1,41 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.auth.AuthState
import org.junit.Assert.assertEquals
import org.junit.Test
class PowerFeatureGateTest {
@Test
fun fromRelayAuth_unpaired_requiresPairing() {
assertEquals(
PowerFeatureGateStatus.RequiresPairing,
PowerFeatureGateStatus.fromRelayAuth(AuthState.Unpaired),
)
}
@Test
fun fromRelayAuth_expiredFailure_mapsToPairingExpired() {
assertEquals(
PowerFeatureGateStatus.PairingExpired,
PowerFeatureGateStatus.fromRelayAuth(AuthState.Failed("Your session expired")),
)
}
@Test
fun fromRelayAuth_tokenFailure_mapsToPairingExpired() {
assertEquals(
PowerFeatureGateStatus.PairingExpired,
PowerFeatureGateStatus.fromRelayAuth(AuthState.Failed("server rejected token")),
)
}
@Test
fun fromRelayAuth_otherFailure_requiresPairing() {
assertEquals(
PowerFeatureGateStatus.RequiresPairing,
PowerFeatureGateStatus.fromRelayAuth(AuthState.Failed("pairing code was invalid")),
)
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id("com.android.application") version "9.2.0" apply false
id("com.android.library") version "9.2.0" apply false
id("com.android.application") version "9.2.1" apply false
id("com.android.library") version "9.2.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" apply false
}
+38 -7
View File
@@ -66,12 +66,34 @@ GET /v1/models
-> { "object": "list", "data": [{ "id": "claude-opus-4-6", "object": "model" }, ...] }
```
### Capabilities
```
GET /v1/capabilities
-> {
"features": {
"session_resources": true,
"session_chat_streaming": true,
"skills_api": true
},
"endpoints": {
"sessions": {"method": "GET", "path": "/api/sessions"},
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
"skills": {"method": "GET", "path": "/v1/skills"},
"toolsets": {"method": "GET", "path": "/v1/toolsets"}
}
}
```
Use this before assuming optional API-server surfaces exist. Older builds may
not have `/v1/capabilities`; in that case Hermes-Relay falls back to targeted
route probes.
### Sessions
```
# List sessions
GET /api/sessions?limit=50&offset=0
-> { "items": [...], "total": N }
-> { "object": "list", "data": [...], "total": N }
# Create session
POST /api/sessions
@@ -92,7 +114,7 @@ DELETE /api/sessions/{session_id}
# Get messages
GET /api/sessions/{session_id}/messages
-> { "items": [...], "total": N }
-> { "object": "list", "data": [...], "total": N }
# Search sessions
GET /api/sessions/search?q=keyword&limit=20
@@ -103,6 +125,10 @@ POST /api/sessions/{session_id}/fork
-> { "session": { ... }, "forked_from": "..." }
```
The native upstream list envelope is `{"object":"list","data":[...]}`.
Older fork/bootstrap builds may return `items`, `sessions`, or `messages`;
clients should continue accepting those as compatibility shapes.
### Chat (Non-Streaming)
```
POST /api/sessions/{session_id}/chat
@@ -259,7 +285,9 @@ GET /api/memory?target=memory // or target=user
### Skills
```
GET /api/skills # optional ?category= filter
GET /v1/skills # native upstream read-only list
GET /v1/toolsets # native upstream toolset inventory
GET /api/skills # legacy compatibility list, optional ?category= filter
GET /api/skills/{name}
```
> `/api/skills/categories` was removed from upstream as dead code (commit 8d023e43) and is not re-injected by the bootstrap.
@@ -270,14 +298,17 @@ Probe endpoints to detect what's available:
```
GET /health -> basic connectivity
GET /api/sessions -> enhanced Hermes session API
GET /v1/capabilities -> native feature and endpoint map
GET /api/sessions -> session API fallback probe
GET /v1/models -> model listing (OpenAI-compatible)
GET /api/skills -> skills support
GET /v1/skills -> native read-only skills support
GET /v1/toolsets -> native read-only toolsets support
GET /api/skills -> legacy skills compatibility
GET /api/memory -> memory support
GET /api/config -> config API
Chat modes:
"enhanced-hermes" -> sessions API available (use /api/sessions/*/chat/stream)
"enhanced-hermes" -> sessions API available (prefer /api/sessions/*/chat/stream)
"portable" -> only /v1/chat/completions available (OpenAI-compatible)
"disconnected" -> nothing works
```
@@ -292,4 +323,4 @@ Chat modes:
| Streaming format | OpenAI delta format | Custom SSE events (see above) |
| Tool visibility | Hidden | Exposed via events (pending/started/completed/failed) |
| Thinking/Reasoning | Not exposed | Exposed via `tool.progress` events |
| Memory/Skills | Not applicable | Full API access |
| Memory/Skills | Not applicable | Native read-only skills/toolsets; memory and skill detail/toggle remain compatibility surfaces |
+230 -40
View File
@@ -80,15 +80,16 @@ The app supports two streaming endpoints, selectable in Settings:
| **Sessions** (`/api/sessions/{id}/chat/stream`) | Inline text annotations (`` `💻 terminal` ``) — client parses from markdown | Hermes-native SSE (assistant.delta, tool.progress, etc.) or OpenAI-format (delta.content) |
| **Runs** (`/v1/runs` + `/v1/runs/{run_id}/events`) | **Structured events** (tool.started, tool.completed) — real-time tool cards | Hermes lifecycle events (message.delta, tool.started, tool.completed, run.completed) |
**Important upstream note:** The `/api/sessions` CRUD endpoints are moving
toward upstream Hermes core through focused PR
[#29302](https://github.com/NousResearch/hermes-agent/pull/29302), which covers
session list/create/read/update/delete, messages, fork, chat, and chat stream.
Until that reaches a released core build, `hermes_relay_bootstrap/` still ships
with the plugin and runs at Python interpreter startup via `.pth`. The bootstrap
now composes with partial upstream support: native routes win per method/path,
and the relay only injects missing compatibility gaps such as config, skills, or
memory when core does not provide them.
**Important upstream note:** The `/api/sessions` CRUD/chat endpoints are now in
upstream Hermes core via focused PR
[#33134](https://github.com/NousResearch/hermes-agent/pull/33134), which
salvaged the useful session-control portion of #29302 and covers session
list/create/read/update/delete, messages, fork, chat, and chat stream. Read-only
skill/toolset discovery is also native via
[#33016](https://github.com/NousResearch/hermes-agent/pull/33016). The bootstrap
still ships for older core builds and for surfaces that remain compatibility-only
(config, memory, legacy skill detail/toggle, available-models, slash middleware),
but sessions and read-only skill lists should now be upstream-first.
The app's `probeCapabilities()` returns a per-endpoint snapshot, and `ConnectionViewModel.resolveStreamingEndpoint()` collapses `streamingEndpoint = "auto"` (the default for new installs) to a concrete `"sessions"` or `"runs"` choice based on what the server actually exposes.
@@ -132,12 +133,12 @@ Phone (WSS) → Relay Server (:8767) [bridge, terminal]
#### 6a. QR Carries Both API and Relay Credentials (updated 2026-05-03)
**Decision:** The Hermes pairing QR payload bundles the API server credentials AND the relay URL + pairing code into a single scan. The pair command (`/hermes-relay-pair` skill or `hermes-pair` shell shim, both backed by `plugin/pair.py`) runs on the Hermes host; if a relay is reachable at `localhost:RELAY_PORT`, the command mints a fresh 6-char code, pre-registers it with the relay via a new loopback-only `POST /pairing/register` endpoint, and embeds `{url, code}` under a nullable `relay` key alongside the existing `host`/`port`/`key`/`tls` fields. The dashboard pairing flow uses the relay's loopback-only `POST /pairing/mint` endpoint instead; when the dashboard omits `api_key`, the relay reads the same host-local Hermes API key config as `hermes-pair` and places it in top-level `key`.
**Decision:** The Hermes pairing QR payload bundles the API server credentials AND the relay URL + pairing code into a single scan. The pair command (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim, all backed by `plugin/pair.py`) runs on the Hermes host; if a relay is reachable at `localhost:RELAY_PORT`, the command mints a fresh 6-char code, pre-registers it with the relay via a new loopback-only `POST /pairing/register` endpoint, and embeds `{url, code}` under a nullable `relay` key alongside the existing `host`/`port`/`key`/`tls` fields. The dashboard pairing flow uses the relay's loopback-only `POST /pairing/mint` endpoint instead; when the dashboard omits `api_key`, the relay reads the same host-local Hermes API key config as `hermes pair` and places it in top-level `key`.
**Trust anchor:** the operator with shell access on the host. Only a process running on the same machine as the relay can hit `/pairing/register` — the handler rejects any non-loopback `request.remote` with HTTP 403. A LAN attacker cannot inject codes. This matches the model we already rely on for reading `~/.hermes/.env` and `~/.hermes/config.yaml`: if you have shell access to the host, you have enough privilege to authorize a device.
**Why the change was necessary:**
- Previously the phone generated its own 6-char pairing code locally via `AuthManager.generatePairingCode()` and sent it to the relay on WSS connect. The relay had no way to know what code to accept, so relay pairing was effectively broken — only API-direct-chat pairing worked via the QR.
- Previously the phone generated its own 6-char pairing code locally via `AuthManager.generatePairingCode()` and sent it to the relay on WSS connect. The relay had no way to know what code to accept, so relay pairing was effectively broken — only direct API chat pairing worked via the QR.
- Pushing the code flow through the host means the operator always has the source of truth, and a single scan configures both chat and terminal/bridge with no manual steps.
**Schema evolution:**
@@ -341,7 +342,7 @@ Key data classes: `MessageEvent` (inbound), `SendResult` (outbound), `SessionSou
**Why the old `skills/hermes-pairing-qr/` was deleted:** It was the pre-plugin bash script era — `hermes-pair` as a shell script + a flat-file `SKILL.md`. The plugin now owns the QR generation (`plugin/pair.py`, pure Python, no `qrencode` dependency), the skill at `skills/devops/hermes-relay-pair/` owns the slash-command surface, and the shell shim at `~/.local/bin/hermes-pair` covers the script-friendly CLI entry point. Keeping the deprecated skill around would have been two sources of truth for the same operation.
**Upstream CLI gap (documented for posterity):** hermes-agent v0.8.0's `PluginContext.register_cli_command()` is wired up on the plugin side, and `plugin/cli.py` calls it correctly. However, `hermes_cli/main.py:5236` only reads `plugins.memory.discover_plugin_cli_commands()` (memory-plugin-specific) and never consults the generic `_cli_commands` dict. Third-party plugin CLI commands never reach the top-level argparser. Documented in DEVLOG as an upstream fix target. Until it lands, `hermes pair` (with a space) is **not** a working entry point — docs point users at `/hermes-relay-pair` (skill-driven slash command) and `hermes-pair` (dashed shell shim) instead.
**Plugin CLI status (updated 2026-06-07):** hermes-agent v0.8.0 had a top-level argparse gap where third-party `PluginContext.register_cli_command()` entries did not reach `hermes <subcommand>`. Current upstream now discovers plugin CLI registrations in `hermes_cli/main.py`, so `hermes pair` and `hermes relay` are the preferred shell entry points when the plugin is enabled. `/hermes-relay-pair` and the dashed `hermes-pair` shim stay as older-build and script compatibility paths until our supported baseline includes the upstream fix.
**References:**
- `install.sh` — canonical installer
@@ -453,7 +454,7 @@ The bare-path fetch is therefore safe as long as operators treat the allowed-roo
- **Grants on a single token (not multiple tokens)** — one WSS connection, one auth envelope, one session lookup. Per-channel expiry is checked at channel message dispatch time via `Session.channel_is_expired(name)`. Simpler to reason about than multiple parallel tokens, and the phone only needs one storage slot.
- **`math.inf` for never-expire** — represents "truly unbounded" in code, serializes to `null` on the wire (JSON doesn't have an infinity literal, and null maps cleanly to Kotlin's nullable `Long?`). `canonicalize()` uses `allow_nan=False` so accidentally trying to sign a payload with a raw `math.inf` crashes loudly — callers must explicitly emit `None`/`0`. Prevents silent serialization bugs.
- **Metadata on pairing entries, host wins over phone** — when the host operator runs `hermes-pair --ttl 7d` and the phone sends `ttl_seconds=30d` in the auth envelope (because the user picked a different value on the TTL dialog), the host value wins. Operator policy is authoritative. If the host didn't specify anything, the phone's value applies.
- **Metadata on pairing entries, host wins over phone** — when the host operator runs `hermes pair --ttl 7d` and the phone sends `ttl_seconds=30d` in the auth envelope (because the user picked a different value on the TTL dialog), the host value wins. Operator policy is authoritative. If the host didn't specify anything, the phone's value applies.
- **Token prefix (not full token) in `/sessions` responses** — a caller already holds their own full token; they should never see another session's full token. First 8 chars are enough to identify devices in a practical deployment (one operator, 1-3 phones) and enough entropy to avoid collisions. Collisions return 409 with the match count.
- **Always open the TTL picker (no skip)** — even when the QR carries an operator-chosen TTL, the dialog opens with that value preselected. The user is always in the loop for the trust decision. A future "don't ask again if QR specifies a TTL" toggle is a plausible refinement but not in this cut.
@@ -499,13 +500,17 @@ Adopting from ARC's workflow patterns:
### 16. Runtime API Server Patch via .pth Bootstrap (2026-04-12)
**Context:** The Android app depends on API-server routes for session history,
profile/config metadata, skills, and memory-backed UI. Upstream core is now
moving in focused pieces rather than one large frontend API patch: PR
[#29302](https://github.com/NousResearch/hermes-agent/pull/29302) covers the
canonical `/api/sessions/*` surface, while config/skills/memory still remain
compatibility routes in this repo until core exposes stable equivalents. Without
the bootstrap, users on older vanilla upstream builds lose session browsing,
metadata-backed settings, and history-on-restart behavior.
profile/config metadata, skills, and memory-backed UI. Upstream core moved in
focused pieces rather than one large frontend API patch: PR
[#33134](https://github.com/NousResearch/hermes-agent/pull/33134) now covers the
canonical `/api/sessions/*` surface, and PR
[#33016](https://github.com/NousResearch/hermes-agent/pull/33016) covers
read-only `/v1/skills` + `/v1/toolsets`. Config, memory, legacy skill
detail/toggle, available-models, and slash-command preprocessing still remain
compatibility routes in this repo until core exposes stable equivalents or the
local UI no longer depends on them. Without the bootstrap, users on older
vanilla upstream builds lose session browsing, metadata-backed settings, and
history-on-restart behavior.
We considered four options:
- **A. Stay fork-only.** Reject vanilla upstream users until the relevant core API surfaces land. Penalises onboarding.
@@ -520,19 +525,24 @@ We considered four options:
1. **Zero modifications to hermes-agent's filesystem.** `git pull` / `hermes update` see no local changes, so they always work cleanly. The patch lives entirely in `hermes_relay_bootstrap/` inside our own repo.
2. **Single-file containment of all ported logic.** `_handlers.py` is 500 lines of straight-line aiohttp handler code with explicit `adapter` parameters (closures, not bound methods). Easy to audit, easy to delete.
3. **Feature detection by method/path, not broad route family.** Native upstream
routes win one method/path at a time. This matters because PR #29302 can land
`/api/sessions/*` before core has stable config/skills/memory APIs; the
routes win one method/path at a time. This matters because #33134 landed
`/api/sessions/*` before core had stable config/memory/legacy skill APIs; the
bootstrap must not skip those remaining compatibility routes just because a
sessions route exists.
4. **Trust model already established.** The user installed our plugin into their hermes-agent venv. They've already consented to having the plugin import hermes-agent internals (it does this for relay tools, voice endpoints, media registry). Monkey-patching `aiohttp.web.Application` is in the same trust bucket.
5. **Surface-by-surface removal.** When PR #29302 or equivalent reaches a
released hermes-agent version, the sessions compatibility routes should go
quiet automatically. Config, skills, memory, and command preprocessing remain
until their native replacements exist. Full bootstrap deletion happens only
after every compatibility route group has a stable core equivalent.
6. **`/v1/runs` is genuinely better for chat than `/api/sessions/{id}/chat/stream`.** It's standard upstream, supports `X-Hermes-Session-Id` for continuation, and emits live structured tool events. The fork's chat handler exists because upstream didn't HAVE this clean structured-event runs API at the time the fork was cut — but upstream does now.
5. **Surface-by-surface removal.** With #33134/#33016 merged, sessions and
read-only skill lists should go quiet automatically on current upstream.
Config, memory, legacy skill detail/toggle, available-models, and command
preprocessing remain until their native replacements exist or the local UI
stops depending on them. Full bootstrap deletion happens only after every
compatibility route group has a stable core equivalent or a deliberate local
removal.
6. **`/v1/runs` remains the fallback run-control path.** Native
`/api/sessions/{id}/chat/stream` is now the preferred session-persisted chat
path when advertised. `/v1/runs` still matters for async run lifecycle/control
and for older builds without native sessions chat.
**The Android client adapts via `streamingEndpoint = "auto"`.** New `ServerCapabilities` data class returned by `HermesApiClient.probeCapabilities()` captures per-endpoint presence (`sessionsApi`, `sessionsChatStream`, `runs`, `portable`, `healthy`). `ConnectionViewModel.resolveStreamingEndpoint()` collapses `"auto"` to `"sessions"` (when chat-stream handler is present, i.e. fork or upstream-merged) or `"runs"` (otherwise, i.e. bootstrap-injected vanilla upstream). The setting still supports manual `"sessions"` / `"runs"` overrides for debugging.
**The Android client adapts via `streamingEndpoint = "auto"`.** `ServerCapabilities` returned by `HermesApiClient.probeCapabilities()` captures per-endpoint presence (`sessionsApi`, `sessionsChatStream`, `runs`, `portable`, `healthy`). `ConnectionViewModel.resolveStreamingEndpoint()` collapses `"auto"` to `"sessions"` when native session chat is present, then falls back to OpenAI-compatible completions or runs according to the probe. The setting still supports manual `"sessions"` / `"completions"` / `"runs"` overrides for debugging.
**Risks accepted:**
- **Plugin load order** — verified: `.pth` files are processed by Python's `site` module BEFORE any application code runs, so our import hook is in place before hermes-agent imports `aiohttp.web`.
@@ -548,15 +558,19 @@ We considered four options:
- `install.sh` step 2 — copies the `.pth` into the venv site-packages
**Removal path** is now per surface:
1. Sessions: after PR #29302 or equivalent ships in released core, keep the
bootstrap installed but verify it skips native `/api/sessions/*` routes.
2. Config/skills/memory: remove those compatibility handlers only after stable
core APIs exist and Android probes prefer them.
3. Slash middleware: remove after native API-server slash preprocessing exists.
4. Full cleanup: delete `hermes_relay_bootstrap/`, delete
1. Sessions: once the supported Hermes baseline includes #33134, remove the
sessions compatibility handlers and any docs that require bootstrap for
history/chat. Until then, verify native `/api/sessions/*` routes win.
2. Read-only skills/toolsets: clients should prefer native `/v1/skills` and
`/v1/toolsets` from #33016. Retire `/api/skills` list dependence; keep legacy
detail/toggle only if the UI still needs it.
3. Config/memory/available-models: remove those compatibility handlers only
after stable core APIs exist or the dependent Android surfaces are redesigned.
4. Slash middleware: remove after native API-server slash preprocessing exists.
5. Full cleanup: delete `hermes_relay_bootstrap/`, delete
`hermes_relay_bootstrap.pth`, remove the `.pth` install block, and update
local agent docs only after all compatibility groups have native replacements.
5. The Android client `probeCapabilities()` and `streamingEndpoint = "auto"` plumbing stays — it's permanent infrastructure that handles mixed-version deployments.
6. The Android client `probeCapabilities()` and `streamingEndpoint = "auto"` plumbing stays — it's permanent infrastructure that handles mixed-version deployments.
---
@@ -928,7 +942,7 @@ The plan called for adding a `// VOICE HOOK` callback to `ChatViewModel` so `Voi
- `plugin/relay/server.py` — route registration alongside `/media/*`
- `plugin/tests/test_voice_routes.py` — 14 unit tests, `unittest`-based (pytest conftest issue documented in `CLAUDE.md`)
- `app/src/main/kotlin/.../audio/VoiceRecorder.kt` — MediaRecorder amplitude StateFlow
- `app/src/main/kotlin/.../audio/VoicePlayer.kt` — MediaPlayer + Visualizer amplitude StateFlow with OEM fallback
- `app/src/main/kotlin/.../audio/VoicePlayer.kt` — Media3 ExoPlayer (gapless TTS queue) + Visualizer amplitude StateFlow with OEM fallback; `audioSessionId` served from a thread-safe `@Volatile` cache (read off-main by barge-in)
- `app/src/main/kotlin/.../network/RelayVoiceClient.kt` — OkHttp multipart + JSON clients
- `app/src/main/kotlin/.../viewmodel/VoiceViewModel.kt` — turn state machine, sentence detection, TTS queue consumer, `ChatViewModel` observation pattern
- `app/src/main/kotlin/.../ui/components/VoiceModeOverlay.kt` — full-screen overlay, VoiceState→SphereState mapping, three interaction modes
@@ -1151,9 +1165,9 @@ session-API endpoints.
All shell out to `tailscale` CLI and return structured dicts; no new
daemon, no new state.
- `scripts/hermes-relay-tailscale` — shell shim mirroring `hermes-pair`
pattern (see `project_hermes_plugin_cli_gap.md` memory — plugin
`register_cli_command` doesn't reach `main.py` argparse on v0.8.0,
so shell shim is the working path).
pattern for scriptability and older Hermes builds. Current upstream supports
generic plugin CLI command dispatch, so native `hermes <subcommand>` should
be preferred when available.
- `install.sh` gets an optional step [7/7]: detect `tailscale` binary;
if present and the operator hasn't declined, offer to run
`tailscale serve --bg --https=8767 http://127.0.0.1:8767`. Skipped
@@ -1525,3 +1539,179 @@ old STT -> Hermes -> TTS pipeline instead of a native realtime agent.
- `plugin/relay/realtime_agent/broker.py`
- `plugin/relay/realtime_agent/providers/xai.py`
- `plugin/relay/realtime_agent/providers/openai.py`
---
## ADR 33 - Realtime Agent foregrounds short Hermes turns and promotes long ones to background tasks
**Status:** Accepted, phased (2026-05-24). Default-on at feature GA, gated by a
prerequisite provider-idle-tolerance spike (Phase 0). Supersedes the
blocking-broker assumption inside ADR 32's sequence; ADR 32's preamble ->
forced-Hermes -> provider-summary shape is preserved for the foreground tier.
**Context.** Today a Realtime Agent turn runs Hermes *synchronously inside the
provider event pump*: `_pump_provider_events` awaits `_handle_provider_tool_call`
-> `_run_brokered_tool`, which streams the entire Hermes SSE run to completion
before the pump consumes the next provider event (`broker.py`). This is correct
and lowest-latency for short Q&A, but it has two costs that grow with task
length:
- The provider realtime socket sits attached-but-idle for the whole run (a live,
billed, audio-clocked WebSocket parked for tens of seconds during research,
multi-tool, or desktop/build tasks).
- The tool surface already advertises a background vocabulary
(`hermes_run_task`, `hermes_get_status`, `hermes_cancel`, `hermes_confirm`)
and a `hermes_run_status` state machine, but `hermes_get_status` /
`hermes_cancel` as *provider* tool calls are unreachable mid-run because the
pump is parked. Only the client->relay `response.cancel` path can interrupt.
The blocking `await` is also an *implicit mutex*: it serializes the three audio
sources that can produce `voice.output_audio.delta` (see "Who speaks" below) so
they never overlap. Removing it requires replacing that mutex with an explicit
floor owner.
**Who speaks (confirmed against both supported providers).** Up to three mouths
exist; only one is active per phase today because of the blocking await:
1. **Realtime provider** - xAI `grok-voice-latest`, OpenAI `gpt-realtime-2`.
Both run with `turn_detection: None` (relay owns turn boundaries; no server
VAD) and audio output modality. Speaks the pre-Hermes acknowledgement and the
final post-result summary.
2. **Relay TTS render** - `xai_tts`/`openai_tts` via `_render_provider_audio`.
A separate, non-realtime synthesizer used as the forced-summary fallback and
the legacy render path. Emits the *same* `voice.output_audio.delta` wire event
as the provider, so Android cannot distinguish them.
3. **Android local TTS** - the `should_speak` long-wait filler, driven by
`hermes.run.progress`. Client-side, not the provider.
Because all three converge on one Android `AudioTrack`, **floor arbitration must
happen relay-side, before bytes reach the socket.**
**Decision.** Keep three turn classes; make promotion automatic and default-on,
with the relay as the single explicit floor owner.
- **Tier A - Foreground (short Q&A).** Unchanged from ADR 32: provider preamble
-> relay-forced Hermes (still awaited) -> provider summary. Lowest latency,
trivial floor. This remains the path for any turn that completes inside the
promotion grace window.
- **Tier B - Promoted (long task detected late).** Start in Tier A. If the
Hermes run has not produced a final result within a grace window
(`promote_after_ms`, default ~6000ms, tunable), the relay *detaches* the run
from the pump: the run continues as a tracked `asyncio.Task`, the pump resumes
consuming provider events, and the provider speaks a short "I've started that -
I'll let you know" handoff. Progress continues via `hermes.run.progress`. When
the background run completes, the relay injects the result as a tool result and
requests a provider summary at the next floor-idle moment.
- **Tier C - Explicitly durable.** `hermes_run_task(mode="background")` returns a
run handle immediately (no grace window). For tasks the model/profile knows up
front are long (research, builds via desktop tools, multi-step). Same
completion-injection path as Tier B.
Promotion is the default behavior, not a flag. Grace-period promotion preserves
Tier A latency for the common case and only forks when a run actually proves
long, so the user never has to pick a mode.
**The relay is the single floor owner.** A per-session floor state
(`idle | provider_speaking | hermes_filler | result_pending`) gates every audio
source:
- Only one mouth may hold the floor. The provider holds it by default.
- A completed background result does **not** barge in. It is queued as
`result_pending` and spoken only when the floor returns to `idle` (provider
finished, user not mid-utterance). Provider VAD being off means the relay
controls `response.create`, so it can withhold the summary until the floor is
clear.
- Android local filler (`should_speak`) is suppressed whenever the provider holds
the floor; it is a Tier B/C long-wait affordance only.
- Relay TTS render (mouth 2) may only fire when it owns the floor and the
provider has drained, exactly as the forced-summary fallback does today.
**Settings (ample, per ADR intent).** Surface in Voice Settings -> Realtime
Agent, with relay-side `realtime_voice` config as source of truth and per-profile
override:
- `promotion_enabled` (default true) - master switch; false pins Tier A blocking.
- `promote_after_ms` (default ~6000) - grace window before Tier B handoff.
- `background_default_mode` - whether ambiguous long turns prefer promote vs.
stay-foreground.
- `spoken_handoff` (default true) - speak the "I've started that" line on
promotion vs. silent + visual only.
- `progress_spoken_after_ms` / `progress_repeat_ms` - reuse existing
`_HERMES_SPOKEN_PROGRESS_*` knobs, now configurable.
- `result_delivery` - `speak_when_idle` (default) vs. `notify_then_speak`
(chime/visual, speak on user re-engage) vs. `visual_only`.
- `max_background_runs` - concurrent background runs per session (default 1 for
the MVP; the existing single-`hermes_task` field assumes 1).
**Protocol additions (relay <-> Android, additive).**
- `hermes.run.promoted` - run moved to background; carries `run_id`,
`promote_after_ms`, `spoken_handoff`.
- `hermes.run.background_completed` - background run finished; precedes the
provider/relay summary.
- Extend `hermes.run.progress` with `tier` and `floor` so the client can render
background state distinctly (e.g. a persistent "working on: ..." chip).
- `hermes_get_status` / `hermes_cancel` become genuinely reachable as provider
tool calls in Tier B/C because the pump is no longer parked; no schema change.
**Prerequisite (Phase 0 spike) — RESOLVED 2026-05-24.** The spike asked how xAI
and OpenAI realtime sessions behave when held open and idle. Verdicts (see
`docs/realtime-voice-poc.md`): **OpenAI `hold-floor-ok` (empirical** — survived
10/20/30s idle with clean post-idle audio); **xAI `hold-floor-ok`** (shipping
Realtime Agent already holds `xai_realtime` sessions open across between-turn
idle with `turn_detection: None` + resume TTL; relay-host probe retained as a
regression check, not a precondition). The premise was also superseded in
implementation: Tier B closes the pending provider call with an interim ack
rather than holding an open response, so the socket only sees the normal
between-turns idle gap — no provider needs the `must-reopen` fallback today, and
default-on is unblocked.
**Rules.**
- Hermes remains the only path for tools, memory, current data, research, side
effects, durable context, and confirmations (unchanged from ADR 29/32).
- Exactly one audio source may hold the floor at a time; the relay enforces this
before audio reaches Android. Background results never barge in.
- Android must not read raw Hermes output aloud; spoken output is always a
provider (or relay-fallback) summary of the compact Hermes result.
- Promotion must be cancel-safe: a promoted/background run is cancelable via both
the provider `hermes_cancel` tool and the client `response.cancel` path, reusing
`_cancel_active_hermes`.
- A turn must never strand: if a background run completes while the WS is
detached, the result is replayed through the existing event-ring/resume path on
reattach.
**Open questions / risks.**
- Floor arbitration is the hard part and the existing `native_forced_*` /
`_should_forward_provider_response_event` suppression machinery is already the
most fragile code in `broker.py`. Concurrency stresses it most; the floor-owner
state machine should *replace* ad-hoc suppression, not stack on top of it.
- Concurrent background runs (`max_background_runs > 1`) are out of scope for the
MVP; the session model assumes a single `hermes_task`.
- "Notify then speak" result delivery needs an Android affordance (chime +
chip + tap-to-hear) that does not yet exist.
**Phased rollout.**
1. **Phase 0** - provider-idle-tolerance spike (above). Gate for default-on.
2. **Phase 1** - introduce the relay floor-owner state machine under the current
blocking behavior (no functional change), with tests that prove single-floor
invariants.
3. **Phase 2** - Tier B grace-period promotion behind `promotion_enabled`,
default **off**, validated on long-task transcripts.
4. **Phase 3** - flip `promotion_enabled` default **on**; add Tier C
`mode="background"`; ship the Voice Settings surface.
**Key Files:**
- `docs/decisions.md` (this ADR; supersedes ADR 32's blocking assumption)
- `docs/plans/2026-05-24-realtime-background-hermes-runs.md` (companion plan)
- `plugin/relay/realtime_agent/broker.py`
- `plugin/relay/realtime_agent/hermes_tool_broker.py`
- `plugin/relay/realtime_agent/models.py`
- `plugin/relay/realtime_agent/providers/xai.py`
- `plugin/relay/realtime_agent/providers/openai.py`
- `plugin/relay/profile_voice.py`
- `docs/relay-protocol.md`
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/VoiceSettingsScreen.kt`
@@ -0,0 +1,77 @@
# Hermes Relay UI Refresh Mockups
Created as a design-only pass. These files do not change the Android app.
## Preview
Open `index.html` in a browser:
```powershell
Start-Process "C:\Users\Bailey\Desktop\Open-Projects\hermes-relay\docs\mockups\hermes-relay-ui-refresh\index.html"
```
## Direction
The proposed layout is a "Relay cockpit":
- Chat remains the default surface and keeps the sessions drawer.
- Manage remains primary for the standard install path.
- Bridge becomes a first-class mode instead of being buried in Settings.
- Terminal is always one tap away from Chat, Manage, and Bridge.
- Settings moves behind the agent/profile area and system menu instead of occupying a fat bottom tab.
- The bottom of the app becomes a thin live status strip, closer to official Hermes Desktop, rather than a large navigation bar.
## Inputs
Local app observations:
- `RelayApp.kt` currently registers Chat, Manage, Settings as bottom nav items, while Terminal and Bridge are routed but reached from secondary surfaces.
- `ChatScreen.kt` already has the useful pieces: session drawer, agent header, endpoint chip, command palette, voice, and attachment composer.
- `SettingsScreen.kt` owns profile inspection, connections, Hermes management, chat/voice/media/appearance, and a "Power tools" section containing Terminal and Bridge.
- `BridgeCoreScreen.kt` already has the right Bridge grouping: Connections, Terminal, Voice, Notification companion, Media, and Relay sessions.
- `AgentInfoSheet` already consolidates Profile, Personality, and Connection. The mockup turns that into the main profile-management affordance.
Official Hermes references:
- [Hermes Agent home](https://hermes-agent.nousresearch.com/) for the stark black/white/electric-blue and ASCII/glyph language.
- [Hermes Desktop page](https://hermes-agent.nousresearch.com/desktop) for the electric-blue duotone visual system and feature ordering.
- [Desktop App docs](https://hermes-agent.nousresearch.com/docs/user-guide/desktop) for the chat-first layout, left sidebar, bottom status bar, right preview rail, management panes, command palette, sessions, and profile concepts.
- [NousResearch/hermes-agent desktop routes](https://github.com/NousResearch/hermes-agent/blob/main/apps/desktop/src/app/routes.ts) for the official top-level management surfaces: settings, command center, skills, messaging, artifacts, cron, profiles, and agents.
## Proposed Implementation Shape
1. Replace the large `NavigationBar` in `RelayApp.kt` with:
- a compact top `RelayModeStrip` for Chat, Manage, Bridge;
- a persistent Terminal icon shortcut in the app chrome;
- a thin bottom `RelayStatusStrip` for connection, model/profile, and safety state.
2. Make Bridge primary:
- keep `BridgeCoreScreen.kt` as the standard-install Bridge landing;
- for sideload builds, keep Device Control under Bridge as a protected advanced section;
- keep Settings links for discoverability, but stop making Settings the only path.
3. Promote profile management:
- use `AgentInfoSheet` as the profile switcher and connection switcher;
- add a clear route from that sheet to `ProfileInspectorScreen`;
- surface default/profile-isolated API state in the header and status strip.
4. Improve sessions:
- add search, pinned/archived groupings, and a profile filter to `SessionDrawerContent`;
- preserve the current drawer model so Chat remains uncluttered.
5. Official-Hermes styling translation:
- keep Hermes Relay's navy/purple base;
- introduce electric blue as a hard accent, not a full repaint;
- add sparse ASCII/glyph texture and sharper dividers;
- use compact mono metadata for connection/session/model state.
## Files to Touch Later
- `app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ChatScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/BridgeCoreScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/components/SessionDrawer.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/components/ConnectionInfoSheet.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/theme/Theme.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/theme/Type.kt`
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

@@ -0,0 +1,330 @@
# Plan: Realtime Agent background Hermes runs (foreground -> promote -> durable)
> **Purpose.** Stop the Realtime Agent's provider event pump from blocking on
> the full Hermes run. Keep short turns synchronous and low-latency, but let
> genuinely long Hermes runs detach to a tracked background task so the provider
> session stays responsive and the run can be monitored, summarized on
> completion, and cancelled.
>
> **Decision of record.** ADR 33 (`docs/decisions.md`). This plan is the
> phased, file-level execution of that ADR. Read ADR 33 first — it defines the
> three tiers, the relay-as-single-floor-owner rule, the settings surface, and
> the Phase 0 prerequisite spike.
>
> **Non-negotiable.** Hermes remains the only authority for tools, memory,
> current data, research, side effects, durable context, and confirmations
> (ADR 29/32). This plan changes *when and how* a Hermes run is awaited and
> spoken — never whether Hermes governs it.
## Current State (grounded in code)
The realtime-agent broker already runs a persistent provider event pump; the
gap is that tool execution blocks it.
- `plugin/relay/realtime_agent/broker.py`
- `_pump_provider_events` (~`broker.py:1124`) is the long-lived
`native_provider_task` — the persistent loop. It survives WS detach/resume.
- `FUNCTION_CALL_COMPLETED` -> `_handle_provider_tool_call` (~`broker.py:1628`)
-> `_run_brokered_tool` (~`broker.py:1930`, `return await task`) ->
`_execute_brokered_tool` (~`broker.py:1956`) `async for`s the entire Hermes
SSE stream before the pump consumes the next provider event. **This is the
blocking await to remove for long runs.**
- `_send_hermes_run_progress` (~`broker.py:2245`) already runs concurrently as
`progress_task`, emitting `hermes.run.progress` every
`_HERMES_PROGRESS_INTERVAL_SECONDS` and spoken filler after
`_HERMES_SPOKEN_PROGRESS_AFTER_SECONDS`. Reuse this for background progress.
- `_cancel_active_hermes` (~`broker.py:2295`) already cancels `hermes_task`
cross-coroutine. Promotion must keep this working.
- `RealtimeAgentSession` (~`broker.py:114`) holds `hermes_task`,
`hermes_run_status`, and the `native_forced_*` suppression flags. Floor state
will live here.
- `_render_provider_audio` (~`broker.py:2509`) is the relay TTS render mouth
(forced-summary fallback). It must obey the floor owner.
- `_request_playback_drain` / `_send_provider_tool_result` /
`_request_provider_response` (~`broker.py:1700`-`1810`) are the existing
result-injection primitives the background-completion path will reuse.
- `plugin/relay/realtime_agent/hermes_tool_broker.py`
- `_TOOL_SURFACE = ("hermes_run_task", "hermes_get_status", "hermes_cancel",
"hermes_confirm")` (~`:189`) — background vocabulary already advertised.
- `stream_task` yields the run; unchanged by this plan.
- `plugin/relay/realtime_agent/models.py` — `SERVER_EVT_*`, `CLIENT_MSG_*`,
`HERMES_TOOL_SCHEMAS`. New events/fields added here.
- Providers (`providers/xai.py`, `providers/openai.py`) — both run
`turn_detection: None` (relay owns `response.create`) and emit audio. This is
what makes "withhold the summary until the floor is idle" possible.
- Settings: `plugin/relay/profile_voice.py` (`realtime_voice_settings`,
`save_profile_voice_section`) + `plugin/relay/config.py`.
- Android: `viewmodel/VoiceViewModel.kt`, `network/RelayVoiceClient.kt`,
`data/VoicePreferences.kt`, `data/RealtimeConversationContext.kt`,
`voice/RealtimeTurnSyncBuilder.kt`, `ui/screens/VoiceSettingsScreen.kt`.
- Tests: `plugin/tests/test_realtime_agent_routes.py`,
`test_realtime_agent_xai_provider.py`, `test_realtime_agent_openai_provider.py`.
### Three audio sources ("who speaks")
All three converge on Android's single `AudioTrack` via `voice.output_audio.delta`:
1. Realtime provider (xAI/OpenAI) — primary.
2. Relay TTS render (`_render_provider_audio`) — fallback; same wire event.
3. Android local TTS — `should_speak` filler on `hermes.run.progress`.
Today the blocking await serializes them. This plan replaces that implicit mutex
with an **explicit relay-side floor owner**.
## Target Architecture
```text
provider event pump (never blocks on a long run)
-> short run: await inline (Tier A, unchanged ADR 32 shape)
-> long run: detach to tracked task, resume pump, speak handoff (Tier B)
-> explicit durable: return handle immediately (Tier C)
|
v
FloorOwner(idle | provider_speaking | hermes_filler | result_pending)
|
background run completes -> queue result_pending -> speak when floor idle
```
**FloorOwner contract (relay-side, per session):**
- Exactly one mouth holds the floor; provider holds it by default.
- A completed background result is queued `result_pending`, never barges in. It
is spoken (provider summary, or relay-TTS fallback) only on transition to
`idle`.
- Android `should_speak` filler is suppressed while `provider_speaking`.
- `_render_provider_audio` may only fire when it owns the floor and the provider
has drained.
## Non-Goals
- Do not change Tier A latency or the ADR 32 preamble -> forced-Hermes ->
provider-summary shape for short turns.
- Do not give the provider authority over tools/memory/confirmations.
- Do not support `max_background_runs > 1` in this plan (single `hermes_task`).
- Do not require adb/device testing for verification unless Bailey asks. JVM/py
unit tests plus the lab smoke gate cover acceptance.
- Do not depend on Hermes upstream changes; the broker already owns the loop.
## Protocol Additions (additive only)
Relay -> client (add to `models.py`):
- `hermes.run.promoted` — `{run_id, promote_after_ms, spoken_handoff, tier}`.
- `hermes.run.background_completed` — `{run_id, ok, tool_count}` (precedes summary).
- Extend `hermes.run.progress` with `tier` (`foreground|promoted|durable`) and
`floor` (`idle|provider_speaking|hermes_filler|result_pending`).
No new client->relay messages required — `response.cancel`, `hermes_cancel`, and
`hermes_get_status` already exist; the latter two become reachable mid-run once
the pump no longer blocks.
## Settings Surface (per ADR 33)
Relay-side `realtime_voice` config (source of truth, per-profile override via
`profile_voice.py`), mirrored into Android `VoicePreferences`:
| Key | Default | Meaning |
|---|---|---|
| `promotion_enabled` | `true` (Phase 3) | Master switch; `false` pins Tier A blocking |
| `promote_after_ms` | `6000` | Grace window before Tier B handoff |
| `background_default_mode` | `promote` | Ambiguous long turn: `promote` vs `foreground` |
| `spoken_handoff` | `true` | Speak "I've started that" on promotion |
| `progress_spoken_after_ms` | `15000` | Reuse `_HERMES_SPOKEN_PROGRESS_AFTER_SECONDS` |
| `progress_repeat_ms` | `30000` | Reuse `_HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS` |
| `result_delivery` | `speak_when_idle` | vs `notify_then_speak` / `visual_only` |
| `max_background_runs` | `1` | Fixed at 1 this plan |
---
## Phase 0 — Provider idle-tolerance spike (GATES default-on)
**Goal.** Determine empirically whether xAI and OpenAI realtime sessions tolerate
being held open and quiescent (no `response.create`, no input audio) for
30–120s, since Tier B holds the floor while a background run completes.
**Tasks.**
1. Add a throwaway script under `plugin/tools/` or a `scripts/` probe (not
shipped) that opens each provider realtime socket via the existing adapters,
sends `session.update`, then idles 30/60/120s and logs: socket survival, any
server-side timeout/close codes, VAD/turn artifacts on the first post-idle
`response.create`, and any keep-alive requirement.
2. Record findings in `docs/realtime-voice-poc.md` under a new "Idle tolerance"
section, per provider.
**Acceptance.**
- A documented per-provider verdict: `hold-floor-ok` | `needs-keepalive` |
`must-reopen`. This verdict selects the Tier B fallback strategy per provider.
**Note.** If a provider is `must-reopen`, Tier B for that provider detaches the
run but closes/reopens the provider socket (or keeps a minimal keep-alive)
rather than holding it conversational. Capture that in the ADR's Phase 0 line.
**Status: DONE (2026-05-24).** Verdicts recorded in
`docs/realtime-voice-poc.md` → Idle tolerance:
- **OpenAI `gpt-realtime-2` — `hold-floor-ok` (empirical).** Probe ran live
(10s/20s/30s idle windows survived; clean post-idle audio each time).
- **xAI `grok-voice-latest` — `hold-floor-ok`.** No xAI creds on the dev box, but
the verdict is not conditional: the shipping Realtime Agent already holds
`xai_realtime` sessions open across between-turn idle gaps
(`turn_detection: None` + resume TTL) with no idle-close reports. A relay-host
probe run is retained as a regression check, not a precondition.
**Premise superseded.** Phase 2/3 implemented Tier B by *closing the pending
provider call with an interim ack* rather than holding an open response, so the
"hold the floor conversational while a run completes" worst case this spike
guarded against does not occur — the socket only sees the normal between-turns
idle gap. Both providers are `hold-floor-ok`, so the default-on gate is satisfied
(no provider needs the `must-reopen` fallback today).
---
## Phase 1 — Introduce FloorOwner under current blocking behavior (no functional change)
**Goal.** Add the explicit floor state machine and route all three mouths through
it, while preserving today's exact audible behavior. This is the de-risking step.
**Tasks.**
1. Add `RealtimeFloor` (new dataclass/enum) to `broker.py` or a new
`realtime_agent/floor.py`: state `idle|provider_speaking|hermes_filler|
result_pending`, with `acquire(mouth)`, `release(mouth)`, and
`can_speak(mouth) -> bool`. Pure, unit-testable, no I/O.
2. Hold a `floor: RealtimeFloor` on `RealtimeAgentSession`.
3. Gate the three emit paths through `floor.can_speak(...)`:
- provider audio in `_pump_provider_events` (`AUDIO_DELTA` -> `provider`),
- `_render_provider_audio` (-> `relay_tts`),
- `should_speak` progress events (-> `android_filler`; suppress while
`provider_speaking`).
4. Transition floor on `RESPONSE_STARTED`/`AUDIO_DONE`/`RESPONSE_DONE` and on
playback-drain completion.
5. Stamp `floor` onto `hermes.run.progress` (additive field).
**Acceptance.**
- All existing `plugin/tests/test_realtime_agent_*` pass unchanged.
- New `test_realtime_floor.py` proves single-mouth invariants:
background-result-never-barges, filler-suppressed-while-provider-speaks,
relay-TTS-only-when-owned.
- Manually/log-verified: a short turn sounds identical to pre-change.
**Test gate.** `python -m unittest plugin.tests.test_realtime_floor` +
existing realtime agent tests green.
---
## Phase 2 — Tier B grace-period promotion (default OFF)
**Goal.** Detach a long Hermes run from the pump after `promote_after_ms`; resume
the pump; speak a handoff; deliver the result on completion via the floor owner.
**Tasks.**
1. `models.py`: add `hermes.run.promoted`, `hermes.run.background_completed`,
`tier` field; add settings keys to the config schema.
2. `profile_voice.py` / `config.py`: read/write the new `realtime_voice` keys
with defaults above (`promotion_enabled=false` this phase).
3. `broker.py` — split `_run_brokered_tool` for `hermes_run_task`:
- Start the run task as today, but `await asyncio.wait({task}, timeout=
promote_after_ms)`.
- If done in time: Tier A path, unchanged.
- If not: emit `hermes.run.promoted`, optionally speak handoff (gated by
`spoken_handoff` + floor), set `tier="promoted"`, **return control to the
pump** without awaiting the task. Keep `session.hermes_task` set.
4. Add a pump-side drain point: between provider events (and on a small timer),
check for a finished background task; when finished, emit
`hermes.run.background_completed`, then inject via the existing
`_send_provider_tool_result` -> `_request_provider_response` path **only when
`floor` is `idle`** (else mark `result_pending` and inject on next `idle`).
5. Preserve cancellation: `hermes_cancel` (now reachable) and `response.cancel`
both route to `_cancel_active_hermes`.
6. Resume safety: if WS detaches mid-background-run, the completion event must
replay through the existing event-ring/resume path on reattach.
**Acceptance.**
- With `promotion_enabled=true` in test config, a run that exceeds
`promote_after_ms` emits `hermes.run.promoted`, the pump processes a subsequent
provider event before the run finishes (proves non-blocking), and the result is
spoken exactly once after completion.
- A run under the window behaves as Tier A (no `promoted` event).
- Cancel during a promoted run stops it and emits `hermes.run.cancelled`.
- Detach+resume during a promoted run replays `background_completed`.
**Test gate.** New `test_realtime_promotion.py` (fake provider connection + fake
Hermes broker with controllable latency) covering: under-window, over-window,
cancel-promoted, detach-resume-completes, result-waits-for-idle-floor.
---
## Phase 3 — Default-on + Tier C durable + Android settings UI
**Goal.** Flip `promotion_enabled` default to `true` (gated on Phase 0 verdict),
add explicit `mode="background"`, and expose settings on Android.
**Tasks.**
1. `models.py` `HERMES_TOOL_SCHEMAS`: document/validate `hermes_run_task.mode`
(`run|background`); `background` returns a handle immediately (skip grace
window, go straight to Tier B detach).
2. Default `promotion_enabled=true`; per-provider Tier B strategy from Phase 0.
3. Android `VoicePreferences.kt` + `RelayVoiceClient.kt`: read/write the new
settings; surface in `VoiceSettingsScreen.kt` under Realtime Agent (promotion
toggle, grace slider, handoff toggle, result-delivery picker).
4. Android `VoiceViewModel.kt` + `RealtimeTurnSyncBuilder.kt`: handle
`hermes.run.promoted` / `hermes.run.background_completed`; render a persistent
"working on: …" chip while `tier=promoted/durable`; implement
`notify_then_speak` affordance (chime + tap-to-hear) if that mode is selected.
5. Docs: `docs/relay-protocol.md` (new events/fields), `user-docs/features/
voice.md` (settings + behavior), `CHANGELOG.md` `[Unreleased]`.
**Acceptance.**
- Default config promotes long runs without user action; short runs unaffected.
- `hermes_run_task(mode="background")` returns immediately and completes via the
same path.
- Android shows promoted state and speaks the result per `result_delivery`.
- `./gradlew lint` clean; py tests green; lab smoke
(`scripts/realtime-voice-lab-smoke.ps1`) still under threshold for short turns.
**Test gate.** Full `plugin.tests.test_realtime_agent_*` + new floor/promotion
suites; Android unit tests for the new `VoicePreferences`/sync mapping;
`./gradlew lint`.
---
## Risks & Mitigations
- **Floor logic stacking on `native_forced_*` suppression.** The forced-summary
state machine is the most fragile code in `broker.py`. Mitigation: Phase 1
introduces FloorOwner *as the serializer*; migrate suppression decisions to ask
the floor rather than adding parallel flags.
- **Double-speak (provider + relay TTS).** Mitigation: both gated by
`floor.can_speak`; result injection only on `idle`.
- **Provider idle close.** Mitigation: Phase 0 verdict picks per-provider Tier B
strategy; `must-reopen` providers don't hold the floor.
- **Stranded background result on disconnect.** Mitigation: reuse event-ring +
resume replay; covered by Phase 2 acceptance.
## Test Strategy Summary
- Pure unit: `RealtimeFloor` invariants (Phase 1).
- Broker integration with fakes: promotion timing, cancel, resume, idle-gated
delivery (Phase 2).
- Android: settings round-trip + event handling (Phase 3).
- Regression: existing realtime agent suites unchanged throughout; lab smoke for
short-turn latency.
## Key Files
- `docs/decisions.md` (ADR 33)
- `plugin/relay/realtime_agent/broker.py`
- `plugin/relay/realtime_agent/floor.py` (new)
- `plugin/relay/realtime_agent/hermes_tool_broker.py`
- `plugin/relay/realtime_agent/models.py`
- `plugin/relay/realtime_agent/providers/xai.py`
- `plugin/relay/realtime_agent/providers/openai.py`
- `plugin/relay/profile_voice.py`
- `plugin/relay/config.py`
- `plugin/tests/test_realtime_floor.py` (new)
- `plugin/tests/test_realtime_promotion.py` (new)
- `docs/relay-protocol.md`
- `docs/realtime-voice-poc.md` (Phase 0 findings)
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/voice/RealtimeTurnSyncBuilder.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/data/VoicePreferences.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/network/RelayVoiceClient.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/VoiceSettingsScreen.kt`
- `user-docs/features/voice.md`
- `CHANGELOG.md`
@@ -0,0 +1,83 @@
# Plan: Persistent realtime-agent session (one socket across turns)
> **Purpose.** Make Realtime Agent voice a *persistent conversation* instead of a
> new session per utterance. Today each turn calls
> `RelayVoiceClient.runRealtimeAgent()` which `createRealtimeAgentSession()`s a
> fresh relay session + provider socket and closes it on `voice.response.done`.
> The provider therefore has no live memory of prior turns (only relay-seeded
> context snippets), and every turn pays session-setup latency.
>
> **Key finding.** This is a **client-only** change. The relay already supports
> many turns on one socket — `_handle_provider_native_ws` loops over
> `input_audio.append` / `input_audio.commit` / `response.create` and only tears
> the session down when the WebSocket disconnects. `voice.response.done` is a
> *turn* boundary, not a *session* boundary. The client is what closes the socket
> on `voice.response.done` (`RelayVoiceClient.kt:1410`).
## Design
Keep one relay session + provider socket open for the lifetime of a Realtime
Agent voice-mode session; feed each utterance as a new turn on that socket.
### RelayVoiceClient
- Add an opt-in **persistent mode** to `runRealtimeAgent` via two optional params
(one-shot path is byte-for-byte unchanged when they're null):
- `turnInputs: ReceiveChannel<RealtimeTurnInput>?` — when non-null, the call is
a long-lived session: after the first turn it reads further turns off the
channel and sends their `input_audio.append`+`commit` on the open socket.
- `onTurnComplete: (RealtimeVoiceSummary) -> Unit` — invoked at each
`voice.response.done` instead of completing+closing.
- In persistent mode:
- `voice.response.done` → call `onTurnComplete`, **do not** close the socket or
complete `finished`.
- A reader coroutine drains `turnInputs` and sends each turn's PCM chunks.
- `finished` completes only when the channel closes (voice-mode exit) or on a
fatal socket/provider error.
- The per-turn idle/turn-limit guards in `awaitRealtimeAgentCompletion` are
scoped to an *active* turn only — between-turn idle is expected and must not
trip `REALTIME_AGENT_IDLE_TIMEOUT_MS`.
- `RealtimeTurnInput(inputPcm, sampleRate, prompt)` data class.
### VoiceViewModel
- Hold a `realtimeLiveSession` (the persistent call's `Job` + the
`SendChannel<RealtimeTurnInput>`), opened lazily on the first Realtime Agent
turn and reused for subsequent turns.
- Per utterance: instead of a fresh `runRealtimeAgentTurn`, do the per-turn UI
setup (state reset, watchdog, `chatVm.startRealtimeAgentTurn`) and
`realtimeLiveSession.submit(RealtimeTurnInput(...))`.
- Close the session (`channel.close()` + cancel job) on `exitVoiceMode`, engine
switch away from Realtime Agent, and `onCleared`.
- The continuous event callback (the `when(event.type)` block) is registered once
for the session and handles every turn's events.
### Fallback flag (risk control)
- `VoicePreferences.realtimePersistentSession` (default **true**). When false,
fall back to the current one-shot `runRealtimeAgentTurn` path. Lets the user
flip back on-device without a rebuild if the persistent path misbehaves.
## Non-Goals
- No relay changes (the relay already supports multi-turn sockets).
- No change to the one-shot path used by the Voice Lab / stable engine.
- Not changing barge-in semantics beyond keeping them working per turn.
## Risks / must-validate-on-device
- Feeding new input while a prior turn's audio is still draining (barge-in vs.
new turn). Mitigation: reuse existing barge-in/cancel before submitting a turn.
- Per-turn UI state resets must not tear down the shared session.
- Between-turn idle must not trip the stall timeout.
- Provider/relay session longevity across long pauses (the socket stays open, so
no resume-TTL dependency — but confirm providers don't idle-close; see ADR 33
Phase 0 `hold-floor-ok`).
## Test strategy
- Unit-test the pure pieces: `RealtimeTurnInput` chunking, the persistent-vs-
one-shot branch decision, idle-guard gating by active-turn.
- On-device (post-merge, by Bailey): multi-turn conversation with follow-up
references ("what did you just say?"), background promotion mid-conversation,
barge-in, exit/re-enter voice mode, engine switch.
## Key Files
- `app/src/main/kotlin/com/hermesandroid/relay/network/RelayVoiceClient.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/data/VoicePreferences.kt`
- `docs/relay-protocol.md` (note: turn vs. session boundary)
@@ -0,0 +1,430 @@
# Standard Dashboard + Relay Power Mode Plan
**Status:** Ready for agent-team implementation
**Date:** 2026-06-07
**Owner surface:** Android app, relay compatibility layer, user docs
**Goal path:** `docs/plans/2026-06-07-standard-dashboard-relay-power-mode.md`
**Goal:** Make the default Android experience feel like a standard Hermes dashboard/client while keeping relay-only capabilities as power-user features that clearly require pairing.
---
## Bottom Line
The app should have a standard default path and a power-user relay path:
- Standard users can connect with the Hermes API server and dashboard auth, then use chat, skills, cron, MCP, profiles, models, and basic settings.
- Relay-specific features remain supported, but are no longer part of the default mental model.
- Terminal, Bridge, relay sessions, route/grant management, media file inspection, and profile memory file editing are power-user features.
- Power-user features must clearly show "Requires pairing" when the current connection is not paired.
- Pairing remains the source of truth for relay grants, terminal/bridge access, TUI/desktop bridge flows, and device-control features.
- Avoid visible tier labels such as Easy, Standard, or Advanced. Use plain product language: "Power tools", "Requires pairing", "Pair to unlock".
This is a UI/data-plane realignment. It is not a relay protocol removal.
---
## Verified Context
### Upstream Hermes Direction
Latest upstream Hermes has moved much of the admin surface into dashboard APIs:
- Dashboard/admin server on `:9119` includes `/api/status`, `/api/config`, `/api/skills`, `/api/cron/jobs`, `/api/mcp/servers`, `/api/profiles`, `/api/memory`, toolsets, env, and plugin routes.
- Dashboard auth supports remote gated sessions using cookies, plus loopback/session-token behavior for the web dashboard.
- API server on `:8642` exposes standard external surfaces such as `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets`.
- The lightweight `rusty4444/hermes-android` model is API/dashboard oriented and does not cover relay pairing, grants, bridge, or terminal behavior.
### Local Current State
Relevant local files:
- `app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ConnectionsSettingsScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/components/ConnectionWizard.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/data/ConnectionData.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/network/HermesApiClient.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/network/EndpointResolver.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/network/RelayProfileInspectorClient.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProfileInspectorScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ProfileInspectorViewModel.kt`
- `hermes_relay_bootstrap/_handlers.py`
- `hermes_relay_bootstrap/_patch.py`
- `docs/android-ui-design-reference.md`
- `user-docs/reference/configuration.md`
Current gaps:
- Bottom navigation currently makes Terminal and Bridge first-class default destinations.
- Standard dashboard/admin capabilities are not first-class native Android screens.
- Profile inspector and memory file editing are relay-session oriented.
- Skill toggle still has compatibility paths around relay/bootstrap behavior.
- Connection state does not separately model API auth, dashboard auth, and relay pairing.
---
## Product Rules
### Standard Default
Default visible areas should prioritize:
- Chat
- Sessions or conversation history
- Skills
- Cron jobs
- MCP servers/catalog
- Profiles and SOUL basics
- Models/config basics
- Connection health
- Appearance and normal app settings
MCP can appear in the standard management surface, but raw server command editing, env/key reveal, and risky install details belong behind power-user disclosure.
### Power Tools
Power tools include:
- Terminal
- Bridge
- Relay sessions
- Route/grant inspection
- Media token/file inspector
- Profile memory file editor
- Raw config editor
- Env reveal and secret management
- Diagnostics and developer options
These features stay supported. They should be quieter in default navigation and clearly gated when pairing is missing.
### Pairing Gate Pattern
Use one shared UI pattern for unpaired power features:
- Show the feature name and short capability summary.
- Show a stable status label: `Requires pairing`.
- Show a primary action: `Pair to unlock`.
- Provide one sentence of explanation: "This feature uses relay grants and requires a paired device session."
- Do not expose raw URL/token forms as the first recovery path.
- Do not present disabled empty screens without a clear pairing CTA.
If the device was previously paired but the relay session expired, use:
- Status: `Pairing expired`
- Primary action: `Pair again`
- Secondary action where appropriate: `View connection`
### Auth Model
Model three separate auth contexts:
| Context | Typical port | Purpose | User-facing language |
| --- | --- | --- | --- |
| API server | `8642` | Chat, runs, read-only standard capabilities | API connection |
| Dashboard | `9119` | Skills, cron, MCP, profiles, config, model/admin APIs | Dashboard sign-in |
| Relay | `8767` | Terminal, Bridge, relay sessions, device grants, media relay | Pairing |
Never imply that dashboard sign-in unlocks relay-grant features. Never imply that relay pairing replaces API/dashboard auth.
---
## Feature Placement Matrix
| Feature | Default placement | Required auth | Pairing required? |
| --- | --- | --- | --- |
| Chat | Main nav | API key/session | No |
| Conversation sessions | Main nav or Chat subview | API key/session | No |
| Skills browse/toggle | Standard management | Dashboard auth | No |
| Cron jobs | Standard management | Dashboard auth | No |
| MCP servers/catalog | Standard management | Dashboard auth | No |
| Profiles/SOUL basics | Standard management | Dashboard auth | No |
| Model/config basics | Standard settings | Dashboard auth | No |
| Memory provider status/reset | Standard settings | Dashboard auth | No |
| Profile memory file editor | Power tools | Relay session | Yes |
| Terminal | Power tools | Relay session/grants | Yes |
| Bridge | Power tools | Relay session/grants | Yes |
| Relay sessions/routes/grants | Power tools | Relay session | Yes |
| Media inspector | Power tools | Relay session | Yes |
| Raw config/env reveal | Power tools | Dashboard auth | No, but confirm/reveal required |
| Diagnostics/developer options | Power tools | Varies | Varies |
---
## Implementation Waves
## Wave 1 - Dashboard Client and Connection Model
**Summary.** Add first-class dashboard connectivity alongside existing API and relay connection state.
**Scope / Acceptance criteria.**
- Add `DashboardApiClient` under `app/src/main/kotlin/com/hermesandroid/relay/network/`.
- Support:
- `GET /api/status`
- `POST /auth/password-login`
- `GET /api/auth/me`
- Dashboard cookie persistence through the existing secure storage pattern or an explicit encrypted cookie store.
- Add typed models for dashboard auth status and feature capabilities.
- Extend `ConnectionData.kt` with:
- `dashboardUrl`
- `dashboardAuthRequired`
- `dashboardAuthProviders`
- `dashboardLastStatus`
- migration/default derivation from API host to `:9119`.
- Keep API key, dashboard auth, and relay token storage separate.
- Unit tests cover URL derivation, auth state parsing, missing dashboard handling, and migration defaults.
**Files likely to touch.**
- `app/src/main/kotlin/com/hermesandroid/relay/data/ConnectionData.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/network/HermesApiClient.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/network/EndpointResolver.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/network/DashboardApiClient.kt` new
- `app/src/test/kotlin/com/hermesandroid/relay/network/DashboardApiClientTest.kt` new
**Agent brief.**
> Implement Wave 1 from `docs/plans/2026-06-07-standard-dashboard-relay-power-mode.md`. Add a dashboard client and connection metadata without changing relay pairing behavior. Keep API, dashboard, and relay auth as separate states. Add focused tests for dashboard status/auth parsing and connection migration.
---
## Wave 2 - Shared Feature Gate UX
**Summary.** Create reusable UI for power features that require pairing.
**Scope / Acceptance criteria.**
- Add a reusable component for gated power features, for example `RequiresPairingCard` or `PowerFeatureGate`.
- Component supports:
- `Requires pairing`
- `Pairing expired`
- `Unavailable on this server`
- `Dashboard sign-in required`
- Component always provides a clear primary action.
- Terminal, Bridge, relay sessions, and profile memory file editor routes use this gate when unpaired.
- No power feature falls through to an empty/error-heavy screen when pairing is missing.
- Compose previews or screenshot tests cover at least unpaired, expired, and paired states.
**Files likely to touch.**
- `app/src/main/kotlin/com/hermesandroid/relay/ui/components/`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ProfileInspectorScreen.kt`
- terminal and bridge screen files discovered during implementation
**Agent brief.**
> Implement Wave 2. Build the shared pairing gate component and wire it into every relay-only route. The UX copy must use "Requires pairing" and "Pair to unlock", not tier labels. Preserve existing paired behavior.
---
## Wave 3 - Standard Management Screens
**Summary.** Add native standard screens backed by upstream dashboard APIs.
**Scope / Acceptance criteria.**
- Add native screens/viewmodels for:
- Skills list/toggle using dashboard `/api/skills` and `/api/skills/toggle`.
- Cron jobs list/detail/actions using `/api/cron/jobs`, runs, pause/resume/trigger/delete.
- MCP servers/catalog using `/api/mcp/servers` and catalog/install endpoints.
- Profiles/SOUL basics using `/api/profiles` and profile SOUL/model/description endpoints.
- Config/model basics using dashboard config/model endpoints where available.
- Each screen handles:
- Dashboard unavailable
- Dashboard sign-in required
- API unsupported on older Hermes
- Empty state
- Loading/error/retry
- Do not expose raw env reveal by default.
- Keep existing chat command palette behavior working while moving management UX to dashboard APIs.
**Files likely to touch.**
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/network/DashboardApiClient.kt`
- new screen/viewmodel/model files under `ui/screens`, `viewmodel`, `data`, and/or `network`
**Agent brief.**
> Implement Wave 3. Add standard native management screens backed by the dashboard APIs. Keep screens compact and operational, following `docs/android-ui-design-reference.md`. Use dashboard sign-in and unsupported-server states instead of raw stack traces or relay pairing prompts.
---
## Wave 4 - Navigation and Settings Realignment
**Summary.** Make standard management the default path and move relay capabilities behind power tools.
**Scope / Acceptance criteria.**
- Adjust bottom navigation so Terminal and Bridge are not default-first for new standard users.
- Add a standard management destination or settings group for Skills, Cron, MCP, Profiles, and Config.
- Add a Power Tools section in Settings for Terminal, Bridge, Relay sessions, Media inspector, raw config/env, diagnostics, and developer options.
- Preserve deep links/routes for existing paired users.
- Do not remove Terminal or Bridge.
- Remove or avoid visible Easy/Standard/Advanced tier copy.
- Existing Settings categories stay compact and use disclosure for advanced controls.
**Files likely to touch.**
- `app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ConnectionsSettingsScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/DeveloperSettingsScreen.kt`
- `user-docs/reference/configuration.md`
**Agent brief.**
> Implement Wave 4. Rebalance navigation toward standard Hermes management while keeping relay tools accessible under Power Tools. Existing paired users must not lose routes; unpaired users should see pairing gates for relay-only features.
---
## Wave 5 - Pairing Flow Integration
**Summary.** Keep pairing strong, but position it as the unlock path for relay power features.
**Scope / Acceptance criteria.**
- Pairing remains a first-class full-screen flow.
- After successful pairing, probe/store dashboard URL if derivable and not already set.
- From any gated power feature, `Pair to unlock` opens the existing pairing flow and returns to the requested feature after success when practical.
- Manual connection remains available, but under Advanced/manual setup.
- Pairing confirmation continues to show route, grants, API server, relay endpoint, TTL, and warnings.
- Expired pairing state is distinct from never paired.
**Files likely to touch.**
- `app/src/main/kotlin/com/hermesandroid/relay/ui/components/ConnectionWizard.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/PairScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/ConnectionsSettingsScreen.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/network/EndpointResolver.kt`
**Agent brief.**
> Implement Wave 5. Keep the existing pairing security model and use it as the unlock path for power tools. Do not flatten relay tokens into API/dashboard auth. Add return-to-feature behavior where the navigation model supports it cleanly.
---
## Wave 6 - Relay Compatibility and Bootstrap Cleanup
**Summary.** Keep relay/profile-memory power paths, but stop treating bootstrap compatibility endpoints as the primary standard admin API.
**Scope / Acceptance criteria.**
- Keep relay profile memory file editing available as a paired power feature.
- Replace standard skills/config management calls with dashboard APIs where supported.
- Keep compatibility fallback for older Hermes versions, but mark it as fallback in code comments/docs.
- Audit `hermes_relay_bootstrap/_handlers.py` and `_patch.py` for routes now covered by upstream dashboard/API server.
- Update install/docs to reflect latest `API_SERVER_KEY` requirement for networked API server use.
- Decide whether to propose an upstream profile-memory file API. If not, document that this remains a relay power feature.
**Files likely to touch.**
- `app/src/main/kotlin/com/hermesandroid/relay/network/RelayProfileInspectorClient.kt`
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ProfileInspectorViewModel.kt`
- `hermes_relay_bootstrap/_handlers.py`
- `hermes_relay_bootstrap/_patch.py`
- `install.sh`
- `README.md`
- `user-docs/reference/configuration.md`
**Agent brief.**
> Implement Wave 6. Keep profile memory file editing and relay-specific surfaces as paired power features. Prefer upstream dashboard APIs for standard features and narrow bootstrap compatibility to older-server fallback behavior.
---
## Wave 7 - Tests, Docs, and Release Notes
**Summary.** Verify the user-facing split and document the new connection model.
**Scope / Acceptance criteria.**
- Android unit tests cover:
- dashboard URL derivation
- dashboard auth status
- pairing gate state mapping
- unpaired vs expired vs paired feature availability
- fallback behavior for older servers
- Compose/UI tests or screenshot checks cover:
- Standard default navigation
- Power feature requiring pairing
- Dashboard sign-in required
- Paired power feature available
- Docs explain:
- API connection
- Dashboard sign-in
- Pairing for relay power tools
- Which features require pairing
- Release notes call out that Terminal/Bridge are still supported but now presented as power tools.
**Files likely to touch.**
- `app/src/test/`
- `app/src/androidTest/` if UI tests already exist
- `docs/android-ui-design-reference.md`
- `user-docs/reference/configuration.md`
- `user-docs/features/dashboard.md`
- `README.md`
- `RELEASE_NOTES.md`
**Agent brief.**
> Implement Wave 7. Add focused tests and docs for the standard-vs-power split. The docs must be explicit that pairing is required for terminal/bridge/relay power features but not for normal API/dashboard use.
---
## Agent Team Execution Model
Recommended parallelization:
| Wave | Parallelism | Notes |
| --- | --- | --- |
| Wave 1 | Serial first | Establishes shared connection/auth models. |
| Wave 2 | Parallel after Wave 1 model shape is known | UI component work can run while API clients progress if contracts are stable. |
| Wave 3 | Parallel by surface | Skills, Cron, MCP, Profiles can be split across agents once `DashboardApiClient` contracts exist. |
| Wave 4 | Serial | Navigation/settings touches shared files. |
| Wave 5 | Serial or paired with Wave 4 | Pairing return paths depend on navigation shape. |
| Wave 6 | Parallel with Wave 7 docs after API decisions | Bootstrap cleanup and docs can proceed once standard APIs are wired. |
| Wave 7 | Final serial verification | Runs after surfaces land. |
Shared-file hot spots:
- `RelayApp.kt`
- `SettingsScreen.kt`
- `ConnectionData.kt`
- `DashboardApiClient.kt`
- `ProfileInspectorScreen.kt`
- `ProfileInspectorViewModel.kt`
Assign only one active agent at a time to each hot-spot file.
---
## Definition Of Complete
The goal is complete when all of the following are true:
- A fresh standard user can connect with API/dashboard credentials and use chat plus standard management screens without relay pairing.
- Terminal, Bridge, relay sessions, media inspector, and profile memory file editor are still available.
- Every relay-only feature clearly shows `Requires pairing` with a `Pair to unlock` path when unpaired.
- Existing paired users keep their relay workflows.
- API auth, dashboard auth, and relay pairing are modeled and displayed as separate connection states.
- Standard screens use upstream dashboard/API server endpoints where available.
- Bootstrap compatibility routes are fallback only, not the primary standard path.
- Docs and release notes explain the new standard/power split.
- Tests cover dashboard connection, pairing gates, and compatibility fallback.
---
## Explicit Non-Goals
- Do not remove pairing.
- Do not remove Terminal or Bridge.
- Do not force every standard user through relay pairing.
- Do not use visible Easy/Standard/Advanced tier language in the app UI.
- Do not expose env/secret reveal in the default settings path.
- Do not replace the native Android app with a dashboard WebView.
- Do not break older paired sessions without a migration path.
+31 -20
View File
@@ -4,17 +4,25 @@
## Short Description (≤80 chars)
Self-hosted Hermes chat, voice, terminal, and notification companion
Your self-hosted Hermes AI agent, in your pocket — chat, voice, and control.
## Full Description
**Hermes-Relay** is a native Android client for the Hermes agent platform. Connect to your self-hosted Hermes AI agent and chat, speak, pair, and manage relay sessions directly from your phone.
**Hermes-Relay** is the native Android client for the Hermes agent platform. Point it at your own self-hosted Hermes server and chat with your agent, talk to it hands-free, and manage it — models, keys, skills, profiles — from anywhere.
Built for developers and AI enthusiasts who run their own Hermes agent instance. This is not a hosted AI service — it is a companion app for your own infrastructure.
Built for developers and AI enthusiasts who run their own Hermes agent instance. This is not a hosted AI service — it is a companion app for your own infrastructure, and it talks only to servers you configure.
━━━ QUICK START ━━━
1. Run hermes-agent with its API server enabled on your computer or home server.
2. Install Hermes-Relay and enter your server's address (for example http://192.168.1.100:8642).
3. The setup wizard probes what your server supports and shows you a readiness card — then you're talking.
A plain Hermes install is enough: chat, management, and voice all work without any plugin or extra services.
━━━ HOW IT WORKS ━━━
Hermes-Relay connects directly to your Hermes API Server over HTTP/SSE for real-time streaming chat. If you also run the Hermes relay service, the app can pair by QR code and use Bridge Core features such as terminal access, voice routes, notification companion, media handoff, and relay-session management.
Hermes-Relay connects directly to your Hermes API Server for real-time streaming chat. The Manage tab and voice use your Hermes dashboard with one sign-in. If you also run the optional Hermes relay service, the app can pair by QR code and add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
━━━ GOOGLE PLAY BUILD ━━━
@@ -24,23 +32,26 @@ Device Control is reserved for sideload builds distributed outside Google Play.
━━━ FEATURES ━━━
◆ Direct API Streaming
Chat with your Hermes agent over SSE. Responses stream token-by-token with animated typing indicators.
◆ Streaming Chat
Chat with your Hermes agent in real time. Responses stream token-by-token with markdown rendering, tool-call visibility, file attachments, and a searchable command palette.
◆ Session Management
Create, switch, rename, and delete chat sessions. Message history is loaded from your Hermes server on demand.
◆ Profiles and Personalities
Switch between configured Hermes profiles and personalities. The active profile context stays visible in chat and voice surfaces.
◆ Manage Your Agent
The full Hermes dashboard, native on your phone: switch models from your provider catalog, manage provider keys (write-only and masked), create and edit agent profiles, and browse, install, and update skills.
◆ Voice Mode
Use optional microphone capture for Hermes Chat + Voice Output, where Hermes owns the chat/tool turn and the relay renders assistant speech. An experimental Realtime Agent engine can use provider-native realtime speech while Hermes remains the tool, profile, confirmation, and memory authority.
Talk to your agent hands-free. Voice works on a plain Hermes install using the speech providers your server is configured with — no plugin needed. Relay-paired setups add per-profile voice providers and an experimental realtime conversation engine.
◆ Bridge Core Pairing
Scan a relay QR code to configure API and relay endpoints, then manage relay sessions and per-feature grants from the app.
◆ Works Away From Home
Add your server's Tailscale or public URL and the app switches routes automatically — local network at home, your secure fallback everywhere else. Routes are editable any time, and when a server is unreachable the status tells you what to fix instead of just going red.
◆ Terminal and TUI Relay
Use paired relay sessions for terminal/TUI access to your Hermes host when your relay grants allow it.
◆ Sessions
Create, switch, rename, and delete chat sessions. Message history loads from your Hermes server on demand.
◆ Multiple Servers, Profiles, and Personalities
Connect to more than one Hermes server (home and work, dev and prod) and switch in one tap. Overlay an agent profile or personality per conversation.
◆ Relay Power Tools (optional)
Scan a relay QR code to pair, then open a remote terminal to your Hermes host, manage relay sessions, and control per-feature grants from the app.
◆ Notification Companion
Optional Android notification access lets Hermes-Relay forward posted-notification metadata to your paired relay so your assistant can summarize recent notifications. You enable or revoke this in Android system settings at any time.
@@ -67,8 +78,8 @@ Full Material 3 with dynamic color theming, light/dark/system modes, animated sp
━━━ REQUIREMENTS ━━━
• Android 8.0 or later (API 26+)
• A running Hermes agent instance
• Optional Hermes relay service for Bridge Core features
• A running Hermes agent instance (chat, management, and voice need nothing else)
• Optional Hermes relay service for power tools (terminal, notifications, media)
• Network access to your server (local network, VPN, or internet)
━━━ OPEN SOURCE ━━━
@@ -79,7 +90,7 @@ This app is a community project and is not affiliated with or endorsed by NousRe
## Release Notes
v0.8.0 improves the Google Play-safe Bridge Core build, voice settings, and realtime voice path. Play remains free of AccessibilityService Device Control while keeping chat, profiles, voice, terminal/TUI relay, media, notification companion, relay sessions, QR pairing, diagnostics, and connection health. Voice Settings now clearly separates Hermes Chat + Voice Output from the experimental Realtime Agent, keeps fallback TTS visible as a global safety net, and Realtime Agent supports provider-native Hermes-brokered speech for xAI and OpenAI.
v0.8.1 makes the standard no-plugin setup first-class. Voice now works on a plain Hermes install — one dashboard sign-in unlocks it. The Manage tab reaches parity with the desktop dashboard: models, provider keys, profiles, and a skills hub with install and update. Connection routes are editable on the phone, and remote access is built into the whole journey — a setup field, a readiness card, a smarter "unreachable" diagnosis, and a one-tap Tailscale shortcut.
## Category
@@ -92,4 +103,4 @@ Not designed for children.
## Tags
ai, agent, hermes, developer tools, chat, voice, terminal, self-hosted, open source
ai, agent, hermes, developer tools, chat, voice, self-hosted, remote, open source
+5 -2
View File
@@ -67,8 +67,11 @@ Notification companion is opt-in. The app only forwards notification metadata af
From Settings, users can:
- **Export** settings such as server URLs and preferences; secrets are excluded
- **Import** a previously exported configuration
- **Export** a full connection backup. The file includes server URLs,
preferences, API keys, relay session tokens, device IDs, and dashboard
cookies so restored connections can work without manual re-entry. Keep it
private.
- **Import** a previously exported backup
- **Full reset** to wipe local data including encrypted credentials
## Stats for Nerds
+68
View File
@@ -1126,3 +1126,71 @@ Pass criteria:
`voice.*.session.detached` followed by `voice.session.resumed`.
- Replayed events are marked `replayed=true`, and the session does not start a
duplicate Hermes run inside the resume TTL.
## Idle tolerance (ADR 33 Phase 0)
Background-Hermes-run promotion (ADR 33, `docs/plans/2026-05-24-realtime-background-hermes-runs.md`)
detaches a long Hermes run from the provider event pump and keeps the provider
session open while the run completes. Whether a provider can hold the floor while
**quiescent** (no `response.create`, no input audio) for tens of seconds is the
factual unknown that gates default-on promotion.
Run the probe on the relay host (provider credentials configured) with the repo
root on `PYTHONPATH`:
```bash
python scripts/realtime-provider-idle-probe.py --provider xai
python scripts/realtime-provider-idle-probe.py --provider openai --windows 30,60,120
```
The probe holds each socket idle across the windows, then issues one short
post-idle turn to check for VAD/turn artifacts, and prints a verdict.
Record the verdict per provider. The verdict selects that provider's Tier B
strategy:
| Verdict | Meaning | Tier B strategy |
|---|---|---|
| `hold-floor-ok` | Socket survives idle; post-idle turn clean | Hold the provider session open during the background run (default) |
| `needs-keepalive` | Survives but post-idle turn degraded | Hold open + send a minimal keep-alive; revalidate the first post-idle turn |
| `must-reopen` | Socket closes/errors while idle | Detach the run but close+reopen (or resume) the provider socket on completion |
### Findings
Both verdicts are **`hold-floor-ok`** and Phase 0 is closed. The probe's original
worst case — a provider holding an *open response* idle for the whole run — does
not occur: the promotion path closes the pending call with an interim ack
(`broker.py:_begin_background_delivery`), so the socket only experiences the
normal between-turns idle gap. That gap is already exercised in production by
every Realtime Agent turn (the session stays open between the user finishing
speaking and the next `response.create`, across the resume TTL).
| Provider | Date | Basis | Windows | Post-idle turn | Verdict |
|---|---|---|---|---|---|
| OpenAI (`gpt-realtime-2`) | 2026-05-24 | empirical (probe) | 10s, 20s, 30s | audio returned, no error | **`hold-floor-ok`** |
| xAI (`grok-voice-latest`) | 2026-05-24 | existing production behavior + protocol parity | between-turn idle in daily use | clean (no idle-close reports) | **`hold-floor-ok`** |
**OpenAI — empirical.** Ran `realtime-provider-idle-probe.py --provider openai
--windows 10,20,30` against the live API. The session stayed open across all
three quiescent windows and produced clean audio on every post-idle
`response.create`. (Incidental observation, not an idle finding: the live API
emitted one `Missing required parameter: 'session.audio.output.format.rate'`
error at `session.update` time — a minor schema drift in
`providers/openai.py:_session_update` worth a follow-up; the session still
functioned and returned audio.)
**xAI — production behavior + parity.** No xAI key/OAuth store is present on the
dev box, so a fresh probe run is deferred to the relay host. The verdict is not
conditional, however: the *shipping* Realtime Agent already holds `xai_realtime`
sessions open across between-turn idle gaps with `turn_detection: None`
(relay-driven turns) and a resume TTL, and there are no reports of xAI closing or
degrading on those idle gaps. Background promotion does not lengthen the
*open-response* duration (the call is closed with an interim ack), so it does not
introduce a new idle condition beyond what xAI already tolerates today. Running
the probe on the relay host is retained as a **regression check**, not a
precondition. If it ever returns `needs-keepalive`/`must-reopen`, set that
provider's `realtime_voice` override accordingly; the per-provider setting
surface already supports it.
**Conclusion.** Both verdicts are `hold-floor-ok`, so `promotion_enabled`
defaults **on**. Phase 0's gate is satisfied; default-on is no longer blocked.
+41
View File
@@ -591,6 +591,47 @@ times, currency, percentages, versions, measurements, counts, paths, URLs, IDs,
JSON, logs, stack traces, tables, and dense numeric strings should be spoken as
human-readable summaries rather than raw character-by-character dumps.
#### Background Hermes runs (ADR 33)
Short Hermes runs answer in-line (the provider speaks the summary as soon as the
brokered result returns). A run that exceeds `promote_after_ms` is **promoted**
to a tracked background task so the provider event pump stays responsive instead
of blocking on the run:
```json
{"type":"hermes.run.promoted","event_id":20,"source":"hermes","run_id":"...","tier":"promoted","promote_after_ms":6000,"spoken_handoff":true,"result_delivery":"speak_when_idle","call_id":"call-1"}
{"type":"hermes.run.background_completed","event_id":41,"source":"hermes","run_id":"...","ok":true,"tool_count":2}
```
- On promotion the relay closes the pending provider function call with an
interim `{"status":"running_in_background"}` output (so the provider socket is
not left awaiting a tool result) and, when `spoken_handoff` is on, has the
provider speak a brief "I'm on it" line.
- When the background run finishes, the relay emits
`hermes.run.background_completed`, waits for the audio **floor** to be idle,
then injects the result through the same forced-summary path so the provider
speaks the answer exactly once. `result_delivery` selects `speak_when_idle`
(default), `notify_then_speak`, or `visual_only`.
- `hermes_run_task(mode="background")` skips the grace window and detaches
immediately (`tier:"durable"`), even when grace-period promotion is disabled.
- `hermes.run.progress` carries two extra fields while a run is in flight:
`tier` (`foreground` | `promoted` | `durable`) and `floor`
(`idle` | `provider_speaking` | `hermes_filler` | `result_pending`). The relay
is the single floor owner — a completed background result never barges in, and
Android local filler is suppressed while the provider holds the floor.
- A promoted run is cancellable via `response.cancel` (client) or the
`hermes_cancel` provider tool; if the WebSocket detaches mid-run, the
completion events replay through the resume event ring.
Promotion is configured per profile under `realtime_voice` (relay) and exposed
on `GET/PATCH /voice/realtime-agent/config` as a `promotion` block:
`enabled` (default on), `promote_after_ms`, `background_default_mode`,
`spoken_handoff`, `progress_spoken_after_ms`, `progress_repeat_ms`,
`result_delivery`, and `max_background_runs`. The default-on path is safe because
it closes the pending call rather than holding an open provider response; the
`scripts/realtime-provider-idle-probe.py` verdict (see `docs/realtime-voice-poc.md`)
confirms per-provider socket survival across the between-turns idle gap.
If a provider-native realtime turn answers directly without Hermes, Android
keeps the local user/assistant bubbles and marks that provider-only assistant
turn as unsynced. The next normal Hermes chat/run request includes those
+3 -3
View File
@@ -16,7 +16,7 @@ The relay server is a lightweight Python service that bridges the Hermes-Relay A
If you only use chat, you do **not** need the relay server. The app connects directly to the Hermes API Server for chat, sessions, profiles, and skills. Voice endpoints live on the relay but can authenticate with the same Hermes API server key used for chat; remote-control features such as terminal, bridge, TUI, media/session management, and Android control still require relay pairing.
When using the dashboard's pair/repair flow, the QR still needs both credential families: top-level `key` for direct Hermes API chat/sessions, and `relay.code` for the relay session token used by voice/bridge/terminal surfaces. The relay's loopback-only `/pairing/mint` endpoint reads `API_SERVER_KEY` from the same host-local config chain as `hermes-pair` when the dashboard does not explicitly pass `api_key`.
When using the dashboard's pair/repair flow, the QR still needs both credential families: top-level `key` for direct Hermes API chat/sessions, and `relay.code` for the relay session token used by voice/bridge/terminal surfaces. The relay's loopback-only `/pairing/mint` endpoint reads `API_SERVER_KEY` from the same host-local config chain as `hermes pair` when the dashboard does not explicitly pass `api_key`.
## Architecture
@@ -193,7 +193,7 @@ Or use a reverse proxy (nginx/Caddy) to terminate TLS in front of the relay. Ful
The relay uses a QR-driven two-step auth flow:
1. **Pairing** — the pair command runs on the Hermes host (either the `/hermes-relay-pair` slash command invoked from any Hermes chat surface, or the `hermes-pair` shell shim), mints a fresh 6-char code (`A-Z / 0-9`), pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, and embeds the relay URL + code in the scanned QR payload. The same payload is also printed as a paste-friendly `hermes-relay://pair?payload=...` invite URL for desktop GUI/CLI setup. The phone sends the code in its first `system/auth` envelope; the relay consumes it and issues a session token. Codes are one-shot and expire 10 minutes after registration. Android clears a failed scanned code after `auth.fail` so a stale QR cannot keep reconnecting into the rate limiter.
1. **Pairing** — the pair command runs on the Hermes host (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim), mints a fresh 6-char code (`A-Z / 0-9`), pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, and embeds the relay URL + code in the scanned QR payload. The same payload is also printed as a paste-friendly `hermes-relay://pair?payload=...` invite URL for desktop GUI/CLI setup. The phone sends the code in its first `system/auth` envelope; the relay consumes it and issues a session token. Codes are one-shot and expire 10 minutes after registration. Android clears a failed scanned code after `auth.fail` so a stale QR cannot keep reconnecting into the rate limiter.
2. **Session token** — Stored in Android's EncryptedSharedPreferences. Used for subsequent relay connections and Relay-protected HTTP routes. Expires after 30 days by default and carries per-channel grants, including `voice:config`, `voice:stt`, `voice:tts`, and `voice:realtime`.
Voice endpoints also accept the existing Hermes API bearer token used by API-server clients such as the Obsidian Hermes Client. That API bearer path is limited to `/voice/config`, `/voice/transcribe`, `/voice/synthesize`, `/voice/output/*`, `/voice/realtime/*`, and `/voice/realtime-agent/*`; it is not accepted for sessions, media, clipboard, terminal, TUI, bridge, profile writes, or Android control routes. Android derives the conventional Relay URL from the configured API URL (`http(s)://host:8642` to `ws(s)://host:8767`) and probes the voice routes, with a manual Relay URL override for custom routing. For non-loopback callers, Hermes API bearer auth requires HTTPS by default, either direct TLS or trusted `X-Forwarded-Proto: https` from an explicitly trusted proxy.
@@ -285,7 +285,7 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
| `/ws`, `/` | GET (upgrade) | Main WebSocket endpoint. Phone connects, sends `system/auth`, then multiplexes `chat`/`terminal`/`bridge` envelopes. |
| `/health` | GET | Returns `{status, version, clients, sessions}` JSON. |
| `/pairing` | POST | Generate a new relay-side pairing code. Returns `{"code": "ABC123"}`. Unrestricted (intended for host-local callers). |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the phone's chosen values (or the SessionManager defaults) are used. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `/hermes-relay-pair` / `hermes-pair`. |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the phone's chosen values (or the SessionManager defaults) are used. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and desktop pair/repair flows. Reads `API_SERVER_KEY` from the host-local config chain when the dashboard does not pass `api_key` explicitly. |
| `/pairing/approve` | POST | **Loopback only, Phase 3 stub.** Same wire shape and loopback gate as `/pairing/register` — present so the Android client can target the route today. The semantic difference (operator reviewing a phone-initiated pending code before approval) still needs the pending-codes store + approval UX, marked `# TODO(Phase 3)` in the handler. |
| `/sessions` | GET | Bearer-auth'd. Returns `{"sessions": [ {token_prefix, device_name, device_id, created_at, last_seen, expires_at, grants, transport_hint, is_current}, ... ]}` for all currently-active paired devices. `token_prefix` is the first 8 characters of the session token — full tokens are NEVER included, so a caller holding one session token can't extract another. `expires_at` and grant values that are `math.inf` serialize as `null` (never expire). `is_current` is true for the session matching the caller's bearer. 401 on missing/invalid bearer. Used by the Android Paired Devices screen. **Loopback branch (2026-04-18):** callers on `127.0.0.1` / `::1` may skip the bearer and receive the same `{sessions: [...]}` payload without the `is_current` flag (no caller context). Added so the dashboard plugin proxy can list paired devices without needing to mint its own bearer. Non-loopback callers still require the bearer and retain `is_current`. |
+4 -3
View File
@@ -202,7 +202,7 @@ QR still embeds all detected candidates; only the probe order changes.
```bash
# All three modes detected, but Tailscale probed first
hermes-pair --mode auto --public-url https://hermes.example.com/relay --prefer tailscale
hermes pair --mode auto --public-url https://hermes.example.com/relay --prefer tailscale
```
Result: `[(0, tailscale), (1, lan), (2, public)]` — phone tries the
@@ -216,7 +216,7 @@ order. **Role already at priority 0** → no-op.
Works identically from three surfaces:
- **CLI:** `hermes-pair --prefer tailscale`
- **CLI:** `hermes pair --prefer tailscale`
- **Skill:** `/hermes-relay-pair` documented in
[`skills/devops/hermes-relay-pair/SKILL.md`](../skills/devops/hermes-relay-pair/SKILL.md)
- **Dashboard:** Remote Access tab → Endpoint preview card →
@@ -294,7 +294,8 @@ that returns true (PR #9295 has landed in your hermes-agent install),
the helper still works but the canonical path
(`hermes gateway run --tailscale`) is preferred and the helper will
be removed in a future release. Same retirement pattern as
`hermes_relay_bootstrap/` after PR #8556.
`hermes_relay_bootstrap/`: retire compatibility per surface once the
supported upstream baseline covers it.
### Forward-auth gateways (Authelia, Cloudflare Access) in front of the API server
+1 -1
View File
@@ -7,7 +7,7 @@ Hermes-Relay gives a remote AI agent full control of an Android device via Acces
## Current Security Model
### Authentication
- **Pairing code**: A random 6-character alphanumeric code. For the QR-driven flow, the pair command (`/hermes-relay-pair` skill or `hermes-pair` shell shim) generates the code on the Hermes host and pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint; the phone-side `AuthManager.generatePairingCode()` generator is retained for the Phase 3 bridge flow.
- **Pairing code**: A random 6-character alphanumeric code. For the QR-driven flow, the pair command (`hermes pair`, `/hermes-relay-pair`, or compatibility `hermes-pair`) generates the code on the Hermes host and pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint; the phone-side `AuthManager.generatePairingCode()` generator is retained for the Phase 3 bridge flow.
- The phone and server must share this code to establish a connection.
- Codes use the full `A-Z / 0-9` alphabet (36 chars). The earlier "no ambiguous 0/O/1/I" restriction was dropped when the pairing flow moved from "human retypes code from display" to "code flows phone ↔ server via QR + HTTP" (see `docs/decisions.md` §6a).
- `POST /pairing/register` is gated to loopback callers only (`127.0.0.1` / `::1`) — only a process with host shell access on the relay machine can inject pairing codes. A LAN attacker cannot.
+5 -5
View File
@@ -161,12 +161,12 @@ Phone control — mirrors upstream relay protocol.
### 3.3 Auth Flow
Pairing is QR-driven. The operator runs the pair command on the host — either `/hermes-relay-pair` from any Hermes chat surface (backed by the `devops/hermes-relay-pair` skill) or the `hermes-pair` shell shim (a thin wrapper around `python -m plugin.pair`). Both share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (and the API server credentials) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (and the API server credentials) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
As of **v3 (ADR 24)**, the QR can also carry an ordered list of **endpoint candidates** (`lan` / `tailscale` / `public` / operator-defined roles). A single pairing covers every network the phone might be on — the phone picks the highest-priority reachable candidate at connect time and re-probes on network change. The single-URL top-level fields still appear in v3 QRs for backward compatibility; old phones ignore `endpoints` via `ignoreUnknownKeys = true`, new phones prefer `endpoints` and fall back to the top-level URL when the array is absent. See [`docs/remote-access.md`](remote-access.md) for the operator-facing setup per mode.
```
1. Operator runs /hermes-relay-pair (or hermes-pair) on the Hermes host,
1. Operator runs `hermes pair` (or `/hermes-relay-pair`) on the Hermes host,
optionally with --ttl <duration>, --grants terminal=7d,bridge=1d,
--mode {auto,lan,tailscale,public} (default auto), --public-url <url>.
2. The pair command reads the API server config (host/port/key) from
@@ -259,7 +259,7 @@ Biometric gate on the app side for terminal access (fingerprint/face) remains pl
- `hermes` — payload version. `1` is the legacy shape (no new fields); `2` is set when any v2-only field (`ttl_seconds`, `grants`, `transport_hint`) is present in the `relay` block; `3` is set when `endpoints` is present (ADR 24). All three versions parse on the current Android client.
- `endpoints` — **optional** ordered list of endpoint candidates. When present, the phone uses these in strict-priority order (0 = highest) and re-probes reachability on network change. When absent, the phone synthesizes a single priority-0 candidate from the top-level `host`/`port`/`tls` + `relay.url`/`transport_hint` fields. `role` is an open string (known values `lan` / `tailscale` / `public` get styled UI; anything else renders as "Custom VPN (<role>)"). Per-endpoint entries intentionally carry **only** `api` + `relay` — the pairing code, TTL, and grants stay at the top level because they're per-pair artifacts, not per-endpoint. Full schema in ADR 24.
- Top-level fields (`host`/`port`/`key`/`tls`) configure the direct-chat Hermes API Server. Unchanged since v1.
- Top-level fields (`host`/`port`/`key`/`tls`) configure the direct Hermes API Server. Unchanged since v1.
- `relay` — **optional** and nullable. Present only when the pair command found a running relay and successfully pre-registered a pairing code with it.
- `relay.url` — full WebSocket URL (`ws://` for dev, `wss://` for production).
- `relay.code` — 6-char one-shot pairing code from `A-Z / 0-9`. Expires 10 minutes after registration.
@@ -287,7 +287,7 @@ Implementation references:
| Transport (default) | WSS / TLS 1.3 (**preferred**) |
| Transport (opt-in) | Plain `ws://` — gated on `InsecureConnectionAckDialog` consent + reason picker (LAN-only / Tailscale or VPN / Local dev). Reason is displayed, not enforced — operator intent is the trust model. |
| Transport indicator | `TransportSecurityBadge` in Settings + Session sheet + Paired Devices card. Three states: 🔒 secure / 🔓 insecure with reason / 🔓 insecure unknown. |
| Pairing (host → phone) | `hermes-pair` / `/hermes-relay-pair` → `POST /pairing/register` (loopback-only) → QR embedded in operator's terminal or chat. |
| Pairing (host → phone) | `hermes pair` / `/hermes-relay-pair` → `POST /pairing/register` (loopback-only) → QR embedded in operator's terminal or chat. |
| Pairing (phone → host, Phase 3) | Stubbed at `POST /pairing/approve` — same wire shape, same loopback gate. Real UX pending bridge work. |
| Session lifetime | User-selected at pair: 1d / 7d / 30d / 90d / 1y / **never**. Never is always selectable; operator intent is the trust model. |
| Per-channel grants | One session token carries per-channel expiries for `chat`, `terminal`, `bridge`, `tui`, and split voice grants (`voice:config`, `voice:stt`, `voice:tts`). Grants are clamped to session lifetime. |
@@ -431,7 +431,7 @@ HTTP routes registered by `create_app()` in `plugin/relay/server.py`:
| `/ws`, `/` | GET (upgrade) | WebSocket handler — main multiplexed channel |
| `/health` | GET | Health check — returns `{status, version, clients, sessions}` |
| `/pairing` | POST | Generate a new relay-side pairing code |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code. Used by the pair command (`/hermes-relay-pair` skill or `hermes-pair` shim) to inject codes that will appear in QR payloads. Request: `{"code": "ABCD12"}`. Rejects non-loopback peers with HTTP 403. |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code. Used by the pair command (`hermes pair`, `/hermes-relay-pair`, or compatibility `hermes-pair`) to inject codes that will appear in QR payloads. Request: `{"code": "ABCD12"}`. Rejects non-loopback peers with HTTP 403. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) for dashboard, desktop GUI, and CLI pair/repair flows. |
| `/api/profiles/{name}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}` — `config` is the parsed `config.yaml` for `~/.hermes/` (when `name == "default"`) or `~/.hermes/profiles/<name>/`. Loopback callers skip bearer; remote callers require the relay session bearer. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
| `/api/profiles/{name}/skills` | GET | Profile-scoped skill enumeration. Walks `<profile>/skills/<category>/<skill>/SKILL.md` recursively; returns `{profile, skills: [{name, category, description, path, enabled: true}], total}`. Same auth model as `/config`. `name`/`description` come from YAML frontmatter when present, else directory basename. All skills report `enabled: true` today — see §22 for the toggle stub. |
+10 -31
View File
@@ -4,7 +4,8 @@ Improvements that would benefit hermes-relay (and other frontends) if added to [
## Current Upstream PR Alignment
- PR #29302 (`feat: add API server session controls`) is the canonical upstream path for `/api/sessions/*`, message history, fork, chat, and chat stream. Hermes-Relay should prefer these native routes when present and keep the bootstrap as a per-route compatibility overlay only for older or partial core builds.
- PR #33134 (`feat(api-server): session control API — sessions/chat/fork/SSE-stream`) merged the canonical upstream path for `/api/sessions/*`, message history, fork, chat, and chat stream. It salvaged the useful portion of PR #29302, which superseded the older broad PR #8556. Hermes-Relay should prefer these native routes and keep the bootstrap only as an older-build compatibility overlay.
- PR #33016 (`feat(api-server): add GET /v1/skills and /v1/toolsets`) merged the canonical read-only skill/toolset discovery path. Hermes-Relay should prefer `/v1/skills` and `/v1/toolsets` over legacy `/api/skills` list shapes.
- PR #8199 (`feat(api): add native audio transcription and speech endpoints`) is the canonical upstream path for core STT/TTS execution through `/v1/audio/transcriptions` and `/v1/audio/speech`. Hermes-Relay should keep `/voice/*` as the paired-device facade but eventually call those native core endpoints internally before falling back to private helper imports.
- PR #29364 (`feat: add API server audio endpoints`) should not become a competing `/api/audio/*` API if #8199 remains the accepted audio base. Rework it as a discovery/compatibility follow-up or close it after confirming the upstream maintainer preference.
@@ -57,35 +58,13 @@ Improvements that would benefit hermes-relay (and other frontends) if added to [
**Workaround (current):** App fetches `config.agent.personalities` map, sends the system prompt as `system_message`.
## 3. Wire Third-Party Plugin CLI Commands into Top-Level Argparser
## 3. Third-Party Plugin CLI Commands (Resolved Upstream)
**Current state (hermes-agent v0.8.0):** `PluginContext.register_cli_command(name, help, setup_fn, handler_fn, description)` is implemented in `hermes_cli/plugins.py:192` and plugins can call it during `register(ctx)`. The resulting registrations are stored in `PluginManager._cli_commands`, and a module-level getter `get_plugin_cli_commands()` exists at line 592. But `hermes_cli/main.py:5236` only consults `plugins.memory.discover_plugin_cli_commands()` (memory-subsystem-specific) when building the top-level argparser — it never iterates the generic `_cli_commands` dict.
**Current state (2026-06-07 source check):** current upstream discovers plugins before top-level CLI parser finalization and iterates `get_plugin_manager()._cli_commands.values()` in `hermes_cli/main.py`. Third-party plugins that call `ctx.register_cli_command(...)` now reach plugin-provided commands such as `hermes pair` and `hermes relay` through the plugin-native path.
**Result:** third-party plugins (like ours) correctly register sub-commands via the documented API, Hermes reports them loaded successfully in `hermes plugins list`, but typing `hermes <subcommand>` at the shell returns `argument command: invalid choice`. The plugin CLI path is effectively dead for anything outside the memory plugin subsystem.
**Impact:** no new upstream patch is needed for generic plugin CLI command dispatch. Hermes-Relay should prefer plugin-registered `hermes pair` / `hermes relay` on current upstream installs once the Hermes-Relay plugin is installed and enabled. These are not built-in Hermes core commands.
**Proposed patch:** immediately after the existing memory discovery loop in `main.py`, add a parallel loop over `get_plugin_cli_commands()` and wire each entry into the subparsers the same way. Something like:
```python
try:
from hermes_cli.plugins import get_plugin_cli_commands
for cmd_name, cmd_info in get_plugin_cli_commands().items():
if cmd_name in subparsers.choices:
continue # memory loop already handled it
plugin_parser = subparsers.add_parser(
cmd_name,
help=cmd_info["help"],
description=cmd_info.get("description", ""),
formatter_class=__import__("argparse").RawDescriptionHelpFormatter,
)
cmd_info["setup_fn"](plugin_parser)
except Exception as _exc:
import logging as _log
_log.getLogger(__name__).debug("Generic plugin CLI discovery failed: %s", _exc)
```
**Impact:** any plugin declaring `ctx.register_cli_command(...)` in `register()` would instantly get a working `hermes <name>` sub-command. Our hermes-relay plugin would unlock `hermes pair` and `hermes relay start` without shell shims. All other third-party plugins would benefit too.
**Workaround (current):** ship a `hermes-pair` shell shim at `~/.local/bin/hermes-pair` that execs `<venv-python> -m plugin.pair "$@"`, plus a `/hermes-relay-pair` skill that auto-registers as a slash command in any Hermes chat session. Both work but are plumbing around the gap rather than using the intended API.
**Compatibility fallback:** keep the dashed `hermes-pair` shell shim and `/hermes-relay-pair` slash command while we support older Hermes builds and existing scripts. They call the same implementation and can be retired only after the supported baseline includes the upstream CLI discovery fix and docs/install examples no longer depend on the shim.
## 4. Follow Symlinks in Skill Discovery
@@ -103,22 +82,22 @@ except Exception as _exc:
**Proposed — a two-stage arc, each stage a small, independently reviewable PR:**
**Stage 1 — stateless preprocessor (sibling follow-up to PR #29302).** A lightweight preprocessor in `api_server.py`'s `/v1/runs` + `/v1/chat/completions` handlers that detects a leading `/` in the user text, matches the first token against `GATEWAY_KNOWN_COMMANDS`, and splits on command type:
**Stage 1 — stateless preprocessor (follow-up to the API-server chat work).** A lightweight preprocessor in `api_server.py`'s `/v1/runs` + `/v1/chat/completions` handlers that detects a leading `/` in the user text, matches the first token against `GATEWAY_KNOWN_COMMANDS`, and splits on command type:
- **Stateless commands** (`/help`, `/commands`, and any others that can execute without touching router-owned state) are dispatched via existing helpers (`gateway_help_lines()` at `hermes_cli/commands.py:340`) and returned as a synthetic SSE stream matching the handlers' existing event shape.
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, and most of the registry) return a deterministic, helpful SSE notice along the lines of *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` (from PR #29302) or a channel with session state (Discord, CLI, Telegram)."*
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, and most of the registry) return a deterministic, helpful SSE notice along the lines of *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` (native since PR #33134) or a channel with session state (Discord, CLI, Telegram)."*
- **Unknown** and **cli-only** commands fall through to the LLM path unchanged.
- **Preprocessor exceptions** fall through to the LLM path unchanged — a preprocessor bug must never take down a normal chat request.
This respects upstream's intentional design (api_server stays stateless, no router coupling) while fixing the hallucination symptom and unlocking the commands that *can* run statelessly.
**Stage 2 — stateful dispatch on `/api/sessions/{id}/chat/stream` (after PR #29302 lands).** Once session management primitives ship, a separate PR adds a preprocessor **scoped to the session chat stream endpoint only**, using the URL's `session_id` as the persistence handle. Stateful commands become session-scoped dict writes (`session.model_override = new_model`) without refactoring `GatewayRouter` or plumbing api_server into the router. This matches upstream's partition cleanly: `/v1/*` remains stateless and OpenAI-compatible; statefulness lives on `/api/sessions/*`.
**Stage 2 — stateful dispatch on `/api/sessions/{id}/chat/stream` (now unblocked by PR #33134).** A separate PR can add a preprocessor **scoped to the session chat stream endpoint only**, using the URL's `session_id` as the persistence handle. Stateful commands become session-scoped dict writes (`session.model_override = new_model`) without refactoring `GatewayRouter` or plumbing api_server into the router. This matches upstream's partition cleanly: `/v1/*` remains stateless and OpenAI-compatible; statefulness lives on `/api/sessions/*`.
**Why not one big PR:** a full GatewayRouter refactor plus api_server plumbing was considered and rejected. It would touch 10+ files across subsystems normally owned separately, fight the documented "api_server is excluded from router notification" design decision, and review as a much larger change than the value added. The two-stage arc ships faster, reviews cleaner, and matches the upstream partition better.
**Impact:** frontends that speak the API server (hermes-relay, hermes-workspace, ClawPort, and any OpenAI-compatible client that points at the Hermes base URL) get the same built-in command surface as Discord/Telegram/CLI, in two predictable stages.
**Workaround (current / near-term):** `hermes_relay_bootstrap/_command_middleware.py` (planned for v0.4.1) mirrors Stage 1 as an aiohttp middleware injected at bootstrap time, so vanilla upstream installs that ship with the relay get the hallucination fix and the stateless commands without waiting for an upstream release. The bootstrap middleware fork-detects the same way the existing route injection does — it no-ops once Stage 1 lands upstream.
**Workaround (current / near-term):** `hermes_relay_bootstrap/_command_middleware.py` mirrors Stage 1 as an aiohttp middleware injected at bootstrap time, so older upstream installs that ship with the relay get the hallucination fix and the stateless commands without waiting for an upstream release. The bootstrap middleware feature-detects native support and should no-op once Stage 1 lands upstream.
## 6. API Server Audio Endpoints for Relay-Compatible STT/TTS
+20 -13
View File
@@ -1,6 +1,6 @@
# Upstream Hermes Integration Sync
Last reviewed: 2026-05-20
Last reviewed: 2026-06-07
This document tracks how Hermes-Relay integrates with Hermes upstream surfaces, which
parts use supported extension points, and which parts are compatibility layers that
@@ -18,6 +18,9 @@ relay, dashboard, Android app, desktop app, bootstrap package, or user docs.
- Local upstream gap tracker: `docs/upstream-contributions.md`
- Local relay reference: `docs/relay-server.md`
- Local wire protocol reference: `docs/relay-protocol.md`
- Source check: `gateway/platforms/api_server.py` on NousResearch/hermes-agent `main`
- Merged session API: https://github.com/NousResearch/hermes-agent/pull/33134
- Merged skills/toolsets API: https://github.com/NousResearch/hermes-agent/pull/33016
## Supported-First Policy
@@ -36,9 +39,10 @@ relay, dashboard, Android app, desktop app, bootstrap package, or user docs.
| Plugin metadata and discovery | `plugin.yaml`, plugin directory discovery, `plugins.enabled`, and `register(ctx)` | `plugin/plugin.yaml`, `plugin/__init__.py` | Aligned | Keep server-owned version metadata in sync with `python scripts/check-server-version-sync.py`. |
| Agent tools | Tool Gateway tools registered through plugin context | `ctx.register_tool(...)` in `plugin/__init__.py`; schemas and handlers in `plugin/tools/*` | Aligned with custom transports | Tool registration should stay in `register(ctx)`; transport details stay behind handlers. |
| Dashboard tab and plugin API | Dashboard plugin manifest plus plugin API routes under the Hermes dashboard plugin mount | `plugin/dashboard/manifest.json`, `plugin/dashboard/plugin_api.py` | Aligned wrapper | Dashboard routes may proxy relay state, but discovery and mounting should stay upstream-native. |
| Chat and model API | OpenAI-compatible API server routes such as `/v1/chat/completions`, `/v1/models`, `/health`, and supported streaming routes | Android `HermesApiClient`, relay docs, Web API docs | Mixed | Prefer standard API routes first; use `/api/sessions` only when capability probes find it. |
| Sessions API | Proposed upstream API-server session controls in NousResearch/hermes-agent PR #29302 (`/api/sessions`, messages, fork, chat, chat stream) | Android `HermesApiClient`; compatibility overlay in `hermes_relay_bootstrap/*` | Upstream-pending with fallback | Prefer native `/api/sessions/*` when present. Bootstrap must skip native routes per method/path and only inject missing compatibility routes. |
| Config, skills, memory APIs | Not documented as stable upstream API-server routes in current public docs | `hermes_relay_bootstrap/*`, `docs/HERMES-WEBAPI-REFERENCE.md` | Compatibility layer | Keep separate from the sessions retirement path. Do not skip these just because native `/api/sessions` exists. |
| Chat and model API | OpenAI-compatible API server routes such as `/v1/chat/completions`, `/v1/models`, `/v1/capabilities`, `/health`, and supported streaming routes | Android `HermesApiClient`, relay docs, Web API docs | Mixed | Prefer `/v1/capabilities` when present, then targeted probes for mixed-version fallback. |
| Sessions API | Native API-server session controls merged in NousResearch/hermes-agent PR #33134 (`/api/sessions`, messages, fork, chat, chat stream) | Android `HermesApiClient`; older-build compatibility overlay in `hermes_relay_bootstrap/*` | Native upstream with fallback | Prefer native `/api/sessions/*`. Bootstrap must skip native routes per method/path and only inject missing compatibility routes for old core builds. |
| Skills and toolsets discovery | Native read-only `/v1/skills` and `/v1/toolsets` merged in NousResearch/hermes-agent PR #33016 | Android `HermesApiClient.getSkills()` prefers `/v1/skills`; desktop/CLI tool surfaces should prefer `/v1/toolsets` where applicable | Native upstream with legacy fallback | Retire `/api/skills` list dependence from clients; keep legacy detail/toggle only where no native equivalent exists. |
| Config, memory, legacy skills, available-models APIs | Not stable current upstream API-server routes as of the 2026-06-07 source check | `hermes_relay_bootstrap/*`, `docs/HERMES-WEBAPI-REFERENCE.md` | Compatibility layer | Keep separate from the sessions/skills retirement path. Do not keep the bootstrap solely for sessions or read-only skill lists once supported baselines include #33134/#33016. |
| Mobile, desktop, and terminal relay transport | No general upstream plugin WSS transport for persistent remote clients in current public docs | `plugin/relay/server.py`, `plugin/relay/channels/*` | Custom | Keep the relay protocol documented and avoid leaking relay-only assumptions into upstream API clients. |
| Pairing QR and relay session minting | No upstream pairing or device-registration method for remote mobile clients in current public docs | `plugin/pair.py`, relay `/pairing/*`, Android QR parser | Custom | QR payloads should keep API credentials (`key`) separate from relay credentials (`relay.code`). |
| Basic STT/TTS over HTTP | Proposed upstream API-server audio endpoints in PR #8199 (`/v1/audio/transcriptions`, `/v1/audio/speech`) | Relay `/voice/config`, `/voice/transcribe`, `/voice/synthesize`; Android `RelayVoiceClient`; `plugin/relay/upstream_voice.py` | Custom wrapper pending upstream replacement | Keep `/voice/*` as the relay auth/session compatibility facade. Once core audio endpoints land, prefer proxying to native `/v1/audio/*` for STT/TTS work before falling back to private helper imports. |
@@ -50,8 +54,8 @@ relay, dashboard, Android app, desktop app, bootstrap package, or user docs.
| Deviation | Owner files | Why it exists | Guard or fallback | Retirement condition |
| --- | --- | --- | --- | --- |
| API bootstrap route and middleware injection | `hermes_relay_bootstrap/*` | Native installs need session/config/skills/memory endpoints and slash-command preprocessing before upstream exposes stable equivalents. | Method/path feature detection skips native upstream routes and injects only missing compatibility gaps; upstream-module checks skip middleware when native slash preprocessing exists. | Retire per surface: sessions after PR #29302 or equivalent ships in a released core; config/skills/memory after stable core APIs exist; slash middleware after native preprocessing exists. |
| Plugin CLI shim fallback | `plugin/__init__.py`, `plugin/cli.py`, install scripts | Some Hermes versions do not wire third-party plugin CLI commands into the top-level parser. | `ctx.register_cli_command` is attempted first; standalone shims fill the gap. | Remove shims once upstream plugin CLI discovery is stable for native installs. |
| API bootstrap route and middleware injection | `hermes_relay_bootstrap/*` | Older native installs need session/config/skills/memory endpoints and slash-command preprocessing before upstream exposes stable equivalents. Current upstream already covers sessions plus read-only skills/toolsets. | Method/path feature detection skips native upstream routes and injects only missing compatibility gaps; upstream-module checks skip middleware when native slash preprocessing exists. | Retire per surface: sessions once the supported Hermes baseline includes #33134; read-only skill lists once clients use `/v1/skills`; config/memory/legacy skill detail/toggle/available-models after stable core replacements or local UX removal; slash middleware after native preprocessing exists. |
| Plugin CLI shim fallback | `plugin/__init__.py`, `plugin/cli.py`, install scripts | Current upstream wires third-party plugin CLI commands into the top-level parser, but older supported Hermes builds and scripts may still call the dashed shims. | Prefer `ctx.register_cli_command` / plugin-provided `hermes pair` on current upstream after Hermes-Relay is installed and enabled; standalone shims stay as compatibility wrappers. | Remove shims only after the supported Hermes baseline includes the upstream CLI discovery fix and release/install docs have switched away from the dashed names. |
| Relay HTTP and WSS server | `plugin/relay/server.py`, `plugin/relay/channels/*` | Mobile, desktop, terminal, media, push, and bridge features need persistent client channels and relay-owned session state. | Keep upstream API calls separate from relay session calls and document the protocol in `docs/relay-protocol.md`. | Replace pieces only when upstream provides equivalent remote-client transport or platform adapters. |
| Pairing schema with `relay.code` | `plugin/pair.py`, Android pairing parser, relay `/pairing/*` | An API bearer key authenticates Hermes API calls but does not create relay sessions or describe WSS endpoints. | QR payloads carry direct API credentials and relay credentials as separate families. | Remove custom pairing when upstream offers native remote-device registration and relay discovery. |
| Voice `/voice/*` endpoints | `plugin/relay/voice.py`, `plugin/relay/upstream_voice.py`, `plugin/relay/voice_auth.py`, Android voice client | Relay clients need paired-session auth, profile labels, transport guards, and stable `/voice/*` shapes even while core audio APIs evolve. | Use native `/v1/audio/*` once available for STT/TTS execution, with helper imports as fallback; pass selected Hermes profile context; require relay session or valid Hermes API bearer auth. | Keep `/voice/*` as a compatibility facade until mobile clients can safely target core audio directly without losing relay auth/grants/profile behavior. |
@@ -65,8 +69,9 @@ relay, dashboard, Android app, desktop app, bootstrap package, or user docs.
- A vanilla Hermes install plus the Hermes-Relay plugin should be able to use
standard chat/model/health API paths without a fork-only requirement.
- Enhanced management features may require the bootstrap compatibility package until
upstream exposes equivalent routes. Those features must be probed before use.
- Enhanced management features may require the bootstrap compatibility package only
for surfaces that still lack upstream equivalents. Sessions and read-only skill
lists should be treated as native-upstream-first.
- The bootstrap must compose with partially-upgraded Hermes core builds. Native
routes win per method/path; missing compatibility routes may still be injected.
- Relay-specific features must authenticate through relay sessions or explicitly
@@ -99,9 +104,9 @@ upgrading the supported Hermes baseline.
- `plugin/dashboard/plugin_api.py`
- `hermes_relay_bootstrap/*`
- `plugin/relay/server.py`
- `plugin/relay/voice.py`
- `plugin/relay/realtime_voice.py`
- `plugin/relay/upstream_voice.py`
- `plugin/relay/voice.py`
- `plugin/relay/realtime_voice.py`
- `plugin/relay/upstream_voice.py`
- `plugin/pair.py`
- Android `HermesApiClient` and pairing/voice clients
- Desktop TUI transport files under `desktop/src`
@@ -115,10 +120,12 @@ upgrading the supported Hermes baseline.
- `GET /health`
- `GET /v1/models`
- `POST /v1/chat/completions` or the supported streaming route for the target version
- `GET /api/sessions?limit=1` only as an enhanced-management capability probe
- `GET /v1/capabilities` and confirm `features.session_chat_streaming`, `features.skills_api`, `endpoints.session_chat_stream`, `endpoints.skills`, and `endpoints.toolsets`
- `GET /api/sessions?limit=1` and `GET /api/sessions/{id}/messages` using the upstream `{"object":"list","data":[...]}` envelope
- `GET /v1/skills` and `GET /v1/toolsets` using the upstream `{"object":"list","data":[...]}` envelope
- Relay health and info endpoints from `docs/relay-server.md`
- Dashboard plugin overview under the Hermes plugin API mount
- `GET /v1/capabilities` and the native `/api/sessions/*` route set when testing a core build with PR #29302 or equivalent
- Native `/api/sessions/*` route set when testing a core build with PR #33134 or equivalent
- `POST /v1/audio/transcriptions` and `POST /v1/audio/speech` when testing a core build with PR #8199 or equivalent
- Voice config, transcription, synthesis, and realtime routes only with relay session auth or a valid Hermes API bearer
6. Update this file when upstream adds a supported replacement for a custom layer.
+3 -4
View File
@@ -1,7 +1,7 @@
[versions]
appVersionName = "0.8.0"
appVersionCode = "10"
agp = "8.13.2"
appVersionName = "0.8.1"
appVersionCode = "11"
agp = "9.2.1"
kotlin = "2.3.20"
compose-bom = "2026.03.01"
navigation-compose = "2.9.7"
@@ -117,7 +117,6 @@ compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-mani
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
play-publisher = { id = "com.github.triplet.play", version.ref = "play-publisher" }

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