Compare commits

...
Author SHA1 Message Date
Bailey DixonandClaude Opus 4.8 f3aba63977 fix(chat): apply model pick on new chats, render relay images, smooth profile switch
UI/UX fixes from a profile-switching + chat-composer audit, verified against
upstream tui_gateway/server.py.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:03:00 -04:00
203 changed files with 11677 additions and 2172 deletions
+90
View File
@@ -0,0 +1,90 @@
name: Bug report
description: Report a reproducible problem in Hermes-Relay.
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Before submitting, remove secrets, access tokens, real hostnames/IPs, private deployment names, and personal names. Public example IPs such as `192.168.1.100` are fine.
- type: dropdown
id: area
attributes:
label: Affected area
description: Pick the closest surface.
options:
- Android app
- Standard Hermes chat or voice
- Relay plugin or server
- Desktop CLI or tray
- Dashboard plugin
- Docs or installer
- CI, release, or packaging
- Unsure
validations:
required: true
- type: textarea
id: summary
attributes:
label: What happened?
description: State the behavior you saw and what you expected instead.
placeholder: |
Observed:
Expected:
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: Include the smallest sequence that reproduces the issue.
placeholder: |
1. Pair or configure...
2. Open...
3. Tap or run...
4. See...
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Include only the fields that apply.
value: |
- Hermes-Relay version/tag:
- Install surface: Google Play / sideload APK / local build / plugin / desktop CLI
- Android device and OS:
- hermes-agent version or commit:
- Connection mode: LAN / Tailscale / public TLS / other
validations:
required: true
- type: textarea
id: logs
attributes:
label: Sanitized logs, screenshots, or traces
description: Paste the smallest useful log excerpt. Remove tokens, private URLs, hostnames, IPs, and user-identifying data.
render: shell
- type: textarea
id: upstream
attributes:
label: Upstream or standard-path notes
description: If relevant, note whether this reproduces against unmodified upstream hermes-agent or only with the relay plugin enabled.
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I searched existing issues first.
required: true
- label: I removed secrets, tokens, private infrastructure, and personal names.
required: true
- label: I included the affected version or install surface where known.
required: true
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: Security guidance
url: https://github.com/Codename-11/hermes-relay/blob/main/docs/security.md
about: Review the security model before posting sensitive vulnerability details publicly.
- name: User documentation
url: https://codename-11.github.io/hermes-relay/
about: Read setup, pairing, remote access, and troubleshooting docs.
- name: Contributing guide
url: https://github.com/Codename-11/hermes-relay/blob/main/CONTRIBUTING.md
about: Review local setup, branch, commit, changelog, and test conventions.
+64
View File
@@ -0,0 +1,64 @@
name: Documentation or setup issue
description: Report unclear, stale, or missing docs and setup guidance.
title: "[Docs]: "
labels: ["documentation"]
body:
- type: markdown
attributes:
value: |
Use this for docs, installer, setup, release-note, or contribution-guide problems. Remove private hostnames/IPs, tokens, and personal names before posting.
- type: dropdown
id: area
attributes:
label: Documentation area
options:
- README
- User docs site
- Android setup
- Relay plugin setup
- Desktop CLI or tray setup
- Release notes or changelog
- Contributor docs
- Other
validations:
required: true
- type: input
id: location
attributes:
label: Page, file, or section
description: Link the page or name the file and heading.
placeholder: user-docs/guide/getting-started.md, README install section, etc.
validations:
required: true
- type: textarea
id: issue
attributes:
label: What is wrong or missing?
description: Explain what was unclear, outdated, misleading, or absent.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Suggested correction
description: Optional. Include the wording, command, screenshot need, or structure that would help.
- type: textarea
id: context
attributes:
label: Context
description: Optional. Include the version, install path, device, or command you were following.
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I checked that this is not already covered in current docs.
required: true
- label: I removed secrets, private hostnames/IPs, internal deployment names, and personal names.
required: true
@@ -0,0 +1,78 @@
name: Feature request
description: Propose a product, workflow, or platform improvement.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Keep requests focused on user-visible outcomes. Do not include private infrastructure, secrets, personal names, or branch/workspace plumbing.
- type: dropdown
id: area
attributes:
label: Affected area
options:
- Android app
- Standard Hermes chat or voice
- Relay plugin or server
- Desktop CLI or tray
- Dashboard plugin
- Docs or installer
- CI, release, or packaging
- Unsure
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem or workflow
description: What is hard, missing, slow, confusing, or unsafe today?
placeholder: Describe the concrete user workflow this would improve.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed behavior
description: Describe the outcome, not just an implementation detail.
placeholder: After this change, a user should be able to...
validations:
required: true
- type: textarea
id: standard_path
attributes:
label: Standard upstream compatibility
description: If this touches chat, voice, dashboard, API routes, or server behavior, note whether it can work against unmodified upstream hermes-agent.
placeholder: This should work on vanilla upstream because... / This requires the relay plugin because...
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Optional. Mention current workarounds or related approaches.
- type: textarea
id: acceptance
attributes:
label: Acceptance criteria
description: What would make the request complete?
placeholder: |
- Users can...
- The app/server handles...
- Documentation covers...
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I searched existing issues first.
required: true
- label: I described the user outcome and affected surface.
required: true
- label: I removed private infrastructure details and personal names.
required: true
+13 -4
View File
@@ -6,11 +6,20 @@
-
## Verification
<!-- List the checks you ran, or explain why a check is not applicable. -->
-
## Checklist
- [ ] `./gradlew assembleDebug` succeeds
- [ ] `./gradlew test` passes
- [ ] Tested on emulator or device (if UI change)
- [ ] Target branch is `dev` unless this is a release PR
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
- [ ] Desktop changes: `npm run build` or a narrower documented check ran, or rationale is listed above
- [ ] Docs/site changes: docs build or link check ran, or rationale is listed above
- [ ] UI changes were tested on emulator/device or desktop surface when applicable
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
- [ ] CHANGELOG.md updated (if user-facing)
- [ ] No credentials or secrets in committed files
- [ ] Public writing hygiene checked: no secrets, private infrastructure, personal names, or AI/process narration
+22
View File
@@ -0,0 +1,22 @@
# GitHub Copilot instructions — Hermes-Relay
This file exists so GitHub Copilot (which reads `.github/copilot-instructions.md`,
not `AGENTS.md`) picks up the project's agent guidance.
**Read [AGENTS.md](../AGENTS.md) first — it is the single source of truth**
for agent guidance: the entry point, the non-negotiables, and the public-repo
writing hygiene. It links on to `CLAUDE.md` for the deep reference
(architecture, upstream Hermes API, repository layout, per-language code style,
the dev loop, and the Key Files map). Follow those; don't restate them here.
Quick non-negotiables (the full list and rationale are in `AGENTS.md`):
- **Standard path = vanilla upstream only.** The default no-plugin connection
must work against unmodified upstream hermes-agent; server-side needs go
through upstream PRs or the optional relay plugin, never fork patches.
- **Conventional Commits**, `main`/`dev` branching — feature branches off
`dev`, `--no-ff` merges, tags cut from `main`.
- **Android:** Jetpack Compose (no XML), kotlinx.serialization (no Gson),
OkHttp (no Ktor), `wss://` only; run `./gradlew lint` before pushing Kotlin.
- **Public repo:** no personal names, no private infrastructure, no
AI/assistant self-narration in committed prose.
+23 -19
View File
@@ -3,7 +3,9 @@
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
# Android-affecting paths so Python-only changes don't spin up the JVM.
#
# Pipeline: lint -> build + test (parallel) -> upload artifacts
# Pipeline: lint, build, and focused tests run concurrently. PRs build debug
# APKs before merge; dev pushes keep lint/tests only to avoid duplicate
# post-merge packaging. Main pushes keep APK artifacts.
name: CI — Android
@@ -38,11 +40,12 @@ concurrency:
jobs:
# ──────────────────────────────────────────────
# Android Lint — gate for build and test jobs
# Android Lint
# ──────────────────────────────────────────────
lint:
name: Lint (Android)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -55,25 +58,20 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
# Prefer ktlintCheck if configured; fall back to Android lint
- name: Run lint checks
run: |
if ./gradlew tasks --all 2>/dev/null | grep -q "ktlintCheck"; then
echo "Running ktlintCheck..."
./gradlew ktlintCheck
else
echo "ktlintCheck not found, falling back to Android lint..."
./gradlew lint
fi
- name: Run Android lint
run: ./gradlew lint --console=plain
# ──────────────────────────────────────────────
# Android Build — assembleDebug + upload APK
# Android Build — assembleDebug for PRs and main pushes
# ──────────────────────────────────────────────
build:
name: Build (Android)
needs: lint
if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -86,12 +84,15 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
- name: Build debug APK
run: ./gradlew assembleDebug
run: ./gradlew assembleDebug --console=plain
- name: Upload debug APK
uses: actions/upload-artifact@v7
if: ${{ github.ref == 'refs/heads/main' }}
with:
name: debug-apk
# Product flavors (googlePlay, sideload) nest APKs under
@@ -109,7 +110,6 @@ jobs:
# ──────────────────────────────────────────────
test:
name: Test (Android)
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 20
# Advisory on dev, strict on main. Evaluates to false (= strict) for
@@ -128,6 +128,8 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
# The broad Gradle `test` aggregate currently hangs in deferred JVM test
# suites tracked by issue #32. Keep CI release-relevant until that suite is
@@ -136,14 +138,16 @@ jobs:
- name: Run focused Android unit tests
run: |
./gradlew :app:testSideloadDebugUnitTest \
--tests com.hermesandroid.relay.network.RelayUrlDeriverTest \
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
--console=plain
# Upload test reports even if tests fail, for debugging
# Upload reports only for failures. Successful PR report uploads add
# noticeable latency and are rarely inspected.
- name: Upload test reports
uses: actions/upload-artifact@v7
if: always()
if: failure()
with:
name: test-reports
path: app/build/reports/tests/
+89
View File
@@ -0,0 +1,89 @@
# Hermes-Relay — Vanilla-Upstream Route Contract (ADR 34)
#
# Proves the Android *standard path* (no-plugin) route surface exists on
# UNMODIFIED NousResearch/hermes-agent — the invariant CLAUDE.md asserts but
# that was never tested. Source-parses upstream's declared routes (no server
# boot, no pip install, no model keys); see scripts/check-upstream-route-contract.py
# for the design + tradeoff (catches renamed/removed routes; not runtime auth).
#
# PR/push runs check a pinned ref (non-flaky); the weekly schedule tracks
# upstream `main` as a drift siren so a route rename surfaces on our clock.
name: CI — Upstream Contract
on:
push:
branches: [main, dev]
paths:
- "scripts/check-upstream-route-contract.py"
- ".github/workflows/ci-contract.yml"
- "app/src/main/kotlin/com/hermesandroid/relay/network/upstream/**"
pull_request:
branches: [main, dev]
paths:
- "scripts/check-upstream-route-contract.py"
- ".github/workflows/ci-contract.yml"
- "app/src/main/kotlin/com/hermesandroid/relay/network/upstream/**"
schedule:
- cron: "0 6 * * 1" # Mondays 06:00 UTC — upstream-drift siren (tracks main)
workflow_dispatch:
inputs:
upstream_ref:
description: "NousResearch/hermes-agent ref to check (branch, tag, or SHA)"
required: false
default: ""
concurrency:
group: ci-contract-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
jobs:
route-contract:
name: Vanilla-upstream route contract
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout hermes-relay
uses: actions/checkout@v6
- name: Resolve upstream ref
id: ref
run: |
# PR/push runs use a known-good NousResearch/hermes-agent commit so
# normal CI is stable. The weekly schedule below intentionally tracks
# main as the upstream-drift siren.
DEFAULT_REF="ef4b897a1843cd32c4f141f55db60f0f0602cc98"
if [ "${{ github.event_name }}" = "schedule" ]; then
REF="main" # weekly drift siren
elif [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
REF="${{ github.event.inputs.upstream_ref }}" # manual override
else
REF="$DEFAULT_REF"
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
echo "Checking standard-path route contract against upstream ref: $REF"
- name: Checkout vanilla upstream (no plugin, no bootstrap)
uses: actions/checkout@v6
with:
repository: NousResearch/hermes-agent
ref: ${{ steps.ref.outputs.ref }}
path: _upstream
fetch-depth: 1
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Assert upstream checkout is vanilla (no relay bootstrap/plugin)
run: |
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
echo "FAIL: upstream checkout contains a relay bootstrap — not vanilla."; exit 1
fi
echo "OK: upstream checkout carries no relay plugin/bootstrap."
- name: Run route-surface contract
run: python scripts/check-upstream-route-contract.py "_upstream"
+5 -5
View File
@@ -5,15 +5,11 @@ on:
branches: [main, dev]
paths:
- "plugin/dashboard/**"
- "scripts/check-plugin-version-sync.py"
- "scripts/check-server-version-sync.py"
- ".github/workflows/ci-dashboard.yml"
pull_request:
branches: [main, dev]
paths:
- "plugin/dashboard/**"
- "scripts/check-plugin-version-sync.py"
- "scripts/check-server-version-sync.py"
- ".github/workflows/ci-dashboard.yml"
permissions:
@@ -55,7 +51,11 @@ jobs:
run: python scripts/check-plugin-version-sync.py
- name: Install dashboard API test deps
run: pip install -r relay_server/requirements.txt fastapi httpx pytest requests
# The suite imports the `plugin` package transitively: __init__ loads
# android_tool/desktop_tool (`import requests`), and one test imports
# `plugin.relay`, whose server.py needs `aiohttp` (+ pyyaml) from
# relay_server/requirements.txt. fastapi+httpx cover plugin_api itself.
run: pip install -r relay_server/requirements.txt fastapi httpx requests
- name: Run dashboard API tests
run: python -m unittest plugin.dashboard.test_plugin_api
+4 -8
View File
@@ -14,6 +14,10 @@ on:
permissions:
contents: read
concurrency:
group: ci-desktop-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
jobs:
typecheck-and-build:
name: Type-check + build
@@ -47,16 +51,8 @@ jobs:
# prebuilt dist/ that references a source file that moved.
run: node bin/hermes-relay.js --version
- name: Upload dist/
uses: actions/upload-artifact@v4
with:
name: desktop-dist
path: desktop/dist
retention-days: 7
smoke-help:
name: Smoke — --help + --version work on every target OS
needs: typecheck-and-build
strategy:
fail-fast: false
matrix:
+1 -11
View File
@@ -4,7 +4,7 @@
# plugin-affecting paths so Android-only changes don't spin up the
# Python toolchain.
#
# Pipeline: syntax-check -> focused plugin tests
# Pipeline: syntax-check and focused plugin tests run concurrently.
name: CI — Plugin
@@ -17,9 +17,6 @@ on:
- "plugin/cli.py"
- "plugin/pair.py"
- "plugin/plugin.yaml"
- "plugin/dashboard/manifest.json"
- "plugin/dashboard/package.json"
- "plugin/dashboard/package-lock.json"
- "plugin/relay/**"
- "plugin/tools/**"
- "plugin/tests/**"
@@ -39,9 +36,6 @@ on:
- "plugin/cli.py"
- "plugin/pair.py"
- "plugin/plugin.yaml"
- "plugin/dashboard/manifest.json"
- "plugin/dashboard/package.json"
- "plugin/dashboard/package-lock.json"
- "plugin/relay/**"
- "plugin/tools/**"
- "plugin/tests/**"
@@ -76,9 +70,6 @@ jobs:
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r relay_server/requirements.txt
- name: Syntax check (plugin relay — canonical location)
run: |
python -m py_compile plugin/relay/server.py
@@ -103,7 +94,6 @@ jobs:
# ──────────────────────────────────────────────
unit-tests:
name: Focused Plugin tests (Python)
needs: syntax-check
runs-on: ubuntu-latest
timeout-minutes: 10
# Advisory on dev, strict on main. Evaluates to false (= strict) for
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
cache-dependency-path: user-docs/package-lock.json
- name: Install dependencies
run: npm install
run: npm ci
working-directory: user-docs
- name: Build VitePress site
+92
View File
@@ -0,0 +1,92 @@
name: Play Store Listing
on:
pull_request:
paths:
- "assets/screenshots/**"
- "assets/play-store-icon-512.png"
- "assets/play-store-feature-1024x500.png"
- "docs/media/screenshots.json"
- "app/src/googlePlay/play/default-language.txt"
- "app/src/googlePlay/play/listings/**"
- "scripts/screenshots.py"
- ".github/workflows/play-listing.yml"
push:
branches:
- main
- dev
paths:
- "assets/screenshots/**"
- "assets/play-store-icon-512.png"
- "assets/play-store-feature-1024x500.png"
- "docs/media/screenshots.json"
- "app/src/googlePlay/play/default-language.txt"
- "app/src/googlePlay/play/listings/**"
- "scripts/screenshots.py"
- ".github/workflows/play-listing.yml"
workflow_dispatch:
inputs:
publish_listing:
description: "Publish Play Store listing metadata after validation"
required: true
default: false
type: boolean
permissions:
contents: read
jobs:
validate:
name: Validate Listing Assets
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install image tooling
run: python -m pip install --upgrade rich Pillow
- name: Validate screenshots and listing metadata
run: python scripts/screenshots.py validate
publish-listing:
name: Publish Listing Metadata
needs: validate
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_listing }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
- name: Write Play service account
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
echo "::error::PLAY_SERVICE_ACCOUNT_JSON is not configured."
exit 1
fi
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
- name: Publish Play Store listing
run: ./gradlew publishGooglePlayReleaseListing
- name: Remove Play service account
if: always()
run: rm -f play-service-account.json
+4 -3
View File
@@ -60,9 +60,8 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Build debug APK
run: ./gradlew assembleDebug
with:
cache-read-only: false
# Keep the tag release gate aligned with CI — Android's broad Gradle
# `test` aggregate currently hangs in deferred JVM suites tracked by
@@ -90,6 +89,8 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
- name: Decode release keystore
env:
+4
View File
@@ -26,6 +26,10 @@ then `docs/spec.md` and `docs/decisions.md`.
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
- **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints
everywhere, structured `logging` (no `print`). **Desktop CLI (Node ≥21):**
zero runtime deps, strict TS + ES modules, ship compiled `dist/`. Full
per-language style and the dev loop live in CLAUDE.md → "Code Style".
## Public-repo writing hygiene
+32
View File
@@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Added
- **Injected-context audit (chat).** Tap the context-usage meter in chat to open a "What the agent sees" sheet showing the exact extra context prepended to your next turn — persona/profile, phone status, and any per-turn (voice) hint. On the gateway path it notes the persona is applied server-side, so the audit is honest about what the phone does and doesn't send.
- **Spoken-turn badges (chat).** Voice-mode replies now carry a "Voice" chip and realtime replies a "Realtime Agent" chip — both with a speaker glyph — so spoken turns are distinguishable from typed ones in the scrollback.
- **App themes.** A new theme picker in Settings → Appearance ships eight looks: the signature Hermes Relay brand (with full light/dark) plus ports of the Nous Hermes baselines — Hermes Teal, Nous Blue (light), Midnight, Ember, Mono, Cyberpunk, and Rosé. The whole app — brand chrome, accents, and chat background — follows the chosen theme. Light/Dark/Auto applies to themes that ship both modes; fixed-mode themes show their own complete look.
- **Hot-swappable agent sphere.** The orb is now a pluggable "skin": an Adaptive skin that recolors to match your theme, built-in Classic / Aurora / Solar / Mono looks, and support for **user-authored skins** loaded from a small JSON spec. Each skin declares which live signals it reacts to (voice, tool bursts, activity), shown as capability badges in the picker. See `docs/sphere-spec.md`.
- **Connections separate features from routes (Android).** Connection settings now distinguish what a connection can *do* (a **Features** section) from how this phone *reaches* Hermes (a **Route** section), so you can enable Relay features over whichever transport you prefer. A plugin-provided **Secure proxy** route is surfaced alongside LAN, Tailscale, public, and custom routes. The standard direct-to-upstream path is unchanged and still needs no plugin. See `docs/plans/2026-06-18-native-secure-routes.md`.
- **Enhanced voice control (Gemini & xAI).** When the relay uses a Gemini or xAI voice provider, Voice Settings can now steer it: pick a Gemini voice and model and turn on expressive tone tags (with optional natural-language voice direction), or set an xAI voice with expressive speech tags. Expressive tags also apply to xAI on the streaming voice-output renderer. Standard (no-plugin) voice stays configured server-side.
- **Voice render-path visibility.** Voice Settings shows which path is rendering speech (streaming vs. basic), and Diagnostics records it each session, making voice issues easier to troubleshoot.
### Changed
- **Voice replies are formatted for listening.** In voice mode the assistant is now guided to answer in short, conversational sentences without markdown, emoji, or raw URLs — without changing what is stored in chat history.
- **Leaner terminal screen (Android).** The extra-keys bar scrolls horizontally with compact, fully-legible keys (no more clipped "CTRL"), the header is a single compact row showing one inline connection-status dot plus state, and the tab strip is hidden for single-tab sessions — the new-tab "+" moves into the header — reclaiming vertical space for the terminal.
- **Relay terminals run on an isolated, TUI-tuned tmux.** Sessions now use a dedicated tmux server/socket with its own config — instant ESC (`escape-time 0`), truecolor `$TERM`, mouse and focus events on, and no status bar — so editors and full-screen tools behave correctly, without touching the user's personal tmux.
### Fixed
- **Clearer error when a feature needs a newer relay.** Toggling a setting an older relay plugin doesn't recognize (e.g. xAI expressive speech tags) now shows "Relay update needed" instead of a generic HTTP 400 with a dead Retry button. Genuine input errors are unaffected.
- **Connection status toast is no longer see-through.** The floating connection-lost/switching toast renders fully opaque so content behind it no longer bleeds through and hurts legibility.
- **Provenance badges survive the post-turn history reload.** "Voice", "Realtime Agent", "Stopped", and "Error" chips are now preserved when the conversation reloads after a turn, instead of silently vanishing.
- **Chat and Manage no longer stay dark in Light mode.** Brand-styled surfaces bypassed the theme and were effectively hardcoded dark; they now follow the selected theme and light/dark mode, and the glow/border flourishes key off the active theme rather than the system setting.
- **Realtime voice no longer drops the conversation mid-session with some providers.** A normal end-of-turn signal was being rejected on certain voice providers, ending the session every turn.
- **Relay voice synthesis no longer leaves temporary audio files behind** on the server.
- **Clearer voice errors and an oversize-recording guard.** Standard voice now rejects an over-long recording before uploading it and shows a helpful message for audio the server can't read, instead of a generic HTTP error.
- **Terminal paste no longer auto-runs multi-line text.** The key-bar PASTE now uses bracketed paste, so multi-line content lands intact in shells and editors instead of executing line by line.
- **Terminal on-screen arrows behave inside TUIs.** Arrow/Home/End keys follow the running app's cursor-key mode (application vs. normal), so they work correctly in vim, less, and fzf.
- **Terminal footer spacing.** A small gap now keeps the last terminal row clear of the key bar (it could previously look like the footer overlapped it), and a redundant navigation-bar inset that left empty space below the keys was removed.
- **In-chat model picker now actually applies on a new chat.** Picking a model and provider in the chat composer (e.g. Grok 4.3 via your xAI subscription) is bound to the new conversation, so the agent runs on the picked model instead of silently falling back to the account's global default. Switching profiles retires an explicit pick so the profile's own model takes over, and the picker label updates immediately instead of lagging a round-trip.
- **Server-generated images render in chat when paired to the relay.** An assistant image that points at a server-side file path is now fetched through the relay's media route and shown inline (tap to zoom), instead of degrading to an "image is on the server" notice. On the SSE chat path the agent is also told it can surface images and files by path when a relay route is configured (visible in the chat "What the agent sees" sheet). Standard (no-plugin) connections are unchanged.
- **Smoother profile switching.** Switching profiles no longer blanks the conversation to an empty/"Loading…" state before the new history loads; the previous transcript is held and cross-fades to the new one.
## [1.1.0] - 2026-06-16
### Added
+1 -1
View File
@@ -374,7 +374,7 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
1. **Edit locally** — Windows checkout. Both plugin (`plugin/`) and app (`app/`) live here.
2. **Python syntax check** — `python -m py_compile plugin/<file>.py`. Full tests run on the server.
3. **Kotlin changes** — do NOT run `gradle build`. Bailey builds via Android Studio's ▶ button. Never `adb install` from Claude.
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs (see `.github/workflows/ci.yml` → `gradlew lint` fallback) and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Lint is a hard blocker in CI: Build + Test show "skipping" until lint passes, and lint prints only the **first failure** before aborting — so CI iterations reveal errors one at a time while a single local lint run surfaces all of them.
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Android CI runs lint alongside build/test for faster feedback, but a local lint run still surfaces issues before the workflow spends runner time compiling and packaging.
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
6. **Pull + restart on server** — see Server Deployment below.
7. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
+121
View File
@@ -1,5 +1,126 @@
# Hermes-Relay — Dev Log
## 2026-06-18 — Chat UX: model-picker apply, relay inbound images, smooth profile switch (Android)
**Why.** An audit of profile switching and the chat composer surfaced three issues: (1) the in-chat model picker showed the picked model but the agent ran on the account's global default; (2) an agent-returned server-local image showed a path/"on server" notice instead of rendering, even when paired to the relay; (3) switching profiles visibly tore down and rehydrated the conversation.
- **Model picker binds to `session.create` (`GatewayChatClient`, `ChatViewModel`, `GatewayModels`).** Verified against upstream `tui_gateway/server.py`: a model is a per-session override, applied via `config.set {session_id,…}` on a live session or `model`/`provider` params on `session.create` for a fresh one. The app only did the first; on a brand-new chat the `config.set` carried no `session_id` (upstream treats it as a no-op) and `session.create` omitted the model, so the agent built from the global default. Added a live `sessionModelProvider` (mirrors `sessionProfileProvider`) so the picker's model+provider bind onto each `session.create`; mid-session switches still go through `config.set`. A profile switch now retires an explicit pick (the profile defines its own model) and seeds the picker pill from the profile model so the header doesn't lag the round-trip. SSE paths already carried the model in the request body. Tests added for the new binding.
- **Relay-backed inbound images (`ChatImageContent`, `ChatScreen`, `ChatViewModel`).** Markdown images (`![](src)`) flowed through a renderer that only understood `http(s)` → Coil; a server-local path fell to a static "image is on the server" notice and never consulted the relay (the relay media route was only wired to the `MEDIA:` marker path). Added a `RelayServerImageResolver` CompositionLocal, provided by ChatScreen from `ChatViewModel.resolveServerImage`, which fetches an absolute server path through the relay's bearer-auth `/media/by-path` route (same route + sandbox the `MEDIA:` path uses), decodes, caches (bounded LRU keyed by path), and renders inline with tap-to-zoom. Null when unpaired → unchanged standard (no-plugin) behavior. Complementary nudge: `composeInjectedContext` appends a one-line media-capability hint to the SSE `system_message` when a relay route is configured (`RelayHttpClient.mediaUrlConfigured()`), surfaced in the "What the agent sees" audit sheet. SSE-only — the gateway has no per-turn system slot, so there the client render fallback (and upstream's own `MEDIA:` instruction) carry it.
- **Profile-switch transition (`ChatViewModel.switchProfileContext`, `ChatScreen`).** Stopped clearing the message list synchronously before the async history fetch; the previous transcript is held and swapped atomically when the new history resolves, so the `LazyColumn`'s per-item `animateItem()` cross-fades old→new instead of blanking to an empty/"Loading…" state. The top loading row is suppressed while held content is on screen.
- **Verification.** Rebased `Codename-11/fix-ui-ux-issues` onto `dev` first (its only unique change was already on `dev`). `:app:compileSideloadDebugKotlin` + `:app:compileSideloadDebugUnitTestKotlin` BUILD SUCCESSFUL (no new warnings in the changed files); `GatewayChatClientTest` extended with model-binding cases. On-device verification via Studio.
## 2026-06-18 — Terminal: TUI input correctness + chrome cleanup (Android + relay)
**Why.** On-device terminal use surfaced input bugs and wasted chrome, benchmarked against Orca's mobile terminal. The extra-keys bar clipped labels ("CTRL" → "CTR") because it was weight-distributed across a fixed width; the on-screen arrows and PASTE bypassed the emulator and sent fixed/raw bytes (wrong inside TUIs and unsafe for multi-line paste); and the header + tab strip + a stray inset ate vertical space, especially in the common single-tab case. Separately, the relay wrapped each PTY in the user's default tmux, inheriting tmux's 500ms `escape-time` and `screen` `$TERM` — the classic source of laggy ESC, mangled Alt, and degraded color in vim/htop.
- **Extra-keys bar (`ExtraKeysToolbar.kt`).** Rewrote from a weight-divided `Row` to `horizontalScroll` with fixed-min-width keys, so labels never clip and the cluster scrolls when wider than the screen (Orca's strategy). Then compacted to Orca's proportions — 32dp height, 36dp min width, 12sp, tighter padding/spacing — and centralized the key haptic.
- **Mode-aware special keys (`index.html` + `TerminalScreen.kt`).** Added `window.termSendKey(name)` that reads xterm's `applicationCursorKeysMode` and encodes arrows/Home/End as SS3 (`\eOA`) vs CSI (`\e[A`); the toolbar arrows now route through it. PASTE routes through `term.paste()` (bracketed paste) instead of a raw `sendInput`, so multi-line paste no longer auto-executes.
- **Compact header (`TerminalScreen.kt`).** Replaced Material's fixed 64dp `TopAppBar` with a ~52dp custom `Row` + `statusBarsPadding`: status is shown once, inline with the title (a `ConnectionStatusBadge` dot + one concise word, ellipsized via `weight(1f, fill = false)` so it can't push the actions off-screen), the whole block opens the info sheet. The subtitle no longer renders the full `hermes-<deviceId>-tabN` wire id (it was wrapping to two lines).
- **Less wasted vertical space.** The tab strip + its divider now render only for 2+ tabs; with one tab the new-tab "+" lives in the header instead. Dropped a redundant `navigationBarsPadding()` on the keys bar (the app Scaffold's bottomBar already owns the nav-bar inset, leaving a dead gap), and added an 8px bottom gap in `#terminal` so the last row clears the keys bar.
- **Isolated, TUI-tuned tmux (`plugin/relay/channels/terminal.py`).** Sessions now spawn on a dedicated `-L hermes-relay` socket with a generated `~/.hermes/hermes-relay-tmux.conf` (written lazily, best-effort): `escape-time 0`, `default-terminal "tmux-256color"`, truecolor `terminal-features/overrides`, `mouse on`, `focus-events on`, `set-clipboard on`, `aggressive-resize on`, `status off`. A dedicated socket is the only safe way to set server-global `escape-time` without altering the user's own tmux; persistence is unchanged (the socket's server outlives the relay process).
- **Verification.** `:app:assembleSideloadDebug` BUILD SUCCESSFUL and deployed to device; `python -m unittest plugin.tests.test_terminal_channel` (4 tests) + `py_compile` pass. Relay change hand-deployed to the staging box and verified live on the `hermes-relay` socket (`escape-time 0`, `default-terminal tmux-256color`, `status off`, `mouse on`, `focus-events on`). tmux 3.4 with `tmux-256color` terminfo present.
## 2026-06-18 — Native secure routes: split connection Features from Routes (Android)
**Why.** Connection setup conflated two separate questions — what a Hermes connection can *do* (features) and how this phone *reaches* it (route) — which coupled Relay features to a single transport. Modeling them separately lets a user enable Relay tools over any route (LAN, Tailscale, public HTTPS, VPN, or a plugin-provided secure proxy) and sets up a plugin-assisted native encrypted route that does not require Tailscale. The standard path stays direct-to-upstream and plugin-free. (Backfilled log entry — the work landed in PR #88; full design in `docs/plans/2026-06-18-native-secure-routes.md`.)
- **Split connection model.** `ConnectionsSettingsScreen` / `ActiveConnectionSections` now render distinct **Features** and **Route** sections; `Endpoint.kt` + `ConnectionData.kt` carry the route/role model and `QrPairingScanner` threads it through pairing.
- **Plugin secure proxy route.** A `plugin_proxy` route role surfaces as a "Secure proxy" option with encrypted / pinned-TLS treatment (recommended, not forced) alongside the existing LAN / Tailscale / public / custom roles.
- **Docs.** Added the `2026-06-18-native-secure-routes` plan and a connections split-model mockup; fixed the docs-site hero sphere to keep its canvas backing store synced to the CSS box (`HeroDemo.vue`).
- **Verification.** CI green on PR #88 (Android Build + Lint + Test).
## 2026-06-18 — Android onboarding permissions review surface
**Why.** Android onboarding already kept the standard path clean, but permissions were scattered between feature-specific prompts, Bridge, and Android Settings. A central review page makes the model explicit: standard Chat and Manage do not need phone-control permissions, while voice, camera, notifications, and sideload Device Control remain opt-in.
- **Shared permission snapshot.** Added `AppPermissionStatusProbe` so Bridge and Settings read the same runtime grants and special-access switches: notifications, microphone, camera, notification listener, accessibility, screen capture, overlay, contacts, SMS, phone, and location.
- **Permissions screen.** Added `PermissionsStatusScreen` with Standard Hermes, On demand, and flavor-aware Device Control sections. Rows show required/optional/session status and link to the relevant Android Settings surface or Bridge session grant.
- **Onboarding and Settings entry points.** The Power Tools onboarding page now has a "Review permissions" action, and Settings -> App includes a Permissions row. Google Play builds show the sideload Device Control section as unavailable rather than implying hidden phone-control permissions.
- **Verification.** `:app:compileGooglePlayDebugKotlin`, `:app:compileGooglePlayDebugAndroidTestKotlin`, and `:app:compileSideloadDebugKotlin` pass with `ANDROID_HOME` pointed at the local SDK.
## 2026-06-17 — Chat transparency + provenance polish: injected-context audit sheet, spoken-turn badges, version-skew error
**Why.** On-device voice testing surfaced two transparency gaps and two papercuts. The per-turn system context the phone injects (persona + phone status + voice hint) was invisible — no way to audit what the agent actually receives. Spoken voice-mode turns were indistinguishable from typed ones in the scrollback, while realtime turns were already badged. A field an older relay plugin doesn't accept produced a misleading "Network error · HTTP 400" with a dead Retry. And the floating connection toast's Warning tone was semi-transparent, letting content bleed through.
- **Injected-context audit sheet.** `ChatViewModel.composeInjectedContext()` extracts the per-turn system-message composition (persona/profile precedence + phone-status block + per-turn interface context) into one builder used by both `startStream` (which sends `combinedSystemMessage`) and the new `previewInjectedContext()` — so the audit can't drift from what is sent. `combinedSystemMessage` is built byte-for-byte as before (`listOfNotNull` over the raw blocks); per-block fields null out blanks only for display, and the resolved profile is passed in to preserve the no-skew invariant with `modelOverride`. Tapping the `ContextMeterBar` (now with an ⓘ affordance) opens `InjectedContextSheet` ("What the agent sees"); on the gateway path the persona block is labeled "added server-side — not sent from this device", since the server owns the soul + personality overlay there.
- **Spoken-turn badges.** Voice-mode replies are tagged "Voice", realtime replies keep "Realtime Agent"; both share a speaker glyph as the modality marker (`MessagePathBadge` gained an optional leading icon). The Voice tag is set on the assistant placeholder from the per-turn interface-context signal and rides the id-swap + content updates.
- **Badges survive history reload.** `ChatHandler.loadMessageHistory` now carries provenance badges forward by message id when it rebuilds the list from server data — the post-turn reload previously wiped them (this also fixes the pre-existing loss of "Stopped"/"Error").
- **Version-skew error.** `RelayErrorClassifier` maps a 400 whose body names an unsupported *field* to "Relay update needed" (non-retryable), distinct from a bad *value* like "unsupported codec" (which keeps its normal classification).
- **Connection toast opacity.** `ConnectionStatusToast` composites its container color over the theme `surface` so the floating overlay is always opaque while keeping each tone's tint; the in-flow `ConnectionStatusBanner` is intentionally left translucent (it blends with a known backdrop).
- **Verification.** `:app:compileSideloadDebugKotlin` BUILD SUCCESSFUL; `./gradlew lint` clean. On-device confirm via Studio/sideload.
## 2026-06-17 — App theming: theme-aware brand tokens, app themes, hot-swappable sphere
**Why.** Chat (and other brand-styled surfaces) were effectively hardcoded dark: the `RelayRefresh` brand palette was a single dark-only `object` of `val Color(...)` constants that ~150 call sites referenced directly, bypassing the Material light scheme. The goal was three-fold: fix that alignment, add real app themes (light/dark plus the Nous Hermes baselines), and make the agent sphere a hot-swappable component with user-authored skins.
- **Theme-aware brand tokens (the fix).** New `ui/theme/BrandPalette.kt` defines a 21-token `BrandPalette`, the `LocalBrand` CompositionLocal, a `toColorScheme()` derivation (Material scheme is now derived from the palette, never authored separately), and the `AppTheme`/`AppThemes` registry. `RelayRefresh` was converted from constants into a **snapshot-backed façade** over an `activePalette`: every `RelayRefresh.X` is now a getter reading a `mutableStateOf`, so reads in composition and draw phases subscribe and repaint on theme change — the ~150 existing call sites became theme-reactive with no edits. `HermesRelayTheme(appThemeId, themePreference, fontScale)` resolves the theme + light/dark/auto mode into one palette, drives the Material scheme + `LocalBrand`, and mirrors it into the façade via `SideEffect`.
- **App themes (8).** Hermes Relay (the brand, with full light + dark) plus ports of the canonical Nous dashboard baselines from upstream `web/src/themes/presets.ts` — Hermes Teal, Nous Blue (light), Midnight, Ember, Mono, Cyberpunk, Rosé. Per the hybrid model, the brand honors Light/Dark/Auto while the character themes are fixed-mode looks (matching how Nous ships themes; light mode lives as the Nous Blue theme). `ConnectionViewModel` gained an `appTheme` id pref (DataStore `app_theme`); the picker is a swatch gallery in `AppearanceSettingsScreen`, and the Light/Dark/Auto control disables + explains itself for fixed-mode themes.
- **Flourish alignment.** The glow/gradient/markdown-highlight flourishes across 13 files keyed off `isSystemInDarkTheme()`; they now read `LocalBrand.current.isDark`, so a fixed dark theme keeps its flourishes on a light-mode phone (and vice-versa). `isSystemInDarkTheme()` now lives only in `Theme.kt` (resolving Auto).
- **Hot-swappable sphere.** The core algorithm (`MorphingSphereCore.kt`, mirrored in `preview/web/sphere.js`) was left untouched — parity contract preserved. A new `SphereSkin` layer supplies per-state colors/params and declares its reactivity (voice/tools/intensity/gaze), gated inside `MorphingSphere` so reactive inputs are optional + detectable. `SphereRegistry` ships Adaptive (recolors to the active theme via `LocalBrand`), Classic (the original look), Aurora, Solar, and Mono. `MorphingSphere` gained a `skin` param defaulting to `LocalSphereSkin.current`, so all ~11 call sites are untouched; `RelayApp` provides the resolved skin + available set. User skins load from app-private `spheres/*.json` via `SphereSpec` (kotlinx.serialization, data-only, validated, invalid files skipped) → `SphereSkinLoader`; surfaced in the picker with capability badges and a "Custom" tag. Format documented in `docs/sphere-spec.md`.
- **Verification.** Reviewed-but-not-compiled — no Android SDK in this worktree and Studio owns builds. Brace/paren balance checked on all new/edited files; import resolution and call-site compatibility reviewed by hand. `./gradlew lint` + on-device confirm pending (via Android Studio). Palettes are single `BrandPalette` literals, easy to tweak after an on-device pass.
## 2026-06-17 — ConnectionViewModel decomposition: extract transport/pairing/profile collaborators (ADR 34 follow-up)
**Why.** `ConnectionViewModel` was the one god-object the ADR 34 fence deferred — ~5,531 lines reaching across both sides of the upstream/relay package boundary (the `HermesApiClient`/`GatewayChatClient`/`DashboardApiClient` zoo *and* the relay `ConnectionManager` *and* pairing *and* profiles). Goal: move cohesive concerns into named, testable collaborators in a new `viewmodel/connection/` package, behind the ViewModel's existing public surface, so the standard-vs-relay wiring lives in explicit seams. Pure mechanical, behavior-preserving extraction — the whole-module compile (production + every test source unchanged) is the public-API-preservation guard. Continues the `ConnectionSwitchCoordinator` precedent.
- **`PairingController`** (229 lines). Owns the paired-devices list (`GET /sessions`) + management (`loadPairedDevices`/`revokeDevice`/`extendDevice`/`revokeChannelGrant`, incl. the optimistic local removal and the full-grants-rebuild encoding) and the insecure-ack DataStore flags (`insecureAckSeen`/`insecureReason`/`setInsecureAckComplete`). Self-contained — nothing else reads its state except `applyPairingPayload`'s `insecureReason.value`. The ViewModel delegates unchanged.
- **`UpstreamTransportController`** (269 lines). Owns the per-connection encrypted `DashboardCookieStore` cache, a single consolidated `DashboardApiClient` factory (was 4+ scattered build sites — the plan's headline), the cached `GatewayChatClient` (lazy build + mid-turn LAN/Tailscale retarget) with its availability tier + sticky-Unsupported verdict, and the per-endpoint capability snapshot + `chatMode` + the `streamingEndpoint`-preference resolution. The `@Synchronized` gateway-cache lock moved *with* the state (now the controller instance) — same mutual exclusion, different monitor. `rebuildApiClient` pushes the probed snapshot via `setCapabilitiesAndMode`.
- **`ProfileController`** (313 lines). Owns the merged `agentProfiles` list (relay `auth.ok` ∪ dashboard `/api/profiles`), the per-connection selected-profile state machine + its three persistence stores, `profileDisplayAlias`, `activeSessionTransport`, and the per-profile last-session restore. Because the state machine is co-driven by ViewModel-level lifecycle observers (connection switch / active-connection change / agent-profile arrival / gateway-availability settle), those observers stay in the ViewModel and call `profileController.*` lifecycle hooks **in their original order** — orchestration stays put; only state + logic moved, so the state machine is now unit-testable in isolation. The three stores are exposed as public vals so the connection-lifecycle orchestrators (`removeConnection`, duplicate-merge, `resetAppData`, `saveLastSessionId`) keep their clear/persist call sites byte-identical.
- **Deferred: `RelayTransportController`** (the plan's Step 2). Left in place per the plan's explicit "too entangled — don't force it" rule. `ConnectionManager` is referenced at ~38 ViewModel sites, including eager `StateFlow` initializers (`relayConnectionState`, `activeEndpoint`, `effectiveApiServerUrl`/`RelayUrl`/`DashboardUrl`, `relayReady`, `insecureMode`), the `ConnectionSwitchCoordinator`, and the `relayHttpClient`/`ScreenCapture`/`tailscaleDetector` URL-provider lambdas. The relay/route methods are inseparable from the central `_relayUrl`/`_apiServerUrl` state (also written by non-relay orchestrators + the switch coordinator) and from shared connection-store helpers (`persistActiveConnectionUrls`, `mergedRouteCandidates`); the route-probe public nested types (`RouteProbeStatus`, `RelayReachable`) are part of the frozen public API. A faithful extraction needs ~18 injected callbacks/refs — relocating coupling into lambdas rather than removing it, against the plan's "named seams, not emergent shared state" goal and at a regression risk the compile-plus-focused-slice verification can't catch. The other three controllers stand on their own; the `ChatTransportProvider` capstone (optional) is likewise not attempted.
- **Result.** `ConnectionViewModel` 5,531 → 5,089 lines (−442); cohesive transport/pairing/profile concerns now live in 811 lines of `viewmodel/connection/` collaborators with narrow, provider-injected interfaces. Public API unchanged.
- **Verification.** `:app:compileSideloadDebugKotlin` + `:app:compileSideloadDebugUnitTestKotlin` BUILD SUCCESSFUL after each extraction (every caller + test source compiles unchanged → public surface byte-identical). Focused slice `*ArchitectureBoundaryTest` (Konsist fence — `viewmodel/connection/` is outside `network.*` so it imports both worlds freely, fence stays green) + `*RelayUrlDeriverTest` + `*ConnectionSwitchTest` passes. `./gradlew lint` clean. On-device confirm pending (Bailey, via Studio).
## 2026-06-17 — Voice mode audit: relay bug fixes, spoken-output hint, Google enhanced voice
**Why.** A post-refactor audit of the standard and relay voice paths surfaced one reachable correctness bug plus several enhancement opportunities: leverage the desktop-style non-persisted per-turn context to instruct spoken-output formatting, and expose Google/Gemini enhanced voice (tone tags, voice/model/persona) that upstream added in recent PRs.
- **Relay realtime-agent loop drift (correctness).** The non-native ("render-after-Hermes") websocket loop in `plugin/relay/realtime_agent/broker.py` lacked a `playback.drained` branch, so the end-of-turn ack every client sends fell through to the unsupported-message error; the client treats `voice.error` as fatal and tore the session down on every turn whenever a non-native provider was configured. Extracted the client→server messages identical across the native and non-native loops (`session.start`, `session.resume`, `client.ack`, `playback.drained`, `hermes.confirm`) into a shared `_handle_common_client_message` dispatcher used by both loops so they can no longer drift, and added the missing `input_audio.clear` handling to the non-native path. The native loop's per-message `provider_task.done()` break check is preserved exactly. (`hermes.confirm` echo is by-design in both loops — the realtime model answers confirmations via its `hermes_confirm` tool, which returns `forwarded_to_hermes_ui` — so it was left unchanged.)
- **TTS file leak.** `plugin/relay/voice.py` synthesize streamed upstream-written `~/voice-memos/*.mp3` files and never deleted them. It now passes its own temp `output_path` into the TTS tool and deletes the artifact after streaming (reads bytes into memory and returns a `web.Response` so cleanup can run; audio is bounded by `MAX_TEXT_CHARS`).
- **Realtime "lab" session binding.** `plugin/relay/realtime_voice.py` `handle_ws` authenticated but never checked the websocket caller created the session. Added `_auth_matches_session` (mirrors `voice_output.py`) binding sessions to the creating principal's kind + device-id / token-hash, reusing `voice_auth`'s shared `AuthPrincipal` / `_bearer_from_request`.
- **Spoken-output formatting hint (standard + relay).** Enriched the per-turn voice interface context (`VoiceViewModel.STABLE_VOICE_INTERFACE_CONTEXT`) to tell the model its reply will be spoken — short conversational sentences, no markdown/emoji/URLs. This rides the existing non-persisted `system_message` slot (upstream `ephemeral_system_prompt`), so it never lands in history. Because the gateway `prompt.submit` RPC has no system-message slot, `ChatViewModel.startStream` now forces any turn carrying a per-turn interface context (voice) onto an SSE endpoint so the hint always reaches the model.
- **Provider-aware enhanced voice (Gemini + xAI) — relay path.** `/voice/synthesize` accepts optional per-request overrides (`voice`, `model`, `audio_tags`, `persona_prompt`, `language`) mapped onto the active provider. Upstream's `text_to_speech_tool` has no per-call override surface, so the relay merges overrides into a config copy and invokes the provider generator directly — `_generate_gemini_tts` (voice/model/`audio_tags` tone-tag rewrite/inline persona via a temp file) or `_generate_xai_tts` (`voice`→`voice_id`, `audio_tags`→`auto_speech_tags`, `language`). Gemini's audio-tag rewrite fails soft when the auxiliary LLM is unavailable. `/voice/config` advertises a provider-aware `tts.enhanced` capability block. Android plumbs `EnhancedVoiceOverrides` from new persisted prefs through `RelayVoiceClient.synthesize` + the relay adapter, with an "Enhanced Voice (<provider>)" Voice Settings card rendered from the capability flags. OpenAI is excluded — upstream exposes only voice/speed for it (no `instructions` tone steering).
- **Enhanced voice on the streaming renderer (`/voice/output`).** Because `voice_output_enabled` defaults true, the streaming renderer — not `/voice/synthesize` — is the normal relay playback path, so the per-request override was effectively fallback-only. Added xAI `auto_speech_tags` as a per-profile `voice_output:` setting threaded through every layer (config dataclass + env + YAML loader, `voice_output_settings`, profile override, `VoiceOutputSession`, `_provider_options`, `config_payload`, the `PATCH /voice/output/config` allow-list), mirroring `text_normalization`. The render applies `upstream_voice.apply_xai_speech_tags()` (fail-soft) to each chunk before the `voice_lab` `xai_tts` renderer — keeping `voice_lab` standalone and the upstream import in the patch-point. Android: `VoiceOutputConfig.auto_speech_tags` + `updateVoiceOutputConfig(autoSpeechTags=)` + an "Expressive speech tags" switch in the **Hermes Chat + Voice Output** card (xai_tts, persisted with the existing Save buttons). No Gemini streaming provider in `voice_lab`, so Gemini enhanced voice stays `/voice/synthesize`-only.
- **Render-path visibility (troubleshooting).** The streaming-vs-synthesize decision was logcat-only. Added a per-session `DiagnosticsLog` entry naming the active path and a persistent "Render path" row in the Voice Settings card (derived from `voiceOutputConfig`).
- **Docs.** `docs/upstream-surface-matrix.md` gained a "Voice Surfaces (standard vs. relay)" section with an explicit **route-ownership table** (every `/voice/*` route is relay-owned; only dashboard `/api/audio/*` is upstream — no upstream streaming/WS audio route) and an enhanced-voice matrix across both relay paths; `docs/spec.md` Phase V documents the override, the `tts.enhanced` block, and `voice_output` `auto_speech_tags`; `user-docs/features/voice.md` gained an "Enhanced Voice (Gemini & xAI)" section + the streaming speech-tags toggle and corrected the stale `~/voice-memos` note.
- **Standard voice polish.** Pre-flight 25 MB transcribe guard (matches upstream `_MAX_TRANSCRIPTION_UPLOAD_BYTES`) + friendly 413/400 copy in `StandardHermesVoiceClient`; hardened the dashboard audio HEAD probe to also try `/api/audio/speak`.
- **Verification.** Python: `py_compile` on all touched relay modules; relay voice suite green at 103 tests except one pre-existing xAI-OAuth env-dependent failure (identical on the unmodified tree). New tests: non-native `playback.drained` regression (red-on-bug/green-on-fix), Gemini + xAI synthesize-override integration, override-parser + capability-block units, `auto_speech_tags` PATCH round-trip, `apply_xai_speech_tags` call-through/fail-soft. Kotlin not built locally (Studio + `./gradlew lint` are the pre-push gate).
## 2026-06-17 — Upstream/Relay isolation: package fence + Konsist rule + vanilla contract test
**Why.** The load-bearing "standard path = vanilla upstream" invariant was enforced only by `CLAUDE.md` convention (network clients were cleanly named but co-located in one package, so nothing *stopped* a standard-path file importing a relay client), and the standard path had never been validated against *true* vanilla upstream (staging runs the fork with relay routes compiled in). ADR 34 records the decision; this lands all three parts. Net-additive, behavior-preserving.
- **Package fence.** Split `app/.../network/` into `network/{upstream,relay,shared}` — main + mirrored test sources (38 files moved, ~83 touched for import repointing). `VoiceAudioClient.kt` split three ways: the `VoiceAudioClient` interface + `AutoVoiceAudioClient` router → `shared`; `StandardHermesVoiceClient` → `upstream`; `RelayVoiceAudioClientAdapter` → `relay` (co-locating them would force one file to import both worlds). `ChatHandler` → `upstream` (per ADR 3 chat never flows through the relay multiplexer; the handler is fed only by upstream transports). `AndroidManifest` `GatewayKeepAliveService` FQCN and the `ci-android.yml` `RelayUrlDeriverTest` path updated for the move.
- **Hidden coupling surfaced.** The move exposed the one real upstream→relay dependency the import grep couldn't see (it was a same-package bare reference): `ChatHandler` renders phone-action bubbles from the bridge's `LocalDispatchResult` DTO. Resolved by moving that passive DTO to `network.shared` — both sides now depend only on shared to speak it.
- **Konsist boundary test.** `ArchitectureBoundaryTest` (`scopeFromProduction`) asserts `upstream` ⊥ `relay` and `shared` imports neither. Added to the `ci-android.yml` explicit `--tests` list (the broad aggregate hangs, issue #32, so a named test is the only way it runs in CI). Konsist 0.17.3 resolves clean on Kotlin 2.3.21.
- **Vanilla-upstream contract.** `scripts/check-upstream-route-contract.py` source-parses upstream's declared routes (aiohttp `add_*` + FastAPI decorators) — no server boot, no pip, no model keys. Two tiers: REQUIRED standard-path routes fail the build if missing; mode-dependent routes (auth-gate, `/api/pty`, `/v1/models`) only warn. A fork-marker guard refuses to pass against our own fork. `ci-contract.yml` checks out vanilla upstream with no relay bootstrap, asserts the checkout is vanilla, runs the contract; weekly schedule tracks upstream `main` as a drift siren, PR/push use a pinned ref. Notable: in the checked upstream commit the dashboard exposes no `/api/auth/ws-ticket` REST route (it uses the injected session token + a ws `ticket` query param), so the Desktop-style auth-gate routes are advisory, not required.
- **Deferred (tracked in ADR 34).** The `ConnectionViewModel` transport-strategy split — the one true god-object leak — is intentionally deferred as the riskiest change; the fence now contains the blast radius. Same-package redundant imports left behind by the move (e.g. a relay file importing its own `network.relay` sibling) are harmless and not cleaned. The contract job's PR-run `UPSTREAM_REF` defaults to `main` until pinned to a confirmed-public known-good SHA.
- **Verification.** `:app:compileSideloadDebugKotlin` + `:app:compileSideloadDebugUnitTestKotlin` BUILD SUCCESSFUL; `ArchitectureBoundaryTest` passes; contract script PASS against the local upstream clone (12/12 REQUIRED routes). `./gradlew lint`: BUILD SUCCESSFUL (clean, all four variants).
## 2026-06-17 — Gateway parity: live session.info sync + YOLO/Fast + stale-state refreshes
**Why.** An audit (full tui_gateway surface vs. what the official desktop uses vs. what we used) found we were dropping most `session.info` fields and fetching several server lists once. Goal: augment upstream, never show stale state. Verified every contract against the up-to-date upstream clone; a parallel review confirmed the new RPCs match `config.set` exactly and caught three race-window bugs (fixed).
- **More of `session.info` consumed live.** The interceptor now also surfaces `reasoning_effort`, `credential_warning`, `yolo`, and `fast` (added to `serverReasoningEffort`/`serverCredentialWarning`/`serverYolo`/`serverFast` flows); `startGatewayStateSync` gained one guarded collector each. A `/reasoning` change made on the desktop/TUI now reflects instantly instead of only on turn-complete.
- **Credential warnings no longer silent.** `session.info.credential_warning` (present only when the active provider key is missing/invalid) is surfaced once per distinct warning as a ⚠ system notice — dedup'd against the constant `session.info` echoes, cleared when the key is fixed. Previously such turns just failed silently.
- **YOLO + Fast mode.** New session-scoped toggles in the agent sheet (`config.set yolo` value `1`/`0` scope `session`; `config.set fast` value `fast`/`normal`) with optimistic set + rollback, live state from `session.info.yolo`/`fast`, and reset across every session/profile/connection switch. YOLO (approval bypass) renders loud — `error` caption + an `errorContainer` "Approvals are OFF" banner — and stays ephemeral so a backgrounded app can't leave global auto-approve armed.
- **No fetch-once staleness.** `refreshSkills()` + `refreshModels()` (SSE `/v1/models`) now fire on agent-sheet open alongside the personality/model refreshes, so server-side skill/model changes appear without an app reload.
- **Review fixes.** `activateGatewayProfile` now nulls YOLO/Fast (the missing 5th clear site); the `setYolo`/`setFast` optimistic rollback guards against a session switch landing during a slow `prewarm` (re-check client identity + only roll back if we still own the value).
- **Verification.** `:app:testSideloadDebugUnitTest` compiles clean; contract-fidelity review = all PASS. Touches only `GatewayChatClient`/`ChatViewModel`/`ConnectionInfoSheet`. The command-palette skills-refresh-on-open is the one optional follow-up (palette lives in the co-owned `ChatScreen.kt`). `./gradlew lint` + on-device confirm still pending.
## 2026-06-17 — Personality: server-owned on the gateway + picker-command handling
**Why.** Two reports against the personality flow. (1) Sending `/personality` (no arg) showed an agent reply bubble that appeared then vanished; (2) `/personality none` returned a confirmation but the app never reflected that the overlay was cleared. Verified the actual contract against the up-to-date upstream clone: `/personality` is a *picker command* (`hermes_cli/commands.py` `_PICKER_COMMANDS`) that the desktop/TUI never raw-forward — a bare command expands to an arg step, and a named/`none` value is applied via `config.set {key:"personality"}`, which persists `display.personality` + applies `ephemeral_system_prompt` live to the session and emits `session.info`. The app instead blindly forwarded every slash to `slash.exec`/`command.dispatch`, had no `none` concept, and never consumed `session.info` — so it kept injecting a stale per-turn personality prompt that fought the server.
- **Slash results stopped vanishing.** `ChatHandler.loadMessageHistory` did a wholesale reload preserving only `voice-intent-`/`steer-`/`ask-` ids; `system-notice-` (every `addSystemNotice` slash result) was wiped by the next turn's reconcile. Added `system-notice-` to the preserve allow-list — fixes the disappearing bubble for `/personality` and all other inline command output.
- **Gateway client owns personality.** `GatewayChatClient` gained `serverPersonality: StateFlow<String?>`, `getPersonality()` (`config.get`), and `setPersonality()` (`config.set {key:"personality", value, session_id}`), plus a connection-level `session.info` interceptor that captures the `personality` field even with no turn in flight. `"none"`/`"default"`/`"neutral"` all clear the overlay (upstream `_validate_personality` conflates them).
- **ViewModel mirrors server truth.** `selectPersonality` pushes via `config.set` on the gateway (optimistic, rolled back on a server reject with a now-durable notice) and only drives per-turn injection on the SSE fallbacks; `startStream` skips the persona-prompt injection entirely on the gateway so it can't double-apply. A `startPersonalitySync` collector + a ready-socket `config.get` seed keep `_selectedPersonality` reconciled to whatever the server/desktop/TUI set.
- **Picker-command UX.** `/personality` is intercepted client-side like the desktop: bare `/personality` opens the agent sheet's Personality section (new `openPersonalityPicker` one-shot), `/personality <name|none>` routes to `selectPersonality`. The synthetic client **"Default" row was removed** — the picker is now **None** + the server-provided personalities (the configured default, if any, shows tagged `(default)` and highlights when active); upstream's active value is just `none` or a name, so the client shouldn't invent a third state. Added a `/personality none` palette entry. `AgentDisplay` treats `none`/`neutral` as cleared-overlay aliases (base identity, not the literal word) via `isClearedPersonality`.
- **`/model` sibling fixed.** `/model` is the other picker command (`_PICKER_COMMANDS = {model, skin, personality}`; `skin` is `cli_only` and already excluded on mobile). A bare `/model` had the same raw-forward dead-end — now it opens the model picker (`openModelPicker` one-shot → `ModelPickerSheet`), while `/model <args>` stays a real gateway switch.
- **Live model/provider sync.** The `session.info` interceptor now also surfaces `model` + `provider` (`serverModel`/`serverProvider` flows); the VM's `startGatewayStateSync` (renamed from `startPersonalitySync`) drives the model pill from them, so a `/model` switch on the desktop/TUI reflects live. Format-safe — the pill normalizes through `AgentDisplay.displayModelName`, and `session.info` keeps model/provider separate like `model.options`.
- **No app reload for server-supplied data.** `refreshPersonalities()` (list + default + active `config.get`) now fires on agent-sheet open alongside `refreshModelOptions`, so a personality added/changed server-side appears without restarting the app; the active value also tracks live via `session.info`.
- **Profile SOUL double-inject fixed.** On the gateway the session is bound to the selected profile (SOUL applied server-side) AND the personality rides `config.set` — so `startStream` now sends NO persona/profile prompt on the gateway (only the phone-status block), where it previously re-injected the profile's `systemMessage` on top of the server's own SOUL. SSE fallbacks keep the client-side precedence rules.
- **Verification.** `:app:testSideloadDebugUnitTest` compiles the full module clean; new `AgentDisplayTest` cases for `isClearedPersonality` / `none`-as-cleared pass. `./gradlew lint` still the pre-push gate. On-device confirm of the live gateway round-trip pending.
## 2026-06-16 — Per-surface release notes (plugin + CLI parity with Android)
**Why.** Plugin and CLI GitHub Release bodies were static boilerplate baked into the workflow YAML (version-interpolated, but change-agnostic — a reader couldn't tell what a `plugin-v*`/`cli-v*` release actually changed). Only Android had real per-release notes (`RELEASE_NOTES.md` via `body_path`). Brought plugin and CLI up to the same Summary/Added/Changed/Fixed format.
+13
View File
@@ -0,0 +1,13 @@
# GEMINI.md
Agent instructions for **Hermes-Relay**. This file exists so Gemini CLI (which
does not read `AGENTS.md` natively) picks up the project's guidance.
**Read [AGENTS.md](AGENTS.md) — it is the single source of truth** for every
coding agent: the entry point, the non-negotiables (standard-path-is-vanilla-
upstream, verify-endpoints, Conventional Commits + `main`/`dev` branching, the
per-language stack rules), and the public-repo writing hygiene. It links on to
`CLAUDE.md` for the deep reference (architecture, upstream Hermes API, repo
layout, code style, the dev loop, and the Key Files map).
Do not restate rules here — keep them in `AGENTS.md` so they can't drift.
+13
View File
@@ -405,6 +405,13 @@ the new app version and a higher `appVersionCode`.
shown in the settings/about screen. Update with the version number
and a brief feature summary. Gets stale silently if forgotten
(v0.4.0 shipped with 0.1.0 content until caught post-release).
- `app/src/googlePlay/play/release-notes/en-US/default.txt` — the Play
Console **"What's new"** text, which gradle-play-publisher reads at
upload to fill the Production-draft release notes. This is **separate**
from `RELEASE_NOTES.md` (that one is only the GitHub Release body) — if
this file is missing or stale, the Play draft ships with empty/wrong
notes (shipped empty in v1.1.0 until caught post-release). Keep it
**≤500 chars per language**, user-facing, Android-only.
- `docs/play-store-listing.md` — Play Store listing copy. Update
the version reference and the "Release Notes" section that gets
pasted into the Play Console "What's new" field. Keep the Play
@@ -533,6 +540,12 @@ Release named `Hermes-Relay-Plugin v<version>` for the plugin package.
> Production **draft** — skip to the Play Console, confirm the draft, and click
> **Start rollout**. The manual path below is the fallback when the secret is
> unset (or for staging on a non-production track).
>
> This automated tag path is intentionally bundle-only. It uploads the
> `googlePlayRelease` AAB and release-scoped "What's new" notes, but it does
> not republish static listing assets such as screenshots, title, description,
> icon, or feature graphic. Use the Play Store Listing workflow when those
> assets change.
**Pick the track first.** The AAB is track-agnostic — the same
`-googlePlay-release.aab` goes to whichever track you publish on. Choose by intent,
+2
View File
@@ -283,6 +283,8 @@ dependencies {
// across priority groups against real local sockets so the behavior we
// validate matches on-device.
testImplementation(libs.okhttp.mockwebserver)
// Konsist — enforces the ADR 34 upstream/relay/shared package fence as a JUnit test
testImplementation(libs.konsist)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
@@ -172,6 +172,17 @@ class OnboardingFlowTest {
.assertIsDisplayed()
}
@Test
fun powerPage_linksToPermissionReview() {
setOnboardingContent()
navigateToPage(3)
composeTestRule
.onNodeWithText("Review permissions")
.assertIsDisplayed()
.assertIsEnabled()
}
@Test
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
setOnboardingContent()
@@ -0,0 +1,45 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollTo
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
import org.junit.Test
class PermissionsStatusScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun permissionsScreen_showsStandardAndOnDemandRows() {
composeTestRule.setContent {
HermesRelayTheme {
PermissionsStatusScreen(
onBack = {},
onOpenBridge = {},
)
}
}
composeTestRule
.onNodeWithText("Permissions and capabilities")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Chat and Manage")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("No Android runtime permission needed. API/dashboard auth is configured separately.")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Camera")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Microphone")
.performScrollTo()
.assertIsDisplayed()
}
}
@@ -1,8 +1,8 @@
package com.hermesandroid.relay.voice
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.handlers.LocalDispatchResult
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.shared.LocalDispatchResult
import com.hermesandroid.relay.network.relay.models.Envelope
/**
* Local in-process bridge dispatcher type. The Play flavor never invokes
@@ -0,0 +1 @@
en-US
@@ -0,0 +1,62 @@
Hermes-Relay is the native Android client for the Hermes agent platform. Point it at your own Hermes instance and chat with your agent, talk to it hands-free, and manage models, keys, skills, and profiles from anywhere.
It is not a hosted AI service. It is a companion app for the Hermes agent you run, and it talks only to the instances you configure.
QUICK START
1. Run hermes-agent with its API server and dashboard enabled on your computer or home server.
2. Install Hermes-Relay and enter your server address, for example http://192.168.1.100:8642.
3. The setup wizard checks what your server supports and shows a readiness card, then you are ready to chat.
A plain Hermes install is enough. Chat, management, and voice work with no plugin or extra service.
HOW IT WORKS
Chat streams directly from your Hermes API Server or dashboard gateway in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
GOOGLE PLAY BUILD
The Google Play build ships Hermes Bridge Core only. It has no AccessibilityService Device Control: it cannot read your screen, tap, type, swipe, screenshot, send SMS, place calls, or access contacts or location. Device Control is reserved for sideload builds distributed outside Google Play.
FEATURES
- Streaming Chat: real-time responses with reasoning, markdown, tool-call visibility, attachments, mid-turn steering, edit-and-resend, and a searchable command palette.
- Manage Your Agent: use your Hermes dashboard from your phone to switch models, manage provider keys, edit profiles, and browse, install, and update skills.
- Voice Mode: talk hands-free using your server's speech providers. Relay-paired setups add per-profile voices and an experimental realtime engine.
- Works Away From Home: add LAN, Tailscale, or public routes and the app chooses the best available path on connect.
- Sessions: create, switch, rename, and delete chats. Message history loads on demand.
- Multiple Servers and Profiles: connect to more than one server and switch in a tap; overlay an agent profile or personality per conversation.
- Relay Power Tools: optional QR pairing for remote terminal, relay-session management, media handoff, and per-feature grants.
- Notification Companion: optionally forward notification metadata to your paired relay so your assistant can summarize it. Toggle it anytime in system settings.
- Stats for Nerds: local-only counters for response timing, token usage, cost, and stream health.
- Material You: Material 3 dynamic color, light/dark/system themes, and haptics.
SECURITY AND PRIVACY
- API keys and relay tokens are stored in encrypted Android storage.
- HTTPS is enforced for remote connections; cleartext is limited to localhost or LAN setups.
- No telemetry, ads, tracking, or third-party analytics SDKs.
- Notification access and the microphone are optional and user-controlled.
- All app traffic goes only to servers you configure.
REQUIREMENTS
- Android 8.0 or later.
- A running Hermes agent for chat, management, and voice.
- Optional Hermes relay service for power tools such as terminal, notifications, and media.
- Network access to your server by local network, VPN, or internet.
OPEN SOURCE
Hermes-Relay is MIT licensed. Source, docs, and issue tracking are on GitHub.
This app is a community project and is not affiliated with or endorsed by NousResearch.
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

@@ -0,0 +1 @@
Your Hermes AI agent, in your pocket - chat, voice, and control.
@@ -0,0 +1 @@
Hermes-Relay
@@ -0,0 +1,5 @@
Settings & chat polish:
• Status chips now show only when something needs attention; Power tools shows one live plugin badge; Connections moved to the top of Settings.
• Chat settings: fixed the streaming-endpoint picker layout; the system-prompt preview now reflects your enabled toggles.
• Fixed a rare crash on connect from a corrupt saved credential (now self-heals).
• Server-side relay-plugin improvements.
+1 -1
View File
@@ -73,7 +73,7 @@
runs while the user has explicitly enabled the toggle. specialUse
needs a Play Console foreground-service declaration at submission. -->
<service
android:name=".network.GatewayKeepAliveService"
android:name=".network.upstream.GatewayKeepAliveService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
+52 -1
View File
@@ -26,7 +26,9 @@
left: 0;
right: 0;
bottom: 0;
padding: 8px 6px 0 8px;
/* Bottom gap so xterm's last row clears the extra-keys footer
instead of butting flush against it (read as an overlap). */
padding: 8px 6px 8px 8px;
box-sizing: border-box;
}
.xterm .xterm-viewport {
@@ -149,6 +151,18 @@
}
});
// Report scroll position so the host can show a "jump to latest" pill
// while the user is scrolled up into scrollback. atBottom is true when
// the viewport is pinned to the live tail.
const reportScroll = function () {
if (!(window.AndroidBridge && window.AndroidBridge.onScrollPosition)) return;
try {
const buf = term.buffer.active;
window.AndroidBridge.onScrollPosition(buf.viewportY >= buf.baseY);
} catch (_) {}
};
term.onScroll(function () { reportScroll(); });
// ── Inbound: Android → terminal ───────────────────────────────────
// Base64-encoded payloads avoid JS string-escaping headaches when the
// stream contains control characters, raw escape sequences, or bytes
@@ -223,6 +237,13 @@
try { term.focus(); } catch (_) {}
};
// Current xterm selection as plain text ('' when nothing selected).
// Read back via WebView.evaluateJavascript for the toolbar Copy key,
// since long-press copy is unreliable inside an Android WebView.
window.getSelectionText = function () {
try { return term.getSelection() || ''; } catch (_) { return ''; }
};
window.clearTerminal = function () {
try { term.clear(); } catch (_) {}
};
@@ -239,6 +260,36 @@
}
};
// Mode-aware encoder for the on-screen toolbar's special keys
// (arrows / Home / End / Page). Arrows must follow xterm's current
// DECCKM (application cursor keys) mode: when an app like vim, less,
// or readline has requested it, an arrow is SS3-encoded (\eOA) rather
// than CSI (\e[A). The old path always sent CSI from Kotlin, which the
// running TUI could misread. We read term.modes here (where the mode
// actually lives) and route bytes back through onInput so sticky
// modifiers still apply. Page keys are mode-independent.
window.termSendKey = function (name) {
var appCursor = false;
try {
appCursor = !!(term.modes && term.modes.applicationCursorKeysMode);
} catch (_) {}
var p = appCursor ? 'O' : '[';
var map = {
ArrowUp: p + 'A',
ArrowDown: p + 'B',
ArrowRight: p + 'C',
ArrowLeft: p + 'D',
Home: p + 'H',
End: p + 'F',
PageUp: '[5~',
PageDown: '[6~',
};
var seq = map[name];
if (seq && window.AndroidBridge && window.AndroidBridge.onInput) {
window.AndroidBridge.onInput(seq);
}
};
// ── Scroll shims + gesture ────────────────────────────────────────
// xterm.js ships a scrollback buffer (scrollback: 10000 above) but
// has no built-in mobile touch-to-scroll — its input handlers are
@@ -21,7 +21,6 @@ import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
import com.hermesandroid.relay.ui.RelayApp
import com.hermesandroid.relay.util.ComposeArrWorkaround
import com.hermesandroid.relay.util.NavRouteRequest
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -118,9 +117,6 @@ class MainActivity : ComponentActivity() {
setContent {
RelayApp()
}
window.decorView.post {
ComposeArrWorkaround.disableForViewTree(window.decorView)
}
}
override fun onNewIntent(intent: Intent) {
@@ -1551,7 +1551,7 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
* googlePlay as a dialer-opener" per the plan.
*
* The destructive-verb confirmation modal is fired in
* [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
* [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
* before we even get here — by the time this method runs, the user
* has explicitly approved the call.
*/
@@ -12,8 +12,8 @@ import android.util.Log
import com.hermesandroid.relay.bridge.BridgeSafetyManager
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.models.Envelope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -68,7 +68,7 @@ class HermesAccessibilityService : AccessibilityService() {
* service is not running. Written on [onServiceConnected],
* cleared on [onUnbind] / [onDestroy].
*
* Read by [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
* Read by [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
* and by the Bridge UI screen (bridge-ui) to check live status.
*/
@Volatile
@@ -6,8 +6,8 @@ import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.models.Envelope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,7 +25,6 @@ import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.hermesandroid.relay.ui.components.BridgeStatusOverlayChip
import com.hermesandroid.relay.ui.components.DestructiveVerbConfirmDialog
import com.hermesandroid.relay.util.ComposeArrWorkaround
import java.util.concurrent.ConcurrentHashMap
/**
@@ -158,7 +157,6 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
Log.w(TAG, "addView(chip) failed", it)
return
}
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
chipView = compose
chipUnattended = unattended
}
@@ -227,7 +225,6 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
onResult(false)
return
}
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
activeConfirmations[request.id] = compose
}
@@ -233,7 +233,7 @@ object UnattendedAccessManager {
* Acquire the screen-bright wake lock + opportunistically request
* keyguard dismiss. Synchronous — does not suspend. The caller (
* [com.hermesandroid.relay.accessibility.ActionExecutor] wrapper, or
* [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
* [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
* pre-dispatch hook) holds onto the result and decides whether to
* proceed with the action.
*
@@ -66,11 +66,17 @@ object AgentDisplay {
localDisplayAlias(localDisplayAlias)?.let { return it }
profileDisplayName(profile)?.let { return it }
// "none"/"neutral" are the upstream "cleared overlay" aliases — treat
// them like "default" for identity: fall through to the server default
// (or the base connection identity) rather than rendering the literal
// word as an agent name.
val personalityName = if (
selectedPersonality == "default" &&
isClearedPersonality(selectedPersonality) &&
defaultPersonality.isNotBlank()
) {
defaultPersonality
} else if (isClearedPersonality(selectedPersonality)) {
""
} else {
selectedPersonality
}
@@ -83,10 +89,18 @@ object AgentDisplay {
}
}
/** True for the upstream "clear the overlay" aliases (default == none == neutral). */
fun isClearedPersonality(value: String): Boolean =
value.trim().lowercase() in setOf("default", "none", "neutral", "")
fun personalityLabel(
selectedPersonality: String,
defaultPersonality: String,
): String = when {
// Explicit "none" — show "None" (or the configured default name, if any)
// so the cleared-overlay state is legible in the picker header.
selectedPersonality.trim().lowercase() in setOf("none", "neutral") ->
if (defaultPersonality.isNotBlank()) titleCase(defaultPersonality.trim()) else "None"
selectedPersonality != "default" && selectedPersonality.isNotBlank() ->
titleCase(selectedPersonality.trim())
defaultPersonality.isNotBlank() -> titleCase(defaultPersonality.trim())
@@ -99,6 +113,19 @@ object AgentDisplay {
?.takeIf { it.isNotEmpty() }
?.takeUnless { it.lowercase() in GENERIC_MODEL_ALIASES }
/**
* A model string safe to SEND to the server as a model override or
* `config.set model=…`. Returns null for the generic agent placeholders
* ("hermes-agent", …) which are NOT real models — the server rejects them
* (HTTP 400) and falls back. Null means "send no model; use the server's
* configured default."
*/
fun requestModelName(model: String?): String? =
model
?.trim()
?.takeIf { it.isNotEmpty() }
?.takeUnless { it.lowercase() in GENERIC_MODEL_ALIASES }
fun isServerDefaultAlias(profileName: String?): Boolean =
profileName?.trim()?.equals("default", ignoreCase = true) == true
@@ -46,7 +46,7 @@ data class ChatMessage(
/**
* Rich content cards emitted by the agent via `CARD:{json}` line
* markers in the text stream. Parsed in
* [com.hermesandroid.relay.network.handlers.ChatHandler.scanForCardMarkers]
* [com.hermesandroid.relay.network.upstream.ChatHandler.scanForCardMarkers]
* and rendered inline by
* [com.hermesandroid.relay.ui.components.HermesCardBubble]. Mirrors
* [attachments]' lifecycle — the marker line is stripped from
@@ -76,7 +76,7 @@ data class ChatMessage(
* The sync builder treats messages with [voiceIntent] != null and
* [VoiceIntentTrace.syncedToServer] == false as the inputs to its
* synthesis pass; on a successful send we flip [VoiceIntentTrace.syncedToServer]
* to true via [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
* to true via [com.hermesandroid.relay.network.upstream.ChatHandler.markVoiceIntentsSynced]
* so they're not re-sent on the next turn.
*/
val voiceIntent: VoiceIntentTrace? = null,
@@ -94,7 +94,7 @@ data class ChatMessage(
/**
* Structured details about a phone-local voice intent that was dispatched
* in-process via [com.hermesandroid.relay.network.handlers.BridgeCommandHandler.handleLocalCommand].
* in-process via [com.hermesandroid.relay.network.relay.BridgeCommandHandler.handleLocalCommand].
*
* Captured on a [ChatMessage] (id prefix `voice-intent-`) so the next chat
* payload can include synthetic OpenAI-format `assistant` + `tool` message
@@ -123,12 +123,12 @@ data class ChatMessage(
* includes an `error` field.
* @property resultJson Compact JSON object describing the dispatch outcome.
* On success, typically `{"ok":true,...}` with any tool-specific fields
* from [com.hermesandroid.relay.network.handlers.LocalDispatchResult.resultJson].
* from [com.hermesandroid.relay.network.shared.LocalDispatchResult.resultJson].
* On failure, an error envelope including `ok:false`, `error`, optionally
* `error_code`. Stored as a string and rendered verbatim into the
* synthetic `tool`-role message's `content` field.
* @property syncedToServer Idempotency guard. Flipped to true by
* [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
* [com.hermesandroid.relay.network.upstream.ChatHandler.markVoiceIntentsSynced]
* the moment we hand the request payload to the API client. Once true,
* the trace is excluded from future sync passes — the server-side
* session has already absorbed it.
@@ -30,7 +30,7 @@ data class DashboardConnectionStatus(
* open the token store.
*
* Switching connection is a HEAVY context swap — caller is expected to tear down
* the current [com.hermesandroid.relay.network.ConnectionManager],
* the current [com.hermesandroid.relay.network.relay.ConnectionManager],
* [com.hermesandroid.relay.auth.AuthManager], and API client, then construct
* fresh ones pointed at the new connection's `tokenStoreKey`.
*
@@ -290,6 +290,8 @@ data class Connection(
role = role.ifBlank { inferRouteRole(apiServerUrl) },
priority = priority,
api = ApiEndpoint(host = host, port = port, tls = tls),
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
?.let { DashboardEndpoint(url = it) },
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
)
}
@@ -8,8 +8,8 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.auth.ConnectionAuthSecrets
import com.hermesandroid.relay.network.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.StoredDashboardCookie
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.StoredDashboardCookie
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -40,6 +40,10 @@ data class EndpointCandidate(
val priority: Int = 0,
val api: ApiEndpoint,
val relay: RelayEndpoint,
val dashboard: DashboardEndpoint? = null,
val proxy: ProxyEndpoint? = null,
val security: String? = null,
val recommended: Boolean = false,
)
/**
@@ -61,6 +65,17 @@ data class ApiEndpoint(
get() = "${if (tls) "https" else "http"}://$host:$port"
}
/**
* Dashboard/admin surface for an [EndpointCandidate]. This is optional so
* older v3 payloads that only carried API + Relay endpoints keep
* deserializing; when absent, Android derives the conventional same-host
* `:9119` dashboard URL from [ApiEndpoint].
*/
@Serializable
data class DashboardEndpoint(
val url: String,
)
/**
* The relay-server half of an [EndpointCandidate] — the WSS URL the phone
* opens for the bridge + terminal channels.
@@ -78,6 +93,22 @@ data class RelayEndpoint(
val transportHint: String? = null,
)
/**
* Optional plugin-owned secure proxy route. Unlike [api], [dashboard], and
* [relay], this is one app-facing base that can cover all Hermes-Relay
* supported traffic after pairing. It is deliberately optional so plugin
* proxy support can be advertised by newer payloads without changing the
* standard upstream connection model.
*/
@Serializable
data class ProxyEndpoint(
val url: String,
@SerialName("transport_hint")
val transportHint: String? = null,
@SerialName("pin_sha256")
val pinSha256: String? = null,
)
/**
* Returns true when [EndpointCandidate.role] is one of the built-in, styled
* roles: `lan`, `tailscale`, or `public`. Case-insensitive match — but the
@@ -89,7 +120,7 @@ data class RelayEndpoint(
*/
fun EndpointCandidate.isKnownRole(): Boolean {
return when (role.lowercase()) {
"lan", "tailscale", "public" -> true
"lan", "tailscale", "public", "plugin_proxy", "plugin-proxy", "https" -> true
else -> false
}
}
@@ -106,7 +137,15 @@ fun EndpointCandidate.displayLabel(): String {
return when (role.lowercase()) {
"lan" -> "LAN"
"tailscale" -> "Tailscale"
"public" -> "Public"
"public" -> if (api.tls) "HTTPS" else "Public"
"https" -> "HTTPS"
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
else -> "Custom VPN ($role)"
}
}
fun EndpointCandidate.hasSecureProxy(): Boolean =
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
role.equals("plugin_proxy", ignoreCase = true) ||
role.equals("plugin-proxy", ignoreCase = true)
@@ -11,7 +11,7 @@ import androidx.datastore.preferences.core.edit
* Shared by [com.hermesandroid.relay.viewmodel.ConnectionViewModel] (the
* StateFlow + setter that drive the foreground service and the client's
* no-background-close flag) and
* [com.hermesandroid.relay.network.GatewayKeepAliveService]'s Stop notification
* [com.hermesandroid.relay.network.upstream.GatewayKeepAliveService]'s Stop notification
* action, so both read/write the same key.
*/
val KEY_GATEWAY_KEEP_ALIVE = booleanPreferencesKey("gateway_keep_alive_background")
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
/**
* A rich content card emitted inline in an assistant message via the
* `CARD:{json}` line marker. Parsed by
* [com.hermesandroid.relay.network.handlers.ChatHandler] and rendered by
* [com.hermesandroid.relay.network.upstream.ChatHandler] and rendered by
* [com.hermesandroid.relay.ui.components.HermesCardBubble].
*
* The marker lives in the text stream alongside `MEDIA:...` for the same
@@ -226,7 +226,7 @@ data class HermesCardAction(
* (with structured `tool_calls`) + `tool` message pairs under a synthetic
* `hermes_card_action` tool name, splicing them into the session history
* the LLM sees. After the API client takes ownership of the request,
* [com.hermesandroid.relay.network.handlers.ChatHandler.markCardDispatchesSynced]
* [com.hermesandroid.relay.network.upstream.ChatHandler.markCardDispatchesSynced]
* flips [syncedToServer] so subsequent turns don't re-send the same
* trace.
*/
@@ -238,7 +238,7 @@ data class HermesCardDispatch(
/**
* Idempotency guard for the server-side session sync path.
* Flipped to true by
* [com.hermesandroid.relay.network.handlers.ChatHandler.markCardDispatchesSynced]
* [com.hermesandroid.relay.network.upstream.ChatHandler.markCardDispatchesSynced]
* once the API client has accepted the request that carried this
* dispatch's synthetic message pair. Once true, the dispatch is
* excluded from future
@@ -37,8 +37,60 @@ data class VoiceSettings(
* docs/plans/2026-05-24-realtime-persistent-session.md.
*/
val realtimePersistentSession: Boolean = true,
/**
* Enhanced-voice overrides for the relay TTS path, mapped onto the active
* provider (Gemini / xAI). Empty string / false means "use the server's
* saved config" — the relay only applies a field when it is set. Surfaced
* in Voice Settings only when the relay advertises an enhanced provider
* (`/voice/config` `tts.enhanced.supported`). Field meaning is generic:
* `enhancedVoice` → Gemini voice / xAI voice_id; `enhancedAudioTags` →
* Gemini audio_tags / xAI auto_speech_tags; `enhancedPersona` is Gemini-only
* and `enhancedLanguage` is xAI-only.
*/
val enhancedVoice: String = "",
val enhancedModel: String = "",
val enhancedAudioTags: Boolean = false,
val enhancedPersona: String = "",
val enhancedLanguage: String = "",
)
/**
* Per-request enhanced-voice overrides forwarded to the relay's
* `/voice/synthesize`. Mirrors the generic fields recognized by
* `plugin/relay/voice.py:_extract_voice_overrides`; the relay maps them onto
* the active provider's config.
*/
data class EnhancedVoiceOverrides(
val voice: String? = null,
val model: String? = null,
val audioTags: Boolean? = null,
val personaPrompt: String? = null,
val language: String? = null,
) {
val isEmpty: Boolean
get() = voice == null && model == null && audioTags == null &&
personaPrompt == null && language == null
companion object {
/**
* Build overrides from persisted settings, or null when nothing is set
* (so the relay falls back to the server's saved config). The audio-tags
* toggle only sends `true` — leaving it off defers to the server default
* rather than forcing it off.
*/
fun fromSettings(s: VoiceSettings): EnhancedVoiceOverrides? {
val overrides = EnhancedVoiceOverrides(
voice = s.enhancedVoice.takeIf { it.isNotBlank() },
model = s.enhancedModel.takeIf { it.isNotBlank() },
audioTags = true.takeIf { s.enhancedAudioTags },
personaPrompt = s.enhancedPersona.takeIf { it.isNotBlank() },
language = s.enhancedLanguage.takeIf { it.isNotBlank() },
)
return overrides.takeUnless { it.isEmpty }
}
}
}
enum class VoiceEngineMode(val storageValue: String) {
HermesVoiceOutput("hermes_voice_output"),
RealtimeAgent("realtime_agent");
@@ -74,6 +126,11 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
private val KEY_REALTIME_PERSISTENT_SESSION =
booleanPreferencesKey("voice_realtime_persistent_session")
private val KEY_ENH_VOICE = stringPreferencesKey("voice_enh_voice")
private val KEY_ENH_MODEL = stringPreferencesKey("voice_enh_model")
private val KEY_ENH_AUDIO_TAGS = booleanPreferencesKey("voice_enh_audio_tags")
private val KEY_ENH_PERSONA = stringPreferencesKey("voice_enh_persona")
private val KEY_ENH_LANGUAGE = stringPreferencesKey("voice_enh_language")
const val DEFAULT_ENGINE_MODE = "hermes_voice_output"
const val DEFAULT_AUDIO_ROUTE = "auto"
@@ -102,6 +159,11 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
?: DEFAULT_REALTIME_TRACE_DETAILS,
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
?: DEFAULT_REALTIME_PERSISTENT_SESSION,
enhancedVoice = prefs[KEY_ENH_VOICE] ?: "",
enhancedModel = prefs[KEY_ENH_MODEL] ?: "",
enhancedAudioTags = prefs[KEY_ENH_AUDIO_TAGS] ?: false,
enhancedPersona = prefs[KEY_ENH_PERSONA] ?: "",
enhancedLanguage = prefs[KEY_ENH_LANGUAGE] ?: "",
)
}
.distinctUntilChanged()
@@ -137,4 +199,28 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
suspend fun setRealtimePersistentSession(enabled: Boolean) {
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
}
/** "" clears the override (relay falls back to the server's saved voice). */
suspend fun setEnhancedVoice(voice: String) {
dataStore.edit { it[KEY_ENH_VOICE] = voice.trim() }
}
/** "" clears the override (relay falls back to the server's saved model). */
suspend fun setEnhancedModel(model: String) {
dataStore.edit { it[KEY_ENH_MODEL] = model.trim() }
}
suspend fun setEnhancedAudioTags(enabled: Boolean) {
dataStore.edit { it[KEY_ENH_AUDIO_TAGS] = enabled }
}
/** "" clears the inline persona/style direction (Gemini). */
suspend fun setEnhancedPersona(persona: String) {
dataStore.edit { it[KEY_ENH_PERSONA] = persona }
}
/** "" clears the language override (xAI). */
suspend fun setEnhancedLanguage(language: String) {
dataStore.edit { it[KEY_ENH_LANGUAGE] = language.trim() }
}
}
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network.handlers
package com.hermesandroid.relay.network.relay
import android.content.ActivityNotFoundException
import android.content.ClipData
@@ -23,9 +23,10 @@ import kotlinx.serialization.json.booleanOrNull
// === PHASE3-tier-C: flavor gate for sideload-only tools ===
import com.hermesandroid.relay.data.BuildFlavor
// === END PHASE3-tier-C ===
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.RelayHttpClient
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.LocalDispatchResult
import com.hermesandroid.relay.util.MediaCacheWriter
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
@@ -2418,33 +2419,9 @@ class BridgeCommandHandler(
}
}
/**
* Captured outcome of a local bridge dispatch. Voice mode reads this to
* emit follow-up chat traces showing the real success/failure state of
* an action after the safety modal resolves and the underlying
* [ActionExecutor] method returns. The fields mirror what the LLM path
* would see on a `bridge.response` envelope:
*
* - [status] — HTTP-style status: 200 success, 400 client error,
* 403 user denial / bridge disabled, 500 executor error
* - [errorMessage] — free-text error from the response payload, or null
* on success. Safe to speak / display verbatim to the user.
* - [errorCode] — structured classification (e.g. `permission_denied`,
* `bridge_disabled`, `user_denied`) when `respondFromResult` or a
* direct respond call includes one. Null for errors we haven't
* classified yet.
* - [resultJson] — the raw result object, for callers that need
* action-specific fields (e.g. the resolved phone number from
* /search_contacts). Optional.
*/
data class LocalDispatchResult(
val status: Int,
val errorMessage: String?,
val errorCode: String?,
val resultJson: JsonObject?,
) {
val isSuccess: Boolean get() = status in 200..299
}
// LocalDispatchResult moved to network.shared (ADR 34 fence): it is a passive
// DTO shared with the upstream chat path (ChatHandler), so it cannot live in
// this relay-package file without creating an upstream -> relay import.
/**
* Coroutine context marker installed by [BridgeCommandHandler.handleLocalCommand]
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.models.Envelope
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import android.content.Context
import android.net.ConnectivityManager
@@ -12,7 +12,8 @@ import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.EndpointResolver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import android.util.Log
import com.hermesandroid.relay.auth.PairedDeviceInfo
@@ -22,7 +22,7 @@ import java.io.IOException
*
* The chat SSE stream can emit tool output containing a marker of the form
* `MEDIA:hermes-relay://<opaque-token>`
* [ChatHandler][com.hermesandroid.relay.network.handlers.ChatHandler] parses
* [ChatHandler][com.hermesandroid.relay.network.upstream.ChatHandler] parses
* the marker, and [ChatViewModel][com.hermesandroid.relay.viewmodel.ChatViewModel]
* calls [fetchMedia] to pull the actual bytes over plain HTTP(S). The relay
* base URL is the WSS relay URL with `ws`/`wss` swapped for `http`/`https`.
@@ -53,6 +53,16 @@ class RelayHttpClient(
}
}
/**
* True when this connection has a relay route configured (a non-blank relay
* URL), so the relay media routes are reachable. Synchronous (URL-only) —
* the bearer token is resolved per request and may lag pairing; callers that
* only need a coarse "relay media is available" gate (e.g. the agent
* media-capability hint) use this. The actual fetch still fails closed if the
* token is missing.
*/
fun mediaUrlConfigured(): Boolean = !relayUrlProvider().isNullOrBlank()
/**
* The result of a successful [fetchMedia] call.
*
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import android.util.Log
import com.hermesandroid.relay.data.ProfileConfigResponse
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import java.net.URI
@@ -0,0 +1,24 @@
package com.hermesandroid.relay.network.relay
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.network.shared.VoiceAudioClient
import java.io.File
/**
* Adapts the relay-only [RelayVoiceClient] (same package) to the neutral
* [VoiceAudioClient] routing seam in `network.shared`. Relay → shared is an
* allowed dependency direction under the ADR 34 package fence.
*/
class RelayVoiceAudioClientAdapter(
private val relayVoiceClient: RelayVoiceClient,
private val enhancedOverridesProvider: () -> EnhancedVoiceOverrides? = { null },
) : VoiceAudioClient {
override val route: VoiceAudioRoute = VoiceAudioRoute.Relay
override suspend fun transcribe(audioFile: File): Result<String> =
relayVoiceClient.transcribe(audioFile)
override suspend fun synthesize(text: String): Result<File> =
relayVoiceClient.synthesize(text, enhancedOverridesProvider())
}
@@ -1,7 +1,8 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.relay
import android.content.Context
import android.util.Log
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import kotlinx.coroutines.CompletableDeferred
@@ -208,7 +209,10 @@ class RelayVoiceClient(
* when done (typical pattern: keep the last N mp3s in the cache dir and
* let the OS reclaim on cache pressure).
*/
suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
suspend fun synthesize(
text: String,
enhanced: EnhancedVoiceOverrides? = null,
): Result<File> = withContext(Dispatchers.IO) {
val httpBase = resolveHttpBase()
?: return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
val token = resolveBearerToken()
@@ -223,6 +227,16 @@ class RelayVoiceClient(
val bodyJson = buildJsonObject {
put("text", JsonPrimitive(text))
// Per-request enhanced-voice overrides. The relay maps these generic
// fields onto the active provider (Gemini/xAI) and ignores them for
// others — see voice.py:_extract_voice_overrides.
enhanced?.let { ov ->
ov.voice?.let { put("voice", JsonPrimitive(it)) }
ov.model?.let { put("model", JsonPrimitive(it)) }
ov.audioTags?.let { put("audio_tags", JsonPrimitive(it)) }
ov.personaPrompt?.let { put("persona_prompt", JsonPrimitive(it)) }
ov.language?.let { put("language", JsonPrimitive(it)) }
}
putProfile()
}.toString()
@@ -724,6 +738,7 @@ class RelayVoiceClient(
codec: String? = null,
optimizeStreamingLatency: Int? = null,
textNormalization: Boolean? = null,
autoSpeechTags: Boolean? = null,
fallbackEnabled: Boolean? = null,
): Result<VoiceOutputConfig> = withContext(Dispatchers.IO) {
val httpBase = resolveHttpBase()
@@ -755,6 +770,7 @@ class RelayVoiceClient(
put("optimize_streaming_latency", JsonPrimitive(it))
}
textNormalization?.let { put("text_normalization", JsonPrimitive(it)) }
autoSpeechTags?.let { put("auto_speech_tags", JsonPrimitive(it)) }
fallbackEnabled?.let { put("fallback_enabled", JsonPrimitive(it)) }
}
@@ -2304,11 +2320,44 @@ data class VoiceProviderInfo(
val voiceId: String? = null,
val enabled: Boolean = false,
val available: Boolean = true,
/**
* Provider-specific enhanced-voice capability hint. Present (non-null) only
* for the TTS block when the relay's active provider supports per-request
* enhanced control (today: Gemini and xAI).
*/
val enhanced: EnhancedVoiceCapabilities? = null,
) {
val displayVoice: String? get() = voice ?: voiceId
val isEnabled: Boolean get() = enabled || (!provider.isNullOrBlank() && available)
}
/**
* Wire shape of the `tts.enhanced` block on `GET /voice/config` — the relay's
* provider-aware enhanced-voice capability advertisement. Mirrors
* `plugin/relay/voice.py:_enhanced_voice_block`. `voices`/`models` may be empty
* (e.g. xAI uses a free-text voice field); the UI renders from the flags.
*/
@Serializable
data class EnhancedVoiceCapabilities(
val provider: String? = null,
val supported: Boolean = false,
val voices: List<String> = emptyList(),
val models: List<String> = emptyList(),
@SerialName("audio_tag_models")
val audioTagModels: List<String> = emptyList(),
@SerialName("audio_tags_enabled")
val audioTagsEnabled: Boolean = false,
@SerialName("audio_tags_label")
val audioTagsLabel: String = "Expressive tone tags",
@SerialName("supports_persona")
val supportsPersona: Boolean = false,
@SerialName("supports_language")
val supportsLanguage: Boolean = false,
@SerialName("persona_prompt_file")
val personaPromptFile: String? = null,
val overrides: List<String> = emptyList(),
)
@Serializable
data class RealtimeVoiceConfig(
val success: Boolean = false,
@@ -2481,6 +2530,8 @@ data class VoiceOutputConfig(
val codec: String = "pcm",
val optimize_streaming_latency: Int = 1,
val text_normalization: Boolean = false,
/** xAI expressive speech tags on the streaming renderer (xai_tts only). */
val auto_speech_tags: Boolean = false,
val fallback_enabled: Boolean = true,
val fallback_provider: String? = null,
val providers: List<RealtimeProviderInfo> = emptyList(),
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network.models
package com.hermesandroid.relay.network.relay.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.shared
import android.content.Context
import android.net.ConnectivityManager
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.shared
import android.util.Log
import com.hermesandroid.relay.data.EndpointCandidate
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.shared
import android.content.Context
import android.net.ConnectivityManager
@@ -0,0 +1,31 @@
package com.hermesandroid.relay.network.shared
import kotlinx.serialization.json.JsonObject
/**
* Transport-neutral result of a phone-control dispatch.
*
* Shared vocabulary between the relay bridge path
* ([com.hermesandroid.relay.network.relay.BridgeCommandHandler], which produces
* it) and the upstream chat path
* ([com.hermesandroid.relay.network.upstream.ChatHandler], which renders a
* phone-action bubble from it). It is a passive DTO — not a client — so it
* lives in `network.shared` to keep the upstream↔relay package fence intact
* (ADR 34); neither side depends on the other to speak it.
*
* - [status] — HTTP-style status of the dispatch (200 = ok).
* - [errorMessage] — human-readable failure text, or null on success.
* - [errorCode] — machine error code when the dispatch failed and was
* classified, or null.
* - [resultJson] — the raw result object, for callers that need
* action-specific fields (e.g. the resolved phone number from
* /search_contacts). Optional.
*/
data class LocalDispatchResult(
val status: Int,
val errorMessage: String?,
val errorCode: String?,
val resultJson: JsonObject?,
) {
val isSuccess: Boolean get() = status in 200..299
}
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.shared
import java.net.URI
@@ -0,0 +1,92 @@
package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.VoiceAudioRoute
import java.io.File
/**
* Transport-neutral STT/TTS contract. The routing seam between the Standard
* (dashboard) and Relay voice clients — implementations live in `network.upstream`
* (`StandardHermesVoiceClient`) and `network.relay` (`RelayVoiceAudioClientAdapter`),
* while this interface and the [AutoVoiceAudioClient] router stay dependency-neutral
* so neither voice backend leaks across the upstream/relay package fence (ADR 34).
*/
interface VoiceAudioClient {
val route: VoiceAudioRoute
suspend fun transcribe(audioFile: File): Result<String>
suspend fun synthesize(text: String): Result<File>
}
/**
* Routes each STT/TTS call to the Standard (dashboard) or Relay voice client.
*
* Auto preference order is **Relay first, then Standard**: a paired Relay is
* the purpose-built mobile facade — profile-aware voice config, no dashboard
* sign-in dependency — so users who installed the plugin keep the richer
* path. Standard is the zero-plugin route for vanilla Hermes installs and is
* used whenever Relay isn't configured/paired (or fails mid-call). Power
* users can force either route in Voice Settings.
*
* Depends only on the [VoiceAudioClient] abstraction (both backends are passed
* in as the interface), so this router carries no upstream or relay imports.
*/
class AutoVoiceAudioClient(
private val standardClient: VoiceAudioClient,
private val relayClient: VoiceAudioClient,
private val routeProvider: () -> VoiceAudioRoute,
private val standardReadyProvider: () -> Boolean,
private val relayReadyProvider: () -> Boolean,
) : VoiceAudioClient {
override val route: VoiceAudioRoute
get() = routeProvider()
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"),
)
}
}
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network.handlers
package com.hermesandroid.relay.network.upstream
import android.util.Log
import com.hermesandroid.relay.data.ChatMessage
@@ -8,9 +8,10 @@ import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeTurnTrace
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoiceIntentTrace
import com.hermesandroid.relay.network.GatewaySubagentEvent
import com.hermesandroid.relay.network.models.MessageItem
import com.hermesandroid.relay.network.models.SessionItem
import com.hermesandroid.relay.network.shared.LocalDispatchResult
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.SessionItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -189,6 +190,18 @@ class ChatHandler {
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
/**
* Latest gateway `status.update` lifecycle line for the in-flight turn
* (model fallback, retries, errors). Surfaced as a transient status line
* above the composer; cleared when the turn completes.
*/
private val _turnStatus = MutableStateFlow<String?>(null)
val turnStatus: StateFlow<String?> = _turnStatus.asStateFlow()
fun setTurnStatus(text: String) {
_turnStatus.value = text
}
private val _isStreaming = MutableStateFlow(false)
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow()
@@ -746,6 +759,17 @@ class ChatHandler {
// mutateMessage lookups find the newly-loaded messages.
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
// Preserve provenance badges ("Voice", "Realtime Agent", "Stopped",
// "Error") across a wholesale reload. The messages reconstructed below
// come from server data and carry no badges, so without this the
// post-turn history reload would silently wipe them. Keyed by message
// id — the live assistant message has already had its id swapped to the
// server id via replaceMessageId, so it matches the reloaded item id.
val priorBadges = _messages.value
.asSequence()
.filter { it.role == MessageRole.ASSISTANT && it.badges.isNotEmpty() }
.associate { it.id to it.badges }
val loaded = items.mapNotNull { item ->
val role = when (item.role) {
"user" -> MessageRole.USER
@@ -805,6 +829,11 @@ class ChatHandler {
} else {
""
},
badges = if (role == MessageRole.ASSISTANT) {
priorBadges[messageId].orEmpty()
} else {
emptyList()
},
)
}
@@ -833,7 +862,12 @@ class ChatHandler {
val preservedVoiceTraces = _messages.value.filter {
it.id.startsWith("voice-intent-") ||
it.id.startsWith("steer-") ||
it.id.startsWith("ask-")
it.id.startsWith("ask-") ||
// Slash-command result bubbles (addSystemNotice) are local-only —
// the server never persists them, so a wholesale reload would wipe
// a just-shown `/personality`, `/status`, … result the moment the
// next turn reconciles. Preserve them like the other client bubbles.
it.id.startsWith("system-notice-")
}
val merged = if (preservedVoiceTraces.isEmpty()) {
loaded
@@ -2083,12 +2117,47 @@ class ChatHandler {
// Note: do NOT set _isStreaming to false — the run is still active
}
/**
* Stamp a "Stopped" badge on a message whose turn the user cancelled, so
* the bubble carries a persistent status (not just a transient toast).
* No-op if already present. Call before [onStreamComplete] on cancel.
*/
fun markStopped(messageId: String) {
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId && "Stopped" !in msg.badges) {
msg.copy(badges = msg.badges + "Stopped")
} else {
msg
}
}
}
}
/**
* Stamp an "Error" badge on a message whose turn ended in a server error
* (e.g. a gateway ❌ lifecycle status), so a failed turn doesn't read as a
* normal answer. No-op if already present.
*/
fun markError(messageId: String) {
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId && "Error" !in msg.badges) {
msg.copy(badges = msg.badges + "Error")
} else {
msg
}
}
}
}
/**
* The entire agent run is complete (run.completed / done).
* Marks the stream as finished and finalizes all messages.
*/
fun onStreamComplete(messageId: String) {
_isStreaming.value = false
_turnStatus.value = null
insideThinkingBlock = false
// Flush any remaining annotation text that didn't end with a newline
@@ -2249,7 +2318,7 @@ class ChatHandler {
*
* The label parameter is the short human-readable action name
* ("Send SMS", "Open App", "Call", etc). Error-code branches mirror the
* `error_code` strings [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
* `error_code` strings [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
* emits on destructive-verb rejections.
*/
internal fun formatPhoneActionResult(
@@ -1,11 +1,11 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.models.MessageItem
import com.hermesandroid.relay.network.models.MessageListResponse
import com.hermesandroid.relay.network.models.SessionItem
import com.hermesandroid.relay.network.models.SessionListResponse
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
import com.hermesandroid.relay.network.upstream.models.SessionItem
import com.hermesandroid.relay.network.upstream.models.SessionListResponse
import com.hermesandroid.relay.auth.KeystoreTokenStore
import com.hermesandroid.relay.auth.LegacyEncryptedPrefsTokenStore
import com.hermesandroid.relay.auth.SessionTokenStore
@@ -520,15 +520,23 @@ class DashboardApiClient(
* 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
// Route exists if HEAD returns anything but a clean 404:
// - 405 Method Not Allowed: path registered, POST-only (FastAPI/Starlette)
// - 401/403: registered but auth-gated
// - 2xx: handled
// A reverse proxy fronting the dashboard can rewrite a 405 into a 404,
// which would read as absent. To cut that false-negative, probe BOTH
// audio routes and treat the surface as present if EITHER answers
// non-404 (they ship together upstream, so one reachable implies both).
fun probe(path: String): Boolean {
val request = Request.Builder().url("$baseUrl$path").head().build()
return try {
okHttpClient.newCall(request).execute().use { it.code != 404 }
} catch (_: Exception) {
false
}
}
probe("/api/audio/transcribe") || probe("/api/audio/speak")
}
suspend fun requestWsTicket(): Result<DashboardWsTicket> = withContext(Dispatchers.IO) {
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.upstream
import android.util.Log
import com.hermesandroid.relay.util.AppForegroundTracker
@@ -188,6 +188,68 @@ class GatewayChatClient(
private val _connectionState = MutableStateFlow(GatewayConnectionState.Idle)
val connectionState: StateFlow<GatewayConnectionState> = _connectionState.asStateFlow()
/**
* Active personality the gateway is applying, as a config value ("none" when
* the overlay is cleared, otherwise the personality name). Tracks the
* upstream `display.personality` the way the desktop/TUI do: updated from the
* [setPersonality] / [getPersonality] round-trips AND from connection-level
* `session.info` events, so a change made via `/personality`, the desktop, or
* the TUI reflects in the app. Null until first observed.
*/
private val _serverPersonality = MutableStateFlow<String?>(null)
val serverPersonality: StateFlow<String?> = _serverPersonality.asStateFlow()
/**
* Active model / provider the gateway reports for our session, tracked off
* `session.info` the same way as [serverPersonality]. Lets a `/model` switch
* made on the desktop/TUI (or our own dispatch) reflect in the app's model
* pill without an app reload. Null until first observed; only ever set to a
* non-blank value.
*/
private val _serverModel = MutableStateFlow<String?>(null)
val serverModel: StateFlow<String?> = _serverModel.asStateFlow()
private val _serverProvider = MutableStateFlow<String?>(null)
val serverProvider: StateFlow<String?> = _serverProvider.asStateFlow()
/**
* Active reasoning EFFORT from `session.info` (string; "" when reasoning is
* disabled). The reasoning DISPLAY mode is NOT on session.info — it stays a
* `config.get reasoning` concern ([getReasoningSettings]). Only ever set to a
* non-blank value so a disabled-reasoning "" never clobbers the chip.
*/
private val _serverReasoningEffort = MutableStateFlow<String?>(null)
val serverReasoningEffort: StateFlow<String?> = _serverReasoningEffort.asStateFlow()
/**
* Server-reported credential warning (upstream `session.info.credential_warning`)
* — present ONLY when the active provider's key is missing/invalid, absent
* (→ null here) when healthy. Cleared on absence so it self-resolves when the
* key is fixed.
*/
private val _serverCredentialWarning = MutableStateFlow<String?>(null)
val serverCredentialWarning: StateFlow<String?> = _serverCredentialWarning.asStateFlow()
/**
* Effective approval-bypass (YOLO) + fast-mode state from `session.info`
* (`yolo`/`fast` booleans). YOLO has NO `config.get` upstream — session.info
* is the only read. Null until first observed.
*/
private val _serverYolo = MutableStateFlow<Boolean?>(null)
val serverYolo: StateFlow<Boolean?> = _serverYolo.asStateFlow()
private val _serverFast = MutableStateFlow<Boolean?>(null)
val serverFast: StateFlow<Boolean?> = _serverFast.asStateFlow()
/**
* Context-window usage `(used, max)` from `session.info`'s `usage` block
* (upstream `_get_usage`). `session.info` is emitted on session resume, so
* this lets the context bar paint immediately on resume instead of waiting
* for the first turn's usage event. Null until observed / when omitted.
*/
private val _serverContext = MutableStateFlow<Pair<Int, Int>?>(null)
val serverContext: StateFlow<Pair<Int, Int>?> = _serverContext.asStateFlow()
/** Serializes connect / session-establish so concurrent sends share one socket. */
private val connectMutex = Mutex()
@@ -223,6 +285,21 @@ class GatewayChatClient(
private fun currentSessionProfile(): String? =
sessionProfileProvider().takeIf { !it.isNullOrBlank() }
/**
* Supplies the explicit in-chat model pick to bind onto each fresh
* `session.create` (upstream honors `model`/`provider` → the new session's
* `model_override`). Pulled live so it always reflects the current picker;
* null = no explicit pick, so the new session inherits the profile / server
* default. Wired by ChatViewModel from the selected-model override. A live
* session keeps its agent's model, so this only affects session creation —
* mid-session switches go through [setModel] (`config.set`).
*/
@Volatile
var sessionModelProvider: () -> GatewaySessionModel? = { null }
private fun currentSessionModel(): GatewaySessionModel? =
sessionModelProvider()?.takeIf { it.model.isNotBlank() }
@Volatile
private var activeTurn: GatewayTurn? = null
@@ -565,6 +642,57 @@ class GatewayChatClient(
},
)
/**
* Read the active personality (`config.get {key:"personality"}`). Returns the
* upstream config value — `"none"` when the overlay is cleared, otherwise the
* personality name. Connects on demand. Used to seed [serverPersonality] when
* a gateway connection comes up so the app reflects whatever the server
* (config / desktop / TUI) currently has active.
*/
suspend fun getPersonality(): Result<String> {
if (webSocket == null || readySignal?.isCompleted != true) {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
}
return rpc("config.get", buildJsonObject { put("key", "personality") })
.map { result ->
(result.stringField("value") ?: "none").ifBlank { "none" }
.also { _serverPersonality.value = it }
}
}
/**
* Set the personality the way the desktop + TUI do (`config.set
* {key:"personality"}`). The gateway persists `display.personality` +
* `agent.system_prompt` to the active profile's config AND applies the
* overlay live to the current session (no history reset). Pass `"none"`
* (or `"default"`/`"neutral"`) to clear the overlay. Returns the resolved
* active value (`"none"` or the name); also updates [serverPersonality]
* directly so observers don't have to wait on the `session.info` echo (which
* only fires when a live session exists).
*/
suspend fun setPersonality(value: String): Result<String> {
if (webSocket == null || readySignal?.isCompleted != true) {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
}
val params = buildJsonObject {
put("key", "personality")
put("value", value)
liveSessionId?.let { put("session_id", it) }
}
return rpc("config.set", params).map { result ->
(result.stringField("value") ?: value).ifBlank { "none" }
.also { _serverPersonality.value = it }
}
}
/**
* Fetch the curated provider/model list (`model.options`) — the same RPC
* the upstream desktop + TUI model picker uses (grok / kimi / gpt-5.5 …,
@@ -592,6 +720,11 @@ class GatewayChatClient(
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
warning = obj.stringField("warning"),
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() ?: 0,
)
}
GatewayModelOptions(
@@ -659,6 +792,58 @@ class GatewayChatClient(
},
)
/**
* Toggle per-session approval bypass (YOLO) via `config.set {key:"yolo"}` —
* the same session-scoped flag the desktop's setSessionYolo and the TUI's
* Shift+Tab use (`value` "1"/"0", `scope` "session" = ephemeral, never writes
* config.yaml). Requires a live session for the per-session flag. Updates
* [serverYolo] from the echo so observers don't wait on `session.info`.
* Returns the resolved enabled state. There is deliberately NO `getYolo()` —
* upstream has no `config.get yolo`; session.info is the only read.
*/
suspend fun setYolo(enabled: Boolean, scope: String = "session"): Result<Boolean> {
if (webSocket == null || readySignal?.isCompleted != true) {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
}
val params = buildJsonObject {
put("key", "yolo")
put("value", if (enabled) "1" else "0")
put("scope", scope)
liveSessionId?.let { put("session_id", it) }
}
return rpc("config.set", params).map { result ->
(result.stringField("value") == "1").also { _serverYolo.value = it }
}
}
/**
* Toggle fast mode (priority service tier) via `config.set {key:"fast"}` —
* desktop parity (`value` "fast"/"normal", session-scoped). Capability-gated
* upstream: enabling fails (error 4002) when the current model has no fast
* tier. Updates [serverFast]; returns the resolved enabled state.
*/
suspend fun setFast(enabled: Boolean): Result<Boolean> {
if (webSocket == null || readySignal?.isCompleted != true) {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
}
val params = buildJsonObject {
put("key", "fast")
put("value", if (enabled) "fast" else "normal")
liveSessionId?.let { put("session_id", it) }
}
return rpc("config.set", params).map { result ->
(result.stringField("value") == "fast").also { _serverFast.value = it }
}
}
fun shutdown() {
activeTurn?.cancel()
activeTurn = null
@@ -802,6 +987,17 @@ class GatewayChatClient(
put("cols", DEFAULT_COLS)
if (!newSessionTitle.isNullOrBlank()) put("title", newSessionTitle)
currentSessionProfile()?.let { put("profile", it) }
// Bind the in-chat model pick to the new session as its
// model_override. Upstream tui_gateway session.create reads
// `model`/`provider`; without this a fresh chat ignores the
// picker and builds the agent from the global default (the
// "picker shows Grok but the agent answers as the default
// model" bug). A live session keeps its own model — this is
// create-only; mid-session switches use config.set (setModel).
currentSessionModel()?.let { sm ->
put("model", sm.model)
sm.provider?.takeIf { it.isNotBlank() }?.let { put("provider", it) }
}
},
).getOrElse { e ->
throw GatewayPreflightException("session.create failed: ${e.message}")
@@ -902,6 +1098,50 @@ class GatewayChatClient(
return
}
// `session.info` is connection-level (personality / model / context
// usage), emitted on a config change even with no turn in flight. Capture
// the active personality here — for our own session only — so a
// `/personality`, desktop, or TUI change keeps the app in sync. Falls
// through to the turn dispatch below so an in-flight turn still sees it.
if (type == "session.info" &&
(eventSessionId == null || liveSessionId == null || eventSessionId == liveSessionId)
) {
payload?.let { p ->
if (p.containsKey("personality")) {
_serverPersonality.value =
(p.stringField("personality") ?: "").ifBlank { "none" }
}
p.stringField("model")?.takeIf { it.isNotBlank() }?.let { _serverModel.value = it }
p.stringField("provider")?.takeIf { it.isNotBlank() }?.let { _serverProvider.value = it }
// reasoning effort: ignore "" (reasoning disabled) so it can't
// clobber the chip; display mode is config.get-only, not here.
p.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
?.let { _serverReasoningEffort.value = it }
// credential_warning: present only when the provider key is
// missing/invalid. ABSENT means healthy — clear to null so the
// warning self-resolves (no ?.let, assign through takeIf).
_serverCredentialWarning.value =
p.stringField("credential_warning")?.takeIf { it.isNotBlank() }
// yolo / fast: effective booleans (approval bypass + priority tier).
(p["yolo"] as? JsonPrimitive)?.booleanOrNull?.let { _serverYolo.value = it }
(p["fast"] as? JsonPrimitive)?.booleanOrNull?.let { _serverFast.value = it }
// Context-window usage. Require used > 0: on a COLD resume the
// agent's token counters + compressor are reset, so _get_usage
// reports context_used=0 until the first turn rebuilds the
// prompt. Painting that 0 would show a misleading "0%" on a
// session that actually has history — so we only adopt a real,
// non-zero figure (warm resume, or post-turn echo). Cold resumes
// fill on the first exchange via the usage callback.
(p["usage"] as? JsonObject)?.let { usage ->
val used = (usage["context_used"] as? JsonPrimitive)?.intOrNull
val max = (usage["context_max"] as? JsonPrimitive)?.intOrNull
if (used != null && used > 0 && max != null && max > 0) {
_serverContext.value = used to max
}
}
}
}
val turn = activeTurn ?: return
// Foreign-session events (another client's chat on the same gateway) are not ours.
if (eventSessionId != null && liveSessionId != null && eventSessionId != liveSessionId) {
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.network.models.UsageInfo
import com.hermesandroid.relay.network.upstream.models.UsageInfo
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -217,8 +217,15 @@ class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
),
)
// Known-but-unrendered (notification.show, status.update, …) and
// unknown types alike: ignore.
"status.update" -> {
val text = payload.string("text")
if (!text.isNullOrBlank()) {
callbacks.onStatusUpdate(payload.string("kind"), text)
}
}
// Known-but-unrendered (notification.show, …) and unknown types
// alike: ignore.
else -> Unit
}
}
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.upstream
import android.annotation.SuppressLint
import android.app.NotificationChannel
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.network.models.UsageInfo
import com.hermesandroid.relay.network.upstream.models.UsageInfo
/**
* Shared types for the Gateway chat transport — upstream hermes-agent's
@@ -146,6 +146,14 @@ data class GatewayModelProvider(
val models: List<String>,
val isCurrent: Boolean,
val warning: String?,
// Picker hints from upstream `model.options` (build_models_payload,
// picker_hints=True). Default to "usable" so older servers that omit them
// don't gray everything out.
val authenticated: Boolean = true,
/** Paid models the current account can't pick (free-tier / no credits). */
val unavailableModels: List<String> = emptyList(),
val freeTier: Boolean = false,
val totalModels: Int = 0,
)
/** Result of the gateway `model.options` RPC. */
@@ -155,6 +163,17 @@ data class GatewayModelOptions(
val currentProvider: String,
)
/**
* The explicit in-chat model pick to bind onto a gateway `session.create` as
* that session's `model_override`. Matches the upstream desktop client, whose
* `session.create` carries `model`/`provider` params (tui_gateway honors them →
* `session_model_override`). Supplied live by ChatViewModel from the picker;
* null = no explicit pick, so the fresh session inherits the profile / server
* default instead of the picker being silently dropped. [provider] is the
* authenticated provider slug (e.g. `xai`) and may be null.
*/
data class GatewaySessionModel(val model: String, val provider: String?)
/** Result of the gateway `config.get {key:"reasoning"}` RPC. */
data class GatewayReasoningSettings(
val effort: String,
@@ -196,4 +215,10 @@ class GatewayTurnCallbacks(
* cancelled.
*/
val onInteractionRequest: (GatewayAsk) -> Unit,
/**
* Gateway `status.update` lifecycle line — model fallback, retries, and
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
* no-op so non-gateway/legacy constructors don't need to provide it.
*/
val onStatusUpdate: (kind: String?, text: String) -> Unit = { _, _ -> },
)
@@ -1,21 +1,21 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.upstream
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.network.models.CreateSessionRequest
import com.hermesandroid.relay.network.models.HermesSseEvent
import com.hermesandroid.relay.network.models.MessageItem
import com.hermesandroid.relay.network.models.MessageListResponse
import com.hermesandroid.relay.network.models.RenameSessionRequest
import com.hermesandroid.relay.network.models.SessionItem
import com.hermesandroid.relay.network.models.SessionListResponse
import com.hermesandroid.relay.network.models.SessionResponse
import com.hermesandroid.relay.network.models.SkillInfo
import com.hermesandroid.relay.network.models.SkillListResponse
import com.hermesandroid.relay.network.models.UsageInfo
import com.hermesandroid.relay.network.upstream.models.CreateSessionRequest
import com.hermesandroid.relay.network.upstream.models.HermesSseEvent
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
import com.hermesandroid.relay.network.upstream.models.RenameSessionRequest
import com.hermesandroid.relay.network.upstream.models.SessionItem
import com.hermesandroid.relay.network.upstream.models.SessionListResponse
import com.hermesandroid.relay.network.upstream.models.SessionResponse
import com.hermesandroid.relay.network.upstream.models.SkillInfo
import com.hermesandroid.relay.network.upstream.models.SkillListResponse
import com.hermesandroid.relay.network.upstream.models.UsageInfo
import com.hermesandroid.relay.util.TurnLatencyTracer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.Attachment
@@ -1,10 +1,10 @@
package com.hermesandroid.relay.network
package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.network.shared.VoiceAudioClient
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
@@ -21,102 +21,12 @@ 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}
* POST {dashboard}/api/audio/transcribe {data_url, mime_type} to {ok, transcript}
* POST {dashboard}/api/audio/speak {text} to {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
@@ -124,7 +34,7 @@ class AutoVoiceAudioClient(
* 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).
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6).
* (No glob spellings in block comments — Kotlin block comments nest.)
*/
class StandardHermesVoiceClient(
@@ -150,6 +60,14 @@ class StandardHermesVoiceClient(
if (!audioFile.exists() || audioFile.length() == 0L) {
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
}
// Upstream caps decoded transcription audio at 25 MB (web_server.py
// _MAX_TRANSCRIPTION_UPLOAD_BYTES → HTTP 413). The decoded size equals
// the file size, so guard here to avoid a wasted ~33 MB base64 upload.
if (audioFile.length() > MAX_TRANSCRIBE_BYTES) {
return@withContext Result.failure(
IOException("Recording too long for Hermes - try a shorter utterance"),
)
}
val dataUrl = buildAudioDataUrl(audioFile)
val payload = buildJsonObject {
@@ -241,8 +159,10 @@ class StandardHermesVoiceClient(
val body = runCatching { response.body.string() }.getOrDefault("")
val detail = body.takeIf { it.isNotBlank() } ?: response.message
val message = when (response.code) {
400 -> "$operation rejected that input - ${detail.ifBlank { "bad request" }}"
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"
413 -> "Recording too long for Hermes - try a shorter utterance"
in 500..599 -> "$operation failed - server error HTTP ${response.code}"
else -> "$operation failed - HTTP ${response.code}: $detail"
}
@@ -292,5 +212,9 @@ class StandardHermesVoiceClient(
private companion object {
val JSON_MEDIA = "application/json".toMediaType()
// Matches upstream _MAX_TRANSCRIPTION_UPLOAD_BYTES (web_server.py): the
// dashboard rejects decoded transcription audio above 25 MB with 413.
const val MAX_TRANSCRIBE_BYTES = 25L * 1024 * 1024
}
}
@@ -1,4 +1,4 @@
package com.hermesandroid.relay.network.models
package com.hermesandroid.relay.network.upstream.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
@@ -6,8 +6,8 @@ import android.provider.Settings
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import com.hermesandroid.relay.network.ChannelMultiplexer
import com.hermesandroid.relay.network.models.Envelope
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.models.Envelope
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
@@ -0,0 +1,86 @@
package com.hermesandroid.relay.permissions
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import com.hermesandroid.relay.accessibility.HermesAccessibilityService
import com.hermesandroid.relay.accessibility.MediaProjectionHolder
/**
* One snapshot of Android grants and special-access switches that Hermes-Relay
* features can consume. Standard Chat and Manage do not need dangerous runtime
* permissions, so they are intentionally not represented as a "required"
* Android grant here.
*/
data class AppPermissionStatus(
val notificationsPermitted: Boolean = false,
val microphonePermitted: Boolean = false,
val cameraPermitted: Boolean = false,
val notificationListenerPermitted: Boolean = false,
val accessibilityServiceEnabled: Boolean = false,
val screenCapturePermitted: Boolean = false,
val overlayPermitted: Boolean = false,
val contactsPermitted: Boolean = false,
val smsPermitted: Boolean = false,
val phonePermitted: Boolean = false,
val locationPermitted: Boolean = false,
)
object AppPermissionStatusProbe {
fun snapshot(context: Context): AppPermissionStatus {
val appContext = context.applicationContext
return AppPermissionStatus(
notificationsPermitted = hasPostNotifications(appContext),
microphonePermitted = hasPermission(appContext, Manifest.permission.RECORD_AUDIO),
cameraPermitted = hasPermission(appContext, Manifest.permission.CAMERA),
notificationListenerPermitted = isNotificationListenerEnabled(appContext),
accessibilityServiceEnabled = isAccessibilityServiceEnabled(appContext),
screenCapturePermitted = MediaProjectionHolder.projection != null,
overlayPermitted = Settings.canDrawOverlays(appContext),
contactsPermitted = hasPermission(appContext, Manifest.permission.READ_CONTACTS),
smsPermitted = hasPermission(appContext, Manifest.permission.SEND_SMS),
phonePermitted = hasPermission(appContext, Manifest.permission.CALL_PHONE),
locationPermitted = hasPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION),
)
}
private fun hasPostNotifications(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
hasPermission(context, Manifest.permission.POST_NOTIFICATIONS)
} else {
true
}
}
private fun hasPermission(context: Context, permission: String): Boolean {
return ContextCompat.checkSelfPermission(
context,
permission,
) == PackageManager.PERMISSION_GRANTED
}
private fun isAccessibilityServiceEnabled(context: Context): Boolean {
val enabled = Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
) ?: return false
val expected = ComponentName(
context.packageName,
HermesAccessibilityService::class.java.name,
).flattenToString()
return enabled.split(':').any { it.equals(expected, ignoreCase = true) } ||
enabled.contains(context.packageName, ignoreCase = true)
}
private fun isNotificationListenerEnabled(context: Context): Boolean {
val enabled = Settings.Secure.getString(
context.contentResolver,
"enabled_notification_listeners",
) ?: return false
return enabled.contains(context.packageName, ignoreCase = true)
}
}
@@ -43,6 +43,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.rememberCoroutineScope
@@ -66,7 +67,11 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.hermesandroid.relay.ui.components.LocalAvailableSphereSkins
import com.hermesandroid.relay.ui.components.LocalSphereSkin
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.SphereRegistry
import com.hermesandroid.relay.ui.components.SphereSkinLoader
import com.hermesandroid.relay.ui.components.ConnectionStatusToast
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
@@ -81,13 +86,16 @@ 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.EnhancedVoiceOverrides
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.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.hermesandroid.relay.util.HumanError
import kotlinx.coroutines.delay
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
@@ -106,6 +114,7 @@ import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
import com.hermesandroid.relay.ui.screens.ConnectionsSettingsScreen
import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
import com.hermesandroid.relay.ui.screens.SettingsScreen
@@ -113,16 +122,17 @@ 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.AppThemes
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.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
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.network.relay.RelayProfileInspectorClient
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
@@ -132,8 +142,8 @@ import com.hermesandroid.relay.audio.VoicePlayer
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.network.relay.RelayVoiceClient
import com.hermesandroid.relay.network.upstream.StandardHermesVoiceClient
import com.hermesandroid.relay.auth.AuthState
import androidx.lifecycle.viewModelScope
@@ -233,6 +243,7 @@ sealed class Screen(
data object NotificationCompanionSettings :
Screen("settings/notifications", "Notification companion", Icons.Filled.Settings)
// === END PHASE3-notif-listener-followup ===
data object PermissionsSettings : Screen("settings/permissions", "Permissions", Icons.Filled.Settings)
// === PHASE3-safety-rails: bridge safety route ===
data object BridgeSafetySettings :
Screen("settings/bridge_safety", "Bridge safety", Icons.Filled.Settings)
@@ -387,6 +398,9 @@ fun RelayApp() {
val relayVoiceReady by connectionViewModel.relayVoiceReady.collectAsState()
val standardVoiceReadyState = rememberUpdatedState(standardVoiceReady)
val relayVoiceReadyState = rememberUpdatedState(relayVoiceReady)
// Latest enhanced-voice overrides (null when nothing is set). Read lazily by
// the relay TTS adapter so changes apply without rebuilding it.
val enhancedOverridesState = rememberUpdatedState(EnhancedVoiceOverrides.fromSettings(voiceSettings))
// Voice pipeline wiring — mirrors ChatViewModel.initializeMedia (above).
// We build a dedicated OkHttpClient so voice requests don't contend with
@@ -438,7 +452,10 @@ fun RelayApp() {
val voiceAudioClient = remember {
AutoVoiceAudioClient(
standardClient = standardVoiceClient,
relayClient = RelayVoiceAudioClientAdapter(voiceClient),
relayClient = RelayVoiceAudioClientAdapter(
voiceClient,
enhancedOverridesProvider = { enhancedOverridesState.value },
),
routeProvider = { selectedAudioRouteState.value },
standardReadyProvider = { standardVoiceReadyState.value },
relayReadyProvider = { relayVoiceReadyState.value },
@@ -732,9 +749,39 @@ fun RelayApp() {
// Observe theme preference
val themePreference by connectionViewModel.theme.collectAsState()
val appThemeId by connectionViewModel.appTheme.collectAsState()
val fontScale by connectionViewModel.fontScale.collectAsState()
HermesRelayTheme(themePreference = themePreference, fontScale = fontScale) {
// Resolve the active sphere skin (built-in / adaptive / user-loaded) and
// publish it + the full available set so every MorphingSphere picks it up
// via LocalSphereSkin without per-call-site threading. Adaptive skins read
// the brand lazily inside MorphingSphere, so this can sit outside the theme.
val sphereSkinId by connectionViewModel.sphereSkin.collectAsState()
val sphereContext = androidx.compose.ui.platform.LocalContext.current
val availableSphereSkins by produceState(
initialValue = SphereRegistry.builtIns,
key1 = sphereContext,
) {
value = SphereRegistry.builtIns +
withContext(Dispatchers.IO) { SphereSkinLoader.loadUserSkins(sphereContext) }
}
val activeSphereSkin = remember(sphereSkinId, appThemeId, availableSphereSkins) {
SphereRegistry.resolve(
selectedId = sphereSkinId,
themeDefaultSkinId = AppThemes.byId(appThemeId).defaultSphereSkinId,
available = availableSphereSkins,
)
}
CompositionLocalProvider(
LocalSphereSkin provides activeSphereSkin,
LocalAvailableSphereSkins provides availableSphereSkins,
) {
HermesRelayTheme(
appThemeId = appThemeId,
themePreference = themePreference,
fontScale = fontScale,
) {
val navController = rememberNavController()
var postOnboardingRoute by remember { mutableStateOf<String?>(null) }
@@ -1311,7 +1358,10 @@ fun RelayApp() {
onManageSignIn = {
postOnboardingRoute = Screen.Manage.route
connectionViewModel.completeOnboarding()
}
},
onOpenPermissions = {
navController.navigate(Screen.PermissionsSettings.route)
},
)
}
composable(
@@ -1613,6 +1663,9 @@ fun RelayApp() {
onNavigateToNotificationCompanion = {
navController.navigate(Screen.NotificationCompanionSettings.route)
},
onNavigateToPermissions = {
navController.navigate(Screen.PermissionsSettings.route)
},
// === PHASE3-safety-rails: bridge safety route ===
onNavigateToBridgeSafety = {
navController.navigate(Screen.BridgeSafetySettings.route)
@@ -1663,6 +1716,16 @@ fun RelayApp() {
)
}
// === END PHASE3-notif-listener-followup ===
composable(Screen.PermissionsSettings.route) {
PermissionsStatusScreen(
onBack = { navController.popBackStack() },
onOpenBridge = {
navController.navigate(Screen.Bridge.route) {
launchSingleTop = true
}
},
)
}
// === PHASE3-safety-rails: bridge safety route ===
composable(Screen.BridgeSafetySettings.route) {
if (BuildFlavor.isSideload) {
@@ -2160,6 +2223,7 @@ fun RelayApp() {
}
} // end Box
}
} // end CompositionLocalProvider (sphere skin)
}
/** One line of the startup sphere's progress narration. */
@@ -56,15 +56,19 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.network.ConnectionState
import com.hermesandroid.relay.network.RelayUrlDeriver
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.hasSecureProxy
import com.hermesandroid.relay.network.relay.ConnectionState
import com.hermesandroid.relay.network.relay.RelayUrlDeriver
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.util.classifyError
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
import com.hermesandroid.relay.viewmodel.asBadgeState
import com.hermesandroid.relay.viewmodel.statusText
import kotlinx.coroutines.flow.first
@@ -211,6 +215,219 @@ fun ActiveCardRelayStatusSection(
)
}
/**
* Capability overview for the active connection. Features are intentionally
* separate from routes: users can see what Hermes can do without reading the
* selected network path as the feature boundary.
*/
@Composable
fun ActiveCardFeaturesSection(
connectionViewModel: ConnectionViewModel,
relayEnabled: Boolean,
onOpenApiInfo: () -> Unit,
onOpenDashboard: () -> Unit,
onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit,
) {
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val standardVoiceAvailability by
connectionViewModel.standardVoiceAvailability.collectAsState()
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
val relayReady by connectionViewModel.relayReady.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val dashboardStatus = activeConnection?.dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val secureProxyAdvertised =
activeConnection?.routeCandidates.orEmpty().any { it.hasSecureProxy() }
val apiValue = when {
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking"
apiReachable -> "Ready"
activeConnection?.apiServerUrl.isNullOrBlank() -> "Missing"
else -> "Offline"
}
val apiTone = when (apiValue) {
"Ready" -> CapabilityTone.Good
"Offline", "Missing" -> CapabilityTone.Warning
else -> CapabilityTone.Neutral
}
val dashboardValue = when {
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Missing"
dashboardStatus == null -> "Unchecked"
!dashboardStatus.reachable -> "Offline"
dashboardSignInRequired -> "Sign in"
dashboardStatus.authenticated == true -> "Signed in"
else -> "Available"
}
val dashboardTone = when (dashboardValue) {
"Signed in", "Available" -> CapabilityTone.Good
"Sign in" -> CapabilityTone.Info
"Offline", "Missing" -> CapabilityTone.Warning
else -> CapabilityTone.Neutral
}
val voiceValue = when (standardVoiceAvailability) {
StandardVoiceAvailability.Ready -> "Ready"
StandardVoiceAvailability.SignInRequired -> "Sign in"
StandardVoiceAvailability.Unsupported -> "Unsupported"
StandardVoiceAvailability.Unreachable -> "Offline"
StandardVoiceAvailability.Unknown -> "Checking"
}
val voiceTone = when (standardVoiceAvailability) {
StandardVoiceAvailability.Ready -> CapabilityTone.Good
StandardVoiceAvailability.SignInRequired -> CapabilityTone.Info
StandardVoiceAvailability.Unsupported,
StandardVoiceAvailability.Unreachable -> CapabilityTone.Warning
StandardVoiceAvailability.Unknown -> CapabilityTone.Neutral
}
val relayValue = when {
!relayEnabled -> "Disabled"
!relayConfigured -> "Optional"
relayReady -> "Ready"
relayUiState == RelayUiState.Stale -> "Reconnect"
else -> "Configured"
}
val relayTone = when {
!relayEnabled || !relayConfigured -> CapabilityTone.Neutral
relayReady -> CapabilityTone.Good
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
else -> CapabilityTone.Info
}
val terminalValue = when {
!relayEnabled -> "Disabled"
authState is AuthState.Paired -> "Ready"
relayConfigured -> "Pair Relay"
else -> "Optional"
}
val terminalTone = when (terminalValue) {
"Ready" -> CapabilityTone.Good
"Pair Relay" -> CapabilityTone.Info
else -> CapabilityTone.Neutral
}
val proxyValue = if (secureProxyAdvertised) "Available" else "Not advertised"
val proxyTone = if (secureProxyAdvertised) CapabilityTone.Good else CapabilityTone.Neutral
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CapabilityChip(
label = "Standard API",
value = apiValue,
tone = apiTone,
onClick = onOpenApiInfo,
modifier = Modifier.weight(1f),
)
CapabilityChip(
label = "Dashboard",
value = dashboardValue,
tone = dashboardTone,
onClick = onOpenDashboard,
modifier = Modifier.weight(1f),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CapabilityChip(
label = "Standard voice",
value = voiceValue,
tone = voiceTone,
onClick = if (standardVoiceAvailability ==
StandardVoiceAvailability.SignInRequired
) {
onOpenDashboard
} else {
null
},
modifier = Modifier.weight(1f),
)
CapabilityChip(
label = "Relay tools",
value = relayValue,
tone = relayTone,
onClick = onOpenRelayInfo,
modifier = Modifier.weight(1f),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CapabilityChip(
label = "Terminal",
value = terminalValue,
tone = terminalTone,
onClick = onOpenSessionInfo,
modifier = Modifier.weight(1f),
)
CapabilityChip(
label = "Secure proxy",
value = proxyValue,
tone = proxyTone,
modifier = Modifier.weight(1f),
)
}
}
}
private enum class CapabilityTone { Neutral, Good, Info, Warning }
@Composable
private fun CapabilityChip(
label: String,
value: String,
tone: CapabilityTone,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
val container = when (tone) {
CapabilityTone.Good -> MaterialTheme.colorScheme.primaryContainer
CapabilityTone.Info -> MaterialTheme.colorScheme.tertiaryContainer
CapabilityTone.Warning -> MaterialTheme.colorScheme.errorContainer
CapabilityTone.Neutral -> MaterialTheme.colorScheme.surface
}
val content = when (tone) {
CapabilityTone.Good -> MaterialTheme.colorScheme.onPrimaryContainer
CapabilityTone.Info -> MaterialTheme.colorScheme.onTertiaryContainer
CapabilityTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
CapabilityTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
modifier = modifier.then(
if (onClick != null) {
Modifier.clickable(onClick = onClick)
} else {
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,
)
}
}
}
/**
* Advanced expandable section — three subsections:
* - Manual URL configuration (API URL + key + Save & Test,
@@ -862,6 +1079,10 @@ fun ActiveCardSecurityPosture(
onNavigateToPairedDevices: () -> Unit,
) {
val relayUrl by connectionViewModel.relayUrl.collectAsState()
val effectiveApiServerUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val effectiveRelayUrl by connectionViewModel.effectiveRelayUrl.collectAsState()
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
val insecureReason by connectionViewModel.insecureReason.collectAsState()
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
@@ -870,14 +1091,43 @@ fun ActiveCardSecurityPosture(
// say "Plain (on LAN)" instead of "Insecure (network unknown)" when
// the resolver already knows which candidate we're on.
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
val selectedRouteUrls = buildList {
effectiveApiServerUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
effectiveDashboardUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
val selectedRelayUrl = effectiveRelayUrl.ifBlank { relayUrl }
if (relayConfigured || selectedRelayUrl.isNotBlank()) {
selectedRelayUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
}
}
val secureUrlCount = selectedRouteUrls.count { url ->
isSelectedRouteUrlSecure(
url = url,
activeEndpoint = activeEndpoint,
isTailscaleDetected = isTailscaleDetected,
)
}
val transportState = when {
selectedRouteUrls.isEmpty() -> null
secureUrlCount == selectedRouteUrls.size -> TransportSecurityState.AllSecure
secureUrlCount > 0 -> TransportSecurityState.Mixed
else -> TransportSecurityState.AllInsecure
}
TransportSecurityBadge(
isSecure = isUrlSecure(relayUrl),
reason = insecureReason.ifBlank { null },
size = TransportSecuritySize.Row,
modifier = Modifier.fillMaxWidth(),
activeRole = activeEndpoint?.role,
)
if (transportState != null) {
TransportSecurityBadge(
state = transportState,
size = TransportSecuritySize.Row,
modifier = Modifier.fillMaxWidth(),
)
} else {
TransportSecurityBadge(
isSecure = isUrlSecure(relayUrl),
reason = insecureReason.ifBlank { null },
size = TransportSecuritySize.Row,
modifier = Modifier.fillMaxWidth(),
activeRole = activeEndpoint?.role,
)
}
if (isTailscaleDetected) {
Row(
@@ -948,6 +1198,29 @@ fun ActiveCardSecurityPosture(
}
}
private fun isSelectedRouteUrlSecure(
url: String,
activeEndpoint: EndpointCandidate?,
isTailscaleDetected: Boolean,
): Boolean {
if (isUrlSecure(url)) return true
return activeEndpoint.isEncryptedOverlayRoute(isTailscaleDetected)
}
private fun EndpointCandidate?.isEncryptedOverlayRoute(isTailscaleDetected: Boolean): Boolean {
if (this == null) return false
val role = role.lowercase()
val securityHint = security.orEmpty().lowercase()
return role == "tailscale" ||
(isTailscaleDetected && securityHint.contains("tailscale")) ||
role == "plugin_proxy" ||
role == "plugin-proxy" ||
hasSecureProxy() ||
securityHint.contains("wireguard") ||
securityHint.contains("https") ||
securityHint.contains("tls")
}
/**
* Numbered step row for the Manual pairing code fallback. Tightly
* coupled to its Card 3 layout — step badge sizing + content shape —
@@ -0,0 +1,53 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import kotlinx.coroutines.delay
/**
* Frame-throttled stand-in for `rememberInfiniteTransition` for slow, ambient
* effects — heartbeat dots, banner glows, drifting orbs. Returns a phase that
* loops `0f → 1f` every [periodMillis], advanced at roughly [fps] instead of
* the display refresh rate.
*
* Why this exists: on Android 15 the platform logs `setRequestedFrameRate` on
* every Compose draw pass (and on Samsung builds at INFO level). An
* always-visible `rememberInfiniteTransition` pins the entire window at the
* panel's refresh (e.g. 120Hz) for as long as it's composed — flooding logcat
* and burning battery to animate motion the eye can't resolve at full rate
* anyway. ~30fps is imperceptible for a multi-second pulse.
*
* When [running] is false the phase holds at `0f` and the loop parks (no frames
* requested), so a hidden/idle effect costs nothing.
*
* Linear by design (matches the `LinearEasing` + `RepeatMode.Restart` the old
* infinite transitions used). Map the phase to your value range at the call
* site, e.g. `1f + 0.8f * phase` for a 1f→1.8f scale.
*/
@Composable
fun rememberAmbientPhase(
periodMillis: Int,
fps: Int = 30,
running: Boolean = true,
): Float {
val phase = remember { mutableFloatStateOf(0f) }
LaunchedEffect(periodMillis, fps, running) {
if (!running || periodMillis <= 0) {
phase.floatValue = 0f
return@LaunchedEffect
}
val frameIntervalMs = (1000L / fps.coerceAtLeast(1)).coerceAtLeast(1L)
var lastNanos = withFrameNanos { it }
while (true) {
val now = withFrameNanos { it }
val dtMs = (now - lastNanos).coerceAtLeast(0L) / 1_000_000f
lastNanos = now
phase.floatValue = (phase.floatValue + dtMs / periodMillis) % 1f
delay(frameIntervalMs)
}
}
return phase.floatValue
}
@@ -104,27 +104,27 @@ fun BridgeSafetySummaryCard(
}
SafetySummaryRow(
label = "Apps blocked",
label = "Apps the agent can't touch",
value = "${settings.blocklist.size}",
)
SafetySummaryRow(
label = "Destructive verbs",
label = "Words that always ask first",
value = "${settings.destructiveVerbs.size}",
)
SafetySummaryRow(
label = "Auto-disable",
label = "Turns itself off when idle",
value = if (autoDisableAtMs != null) {
val remainMs = (autoDisableAtMs - nowMs).coerceAtLeast(0L)
val remainMin = (remainMs / 60_000L).toInt()
val remainSec = ((remainMs % 60_000L) / 1000L).toInt()
"in ${remainMin}:${remainSec.toString().padStart(2, '0')}"
} else {
"${settings.autoDisableMinutes} min idle"
"${settings.autoDisableMinutes} min"
},
)
Text(
text = "Tap Manage to edit blocklist, destructive verbs, and timers.",
text = "These guardrails keep Hermes in bounds. Tap to adjust.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.ui.components
import android.content.Intent
import android.graphics.BitmapFactory
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -23,14 +24,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
@@ -41,6 +46,8 @@ import com.hermesandroid.relay.util.MediaSaver
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* One markdown image reference (`![alt](src)`) pulled out of an assistant
@@ -52,6 +59,43 @@ data class ChatInlineImage(val alt: String, val src: String)
// `![alt](src)` and `![alt](src "title")`. src = first non-space, non-`)` run.
private val MARKDOWN_IMAGE_REGEX = Regex("""!\[([^\]]*)]\(([^)\s]+)[^)]*\)""")
/**
* Resolves a server-local image path — an absolute path the agent put in a
* markdown image `![alt](/abs/path)` — to raw bytes, via the relay's
* `/media/by-path` route when a relay session is paired. Returns null when no
* relay is available or the fetch fails, so the renderer falls back to the
* "this image is on the server" notice. Provided by ChatScreen from
* [com.hermesandroid.relay.viewmodel.ChatViewModel.resolveServerImage]; the
* default is null, which preserves the standard (no-plugin) behavior where a
* server-local path simply can't be shown.
*/
fun interface RelayServerImageResolver {
suspend fun fetch(serverPath: String): ByteArray?
}
val LocalRelayServerImageResolver = staticCompositionLocalOf<RelayServerImageResolver?> { null }
/**
* Small bounded LRU of decoded inline images keyed by server path, so a
* markdown image survives LazyColumn item recycling (scroll away + back)
* without re-fetching+decoding over the relay each time. Bounded to keep bitmap
* memory in check; eldest-accessed is evicted first.
*/
private const val INLINE_IMAGE_CACHE_MAX = 12
private val inlineImageCache =
object : LinkedHashMap<String, ImageBitmap>(16, 0.75f, true) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<String, ImageBitmap>,
): Boolean = size > INLINE_IMAGE_CACHE_MAX
}
private fun cachedInlineImage(key: String): ImageBitmap? =
synchronized(inlineImageCache) { inlineImageCache[key] }
private fun putInlineImage(key: String, bitmap: ImageBitmap) {
synchronized(inlineImageCache) { inlineImageCache[key] = bitmap }
}
/**
* Split assistant [content] into (markdown body without image links, parsed
* images). The `![...]()` token is removed from the body so it doesn't render
@@ -74,6 +118,12 @@ private fun ChatInlineImage.isRemote(): Boolean {
return s.startsWith("http://") || s.startsWith("https://")
}
/**
* An absolute server-side path (e.g. `/home/agent/out.png`) — what the relay's
* `/media/by-path` route expects. Not a remote URL and not a relative ref.
*/
private fun ChatInlineImage.isServerLocalPath(): Boolean = src.startsWith("/")
/**
* Render generated/inline images for an assistant bubble. Remote `http(s)`
* URLs load via Coil with loading/error states; anything else (a server-local
@@ -87,12 +137,18 @@ fun ChatInlineImages(
maxWidth: Dp = 280.dp,
) {
if (images.isEmpty()) return
val relayResolver = LocalRelayServerImageResolver.current
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
images.forEach { image ->
if (image.isRemote()) {
RemoteChatImage(image, maxWidth)
} else {
UnrenderableImageNotice(image)
when {
image.isRemote() -> RemoteChatImage(image, maxWidth)
// A relay session is paired and the agent referenced a
// server-local file — fetch it through /media/by-path and
// render it inline instead of showing the "on the server"
// notice. Falls back to the notice if the fetch fails.
relayResolver != null && image.isServerLocalPath() ->
RelayServerImage(image, maxWidth, relayResolver)
else -> UnrenderableImageNotice(image)
}
}
}
@@ -141,6 +197,98 @@ private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
}
}
private sealed interface RelayImagePhase {
data object Loading : RelayImagePhase
data class Loaded(val bitmap: ImageBitmap) : RelayImagePhase
data object Failed : RelayImagePhase
}
/**
* A server-local image fetched through the relay's `/media/by-path` route. Shows
* a brief loading box, then the decoded image (tap → full-screen viewer), or the
* standard notice if the relay can't return it (unpaired / sandboxed / missing).
* Decoded bitmaps are cached by path so scrolling doesn't re-fetch.
*/
@Composable
private fun RelayServerImage(
image: ChatInlineImage,
maxWidth: Dp,
resolver: RelayServerImageResolver,
) {
var phase by remember(image.src) {
mutableStateOf<RelayImagePhase>(
cachedInlineImage(image.src)
?.let { RelayImagePhase.Loaded(it) }
?: RelayImagePhase.Loading,
)
}
LaunchedEffect(image.src) {
if (phase is RelayImagePhase.Loaded) return@LaunchedEffect
val bitmap = withContext(Dispatchers.IO) {
val bytes = runCatching { resolver.fetch(image.src) }.getOrNull()
?: return@withContext null
runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }
.getOrNull()
?.asImageBitmap()
}
phase = if (bitmap != null) {
putInlineImage(image.src, bitmap)
RelayImagePhase.Loaded(bitmap)
} else {
RelayImagePhase.Failed
}
}
when (val current = phase) {
RelayImagePhase.Loading -> Box(
modifier = Modifier
.widthIn(max = maxWidth)
.height(120.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
}
is RelayImagePhase.Loaded -> RelayServerImageContent(image, current.bitmap, maxWidth, resolver)
RelayImagePhase.Failed -> UnrenderableImageNotice(image)
}
}
@Composable
private fun RelayServerImageContent(
image: ChatInlineImage,
bitmap: ImageBitmap,
maxWidth: Dp,
resolver: RelayServerImageResolver,
) {
var viewerOpen by remember { mutableStateOf(false) }
if (viewerOpen) {
ChatImageViewer(
source = ChatImageViewerSource.Bitmap(
bitmap = bitmap,
displayName = image.alt.ifBlank {
image.src.substringAfterLast('/').ifBlank { "image" }
},
mime = "image/*",
// Save/Share re-fetch the original bytes on demand so we don't
// hold them in memory next to the decoded bitmap.
bytesProvider = { resolver.fetch(image.src) },
),
onDismiss = { viewerOpen = false },
)
}
androidx.compose.foundation.Image(
bitmap = bitmap,
contentDescription = image.alt.ifBlank { "Generated image" },
contentScale = ContentScale.Fit,
modifier = Modifier
.widthIn(max = maxWidth)
.heightIn(max = 360.dp)
.clip(RoundedCornerShape(12.dp))
.clickable { viewerOpen = true },
)
}
@Composable
private fun UnrenderableImageNotice(
image: ChatInlineImage,
@@ -145,6 +145,7 @@ fun ChatInputBar(
isDarkTheme: Boolean,
modelControl: ChatInputPickerControl? = null,
onModelOptionSelected: (ChatInputPickerOption) -> Unit = {},
onModelPickerClick: (() -> Unit)? = null,
effortControl: ChatInputPickerControl? = null,
onEffortOptionSelected: (ChatInputPickerOption) -> Unit = {},
modifier: Modifier = Modifier,
@@ -294,6 +295,7 @@ fun ChatInputBar(
control = modelControl,
onSelect = onModelOptionSelected,
modifier = Modifier.widthIn(max = 126.dp),
onClickOverride = onModelPickerClick,
)
}
@@ -422,6 +424,9 @@ private fun ChatInputPickerChip(
control: ChatInputPickerControl,
onSelect: (ChatInputPickerOption) -> Unit,
modifier: Modifier = Modifier,
// When set, tapping the chip opens this instead of the inline dropdown —
// used by the model chip to open the full searchable ModelPickerSheet.
onClickOverride: (() -> Unit)? = null,
) {
var expanded by remember { mutableStateOf(false) }
val enabled = control.enabled && control.options.isNotEmpty()
@@ -439,7 +444,9 @@ private fun ChatInputPickerChip(
modifier = Modifier
.heightIn(min = 32.dp)
.clip(ChatInputChipShape)
.clickable(enabled = enabled) { expanded = true },
.clickable(enabled = enabled) {
if (onClickOverride != null) onClickOverride() else expanded = true
},
) {
Row(
modifier = Modifier.padding(start = 10.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
@@ -1,10 +1,6 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
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
@@ -35,6 +31,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.gestures.detectVerticalDragGestures
@@ -237,13 +234,18 @@ fun ConnectionStatusToast(
onDismiss: (() -> Unit)? = null,
) {
val current = status ?: return
val surface = MaterialTheme.colorScheme.surface
// This toast floats OVER arbitrary content, so a translucent container would
// let the UI bleed through and hurt legibility. Composite any alpha over the
// opaque surface: the result is always opaque while each tone keeps its
// intended tint (e.g. Warning stays a lighter error than Error).
val containerColor = when {
current.tone == ConnectionStatusTone.Error -> MaterialTheme.colorScheme.errorContainer
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.82f)
current.success -> MaterialTheme.colorScheme.tertiaryContainer
current.active -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
}.compositeOver(surface)
val contentColor = when {
current.tone == ConnectionStatusTone.Error ||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
@@ -392,16 +394,11 @@ fun ConnectionStatusToast(
@Composable
private fun PulsingSyncIcon(color: androidx.compose.ui.graphics.Color) {
val infinite = rememberInfiniteTransition(label = "connection-handoff-pulse")
val alpha by infinite.animateFloat(
initialValue = 0.45f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 900),
repeatMode = RepeatMode.Reverse,
),
label = "connection-handoff-alpha",
)
// Throttled to ~30fps. Reverse ping-pong over 0.9s each way → a 1.8s linear
// phase folded into a 0→1→0 triangle. See [rememberAmbientPhase].
val phase = rememberAmbientPhase(periodMillis = 1800)
val triangle = 1f - kotlin.math.abs(2f * phase - 1f)
val alpha = 0.45f + 0.55f * triangle
Icon(
imageVector = Icons.Filled.Sync,
contentDescription = null,
@@ -37,6 +37,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
@@ -69,8 +70,8 @@ import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.network.ChatMode
import com.hermesandroid.relay.network.ConnectionState
import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.relay.ConnectionState
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -645,10 +646,20 @@ fun AgentInfoSheet(
val availableModels by chatViewModel.availableModels.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
val fastEnabled by chatViewModel.fastEnabled.collectAsState()
// Pull the gateway's curated provider/model list (model.options) when the
// sheet opens — the real switchable models, grouped by provider.
LaunchedEffect(Unit) { chatViewModel.refreshModelOptions() }
// Re-pull server-supplied personalities (list + default + active) on open so
// a server-side change shows without an app reload.
LaunchedEffect(Unit) { chatViewModel.refreshPersonalities() }
// Re-pull the SSE-fallback model list + skill catalog on open so server-side
// changes surface without an app reload (gateway model groups are covered by
// refreshModelOptions above; skills feed the command palette).
LaunchedEffect(Unit) { chatViewModel.refreshModels() }
LaunchedEffect(Unit) { chatViewModel.refreshSkills() }
// Pull the host's agent profiles from the dashboard so they appear in the
// Profile picker even on a dashboard-only (non-relay) connection.
LaunchedEffect(Unit) { connectionViewModel.refreshDashboardProfiles() }
@@ -662,6 +673,7 @@ fun AgentInfoSheet(
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
val pairingCode by connectionViewModel.pairingCode.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
val gatewayCurrentProvider by chatViewModel.gatewayCurrentProvider.collectAsState()
// Multi-connection switcher state — folded into this sheet in place of
// the separate top-bar ConnectionChip (see 2026-04-20 DEVLOG). Read
@@ -731,12 +743,19 @@ fun AgentInfoSheet(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ---- Header: avatar + agent name + live status ----
// Detail view shows the provider next to the model (desktop-style);
// the chat composer pill stays model-only.
val currentProviderLabel = modelProviders
.firstOrNull { gatewayCurrentProvider.isNotBlank() && it.slug.equals(gatewayCurrentProvider, ignoreCase = true) }
?.name
?.takeIf { it.isNotBlank() }
AgentSheetHeader(
profile = effectiveDisplayProfile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
localDisplayAlias = profileDisplayAlias,
serverModelName = serverModelName,
modelProviderLabel = currentProviderLabel,
apiServerReachable = apiServerReachable,
chatMode = chatMode,
isCustomized = selectedProfile != null ||
@@ -1017,47 +1036,47 @@ fun AgentInfoSheet(
modifier = Modifier.alpha(if (profileOverridesPersonality) 0.55f else 1f),
) {
// Default row — maps to selectedPersonality == "default" which
// the VM resolves to whatever server-side personality is
// currently active.
// None row — the explicit "no personality overlay" state
// (upstream `/personality none`). On the gateway this is
// server-applied; on SSE it just sends no persona prompt. There
// is intentionally no synthetic client "Default" row — upstream's
// active value is `none` or a named personality, and the server's
// configured default (if any) shows below as the row tagged
// "(default)", highlighted whenever it's the active one.
ProfileRadioRow(
primary = if (defaultPersonality.isNotBlank()) {
"${defaultPersonality.replaceFirstChar { it.uppercase() }} (default)"
} else {
"Default"
},
secondary = null,
selected = selectedPersonality == "default",
primary = "None",
secondary = "No personality overlay",
selected = selectedPersonality == "none" || selectedPersonality == "neutral",
enabled = !isStreaming,
onSelect = {
if (selectedPersonality != "default") {
chatViewModel.selectPersonality("default")
if (selectedPersonality != "none") {
chatViewModel.selectPersonality("none")
if (!profileOverridesPersonality) {
toast("Using default personality")
toast("Personality cleared")
}
}
},
)
personalityNames
.filter { it != defaultPersonality }
.forEach { name ->
ProfileRadioRow(
primary = name.replaceFirstChar { it.uppercase() },
secondary = null,
selected = selectedPersonality == name,
enabled = !isStreaming,
onSelect = {
if (selectedPersonality != name) {
chatViewModel.selectPersonality(name)
if (!profileOverridesPersonality) {
val display = name.replaceFirstChar { it.uppercase() }
toast("Personality: $display")
}
personalityNames.forEach { name ->
val isServerDefault = name.equals(defaultPersonality, ignoreCase = true)
ProfileRadioRow(
primary = name.replaceFirstChar { it.uppercase() } +
if (isServerDefault) " (default)" else "",
secondary = null,
selected = selectedPersonality == name,
enabled = !isStreaming,
onSelect = {
if (selectedPersonality != name) {
chatViewModel.selectPersonality(name)
if (!profileOverridesPersonality) {
val display = name.replaceFirstChar { it.uppercase() }
toast("Personality: $display")
}
},
)
}
}
},
)
}
// When a profile SOUL is active AND the user has picked a
// non-default personality, make the precedence explicit
@@ -1065,7 +1084,9 @@ fun AgentInfoSheet(
// The mirror caption in the Profile section tells the same
// story from the other direction — both are kept because a
// user scanning either section should see the constraint.
if (profileOverridesPersonality && selectedPersonality != "default") {
if (profileOverridesPersonality &&
selectedPersonality != "default" && selectedPersonality != "none"
) {
Text(
text = "Profile SOUL overrides personality while active.",
style = MaterialTheme.typography.labelSmall,
@@ -1157,6 +1178,85 @@ fun AgentInfoSheet(
}
}
// ---- Safety & speed section (gateway only) ----
// YOLO (approval bypass) + Fast (priority tier) mirror the desktop
// config.set yolo/fast. Gated on a live gateway (model.options groups
// present); both are session-scoped and track live via session.info.
if (modelProviders.isNotEmpty()) {
HorizontalDivider()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionLabel(title = "Safety & speed", hint = null)
// YOLO — bypasses command approvals. On-state is loud.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("YOLO mode", style = MaterialTheme.typography.bodyLarge)
Text(
text = "Bypasses command approvals — Hermes runs tools without asking.",
style = MaterialTheme.typography.labelSmall,
color = if (yoloEnabled == true) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Switch(
checked = yoloEnabled == true,
enabled = !isStreaming,
onCheckedChange = { chatViewModel.setYolo(it) },
)
}
if (yoloEnabled == true) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = "Approvals are OFF for this session.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
// Fast — priority service tier (model-gated server-side).
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Fast mode", style = MaterialTheme.typography.bodyLarge)
Text(
text = "Priority service tier — lower latency where the model supports it.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = fastEnabled == true,
enabled = !isStreaming,
onCheckedChange = { chatViewModel.setFast(it) },
)
}
}
}
HorizontalDivider()
// ---- Session + stats section ----
@@ -1632,6 +1732,7 @@ private fun AgentSheetHeader(
defaultPersonality: String,
localDisplayAlias: String?,
serverModelName: String,
modelProviderLabel: String? = null,
apiServerReachable: Boolean,
chatMode: ChatMode,
isCustomized: Boolean,
@@ -1697,7 +1798,8 @@ private fun AgentSheetHeader(
)
modelLabel?.takeIf { it.isNotBlank() }?.let { label ->
Text(
text = label,
text = modelProviderLabel?.takeIf { it.isNotBlank() }
?.let { "$label · $it" } ?: label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -1,11 +1,5 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -107,33 +101,14 @@ fun ConnectionStatusBadge(
}
}
val pulseScale: Float
val pulseAlpha: Float
if (showPulse) {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
pulseScale = infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.8f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = pulseDurationMs, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "pulseScale"
).value
pulseAlpha = infiniteTransition.animateFloat(
initialValue = 0.35f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = pulseDurationMs, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "pulseAlpha"
).value
} else {
pulseScale = 1f
pulseAlpha = 0f
}
// Heartbeat ring driven by a throttled ambient phase rather than an
// infinite transition: the badge is always on screen while connected, so a
// full-refresh transition would pin the whole window at 120Hz forever (and
// log setRequestedFrameRate every frame on Android 15). ~30fps is plenty
// for a 0.8–1.5s pulse. Phase 0→1 maps to the old linear scale/alpha ramps.
val pulsePhase = rememberAmbientPhase(periodMillis = pulseDurationMs, running = showPulse)
val pulseScale = if (showPulse) 1f + 0.8f * pulsePhase else 1f
val pulseAlpha = if (showPulse) 0.35f * (1f - pulsePhase) else 0f
Box(
modifier = modifier
@@ -88,8 +88,8 @@ import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.network.HermesLanDiscovery
import com.hermesandroid.relay.network.HermesLanDiscoveryResult
import com.hermesandroid.relay.network.shared.HermesLanDiscovery
import com.hermesandroid.relay.network.shared.HermesLanDiscoveryResult
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
import kotlinx.coroutines.TimeoutCancellationException
@@ -236,10 +236,15 @@ fun ConnectionWizard(
if (granted) {
showQrScanner = true
} else {
// Don't dead-end on denial — return to the chooser and point the
// user at the manual pairing paths (URL entry / 6-char code)
// instead of leaving them on a vanishing toast with no scanner.
step = WizardStep.Method
Toast.makeText(
context,
"Camera permission needed to scan QR codes",
Toast.LENGTH_SHORT
"Camera permission denied. Pair manually instead — choose " +
"\"Pair Relay by code\" or enter your server URL.",
Toast.LENGTH_LONG
).show()
}
}
@@ -319,8 +324,22 @@ fun ConnectionWizard(
"ConnectionWizard",
"verify[$verifyAttempt] TIMEOUT after 15s (current=${connectionViewModel.authState.value::class.simpleName})"
)
verifyError = "Timed out waiting for the relay. " +
"Check that the relay is running and the URL is correct."
// Method-aware timeout copy. The watchdog only sees authState, but
// it knows which pairing method the user chose — enough to name the
// most likely cause instead of one generic "relay timed out."
verifyError = when (chosenMethod) {
PairMethod.EnterCode, PairMethod.ShowCode ->
"The host hasn't accepted this pairing code yet. Run the pairing " +
"command on your Hermes host (or re-check the code), then tap Retry."
PairMethod.Scan ->
"Timed out before the relay confirmed pairing. Check the relay is " +
"running and reachable on this network, then Retry. If pairing " +
"seems to succeed but Hermes still can't be reached, the API " +
"server may be behind a login gateway."
else ->
"Timed out waiting for the relay. Check that the relay is running " +
"and the URL is correct."
}
}
}
@@ -2729,7 +2748,7 @@ private fun SoftPill(
* Reorder the endpoints array so the chosen role lands at priority 0.
* Priority values are renumbered to match the new order — this matters at
* persist time because `setDeviceEndpoints` stores the list verbatim and
* downstream [com.hermesandroid.relay.network.EndpointResolver] trusts the
* downstream [com.hermesandroid.relay.network.shared.EndpointResolver] trusts the
* `priority` field (see ADR 24 "strict priority").
*
* No-op when the preferred role is already at index 0, or when the role
@@ -3,14 +3,30 @@ package com.hermesandroid.relay.ui.components
import androidx.compose.animation.core.animateFloatAsState
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.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.lerp
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@@ -18,47 +34,93 @@ import com.hermesandroid.relay.ui.theme.RelayRefresh
import kotlin.math.roundToInt
/**
* Ambient context-window meter — a 2dp hairline strip seated at the seam
* between the TopAppBar and RelayModeStrip. The Telegram answer: zero-tap,
* invisible until it matters.
* Per-session context-window gauge — mirrors the desktop TUI status line's
* context meter: a slim filled bar plus a `NN% · used/max` token readout,
* color-graded by fullness (green < 50% · amber 50–80% · orange 80–95% ·
* red ≥ 95%). The value is session-cumulative and reset per session by the
* ViewModel, so this always reflects the active conversation.
*
* Silent below 50% usage (composes to nothing — no reserved height, the
* seam simply stays a seam). From 50% the fill tracks
* [usedFraction] through Relay → Amber (≥75%) → Danger (≥90%), the same
* caution ladder the sudo countdown and voice badge use. Amber/Danger are
* deliberate direct RelayRefresh reads — identical in both schemes.
*
* Render only when the gateway usage carries a context_max (the compressor
* is present); pass null otherwise and the strip vanishes.
* Renders only when [usedFraction] is non-null — i.e. the server's context
* compressor reported a `context_max`. Pass [usedTokens]/[maxTokens] to show
* the absolute counts; when they're absent (percent-only servers) the bar
* shows just the percent.
*/
@Composable
fun ContextMeterBar(usedFraction: Float?, modifier: Modifier = Modifier) {
if (usedFraction == null || usedFraction < 0.5f) return
fun ContextMeterBar(
usedFraction: Float?,
usedTokens: Int? = null,
maxTokens: Int? = null,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
if (usedFraction == null) return
val target = usedFraction.coerceIn(0f, 1f)
val fill by animateFloatAsState(
targetValue = usedFraction.coerceIn(0f, 1f),
targetValue = target,
animationSpec = tween(600),
label = "ctxFill",
)
val color = when {
fill >= 0.9f -> RelayRefresh.Danger
fill >= 0.75f -> RelayRefresh.Amber
else -> RelayRefresh.Relay.copy(alpha = 0.8f)
fill >= 0.95f -> RelayRefresh.Danger
fill >= 0.80f -> lerp(RelayRefresh.Amber, RelayRefresh.Danger, 0.5f) // orange
fill >= 0.50f -> RelayRefresh.Amber
else -> RelayRefresh.Green
}
val percent = (usedFraction.coerceIn(0f, 1f) * 100).roundToInt()
val percent = (target * 100).roundToInt()
val tokenSuffix = if (usedTokens != null && maxTokens != null && maxTokens > 0) {
" · ${fmtTokens(usedTokens)}/${fmtTokens(maxTokens)}"
} else {
""
}
val label = "$percent%$tokenSuffix"
Box(
Row(
modifier = modifier
.fillMaxWidth()
.height(2.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
.semantics { contentDescription = "Context $percent% used" },
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier)
.padding(horizontal = 12.dp, vertical = 3.dp)
.semantics {
contentDescription = "Context $percent% used" +
if (tokenSuffix.isNotEmpty()) ", ${fmtTokens(usedTokens!!)} of ${fmtTokens(maxTokens!!)} tokens" else ""
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Box(
modifier = Modifier
.fillMaxWidth(fill)
.fillMaxHeight()
.background(color),
.weight(1f)
.height(5.dp)
.clip(RoundedCornerShape(3.dp))
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Box(
modifier = Modifier
.fillMaxWidth(fill)
.fillMaxHeight()
.clip(RoundedCornerShape(3.dp))
.background(color),
)
}
Spacer(Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = if (fill >= 0.50f) color else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onClick != null) {
// Subtle affordance that the meter is tappable → injected-context audit.
Spacer(Modifier.width(6.dp))
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = "View injected context",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(13.dp),
)
}
}
}
/** Compact token count: 31000 → "31k", 200000 → "200k", 850 → "850". */
private fun fmtTokens(n: Int): String =
if (n >= 1000) "${n / 1000}k" else n.toString()
@@ -47,8 +47,8 @@ import androidx.compose.ui.unit.dp
*
* Shows the agent's exact requested action + the flagged verb so the
* user isn't guessing what they're allowing. Two buttons:
* - Deny (primary-tonal, safe default) — caller maps to false
* - Allow (tonal with a warning tint) — caller maps to true
* - Deny (filled primary, the dominant safe default) — caller maps to false
* - Allow (low-emphasis amber outline) — caller maps to true
*
* Kept UI-layer stateless: both `onAllow` and `onDeny` return directly.
* The callers in [BridgeStatusOverlay] update the overlay registry and
@@ -178,24 +178,25 @@ fun DestructiveVerbConfirmDialog(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedButton(
// Deny is the dominant, safe default. Denying never writes
// trust — even if the user ticked the checkbox first.
Button(
modifier = Modifier.weight(1f),
// Deny never writes trust — denying a command isn't
// consent to anything, even if the user happened to
// tick the checkbox before changing their mind.
onClick = onDeny,
) {
Text("Deny")
}
Button(
// Allow is intentionally lower-emphasis (amber caution, not a
// loud red CTA) so proceeding with a risky action never reads
// as the default tap. Weight comes from the wording.
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = { onAllow(trustVerb && canTrust) },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFE53935),
contentColor = Color.White,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color(0xFFE65100),
),
) {
Text("Allow")
Text("Allow this action")
}
}
}
@@ -50,7 +50,7 @@ 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.network.shared.RouteProbeOutcome
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
@@ -3,34 +3,45 @@ package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
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.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
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
import com.hermesandroid.relay.viewmodel.TerminalViewModel.SpecialKey
/**
* Horizontal toolbar of keys that the Android soft keyboard doesn't offer:
* ESC, TAB, CTRL (sticky), ALT (sticky), and arrow keys.
* ESC, TAB, CTRL (sticky), ALT (sticky), clipboard, arrows, and scrollback.
*
* Layout: a single [Row] wrapped in [horizontalScroll]. Keys size to their
* label (clamped to a min width) and the row scrolls when the cluster is
* wider than the screen — the same strategy Orca's mobile terminal uses.
* The earlier weight-distributed layout squeezed every key into the screen
* width, which clipped labels ("CTRL" → "CTR") on narrow phones; fixed-width
* keys plus horizontal scroll render every label cleanly at any key count.
*
* Sticky modifier behavior: tapping CTRL or ALT highlights the key and
* applies the modifier to the next character typed (via
@@ -51,172 +62,131 @@ fun ExtraKeysToolbar(
onScrollUp: (() -> Unit)? = null,
onScrollDown: (() -> Unit)? = null,
onScrollToBottom: (() -> Unit)? = null,
onPaste: (() -> Unit)? = null,
onCopy: (() -> Unit)? = null,
onToggleKeyboard: (() -> Unit)? = null,
) {
val haptic = LocalHapticFeedback.current
val containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
Row(
// Column so the hairline top divider spans the full bar width while the
// key row underneath scrolls independently. The caller's modifier (nav-bar
// + IME padding) is applied to the outer container.
Column(
modifier = modifier
.fillMaxWidth()
.background(containerColor)
.padding(horizontal = 4.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
.background(containerColor),
) {
ToolbarKey(
label = "ESC",
active = false,
weight = 1.2f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onEsc()
}
)
ToolbarKey(
label = "TAB",
active = false,
weight = 1.2f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTab()
}
)
ToolbarKey(
label = "CTRL",
active = ctrlActive,
weight = 1.3f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onCtrlToggle()
}
)
ToolbarKey(
label = "ALT",
active = altActive,
weight = 1.2f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onAltToggle()
}
HorizontalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f),
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ToolbarKey(label = "ESC", onClick = onEsc)
ToolbarKey(label = "TAB", onClick = onTab)
ToolbarKey(label = "CTRL", active = ctrlActive, onClick = onCtrlToggle)
ToolbarKey(label = "ALT", active = altActive, onClick = onAltToggle)
Spacer(modifier = Modifier.width(4.dp))
// Clipboard + keyboard cluster — selecting/copying/pasting and
// raising the soft keyboard are all unreliable through long-press
// inside an Android WebView, so these explicit keys are the
// dependable path.
onCopy?.let { copy ->
ToolbarKey(label = "COPY", onClick = copy)
}
onPaste?.let { paste ->
ToolbarKey(label = "PASTE", onClick = paste)
}
onToggleKeyboard?.let { toggle ->
ToolbarKey(label = "⌨", onClick = toggle) // show/hide soft keyboard
}
ToolbarKey(
label = "\u2190",
active = false,
weight = 1f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onArrow(SpecialKey.ARROW_LEFT)
}
)
ToolbarKey(
label = "\u2193",
active = false,
weight = 1f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onArrow(SpecialKey.ARROW_DOWN)
}
)
ToolbarKey(
label = "\u2191",
active = false,
weight = 1f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onArrow(SpecialKey.ARROW_UP)
}
)
ToolbarKey(
label = "\u2192",
active = false,
weight = 1f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onArrow(SpecialKey.ARROW_RIGHT)
}
)
GroupSpacer()
// Scrollback controls — target xterm.js's viewport, NOT the remote
// PTY. Unlike the arrow keys above (which send ANSI escapes into the
// running shell), these just move the local scrollback window, so
// the user can look at older output without disturbing whatever the
// shell thinks the cursor position is.
if (onScrollUp != null || onScrollDown != null) {
Spacer(modifier = Modifier.width(4.dp))
}
onScrollUp?.let { scrollUp ->
ToolbarKey(
label = "\u21D1", // upwards double arrow — distinct from ARROW_UP
active = false,
weight = 1f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
scrollUp()
}
)
}
onScrollDown?.let { scrollDown ->
ToolbarKey(
label = "\u21D3", // downwards double arrow
active = false,
weight = 1f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
scrollDown()
}
)
}
// "Jump to bottom" — small and always present when any scroll
// callback is. Covers the case where the user has scrolled way up
// and wants to snap back without swiping endlessly.
onScrollToBottom?.let { scrollToBottom ->
ToolbarKey(
label = "\u21F2", // south-east double arrow; reads as "end"
active = false,
weight = 1f,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
scrollToBottom()
}
)
ToolbarKey(label = "←", onClick = { onArrow(SpecialKey.ARROW_LEFT) })
ToolbarKey(label = "↓", onClick = { onArrow(SpecialKey.ARROW_DOWN) })
ToolbarKey(label = "↑", onClick = { onArrow(SpecialKey.ARROW_UP) })
ToolbarKey(label = "→", onClick = { onArrow(SpecialKey.ARROW_RIGHT) })
// Scrollback controls — target xterm.js's viewport, NOT the remote
// PTY. Unlike the arrow keys above (which send ANSI escapes into
// the running shell), these just move the local scrollback window,
// so the user can look at older output without disturbing whatever
// the shell thinks the cursor position is.
if (onScrollUp != null || onScrollDown != null || onScrollToBottom != null) {
GroupSpacer()
}
onScrollUp?.let { scrollUp ->
// upwards double arrow — distinct from ARROW_UP
ToolbarKey(label = "⇑", onClick = scrollUp)
}
onScrollDown?.let { scrollDown ->
ToolbarKey(label = "⇓", onClick = scrollDown) // downwards double arrow
}
// "Jump to bottom" — covers the case where the user has scrolled
// way up and wants to snap back without swiping endlessly.
onScrollToBottom?.let { scrollToBottom ->
ToolbarKey(label = "⇲", onClick = scrollToBottom) // SE double arrow; "end"
}
}
}
}
/** Extra gap between key clusters (modifiers · arrows · scrollback). */
@Composable
private fun androidx.compose.foundation.layout.RowScope.ToolbarKey(
private fun GroupSpacer() {
Spacer(modifier = Modifier.width(6.dp))
}
@Composable
private fun ToolbarKey(
label: String,
active: Boolean,
weight: Float,
onClick: () -> Unit
onClick: () -> Unit,
active: Boolean = false,
minWidth: Dp = 36.dp,
) {
val haptic = LocalHapticFeedback.current
val shape = RoundedCornerShape(6.dp)
val scheme = MaterialTheme.colorScheme
val bg = if (active) scheme.primary.copy(alpha = 0.22f) else scheme.surface
val fg = if (active) scheme.primary else scheme.onSurface
val borderColor = if (active) scheme.primary.copy(alpha = 0.6f) else scheme.outlineVariant.copy(alpha = 0.4f)
val borderColor = if (active) {
scheme.primary.copy(alpha = 0.6f)
} else {
scheme.outlineVariant.copy(alpha = 0.4f)
}
Box(
modifier = Modifier
.weight(weight)
.heightIn(min = 36.dp)
.height(36.dp)
// Clamp to a tappable minimum but let longer labels (CTRL, PASTE)
// grow so nothing clips. Width is content-driven inside the
// horizontally-scrolling parent, never weight-divided.
.widthIn(min = minWidth)
.height(32.dp)
.clip(shape)
.background(bg, shape)
.border(width = 1.dp, color = borderColor, shape = shape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onClick()
},
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = fg,
fontSize = 13.sp,
fontSize = 12.sp,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium,
fontFamily = FontFamily.Monospace
fontFamily = FontFamily.Monospace,
maxLines = 1,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
}
@@ -88,7 +88,7 @@ import kotlinx.coroutines.launch
/**
* Inline rich-card render for a [HermesCard] extracted from an assistant
* message via the `CARD:{json}` marker pipeline in
* [com.hermesandroid.relay.network.handlers.ChatHandler].
* [com.hermesandroid.relay.network.upstream.ChatHandler].
*
* Layout (top → bottom):
* - Accent stripe (leading 3dp bar, colored by [HermesCard.accent])
@@ -0,0 +1,126 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.viewmodel.ChatViewModel
/**
* Bottom-sheet audit of the exact extra context the agent is injected with on
* the next turn — opened by tapping the chat [ContextMeterBar].
*
* Renders the SAME [ChatViewModel.InjectedContext] the send path builds (via
* [ChatViewModel.previewInjectedContext] → `composeInjectedContext`), so it is
* a faithful audit, not a re-derivation that could drift. Empty blocks show a
* labeled note instead of vanishing, and the gateway's server-side persona is
* explicitly called out as not-sent-from-this-device.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InjectedContextSheet(
context: ChatViewModel.InjectedContext,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(start = 20.dp, end = 20.dp, bottom = 28.dp),
) {
Text(
text = "What the agent sees",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(4.dp))
Text(
text = "The exact extra context prepended to your next turn, for " +
"transparency. Transport: ${context.transport}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
ContextSection(
title = "Persona / profile",
body = context.personaPrompt,
emptyNote = if (context.personaOwnedServerSide) {
"Added server-side by Hermes (profile soul + personality " +
"overlay) — not sent from this device."
} else {
"No persona prompt is being sent — the server uses its " +
"configured default."
},
)
ContextSection(
title = "Phone status",
body = context.appContext,
emptyNote = "No phone-status block — enable it in App Context settings.",
)
ContextSection(
title = "Media capability",
body = context.mediaCapability,
emptyNote = "Not sent. Added only on the SSE path when a relay " +
"route is configured — the gateway transport has no slot for it.",
)
ContextSection(
title = "This turn",
body = context.interfaceContext,
emptyNote = "Nothing extra for a typed turn. Voice turns add a " +
"spoken-output hint here.",
)
}
}
}
@Composable
private fun ContextSection(
title: String,
body: String?,
emptyNote: String,
) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(6.dp))
if (body.isNullOrBlank()) {
Text(
text = emptyNote,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(
text = body,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
RoundedCornerShape(10.dp),
)
.padding(12.dp),
)
}
Spacer(Modifier.height(18.dp))
}
@@ -1,7 +1,7 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -36,7 +36,7 @@ fun MarkdownContent(
textColor: Color,
modifier: Modifier = Modifier
) {
val isDarkTheme = isSystemInDarkTheme()
val isDarkTheme = LocalBrand.current.isDark
val highlightsBuilder = remember(isDarkTheme) {
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
}

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