Compare commits

...
Author SHA1 Message Date
Bailey DixonandClaude Opus 4.8 f1e8bfd7ac feat(android): connection security indicator across all surfaces
Implements the spec in docs/plans/2026-06-24-connection-security-indicator.md
(decisions: Tailscale=green, ship all surfaces, "Encrypted · <mechanism>").

Single source of truth: data/ConnectionSecurity.kt computes a per-surface +
rollup verdict (TLS / Overlay / Mixed / Plain) from the active route's
schemes; ConnectionViewModel exposes it as a StateFlow. Overlay transports
(Tailscale/WireGuard/plugin proxy) count as encrypted, not just TLS — so a
ws:// route over a tailnet reads "Encrypted · Tailscale" (green), fixing the
old badge's hardcoded "Secure — TLS" lie.

Surfaces (all read the one flow):
- Chat status chip: leading security glyph (RelayStatusStrip slot).
- Connection card: full-width badge promoted out of the Advanced fold.
- Route picker: per-route glyph on each candidate.
- New ConnectionSecuritySheet: tap any badge for the per-transport
  breakdown + mechanism explainer + docs link.

Removed the duplicated, buried security computation from
ActiveConnectionSections (now delegates to the shared model).

Docs: new user-docs "Is my connection secure?" page; fixes the
Tailscale=TLS conflation in decisions.md / security.md / remote-access.md;
first user-facing mention of TOFU cert pinning.

Verified: ./gradlew :app:testSideloadDebugUnitTest (ConnectionSecurityTest
7/7) + :app:lintSideloadDebug both green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:40:19 -04:00
Bailey DixonandClaude Opus 4.8 75e617bfb1 docs(plan): connection security indicator — surfacing, wording & docs spec
Design spec for making connection security legible at a glance. Companion
to docs/plans/2026-06-18-native-secure-routes.md (which owns the routes /
plugin-proxy mechanics).

Key findings from the UI/code/docs audit:
- The security model already exists (TransportSecurityBadge tri-state,
  isEncryptedOverlayRoute, ActiveCardSecurityPosture) but is buried under
  Manage > Connections > Advanced and absent from every at-a-glance surface.
- The badge hardcodes "Secure - TLS" even for Tailscale/WireGuard routes
  (the "TLS lie") - likely why users keep asking "is it secure?".
- Security is inherently per-surface (gateway/API/dashboard/relay schemes
  are independent), so a binary verdict can't be honest - propose a
  connection rollup for the glance + per-surface truth on tap.

Spec covers: corrected mechanism-first wording (TLS / Tailscale / Mixed /
Not encrypted, with overlay = secure), placement (chat status chip, header,
route picker, new detail sheet) with mockups, the secure-proxy stub status,
a documentation plan to fix the Tailscale=TLS conflation, open decisions
for review, and tiered implementation with effort sizing.

No implementation yet - placement/wording decisions pending review.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:11:30 -04:00
Bailey DixonandClaude Opus 4.8 80ea95db1c docs(devlog): record plugin-v1.2.1 release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:53:43 -04:00
Bailey DixonandClaude Opus 4.8 ed0b32e246 docs(devlog): record plugin-v1.2.1 release + live-server deploy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:52:02 -04:00
83 changed files with 3105 additions and 387 deletions
+25 -2
View File
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added
- **Connection security indicator.** The chat status chip, the connection card, and the route picker now show at a glance whether your connection is encrypted — 🔒 **Encrypted · TLS**, 🛡️ **Encrypted · Tailscale** (both secure), 🛡️ **Mixed routes**, or ⚠️ **Not encrypted** — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale/WireGuard route is now correctly shown as encrypted rather than implied insecure. Adds a new "Is my connection secure?" docs page explaining the difference between TLS and overlay (WireGuard) encryption.
- **Desktop CLI: `hermes-relay audit`.** Shows what the remote agent has actually run on this machine through the desktop tools — tool, status, and a short detail per call — read from a local log, no network or auth. Answers "what did the agent just do?" at a glance.
- **Desktop CLI: `hermes-relay relay`.** Inspect the relay server itself: `relay info` (version, uptime, sessions — on the relay host), `relay security` (runtime auth toggles), and `relay context` (audit the system-prompt context the relay injects into the agent, which works from a remote machine with your session).
- **Desktop CLI: background daemon.** `hermes-relay daemon start` runs the headless tool router in the background (no console window, survives closing the terminal), with `daemon stop` and `daemon status` to manage it. `daemon status` reports state, uptime, relay, and advertised-tool count; bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
@@ -20,6 +21,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
## [1.2.3] - 2026-06-23
### Fixed
- **Crash on connect over TLS / Tailscale.** Connecting to a server over an encrypted link (Tailscale Serve or public HTTPS) could hard-close the app with `NetworkOnMainThreadException`. Tearing down an HTTP client closed live SSL sockets on the main thread, and a TLS socket close performs a network write — which Android forbids on the main thread. Client shutdown now always closes sockets off the main thread, so connecting over a secured link no longer crashes. (#118, #124; likely the v1.1.0 / Tailscale crash in #70)
## [1.2.2] - 2026-06-22
### Added
- **Diagnostics: status timeline.** Diagnostics now opens full-screen and leads with a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a clear pass / warning / fail state and, when something's wrong, the reason why; tap a failing check for full detail. The recent-activity log stays below it.
### Changed
- **Connections wording simplified.** The default connection is now just "Hermes" (previously "Vanilla" / "Standard Hermes"), and the optional power features are labelled "Relay" / "Relay plugin", across the connection setup, switcher, voice, and permissions screens.
- **Clean chat mode shows more text.** The distraction-free chat view gives its text a noticeably taller, scrollable area instead of capping it near a third of the screen.
### Fixed
- **Deleting a session on a non-default profile now sticks.** Removing a chat while a non-default agent profile was active could leave it on the server, so it reappeared after the list refreshed; the delete is now scoped to the active profile.
- **Session drawer opens on the right profile from a cold start.** When launching with a non-default profile selected, the session list could briefly show the default profile's chats and then snap to the correct ones; it now waits for the profile to resolve and loads the right list directly.
## [1.2.1] - 2026-06-21
### Added
@@ -346,11 +369,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a Server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://172.16.24.250:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://192.168.1.100:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
- **Desktop CLI v0.2 — PTY shell, local tool routing, multi-endpoint pairing, reconnect + TOFU, devices, contextual banner.** The `@hermes-relay/cli` package at `desktop/` grew from a chat-only scripting surface into a full Hermes-experience thin client. Bare `hermes-relay` now drops into `shell` mode (interactive PTY pipe through the existing relay `terminal` channel → `tmux new-session -A` + post-attach `exec hermes` → the full local `hermes` banner/skin/session id verbatim, zero server changes). `Ctrl+A .` detaches preserving tmux; `Ctrl+A k` destroys it. New `devices` subcommand drives the relay's `GET/DELETE/PATCH /sessions` HTTP endpoints for listing, revoking, and extending server-side paired-device tokens. Status now surfaces `grants:` (per-channel expiry) and `expires:` (session TTL) pulled from the `auth.ok` handshake the transport already received — `RemoteSessionRecord` gained `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented` (additive, back-compat preserved via a `SaveSessionOptions | string | null` overload on `saveSession`). Contextual connect banner (`Connected via LAN (plain) — server 0.6.0`) replaces the flat `Connected (server X)` line across `chat` + `shell`. Multi-endpoint pairing (ADR 24): `--pair-qr <payload>` / `HERMES_RELAY_PAIR_QR` accepts a full v3 QR payload (compact JSON or base64), decodes the `endpoints[]` array, probes each candidate with strict-priority-within-tier racing (`Promise.any` + `AbortSignal.any`, 4 s per-candidate timeout, 60 s reachability cache), and auto-selects the first reachable — role propagates into the banner + stored record. Reconnect-on-drop: `RelayTransport` gained a `ReconnectState` machine (`idle|connecting|connected|reconnecting`), exponential backoff (1 s → 30 s, 5 min on 429), `reconnectGate` re-checked both at schedule time and post-backoff (matches Android's mid-sleep purge-race lesson), `'reconnecting'` + `'reconnected'` events, and bufferedEvents-cleared-on-reconnect. TOFU cert pinning: TLS probe runs before the WebSocket opens on `wss://`, extracts peer-cert SPKI sha256 (`sha256/<base64>`, OkHttp-compatible), compares against the stored pin or captures it first-time; mismatches error out with a human-readable "re-pair to reset" pointer. Client-side tool routing (Phase B): new `desktop` relay channel on the server (`plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` registering `desktop_read_file` / `desktop_write_file` / `desktop_terminal` / `desktop_search_files` / `desktop_patch`) forwards tool calls from Hermes to the connected Node CLI; client-side `DesktopToolRouter` dispatches to in-process handlers (`fs`, `terminal`, `search`) under a 30 s AbortController, 30 s heartbeat advertising the tool names. Gated behind a one-time per-URL consent prompt (`toolsConsented` on the session record) + `--no-tools` kill-switch; non-TTY stdin fails closed. New files on the client: `src/banner.ts`, `src/endpoint.ts`, `src/pairingQr.ts`, `src/certPin.ts`, `src/commands/devices.ts`, `src/tools/router.ts`, `src/tools/consent.ts`, `src/tools/handlers/{fs,terminal,search}.ts`. New files on the server: `plugin/relay/channels/desktop.py`, `plugin/tools/desktop_tool.py`, `docs/relay-protocol.md §3.5`. Still zero runtime deps on the client (Node ≥21 global `WebSocket` + `fetch` + `tls.connect` + `node:crypto` X509Certificate + `AbortSignal.any`). Build clean; live smoke passed for `status` / `tools` / `devices`; interactive `shell` + tool-call smoke pending user walk-through. Delivered as four parallel implementation agents (multi-endpoint, reconnect+TOFU, server-side desktop, client-side tool handlers) + one synthesis-and-integration pass; the `connectAndAuth → {relay, url, endpointRole}` return-shape refactor in `chat.ts` / `shell.ts` / `tools.ts` unifies how `--pair-qr`'s winning-endpoint URL overrides `--remote` across every subcommand.
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://172.16.24.250:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://192.168.1.100:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
### Changed
+43 -2
View File
@@ -1,6 +1,47 @@
# Hermes-Relay — Dev Log
## 2026-06-21 — Released android-v1.2.1
## 2026-06-23 — Fix NetworkOnMainThreadException crash on TLS connect
**Why.** Two external bug reports (#118, #124) and the later comment on #70 reported the app hard-closing on connect over an encrypted link (Tailscale Serve / public HTTPS). The auto-captured traces were identical: `android.os.NetworkOnMainThreadException` from `okhttp3.ConnectionPool.evictAll()`, with a suppressed `Dispatchers.Main.immediate [Cancelling]` frame — i.e. a `viewModelScope` coroutine.
**Root cause.** `HermesApiClient.shutdown()`, `DashboardApiClient.shutdown()`, and `ConnectionManager.shutdown()` each call `connectionPool.evictAll()` inline. `evictAll()` closes pooled sockets synchronously; for a live `https`/`wss` keep-alive connection a TLS close drains a close-notify through `SSLOutputStream` — a real network write StrictMode forbids on the main thread. Several call sites reach `shutdown()` from a `viewModelScope` (`Dispatchers.Main.immediate`) coroutine: `probeStandardVoice()`'s `finally { client.shutdown() }` fires on every connect/voice probe, and `onCleared()` called `connectionManager.shutdown()` directly on the main thread. The off-main handling existed only as scattered per-call-site `withContext(Dispatchers.IO)` / background-`Thread` wrappers, so the unwrapped paths still crashed. TLS-only because a plaintext socket close writes nothing — matching every report being on Tailscale/public TLS.
**Fix.** Pushed the guard into the leaf. New `network/NetworkShutdown.kt#shutdownOffMainThread(name, block)` runs the executor-shutdown + `evictAll()` on a short-lived daemon thread when called from the main thread, and inline otherwise (preserving the blocking `awaitTermination` semantics for callers already on IO). Wrapped all three `shutdown()` bodies with it, so every call site is safe regardless of dispatcher. Simplified `ConnectionViewModel.onCleared()` — its now-redundant manual `Thread` wrappers were removed and `connectionManager.shutdown()` is no longer an unguarded main-thread `evictAll()`.
**Verification.** New Robolectric `NetworkShutdownTest` (2 cases) asserts the teardown runs off the main thread when invoked from the main looper, and inline when invoked off it. `./gradlew :app:testSideloadDebugUnitTest --tests NetworkShutdownTest` green (compiles the full module + both cases pass). On-device confirmation over a real Tailscale/TLS connection pending a Studio build.
## 2026-06-22 — Released android-v1.2.2
Cut Android **1.2.2** (appVersionName 1.2.2 / appVersionCode 16) — "Multi-profile polish". The version bump + release docs were already on `dev`; the cut first integrated `origin/dev`, which had advanced to **compileSdk 37** (`206d182`) and typed `stream.event` passthrough (PR #120) — dropping the temporary 1.2.2-prep `markdown-renderer 0.41.0` / `lifecycle 2.10.0` pins (a compileSdk-36 workaround) for compileSdk 37 + the `0.42.0` / `2.11.0` deps. `dev` CI (Android build + tests on compileSdk 37) green; release PR #122 (`dev` → `main`, `--no-ff`, merge `984d9a2`) merged on green Required-checks + claude-review; `android-v1.2.2` tagged from the `main` tip triggered `release-android.yml` → signed APK/AAB (googlePlay + sideload) + `SHA256SUMS.txt` → GitHub Release **Hermes-Relay-Android v1.2.2** (published, not draft). Headline 1.2.2: session-delete persists on non-default profiles, cold-start profile isolation for the session drawer, full-screen Diagnostics status timeline, "Hermes"/"Relay" connection wording, and the clean-chat layout + scrollable history; also ships the typed `stream.event` relay passthrough (first slice) integrated from `dev`. Post-cut: `main` back-merged into `dev` (fast-forward) so they stay aligned. Follow-up: CLAUDE.md still says "Compile SDK 36" — update to 37 to match the build.
## 2026-06-22 — Outstanding-TODO batch (orchestration): four User-Added fixes
**Why.** Four open User-Added TODO items, resolved in one 4-worker orchestration pass with disjoint file ownership and coordinator-serialized commits (workers edited only; the coordinator committed each task's files by pathspec to avoid the shared-index race). A read-only Explore pass mapped each task to its files first, surfacing the two collision hubs (`ChatScreen.kt`, `RelayApp.kt`) so ownership could be partitioned to keep all four file sets disjoint. All changes are client-side Kotlin. **Unbuilt at time of writing — pending Studio build + `./gradlew lint`.**
- **Session delete on a non-default profile now persists (`6552566`).** Root cause: a non-default Hermes profile keeps its sessions in that profile's own `state.db`, but `ChatViewModel.deleteSession()` issued the unscoped api_server `DELETE /api/sessions/{id}` (shared DB, no profile) and never re-fetched — so the row survived and the next profile-scoped list resurrected it. Fix mirrors the read path onto the write path: `DashboardApiClient.deleteSession(id, profile)` (reusing the `deleteCronJob` plumbing — `deleteJsonObject`+`pathSegment`+`profileQuery`), `ConnectionViewModel.deleteProfileScopedSession()` (twin of `listProfileScopedSessions`), a `ChatViewModel.profileSessionDeleter` hook wired in `RelayApp` beside `setProfileSessionLister`, and a `refreshSessions()` after a successful delete. Gateway deletes route through the dashboard surface; off-gateway (one shared DB) the plain delete is unchanged. `HermesApiClient` left untouched — the api_server has no profile concept.
- **Diagnostics → full-screen status-check timeline; analytics polish (`c3098a9`).** Replaced the Diagnostics modal bottom sheet with a dedicated `DiagnosticsScreen` behind a new `Screen.Diagnostics` nav route. It leads with a vertical status-check timeline — Network, API server, server capabilities, chat transport, pairing/auth, relay, voice — each a green/amber/red/gray dot on a connecting rail with an inline failure reason; a check backed by a logged error is tappable into the existing `DiagnosticDetailDialog`. Checks derive **read-only** from existing `ConnectionViewModel` flows + the recent `DiagnosticsLog` via a pure, testable `buildStatusChecks()` (no new probing — honest snapshot, first-class `Unknown`). New `StatusCheck`/`CheckStatus` models in `DiagnosticsLog.kt`, a reusable `StatusCheckTimeline` composable in `TimelineView.kt`; the recent-activity log panel stays below. Analytics: `AnalyticsScreen`/`StatsForNerds` visual hierarchy tidied (de-duped the header, section subtitle, cleaner separators) with no data/behavior change.
- **Connections reframe: "Vanilla/Standard Hermes" → "Hermes" (`c9fa8f7`).** 28 user-facing display strings across 10 connection/voice/permissions files; "Hermes-Relay plugin" → "Relay plugin" where it reads naturally. Display copy only — `StandardVoiceAvailability`, `VoiceAudioRoute.Standard("standard")` (enum + storage value), `RelayUiState`, and all when-branch identifiers left intact.
- **Clean-chat: taller scrollable text viewport (`1dca285`).** Replaced the fragile `screenHeightDp*0.34f` height cap on the clean-mode text flow with a weight split (centered sphere `weight(1f)` / flow `weight(1.1f)` ≈ 52% of the vertical slack, up from ~34%); kept the `min=96.dp` floor, internal scroll, top-fade, and a11y mirror paths; dropped the now-dead `LocalConfiguration` import.
- **Method.** Coordinator mapped files (4 parallel Explore agents) → partitioned disjoint ownership (A: `ChatViewModel`/`HermesApiClient`/`DashboardApiClient`/`ConnectionViewModel`; B: 9 connection/voice files + `ChatScreen.kt` 2 strings; C: `AgentTextFlow.kt`; D: analytics/diagnostics + new screen + `SettingsScreen`/`RelayApp`) → file-briefed 4 Claude workers in the active worktree (Orca `--inject` no-ops here) → serialized pathspec commits as each `worker_done` landed. The session-delete fix's one `RelayApp` wiring line was held and applied by the coordinator after the diagnostics worker's `RelayApp` route changes committed, so both edits to that hub landed as clean, separate commits.
**Verification.** Symbol-existence verified by grep before committing the new `DiagnosticsScreen` (the highest compile risk, since workers can't run gradle): all 11 referenced `ConnectionViewModel` flows, `HealthStatus`/`ConnectivityObserver.Status`/`AuthState`/`DiagnosticCategory` enum shapes, `ServerCapabilities` members, and the `DiagnosticsLogPanel`/`DiagnosticDetailDialog`/`StatusCheckTimeline` signatures resolve. Each worker diff was reviewed before commit. **Not built or linted** — Studio build + `./gradlew lint` + on-device checks pending (see TODO.md "Orchestration batch (2026-06-22)").
**Follow-up (same session) — cold-start profile-isolation race (`889273a`).** A user-reported sibling of the session-delete bug: on cold start the session drawer (and the restored session context) briefly loaded the SERVER-DEFAULT profile's sessions, then visibly snapped to the persisted profile. Root cause: the `activeConnectionId` observer stamps the persisted profile name pending, calls `resolvePendingProfileFrom(agentProfiles.value)` (empty at that point), then `rebuildChatApiClient()` — so `chatClientReady` flips true and the `RelayApp` `LaunchedEffect` fires the first `refreshSessions()` with a null (server-default) profile *before* the per-connection profile list arrives to resolve the selection; the list lands a tick later, re-resolves, and re-fetches correctly (the "self-reload"). Fix: new `ProfileController.selectionSettled` StateFlow — true once the selection resolved, OR no non-default profile is pending, OR the profile list has arrived (resolution attempted, so a genuinely-missing profile falls back to default rather than gating forever) — exposed via `ConnectionViewModel.profileSelectionSettled` and added as a key + gate to the cold-start effect. While unsettled the first load waits on a 2.5s backstop; the effect re-fires the instant the profile resolves, cancelling the wait so only the correct profile-scoped load lands, and the backstop prevents a permanently-empty drawer if the list never arrives. Same effect also defers the per-profile session-context/transcript restore. Other profile-scoped surfaces (voice prefs, display alias, profile icon) read the live `selectedProfile` and self-correct on resolution without a visible content-flash; gating them on `selectionSettled` is noted as a follow-up. Unbuilt — verify the cold-start drawer on device.
## 2026-06-22 — Typed stream.event Relay passthrough first slice
**Why.** AXI-75 asks Relay/native clients to stop flattening Hermes SSE into assistant text and preserve runtime structure for native UI cards/timelines.
- **Protocol + fixture.** `docs/relay-protocol.md` now defines auth capability negotiation (`supports.typed_stream_events` + `event_schema_version: 1`), the versioned `chat`/`stream.event` envelope, stable event families, ordering/de-dupe semantics, payload safety, fallback behavior, and native rendering guidance. Added `docs/fixtures/typed-stream-v1.jsonl` as a golden tool-using stream.
- **Relay server.** `plugin/relay/server.py` records per-WebSocket client capabilities during `system/auth` and passes them to `ChatHandler`. `plugin/relay/channels/chat.py` now forwards Hermes/API-server SSE as ordered `stream.event` payloads for capable clients, emits final `done`, redacts secret-shaped keys, truncates large result fields, and keeps legacy `chat.delta`/`chat.tool.*`/`chat.completed` fallback for old clients.
- **Native clients.** Android and Desktop auth envelopes advertise typed-stream support. Android gained `RelayStreamEventEnvelope` plus `ChatHandler.applyRelayStreamEvent()` that maps typed events to existing native assistant text, thinking/progress, tool-card, artifact/memory/skill chip, error, and completion state.
- **Verification.** `PYTHONPATH=$PWD python -m unittest discover -s plugin/tests -p test_chat_typed_stream.py` green (typed ordering/final done/redaction + legacy fallback). `python -m py_compile plugin/relay/channels/chat.py plugin/relay/server.py plugin/tests/test_chat_typed_stream.py` green. `desktop/npm ci` then `npm run type-check` green. Android unit task was attempted with `ANDROID_HOME=/home/bailey/Android/Sdk ./gradlew :app:testSideloadDebugUnitTest --tests ...`; it is blocked before Kotlin compile by the current dependency/SDK mismatch (AAR metadata requires compileSdk 37; installed SDK only has android-36). Follow-up commits bump app/relay-core/relay-ui/quest compileSdk to 37 to satisfy current AndroidX/Markdown AAR metadata in CI without changing targetSdk.
## 2026-06-22 — Released plugin-v1.2.1
Cut the Plugin 1.2.1 release — a Realtime Agent reliability patch. Both fixes were already on `dev`: the `session_not_found` brokered-handoff fix (`f6b965a`) and the realtime voice heartbeat-during-long-runs fix (`d1820fb`); 1.2.1 only adds the version bump and release packaging. Release-prep bumped the six plugin version sources via `scripts/bump-plugin-version.sh` (sync check green), folded the relay `session_not_found` fix into the existing `[1.2.1]` `CHANGELOG.md` line (the Desktop-CLI entries stay under `[Unreleased]` for their own `cli-v*` cut), and rewrote `PLUGIN_RELEASE_NOTES.md` as a Fixed-only release body.
- **Release.** `dev` had drifted behind `main` (12 Dependabot bumps merged straight to `main` + 3 prior `dev`→`main` release-merge commits never back-merged), so release PR #119 was `BEHIND`; `gh pr update-branch` merged `main` into `dev` (conflict-free — no overlap with the version/CHANGELOG files). Only `Required checks` + `claude-review` gate `main` (the path-optimized sentinel pattern); both green, with the plugin-relevant jobs (focused plugin tests, dashboard build, Python syntax) also green on the head. Merged `--no-ff` (merge `41037a3`); `plugin-v1.2.1` tagged from the `main` tip triggered `release-plugin.yml` → validate-metadata → wheel + sdist + `SHA256SUMS.txt` → GitHub Release **Hermes-Relay-Plugin v1.2.1**.
Cut the Android 1.2.1 release. Version source (`appVersionName 1.2.1` / `appVersionCode 15`) was already on `dev`; release-prep promoted `CHANGELOG.md` `[Unreleased]` → `[1.2.1]` (**Android-only** — the Desktop-CLI entries and the relay `session_not_found` fix stay under `[Unreleased]` for their own `cli-v*`/`plugin-v*` cuts) and rewrote `RELEASE_NOTES.md`, in-app `whats_new.txt`, the Play release notes, and the Play listing copy, all scrubbed for public distribution. Release PR #102 (`dev` → `main`, `--no-ff`) auto-merged on green CI (merge `39cafc2`); `android-v1.2.1` tagged from the `main` tip triggers `release-android.yml` (validate → signed APK/AAB + checksums + GitHub Release; Play Production *draft* when the service-account secret is set, operator clicks Start rollout). Headline 1.2.1 changes: profile lock, in-app changelog, diagnostics detail + Copy/Share/Create-issue, a dismissable update-available nudge, plus voice/realtime fixes (override applies in Auto, realtime Stop halts playback, steadier hold-to-talk, readable overlay, faster connection-overlay dismiss) and a debug-only Developer-options test harness. `RELEASE.md` §2 gained a per-surface CHANGELOG-split clarification.
@@ -563,7 +604,7 @@ Tests: +4 resolver outcome tests, +2 ConnectionManager `probeAndReconnectNow` pu
**user-docs cockpit rechrome + content refresh (same day).** The docs site still wore the pre-refresh "Nothing-inspired" chrome (OLED `#000`, neutral grays, `#7C3AED` purple) while the app shipped the relay cockpit palette two days earlier. Rechromed `user-docs/.vitepress` to mirror `RelayRefresh.kt`: dark mode is now navy-black `#08090D` with navy panels (`#121426`/`#191B31`), warm-white ink `#F7F6F0`, alpha-based warm-white hairlines (the `Line`/`LineStrong` trick), Relay periwinkle `#AEBFFF` for links/active text vs ElectricMuted `#4F5BD5` for fills (same glare lesson as the app), status colors from the app's Green/Amber/Danger, a 42px-grid + 10px Relay-dot lattice on the home surface mirroring `relayGridTexture()`, and light mode moved to warm paper `#F7F3EA`. Hardcoded old-palette colors swept from HermesFlow/HermesFlowNode (edges, nodes — diagrams stay dark in both modes like instrument panels), HeroDemo (navy bezel + periwinkle glow), ExperimentalBadge (app Amber), FeatureMatrix (sideload tint). Content pass from a full staleness audit: four complete pages were unreachable from the sidebar (`features/voice`, `features/voice-intents`, `features/phone-control-tools`, `reference/relay-server`) plus `architecture/flavor-differences` linked from nowhere — all five added to `config.mts`; `guide/index.md` version heading bumped 0.8.0→0.8.1; `desktop/installation.md` example pins bumped alpha.14→alpha.18. Build verified: compiled CSS/JS contain the new palette and zero old-palette hex values. Deferred: demo video + the 5 dashboard screenshot TODOs in `features/dashboard.md` (chat_demo.mp4 and the poster also predate the cockpit refresh and should be re-captured). Feedback round (live design review on the dev server): dark brand accent shifted from Relay periwinkle `#AEBFFF` → electric indigo `#6E7CFF` ("too light, not our app blue" — periwinkle survives only in the dot texture); SphereMark gained a radial occlusion halo so the home dot-grid fades behind/around the sphere, plus an `isConnected` guard on the cached install-section anchor (a detached node's rect is all zeros → `scrollVy` locked at 1 and the eye stared down forever after HMR/route swaps — the reported "tracking breaks after scrolling"); install-extras cards un-crunched from 2-col to stacked full-width with one-liners wrapping (`pre-wrap`) instead of horizontal-scrolling. Verified live via Orca browser screenshots: gaze tracks cursor left/right post-scroll, halo clean, no horizontal scroll.
**Server-side root cause + fix (same evening, via SSH).** `ss -tlnp` on docker-server showed the real story: API (`:8642`) and relay (`:8767`) on `0.0.0.0`, but `hermes-dashboard.service` ran with `--host 192.168.1.100` — LAN interface only, so `100.64.0.100:9119` was connection-refused (not 401, hence no sign-in card; the "existing login" observed on-device was last-known persisted state). Rebound to `--host 0.0.0.0` + restart (authorized via prompt), verified `/api/status` on both IPs, updated the server's `~/SYSTEM.md` services table. Phone (adb) confirmed end-to-end: Manage's new target line showed `100.64.0.100:9119 · Tailscale route`, banner flipped to "sign-in required", sign-in card rendered with the route strip. Two learnings recorded: the app's `DashboardCookieJar` is per-connection, NOT host-scoped (sends the stored session cookie to whichever host the route resolves to — sessions normally roam; the restart wiping in-memory dashboard sessions is what forced re-sign-in), and the sign-in strip's "per host" wording could be tightened later.
**Server-side root cause + fix (same evening, via SSH).** `ss -tlnp` on hermes-host showed the real story: API (`:8642`) and relay (`:8767`) on `0.0.0.0`, but `hermes-dashboard.service` ran with `--host 192.168.1.100` — LAN interface only, so `100.64.0.100:9119` was connection-refused (not 401, hence no sign-in card; the "existing login" observed on-device was last-known persisted state). Rebound to `--host 0.0.0.0` + restart (authorized via prompt), verified `/api/status` on both IPs, updated the server's `~/SYSTEM.md` services table. Phone (adb) confirmed end-to-end: Manage's new target line showed `100.64.0.100:9119 · Tailscale route`, banner flipped to "sign-in required", sign-in card rendered with the route strip. Two learnings recorded: the app's `DashboardCookieJar` is per-connection, NOT host-scoped (sends the stored session cookie to whichever host the route resolves to — sessions normally roam; the restart wiping in-memory dashboard sessions is what forced re-sign-in), and the sign-in strip's "per host" wording could be tightened later.
**Manage loading/overview pass (same day).** Three complaints: the cold-load skeleton stacked four progress bars with fake narrative labels; every re-entry to Manage was a cold load; the KPI glyphs (`ok/…/!`) and the one-line status banner (truncated by two trailing buttons, one a duplicate "Connection" link) were weak. Shipped: (1) **process-lifetime payload cache** — `DashboardPayloadCache` singleton replaces the `remember{}` maps, keyed `connection|dashboardUrl|section` so connection switches and route handoffs stay partitioned; `Loaded.fetchedAtMillis` drives a 30s stale-while-revalidate window (fresh → no fetch; stale → cached content + thin refresh bar); sign-in/out clears as before. (2) **App-start pre-warm** — section fetch core extracted to `fetchDashboardSectionState()`; `prewarmDashboardManage()` (internal, same file) fills cold keys only, aborts the sweep on first unreachable/auth failure, never marks Loading so it can't fight the open screen; RelayApp fires it (1.5s debounce) when the persisted snapshot says reachable + signed-in/auth-free, re-firing on route handoff. (3) **Skeleton** — one LinearProgressIndicator + three pulsing content-shaped ghost cards. (4) **KPI strip** — count / tone-colored dashboard state word (ready/sign-in/offline/error) / server version (`RelayMetricCard` gains optional `valueColor`). (5) **Status banner** — two-line layout (state+identity+Sign out / URL·route·checked), duplicate "Connection" button removed (Connections tile is directly below).
+14 -27
View File
@@ -1,22 +1,22 @@
# Hermes-Relay-Android v1.2.1
# Hermes-Relay-Android v1.2.3
**Release Date:** June 21, 2026
**Since v1.2.0:** A focused follow-up to the big personalization release — add a **profile lock**, an in-app **changelog**, a clean **diagnostics → report** flow, and a non-nagging **update nudge**, plus a round of voice and realtime reliability fixes.
**Release Date:** June 23, 2026
**Since v1.2.2:** A connection-stability hotfix. Connecting to a server over an **encrypted link** (Tailscale Serve or public HTTPS) could hard-close the app the moment the connection came up; that crash is fixed, so securing your connection no longer force-closes Hermes-Relay.
v1.2.1 builds on 1.2.0's personalization and transparency themes with quality-of-life and reliability work. Pin the app to one agent profile, review past release notes any time, turn a logged error into a one-tap GitHub issue, and get a tasteful in-app prompt when a newer build is live. Voice mode is calmer and more correct — Stop actually stops, hold-to-talk is steadier, the overlay is readable — and realtime turns that reach back to Hermes no longer fail with a session error.
v1.2.3 is a focused fix for anyone connecting over Tailscale or public TLS. Plain-LAN connections were never affected.
---
## Download
v1.2.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
v1.2.3 ships in two Android build flavors. APK and AAB filenames are version-tagged:
| Flavor | File | Who it's for |
|---|---|---|
| Google Play | `hermes-relay-1.2.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
| sideload | `hermes-relay-1.2.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-1.2.1-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-1.2.1-sideload-release.aab` | Parity/testing artifact. |
| Google Play | `hermes-relay-1.2.3-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
| sideload | `hermes-relay-1.2.3-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-1.2.3-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-1.2.3-sideload-release.aab` | Parity/testing artifact. |
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
@@ -24,25 +24,12 @@ Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload
## Highlights
### Make it yours
- **Profile lock.** Pin the app to a single agent profile from Settings → Profile lock. Every other profile picker collapses to a locked state, and the lock screen stays the one place that lists every profile — with a clear notice if the locked profile isn't on the current server.
### Find your way back
- **In-app What's New & changelog.** A new Settings entry shows the current and past release notes any time, not just the post-update popup.
### When something breaks
- **Diagnostics → tap for detail + report.** Logged errors now carry clean titles and open a detail view with **Copy / Share / Create-GitHub-issue** — the same flow as crash reports. Classified errors across voice, chat, and connection are captured centrally.
- **Update-available nudge.** A dismissable in-app banner when a newer version is live — Google Play In-App Update on Play installs, GitHub Releases on sideload. Per-version dismissal, throttled, never nags.
### Voice & realtime
- **Voice override applies in Auto mode.** A chosen per-profile/enhanced voice now takes effect on Auto with the relay paired (previously only "Relay" mode applied it); voice settings are also namespaced per connection.
- **Realtime "Stop" stops immediately**, over-chatty spoken status is throttled, and long background tasks no longer time out the turn.
- **Steadier voice controls.** Hold-to-talk holds until you genuinely lift your finger, and the voice overlay is readable — opaque panel and status bubbles, non-wrapping labels, and invalid engine/route combinations disabled.
- **Connection status overlay** clears faster — resolved (error/warning) toasts auto-dismiss within ~5s instead of lingering.
### Fixed
- **No more crash on connect over TLS / Tailscale.** Connecting over an encrypted link (Tailscale Serve or public HTTPS) could force-close the app with a `NetworkOnMainThreadException` as the connection came up — a live SSL socket was being closed on the main thread during client teardown, and a TLS close performs a network write. Socket teardown now always runs off the main thread, so connecting over a secured link is stable. Plain-LAN connections were never affected.
---
## Upgrade notes
- All new app features are available on **both** flavors (client-side; no Device Control needed).
- **Relay-side fix (ships in the plugin, not the APK):** brokered Realtime Agent turns that reach back to Hermes no longer fail with `session_not_found` — the relay now mints/reuses a valid API-server session and reads the current nested create-session response. Relay operators pick this up via `hermes-relay-update`.
- `appVersionCode` is **15**.
- This is an app-side fix on **both** flavors — no Device Control or server changes needed.
- If you were crashing on connect over Tailscale or HTTPS, update and reconnect.
- `appVersionCode` is **17**.
+17 -5
View File
@@ -8,18 +8,30 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
## User-Added:
- [ ] Enhance the 'clean chat' view mode to allow more a little more vertical visible text area and scrolling within.
- [x] **Clean-chat: taller scrollable text viewport** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Replaced the fragile `screenHeightDp*0.34f` cap with a weight split (sphere `weight(1f)` / flow `weight(1.1f)` ≈ 52% of the vertical slack); kept the internal scroll + top-fade + `min=96.dp` floor. `AgentTextFlow.kt` (`1dca285`).
- [ ] Verify profile selection retains voice config selections in all voice modes/configuration combinations - enhance UI/configurability/management for this.
- [x] **Session delete on a non-default profile now persists** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Root cause: a non-default profile's sessions live in that profile's own `state.db`, but the delete went through the unscoped api_server `DELETE /api/sessions/{id}` (shared DB) so the row survived and the next profile-scoped list resurrected it. Fix routes gateway deletes through the dashboard profile-scoped surface (write twin of the list path) + `refreshSessions()` after success. `DashboardApiClient`/`ConnectionViewModel`/`ChatViewModel`/`RelayApp` (`6552566`).
- [x] **Voice-settings profile override in 'auto' mode** *(impl 2026-06-21, orchestration batch — unbuilt; verify in Studio. See DEVLOG + "Orchestration batch (2026-06-21)" below.)* Root cause: `VoiceViewModel.shouldPreferRealtimeVoice()` gated on `.route` (configured) not `.effectiveRoute` (resolved), so 'auto'+relay never engaged the override-capable relay path and fell back to host-global Standard `/api/audio/speak` (no override slot). Fixed + wired `connectionId` for per-profile voice-prefs namespacing. Original note: *Look into the voice-settings profile specific capabilities - in 'auto' mode the user-override voice wasn't applied (system default used) despite being displayed; only 'Relay' applied it.*
- [ ] Analytics and diagnostics pages need cleaned up, improved, enhancements for UI/UX/layout. Diagnostics should have timeline vertical status checks with failure reason etc.
- [x] **Analytics + Diagnostics overhaul** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Diagnostics is now a full-screen `DiagnosticsScreen` (new `Screen.Diagnostics` route, replacing the modal sheet) led by a vertical status-check timeline — Network, API server, capabilities, chat transport, pairing/auth, relay, voice — each a green/amber/red/gray dot on a connecting rail with an inline failure reason; checks backed by a logged error are tappable into `DiagnosticDetailDialog`. Derived read-only from existing `ConnectionViewModel` flows + recent `DiagnosticsLog` via a pure `buildStatusChecks()`; recent-activity log kept below. Analytics hierarchy tidied. `c3098a9`. See follow-ups below.
- [x] **Realtime voice stall + over-chatty status** *(client half impl 2026-06-21, orchestration batch — unbuilt; server half deferred, see below.)* Client now relaxes the 90s idle watchdog on promoted/long runs (5-min backstop kept) and throttles spoken status (≥22s gap, ≤3/turn); realtime waveform now gates on real playback-start. Original note: *Realtime voice mode stalls/times-out when calling a background Hermes task and repeatedly reports status vocally when not necessary.*
- [ ] Clean up connections page - reframe standard/vanilla Hermes as just 'Hermes' - relay enhanced connection becomes 'Relay' or 'Relay plugin' where descriptively appropriate.
- [x] **Connections reframe: "Vanilla/Standard Hermes" → "Hermes"** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* 28 user-facing display strings across 10 connection/voice/permissions files; "Hermes-Relay plugin" → "Relay plugin" where it reads naturally. Display text only — no enum names, sealed types, when-branches, or stored route values touched. `c9fa8f7`.
- [x] **Lock app to a specific profile** *(impl 2026-06-21, orchestration batch — unbuilt; verify in Studio.)* Per-connection lock: new `ProfileLockStore`, `ProfileController` lock flows + enforcement, `ConnectionInfoSheet` collapses the picker to a static "Locked to <name>" row, `SettingsScreen` adds the lock card + dialog (the one surface still listing all profiles). Original note: *Allow locking app to a specific profile, hiding all other profiles except from this setting - cleanly hide profile specific UI elements based on this gate.*
- [x] **Profile icon in the floating voice overlay** *(impl 2026-06-21, orchestration batch — unbuilt.)* `VoiceModeOverlay` header pill now shows the per-profile icon (`LocalAgentIconPath`); sphere/pet stays the fallback.
- [x] **Voice dropdown state mixes + label overflow** *(impl 2026-06-21, orchestration batch — unbuilt.)* Invalid engine/route combos made unreachable (RealtimeAgent disabled without relay, unavailable routes disabled, `coerceAudioRoute` auto-corrects); long dropdown/provider labels get `maxLines=1`+ellipsis. Original note: *Fix the voice dropdown mode toggles to not allow weird state mixes - labels need overflow control to prevent 2 lines or crunching.*
- [x] **Per-profile agent icon + static-image avatar (shipped 2026-06-20 —** `d827e46`**, see DEVLOG).** Per-profile icon: client-side `ProfileIconStore` (per `(connection, profile)`, never sent to Hermes; stores a copied-file path) → small Coil image beside the agent name in `MessageBubble` via `LocalAgentIconPath`; picker is `AgentIconRow` under the local-name row in `ConnectionInfoSheet`. Static image: "Add a pet" accepts a single image (magic-byte detect → one-frame static pet). Scope shipped: small name-adjacent icon only; big avatar stays global. Follow-ups: on-device smoke (import an image as a pet; set a profile icon, confirm it shows by the name + persists across restart); optionally also show the icon in the profile picker.
## Orchestration batch (2026-06-22) — deferred follow-ups
Four User-Added items resolved via a 4-worker orchestration pass (disjoint file ownership, coordinator-serialized commits): clean-chat viewport (`1dca285`), connections reframe (`c9fa8f7`), diagnostics/analytics (`c3098a9`), session-delete fix (`6552566`). Plus a follow-on profile-isolation fix raised mid-session: cold-start session-drawer hydration (`889273a`). **Committed to `dev`, NOT built/linted/verified.** Remaining:
- **Build + lint + on-device verify all five (Studio).** Run `./gradlew lint` and a Studio build before pushing `dev` (workers couldn't run gradle). Then confirm on device: clean-chat shows a noticeably taller text area that scrolls; deleting a session on a *non-default* profile sticks (no resurrection after the drawer re-fetches); the Diagnostics screen renders honest per-check status + failure reasons and opens detail on a failing tappable row; connections/voice/permissions copy reads "Hermes"/"Relay"; **and on a cold start while a non-default profile is selected, the session drawer loads that profile's sessions directly with no flash of the server-default list.**
- **Profile isolation — broader sweep (cold-start race).** The session drawer + restored session context are now gated on `ProfileController.selectionSettled` (`889273a`), so they no longer load the server-default profile before the persisted profile resolves. Other profile-scoped surfaces read the *live* `selectedProfile.value` and self-correct when it resolves but aren't gated: voice prefs (`VoiceViewModel.onProfileChanged` at the `RelayApp` voice effect), `profileDisplayAlias`, `profileIcon`. They re-seed on resolution (no visible content-flash like the drawer), but if any shows a wrong-profile beat on cold start, gate its first use on `profileSelectionSettled` the same way. Also: `selectionSettled`'s decision logic is unit-testable (pure over connId/selected/pending/profiles) — add a `ProfileControllerSettledTest` when convenient.
- **Diagnostics: no live re-probe trigger.** The status checks reflect the *last* probe state (read-only snapshot). A "Re-run checks" button would need `ConnectionViewModel` to expose probe methods — deferred so the diagnostics work didn't have to edit a concurrently-owned VM.
- **Diagnostics: Pass checks lack a last-checked timestamp/duration.** `StatusCheck` carries `timestampMs`/`durationMs`, but the VM doesn't expose probe timing, so passing rows show no "checked Ns ago". Wire when/if the VM surfaces probe timestamps.
- **Connections reframe — out-of-scope occurrences left intentionally.** `ConnectionViewModel.kt`, `VoiceAudioClient.kt`, `VoiceViewModel.kt`, `BridgeCoreScreen.kt`, and `RelayApp.kt` still contain "Standard"/"Vanilla" in code identifiers/log strings; only user-facing display copy was reframed. Revisit if any of those surface to users.
## Orchestration batch (2026-06-21) — deferred follow-ups
Client-side profile-lock + voice fixes (the items marked above) landed via a planning→implementation orchestration pass, **built + deployed to device as 1.2.1 (versionCode 15)**; new unit suite green (36 Kotlin + 11 Python). On-device behaviour verification still pending. Remaining from that batch:
@@ -30,7 +42,7 @@ Client-side profile-lock + voice fixes (the items marked above) landed via a pla
- **Profile lock: ChatScreen glyph + export.** The optional lock glyph on the chat-header avatar was skipped (`ChatScreen.kt` is owned by a concurrent session). Decide whether the per-connection lock belongs in settings export/import (it rides the `profile_selections` DataStore).
- **Unit tests — DONE 2026-06-21 (36/36 pass via `:app:testSideloadDebugUnitTest`).** `ProfileLockStoreTest` (9 — uses an in-memory `DataStore` harness; the file-backed factory hits a Windows write-rename/instance race), `ProfileControllerLockTest` (8, Robolectric), `CoerceAudioRouteTest` (7), `VoiceStatusGatesTest` (12).
- **CHANGELOG.** Add `[Unreleased]` entries (Profile lock → Added; voice override + realtime → Fixed) at build-verify/PR time.
- **On-device verification.** Override applies in 'auto'+relay; realtime survives a >90s background task without stalling and stops over-narrating; Speaking waveform unfolds at first audible frame; profile lock hides pickers + holds on a missing profile; overlay shows the profile icon.
- **On-device verification.** Override applies in 'auto'+relay; realtime survives a &gt;90s background task without stalling and stops over-narrating; Speaking waveform unfolds at first audible frame; profile lock hides pickers + holds on a missing profile; overlay shows the profile icon.
## Hands-free agentic voice backlog
@@ -170,7 +182,7 @@ When the answer becomes clearer, this section becomes either an ADR in `docs/dec
- **LLM client wiring for `android_navigate`** — `_default_vision_model` is stubbed; production swap to a real Anthropic/OpenAI vision client
- **Real screenshots of each flavor's a11y permission dialog** — for `user-docs/guide/release-tracks.md`
- `**llms.txt` standard** — explicitly skipped in favor of the `hermes-relay-self-setup` SKILL.md path; revisit if the standard gains traction in the agent ecosystem
- `**markdown-renderer` 0.40.x API update** — pinned at `0.30.0` in `gradle/libs.versions.toml` because 0.40.2 introduced breaking API changes that `app/src/main/kotlin/com/hermesandroid/relay/ui/components/MarkdownContent.kt` hasn't been updated for. Specifically: `markdownColor()` drops `codeText`/`linkText`, `MarkdownCodeBlock`/`MarkdownCodeFence` inner lambdas now take a 3rd `TextStyle` arg, and `MarkdownHighlightedCode`'s 3rd param is now `TextStyle` instead of `Highlights.Builder`. Dependabot auto-merged the bump on 2026-04-13 which silently broke CI; reverted for the v0.3.0 release. Update requires reading the new library API docs and testing in Studio — not a blind fix. Consider adding a dependabot ignore rule for `markdown-renderer` major bumps until this is handled.
- `**markdown-renderer`/`lifecycle` compileSdk ceiling — RESOLVED via compileSdk 37 (2026-06-22).** `MarkdownContent.kt` is on the 0.4x API, and `markdown-renderer 0.42.0` / `lifecycle 2.11.0` (the Dependabot bumps) require `compileSdk 37`. The project moved to **compileSdk 37** (`206d182`, across app/quest/relay-core/relay-ui; `targetSdk` stays 35), which satisfies them — so the temporary 1.2.2-prep pins (0.41.0 / 2.10.0 on compileSdk 36) were dropped when integrating `origin/dev`. **CLAUDE.md still says "Compile SDK 36" — update it to 37 to match the build.** A Dependabot ignore rule is still worth adding so a future bump that raises the compileSdk floor again fails loudly rather than silently (see next item).
- **Dependabot auto-merge guardrails** — Dependabot merged breaking bumps despite CI failing. Investigate why `.github/workflows/dependabot-auto-merge.yml` isn't gating on CI status, and consider adding an ignore rule for packages we know need manual attention on major bumps (`markdown-renderer`, compose BOM, activity-compose).
---
+1 -1
View File
@@ -27,7 +27,7 @@ android {
// and `applicationId` is the runtime install identity; they don't have
// to match.
namespace = "com.hermesandroid.relay"
compileSdk = 36
compileSdk = 37
defaultConfig {
// Axiom-Labs, LLC Play Console listing. Changed from the original
@@ -1,7 +1,3 @@
v1.2.1 — Polish & control.
v1.2.3 — Connection crash fix.
• Lock the app to a single agent profile and hide the rest.
• In-app "What's New" with current and past release notes.
• Diagnostics with clean error titles and one-tap reporting.
• A tasteful, dismissable "update available" nudge.
• Voice fixes: Stop halts speech instantly, steadier hold-to-talk, a more readable overlay, and chosen voices apply in Auto mode.
• Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS). Connecting over a secured connection is now stable. Plain local-network connections were never affected.
+73
View File
@@ -1,5 +1,78 @@
{
"versions": [
{
"version": "1.2.3",
"title": "Connection crash fix",
"date": "2026-06-23",
"sections": [
{
"header": "Stability",
"bullets": [
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
]
}
]
},
{
"version": "1.2.2",
"title": "Multi-profile polish",
"date": "2026-06-22",
"sections": [
{
"header": "Profiles that behave",
"bullets": [
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
]
},
{
"header": "Clearer diagnostics",
"bullets": [
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
]
},
{
"header": "Small touches",
"bullets": [
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
"Distraction-free chat mode gives its text a taller, scrollable area."
]
}
]
},
{
"version": "1.2.1",
"title": "Polish & control",
"date": "2026-06-21",
"sections": [
{
"header": "Yours to control",
"bullets": [
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
]
},
{
"header": "Find your way back",
"bullets": [
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
]
},
{
"header": "When something breaks",
"bullets": [
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
]
},
{
"header": "Voice fixes",
"bullets": [
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
"Realtime turns that reach back to Hermes no longer drop with a session error."
]
}
]
},
{
"version": "1.2.0",
"title": "Make it yours",
+5 -20
View File
@@ -1,21 +1,6 @@
v1.2.1 - Polish & control
v1.2.3 - Connection crash fix
Yours to control
* Lock the app to a single agent profile (Settings → Profile lock) and hide
the rest from the pickers.
Find your way back
* A new "What's New" entry in Settings shows current and past release notes
any time — not just after an update.
When something breaks
* Diagnostics now show clean error titles — tap any entry for a detail view
with Copy, Share, and a one-tap GitHub issue.
* A tasteful in-app banner tells you when a newer version is live (Play or
sideload) — dismissable, and it never nags.
Voice fixes
* Stop now halts realtime speech instantly, hold-to-talk is steadier, the
voice overlay is easier to read, and a chosen voice applies in Auto mode.
* Realtime turns that reach back to Hermes no longer drop with a session
error.
Stability
* Fixed a crash that could close the app right after connecting over an
encrypted link (Tailscale or HTTPS). Securing your connection no longer
force-closes the app. Plain-LAN connections were never affected.
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.booleanOrNull
@@ -702,6 +703,18 @@ class AuthManager(
pendingEndpoints = endpoints?.takeIf { it.isNotEmpty() }
}
/**
* Capability negotiation advertised in the first system/auth envelope.
* Older relays ignore this object; newer relays use it to send versioned
* `chat:stream.event` payloads instead of flattening Hermes SSE into text.
*/
private fun JsonObjectBuilder.putRelayClientSupports() {
put("supports", buildJsonObject {
put("typed_stream_events", true)
put("event_schema_version", 1)
})
}
/**
* Send auth envelope when connection is established.
*
@@ -737,6 +750,7 @@ class AuthManager(
}
put("device_id", deviceId)
put("device_name", android.os.Build.MODEL)
putRelayClientSupports()
}
}
else -> {
@@ -752,6 +766,7 @@ class AuthManager(
put("pairing_code", codeToSend)
put("device_id", deviceId)
put("device_name", android.os.Build.MODEL)
putRelayClientSupports()
pendingTtlSeconds?.let { put("ttl_seconds", it) }
pendingGrants?.let { grants ->
val obj = buildJsonObject {
@@ -0,0 +1,156 @@
package com.hermesandroid.relay.data
/**
* Single source of truth for "is this connection encrypted, and by what?"
*
* Security is **per-surface**: a single paired connection fans out to several
* transports (chat/gateway + Manage over the dashboard, API/sessions, relay
* tools) and each can independently be TLS, overlay-encrypted, or plain (see
* [computeConnectionSecurity]). Every UI surface — the chat status chip, the
* connection header, the route picker, the detail sheet — renders the same
* derived [ConnectionSecurity] so no two places disagree about what "secure"
* means.
*
* Crucially, **"encrypted" includes overlay transports** (Tailscale/WireGuard,
* the plugin secure proxy), not just TLS. A `ws://` link over a tailnet is
* WireGuard-encrypted end-to-end — genuinely secure, just not TLS — so it is
* never labelled "insecure". Only a plain scheme with no overlay warns.
*/
enum class SurfaceSecurityKind { Tls, Overlay, Plain }
/** Connection-level rollup across the surfaces actually in use. */
enum class ConnectionSecurityLevel { Tls, Overlay, Mixed, Plain, Unknown }
/** Security verdict for one transport surface of a connection. */
data class SurfaceSecurity(
val label: String,
val kind: SurfaceSecurityKind,
/** Human mechanism: "TLS", "Tailscale", "WireGuard", "Proxy", "Plain". */
val mechanism: String,
val url: String,
)
data class ConnectionSecurity(
val level: ConnectionSecurityLevel,
/** Dominant mechanism for the at-a-glance label. */
val mechanism: String,
val surfaces: List<SurfaceSecurity>,
) {
/** True when every in-use surface is encrypted (TLS or overlay). */
val isEncrypted: Boolean
get() = level == ConnectionSecurityLevel.Tls || level == ConnectionSecurityLevel.Overlay
companion object {
val UNKNOWN = ConnectionSecurity(ConnectionSecurityLevel.Unknown, "", emptyList())
}
}
/** True when the URL scheme is TLS (`wss://` / `https://`). */
fun isTlsUrl(url: String?): Boolean {
if (url.isNullOrBlank()) return false
val lower = url.trim().lowercase()
return lower.startsWith("wss://") || lower.startsWith("https://")
}
/**
* True when the active route is encrypted by an overlay network (Tailscale /
* WireGuard) or the plugin secure proxy, even if its scheme is plain. Mirrors
* the logic that previously lived privately in `ActiveConnectionSections`.
*/
fun EndpointCandidate?.isEncryptedOverlayRoute(isTailscaleDetected: Boolean): Boolean {
if (this == null) return false
val r = role.lowercase()
val hint = security.orEmpty().lowercase()
return r == "tailscale" ||
(isTailscaleDetected && hint.contains("tailscale")) ||
r == "plugin_proxy" ||
r == "plugin-proxy" ||
hasSecureProxy() ||
hint.contains("wireguard") ||
hint.contains("https") ||
hint.contains("tls")
}
/** Human label for the overlay mechanism encrypting a route. */
fun EndpointCandidate?.overlayMechanism(isTailscaleDetected: Boolean): String {
if (this == null) return "Encrypted"
val r = role.lowercase()
val hint = security.orEmpty().lowercase()
return when {
r == "tailscale" || (isTailscaleDetected && hint.contains("tailscale")) -> "Tailscale"
r == "plugin_proxy" || r == "plugin-proxy" || hasSecureProxy() -> "Proxy"
hint.contains("wireguard") -> "WireGuard"
hint.contains("https") || hint.contains("tls") -> "TLS"
else -> "Encrypted"
}
}
/** Classify a single surface URL against the active route. */
fun classifySurfaceSecurity(
label: String,
url: String,
activeEndpoint: EndpointCandidate?,
isTailscaleDetected: Boolean,
): SurfaceSecurity {
val (kind, mechanism) = when {
isTlsUrl(url) -> SurfaceSecurityKind.Tls to "TLS"
activeEndpoint.isEncryptedOverlayRoute(isTailscaleDetected) ->
SurfaceSecurityKind.Overlay to activeEndpoint.overlayMechanism(isTailscaleDetected)
else -> SurfaceSecurityKind.Plain to "Plain"
}
return SurfaceSecurity(label = label, kind = kind, mechanism = mechanism, url = url)
}
/**
* Roll up the per-surface verdicts into one connection-level [ConnectionSecurity].
* Pure + side-effect free so it is unit-testable without Android.
*/
fun computeConnectionSecurity(
apiUrl: String,
dashboardUrl: String,
relayUrl: String,
relayConfigured: Boolean,
activeEndpoint: EndpointCandidate?,
isTailscaleDetected: Boolean,
): ConnectionSecurity {
val surfaces = buildList {
dashboardUrl.trim().takeIf { it.isNotBlank() }?.let {
add(classifySurfaceSecurity("Chat & Manage", it, activeEndpoint, isTailscaleDetected))
}
apiUrl.trim().takeIf { it.isNotBlank() }?.let {
add(classifySurfaceSecurity("API / sessions", it, activeEndpoint, isTailscaleDetected))
}
if (relayConfigured) {
relayUrl.trim().takeIf { it.isNotBlank() }?.let {
add(classifySurfaceSecurity("Relay tools", it, activeEndpoint, isTailscaleDetected))
}
}
}
if (surfaces.isEmpty()) return ConnectionSecurity.UNKNOWN
val kinds = surfaces.map { it.kind }.toSet()
val hasPlain = SurfaceSecurityKind.Plain in kinds
val hasSecure = kinds.any { it != SurfaceSecurityKind.Plain }
val level = when {
!hasSecure -> ConnectionSecurityLevel.Plain
hasPlain -> ConnectionSecurityLevel.Mixed
kinds == setOf(SurfaceSecurityKind.Tls) -> ConnectionSecurityLevel.Tls
else -> ConnectionSecurityLevel.Overlay
}
val mechanism = when (level) {
ConnectionSecurityLevel.Tls -> "TLS"
ConnectionSecurityLevel.Overlay ->
surfaces.firstOrNull { it.kind == SurfaceSecurityKind.Overlay }?.mechanism ?: "Encrypted"
ConnectionSecurityLevel.Mixed -> "Mixed"
ConnectionSecurityLevel.Plain -> when (activeEndpoint?.role?.lowercase()) {
"lan" -> "LAN"
"public" -> "Public"
null, "" -> "Plain"
else -> activeEndpoint.role
}
ConnectionSecurityLevel.Unknown -> ""
}
return ConnectionSecurity(level = level, mechanism = mechanism, surfaces = surfaces)
}
@@ -36,6 +36,35 @@ data class DiagnosticLogEntry(
val stacktrace: String? = null,
)
/**
* Current health of a single subsystem on the Diagnostics status timeline.
*
* Distinct from [DiagnosticSeverity], which classifies a *logged event* after
* the fact. A [CheckStatus] is the *live* state of a subsystem, derived
* read-only from connection state + the recent [DiagnosticsLog]. [Unknown] is
* a first-class, honest state — "not checked / not applicable" — never an
* implied pass or fail.
*/
enum class CheckStatus { Pass, Warn, Fail, Unknown }
/**
* One row on the Diagnostics status timeline: a named subsystem check with its
* current [status] and, when not [CheckStatus.Pass], a human [reason] — the
* whole point of the screen is answering "why is this failing?".
*
* [category] links the check back to a [DiagnosticCategory]; when [timestampMs]
* is non-null the reason came from a concrete [DiagnosticLogEntry], so the row
* is tappable and the UI can open that entry's full detail.
*/
data class StatusCheck(
val name: String,
val status: CheckStatus,
val reason: String? = null,
val category: DiagnosticCategory? = null,
val timestampMs: Long? = null,
val durationMs: Long? = null,
)
object DiagnosticsLog {
private const val MAX_ENTRIES = 200
private const val MAX_TEXT_LENGTH = 180
@@ -0,0 +1,29 @@
package com.hermesandroid.relay.network
import android.os.Looper
/**
* Run an OkHttp teardown [block] without ever performing a network write on
* the main thread.
*
* [okhttp3.ConnectionPool.evictAll] closes pooled sockets synchronously. For
* a live `https`/`wss` keep-alive connection that close drains the SSL output
* queue — a real network write (`SSLOutputStream.writeInternal`) — which trips
* StrictMode's [android.os.NetworkOnMainThreadException]. Reported as a hard
* crash on connect over TLS/Tailscale (issues #70 / #118 / #124): a
* `viewModelScope` (i.e. `Dispatchers.Main.immediate`) coroutine resumes on the
* main thread and shuts a dashboard/API client down in a `finally` block.
*
* Client shutdown is fire-and-forget cleanup, so when the caller is on the main
* thread we hand [block] to a short-lived daemon thread. Off the main thread
* (already on `Dispatchers.IO` or a background thread) we run it inline so
* callers that deliberately moved off main keep their ordering and any blocking
* `awaitTermination` waits stay where the caller put them.
*/
internal fun shutdownOffMainThread(threadName: String, block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
Thread({ runCatching(block) }, threadName).apply { isDaemon = true }.start()
} else {
block()
}
}
@@ -14,6 +14,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.EndpointResolver
import com.hermesandroid.relay.network.shutdownOffMainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -705,8 +706,12 @@ class ConnectionManager(
disconnect()
unregisterNetworkCallback()
supervisorJob.cancel()
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
// evictAll() closes live wss sockets synchronously; on a TLS keep-alive
// that close is a network write, so keep it off the main thread.
shutdownOffMainThread("ConnectionManager-shutdown") {
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}
fun send(envelope: Envelope) {
@@ -12,6 +12,7 @@ import com.hermesandroid.relay.data.VoiceIntentTrace
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.RelayStreamEventEnvelope
import com.hermesandroid.relay.network.upstream.models.SessionItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -227,6 +228,78 @@ class ChatHandler {
private val _currentSessionId = MutableStateFlow<String?>(null)
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
/**
* Apply a versioned Relay `stream.event` payload to native chat state.
*
* This is the WebSocket counterpart to the direct Hermes SSE mapper in
* HermesApiClient: assistant deltas mutate message text, tool lifecycle
* events update ToolProgressCard rows, progress/thinking stays in the
* subdued reasoning area, artifacts/skill/memory notices become low-noise
* status chips, and terminal/error/completion events explicitly settle the
* streaming state.
*/
fun applyRelayStreamEvent(messageId: String, envelope: RelayStreamEventEnvelope) {
if (envelope.type != "stream.event" || envelope.schemaVersion != 1) {
Log.d(TAG, "Ignoring unsupported relay stream event schema: ${envelope.type} v${envelope.schemaVersion}")
return
}
val payload = envelope.payload
fun textField(vararg names: String): String? = names
.asSequence()
.mapNotNull { name -> (payload[name] as? JsonPrimitive)?.contentOrNull }
.firstOrNull { it.isNotBlank() }
fun boolField(name: String): Boolean? = (payload[name] as? JsonPrimitive)?.booleanOrNull
val toolName = textField("tool_name", "tool", "name") ?: "unknown"
val callId = textField("call_id", "tool_call_id") ?: toolName
when (envelope.event) {
"message.started" -> {
val msgObj = payload["message"] as? JsonObject
val serverMsgId = (msgObj?.get("id") as? JsonPrimitive)?.contentOrNull
if (!serverMsgId.isNullOrBlank()) replaceMessageId(messageId, serverMsgId)
}
"assistant.delta" -> {
textField("delta", "content", "text")?.let { onTextDelta(messageId, it) }
}
"tool.progress" -> {
textField("delta", "thinking_delta", "thinking", "text", "message")?.let {
onThinkingDelta(messageId, it)
}
}
"tool.pending", "tool.started" -> onToolCallStart(messageId, callId, toolName)
"tool.completed" -> onToolCallComplete(messageId, callId, textField("result_preview", "summary", "message"))
"tool.failed" -> onToolCallFailed(messageId, callId, textField("error", "message") ?: "Tool failed")
"memory.updated", "skill.loaded" -> {
val label = when (envelope.event) {
"memory.updated" -> "Memory"
else -> "Skill"
}
addMessageBadges(messageId, listOf(label))
}
"artifact.created" -> {
addMessageBadges(messageId, listOf("Artifact"))
textField("url", "path", "preview", "title")?.takeIf { it.isNotBlank() }?.let {
onThinkingDelta(messageId, "Artifact: $it")
}
}
"assistant.completed" -> {
if (boolField("interrupted") == true) {
onStreamError("Response interrupted")
} else {
onTurnComplete(messageId)
}
}
"run.completed", "done" -> onStreamComplete(messageId)
"error" -> {
addMessageBadges(messageId, listOf("Error"))
onStreamError(textField("message", "error") ?: "Unknown error")
}
"session.created", "run.started" -> Unit
else -> Log.d(TAG, "Unhandled relay stream event: ${envelope.event}")
}
}
// --- Message management ---
fun addUserMessage(message: ChatMessage) {
@@ -1964,6 +2037,22 @@ class ChatHandler {
}
}
private fun addMessageBadges(messageId: String, badges: List<String>) {
val cleaned = badges
.map { it.trim() }
.filter { it.isNotEmpty() }
if (cleaned.isEmpty()) return
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
msg.copy(badges = (msg.badges + cleaned).distinct().take(4))
} else {
msg
}
}
}
}
/** Monotonic suffix for synthetic generating / subagent ToolCall ids. */
private var syntheticToolSeq = 0
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.shutdownOffMainThread
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
import com.hermesandroid.relay.network.upstream.models.SessionItem
@@ -448,6 +449,18 @@ class DashboardApiClient(
}
}
/**
* Delete a session scoped to its owning profile via the dashboard
* `DELETE /api/sessions/{id}?profile=`. The write twin of [listSessions]:
* a non-default profile's sessions live in that profile's own `state.db`, so
* deleting through the api_server (one shared DB, no profile) leaves the row
* intact and the next profile-scoped list resurrects it. [profile] null/blank
* → the launch profile's DB (param omitted). Mirrors [deleteCronJob]'s
* profile-scoped delete plumbing.
*/
suspend fun deleteSession(sessionId: String, profile: String? = null): Result<JsonObject> =
deleteJsonObject("/api/sessions/${pathSegment(sessionId)}${profileQuery(profile)}")
private fun parseProfiles(root: JsonObject): List<Profile> {
fun decode(element: JsonElement, nameOverride: String?): Profile? = runCatching {
val obj = element as? JsonObject ?: return null
@@ -562,7 +575,7 @@ class DashboardApiClient(
fun gatewayWebSocketUrl(ticket: String, path: String = "/api/ws"): String? =
gatewayWebSocketUrl(baseUrl = baseUrl, ticket = ticket, path = path)
fun shutdown() {
fun shutdown() = shutdownOffMainThread("DashboardApiClient-shutdown") {
okHttpClient.dispatcher.executorService.shutdown()
okHttpClient.connectionPool.evictAll()
}
@@ -5,6 +5,7 @@ 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.shutdownOffMainThread
import com.hermesandroid.relay.network.upstream.models.CreateSessionRequest
import com.hermesandroid.relay.network.upstream.models.HermesSseEvent
import com.hermesandroid.relay.network.upstream.models.MessageItem
@@ -1343,7 +1344,7 @@ class HermesApiClient(
// --- Lifecycle ---
fun shutdown() {
fun shutdown() = shutdownOffMainThread("HermesApiClient-shutdown") {
client.dispatcher.executorService.shutdown()
try {
if (!client.dispatcher.executorService.awaitTermination(2, TimeUnit.SECONDS)) {
@@ -261,6 +261,23 @@ data class MessageItem(
// error — { message (string), error }
// done — { session_id, run_id, state: "final" }
@Serializable
data class RelayStreamEventEnvelope(
val type: String = "stream.event",
@SerialName("schema_version") val schemaVersion: Int = 1,
@SerialName("session_id")
@Serializable(with = FlexibleIdSerializer::class)
val sessionId: String? = null,
@SerialName("run_id")
@Serializable(with = FlexibleIdSerializer::class)
val runId: String? = null,
val seq: Int? = null,
val event: String,
val ts: String? = null,
val payload: JsonObject = kotlinx.serialization.json.buildJsonObject { },
)
@Serializable
data class HermesSseEvent(
// Event type — may come as "type" or "event" depending on server version
@@ -86,6 +86,7 @@ import com.hermesandroid.relay.ui.components.ConnectionStatusToast
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
import com.hermesandroid.relay.ui.components.ChatTransportStatusBadge
import com.hermesandroid.relay.ui.components.ChatTransportTier
import com.hermesandroid.relay.ui.components.ConnectionSecurityGlyph
import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
import com.hermesandroid.relay.ui.components.RelayStatusStrip
@@ -115,6 +116,7 @@ import com.hermesandroid.relay.ui.screens.AboutScreen
import com.hermesandroid.relay.ui.screens.AnalyticsScreen
import com.hermesandroid.relay.ui.screens.AppearanceSettingsScreen
import com.hermesandroid.relay.ui.screens.BridgeCoreScreen
import com.hermesandroid.relay.ui.screens.DiagnosticsScreen
import com.hermesandroid.relay.ui.screens.BridgeScreen
// === PHASE3-safety-rails: bridge safety route ===
import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
@@ -270,6 +272,7 @@ sealed class Screen(
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
data object Analytics : Screen("settings/analytics", "Analytics", Icons.Filled.Settings)
data object Diagnostics : Screen("settings/diagnostics", "Diagnostics", Icons.Filled.Settings)
data object DeveloperSettings : Screen("settings/developer", "Developer", Icons.Filled.Settings)
data object RealtimeVoiceTest : Screen("settings/developer/realtime_voice", "Realtime voice", Icons.Filled.Settings)
data object About : Screen("settings/about", "About", Icons.Filled.Settings)
@@ -395,6 +398,7 @@ fun RelayApp() {
val chatApiClient by connectionViewModel.chatApiClient.collectAsState()
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
@@ -646,6 +650,11 @@ fun RelayApp() {
chatViewModel.setProfileMessageLoader { sessionId ->
connectionViewModel.loadProfileScopedMessages(sessionId)
}
// …and delete from that same profile's DB so a non-default profile's
// session can't be resurrected by the next profile-scoped list.
chatViewModel.profileSessionDeleter = { sessionId ->
connectionViewModel.deleteProfileScopedSession(sessionId)
}
// Wire session persistence callback
chatViewModel.onSessionChanged = { sessionId ->
@@ -660,15 +669,29 @@ fun RelayApp() {
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
// already no-ops when the context key + session are unchanged.
val chatClientReady = chatApiClient != null
LaunchedEffect(chatClientReady, activeConnectionId, selectedProfile?.name, lastSessionId) {
LaunchedEffect(chatClientReady, activeConnectionId, selectedProfile?.name, lastSessionId, profileSelectionSettled) {
if (!chatClientReady) return@LaunchedEffect
// Coalesce the rapid lastSessionId null→value churn a profile switch
// produces: selectProfile() nulls lastSessionId, then the persisted
// per-profile session resolves a tick later. This effect re-fires on that
// change, cancelling the delay below before it commits — so we skip
// painting the intermediate empty draft and land straight on the resolved
// session (or a genuine fresh draft when the profile has no history).
delay(160)
// Cold-start profile-isolation guard: hold the first profile-scoped load
// until the persisted profile selection has SETTLED, so the session
// drawer (and the restored session context) don't briefly load the
// SERVER-DEFAULT profile and then visibly snap to the real one. While a
// non-default profile is still resolving we wait on a backstop instead of
// fetching now; this effect re-fires the instant the profile resolves
// (selectedProfile / profileSelectionSettled change), cancelling the wait
// so only the correct, profile-scoped load lands. The backstop guarantees
// the drawer is never permanently empty if the profile list never lands.
if (!profileSelectionSettled) {
delay(2_500L)
} else {
// Coalesce the rapid lastSessionId null→value churn a profile switch
// produces: selectProfile() nulls lastSessionId, then the persisted
// per-profile session resolves a tick later. This effect re-fires on
// that change, cancelling the delay below before it commits — so we
// skip painting the intermediate empty draft and land straight on the
// resolved session (or a genuine fresh draft when the profile has no
// history).
delay(160)
}
chatViewModel.switchProfileContext(
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnectionId,
@@ -940,6 +963,7 @@ fun RelayApp() {
val relayReady by connectionViewModel.relayReady.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val appReady by connectionViewModel.isReady.collectAsState()
@@ -1388,6 +1412,11 @@ fun RelayApp() {
// Connections — preserves the affordance the dropped
// header endpoint chip used to provide.
onClick = openConnections,
securityGlyph = if (transportStatus.tier != ChatTransportTier.Offline) {
{ ConnectionSecurityGlyph(connectionSecurity) }
} else {
null
},
)
}
}
@@ -1740,6 +1769,9 @@ fun RelayApp() {
onNavigateToAnalytics = {
navController.navigate(Screen.Analytics.route)
},
onNavigateToDiagnostics = {
navController.navigate(Screen.Diagnostics.route)
},
onNavigateToVoiceSettings = {
navController.navigate(Screen.VoiceSettings.route)
},
@@ -2068,6 +2100,12 @@ fun RelayApp() {
chatViewModel = chatViewModel,
)
}
composable(Screen.Diagnostics.route) {
DiagnosticsScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
)
}
composable(Screen.DeveloperSettings.route) {
DeveloperSettingsScreen(
connectionViewModel = connectionViewModel,
@@ -101,7 +101,7 @@ import kotlinx.coroutines.launch
*/
/**
* Standard Hermes status rows (API / Dashboard). Dashboard auth is surfaced
* Hermes status rows (API / Dashboard). Dashboard auth is surfaced
* here so users do not have to open Manage just to discover sign-in is needed.
*/
@Composable
@@ -116,6 +116,15 @@ fun ActiveCardStandardStatusSection(
val dashboardStatus = activeConnection?.dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
// At-a-glance security rollup, promoted out of the Advanced fold. Tap for
// the per-surface breakdown. Single source of truth: ConnectionSecurity.
ConnectionSecurityBadgeWithSheet(
security = connectionSecurity,
size = TransportSecuritySize.Row,
modifier = Modifier.fillMaxWidth(),
)
ConnectionStatusRow(
label = "API Server",
@@ -330,7 +339,7 @@ fun ActiveCardFeaturesSection(
) {
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
CapabilityRow(
label = "Vanilla Hermes API",
label = "Hermes API",
value = apiValue,
tone = apiTone,
onClick = onOpenApiInfo,
@@ -344,7 +353,7 @@ fun ActiveCardFeaturesSection(
)
CapabilityDivider()
CapabilityRow(
label = "Vanilla Hermes voice",
label = "Hermes voice",
value = voiceValue,
tone = voiceTone,
onClick = if (standardVoiceAvailability ==
@@ -623,7 +632,7 @@ private fun ManualUrlSubsection(
when {
result.apiReachable && result.voiceConfigReachable ->
if (result.voiceRoute == "standard") {
"API and standard voice reachable"
"API and Hermes voice reachable"
} else {
"API and relay voice reachable"
}
@@ -665,7 +674,7 @@ private fun ManualUrlSubsection(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Relay is optional for voice. Vanilla Hermes voice uses the Hermes API; Relay voice uses this route when selected or needed.",
text = "Relay is optional for voice. Hermes voice uses the Hermes API; Relay voice uses this route when selected or needed.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -710,7 +719,7 @@ private fun ManualUrlSubsection(
Text(
text = if (result.voiceConfigReachable) {
if (result.voiceRoute == "standard") {
"Voice ready via standard Hermes API"
"Voice ready via Hermes API"
} else {
"Voice ready via ${result.relayUrl ?: "relay"}"
}
@@ -1110,56 +1119,19 @@ fun ActiveCardSecurityPosture(
connectionViewModel: ConnectionViewModel,
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 connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
// ADR 24 — surface the live endpoint role so the insecure badge can
// 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
}
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,
)
}
// Connection-level security rollup (single source of truth —
// ConnectionSecurity). Tap for the per-surface breakdown + the
// mechanism explainer (TLS vs Tailscale/WireGuard vs plain).
ConnectionSecurityBadgeWithSheet(
security = connectionSecurity,
size = TransportSecuritySize.Row,
modifier = Modifier.fillMaxWidth(),
)
if (isTailscaleDetected) {
Row(
@@ -1230,29 +1202,6 @@ 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 —
@@ -17,8 +17,10 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -57,6 +59,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.LiveRegionMode
@@ -85,8 +88,8 @@ import kotlinx.coroutines.delay
* visual line so the bounded buffer maps cleanly to "≤6 lines". */
private const val FLOW_MAX_CHARS = 42
/** Soft-wrap target only — the visible buffer is now bounded by the ~1/3
* screen viewport + scroll, not a hard line count. */
/** Soft-wrap target only — the visible buffer is now bounded by the
* scrollable viewport height + scroll, not a hard line count. */
private const val FLOW_MAX_LINES = 6
/** Memory ceiling for the persistent line buffer. Lines past this (already
@@ -276,7 +279,9 @@ fun AgentTextFlow(
Column(
modifier = modifier
.semantics { liveRegion = LiveRegionMode.Polite }
.topFadeEdge()
// Fade the top edge ONLY when there's content scrolled above it —
// a message that fits shows its first line crisply (no cut-off look).
.topFadeEdge(fade = if (staticScroll.canScrollBackward) 28.dp else 0.dp)
.verticalScroll(staticScroll),
verticalArrangement = Arrangement.Bottom,
) {
@@ -297,41 +302,38 @@ fun AgentTextFlow(
// --- Animated path ----------------------------------------------------
val flowLines = remember(messageId) { mutableStateListOf<FlowLine>() }
val currentContent by rememberUpdatedState(content)
val currentStreaming by rememberUpdatedState(streaming)
LaunchedEffect(messageId) {
flowLines.clear()
// Largest segment index ever materialized — guards against re-adding a
// line that was dropped from the front by the memory cap.
var maxKeyAdded = -1
var lastText: String? = null
while (true) {
val text = currentContent
val isStreamingNow = currentStreaming
val segs = segmentFlowLines(text, FLOW_MAX_CHARS)
// Add new lines (they slide in) and grow the still-streaming tail.
// Lines PERSIST — they never fade out; older ones simply scroll up
// within the bounded ~1/3-height viewport and dissolve at the top
// fade edge. (No dwell / fade-out / removal anymore.)
segs.forEachIndexed { i, s ->
val existing = flowLines.firstOrNull { it.key == i }
if (existing == null) {
if (i > maxKeyAdded) {
flowLines.add(FlowLine(key = i, initialText = s))
maxKeyAdded = i
// Re-diff only when the transcript changed, so an idle clean mode
// (no streaming, no new turn) doesn't churn. We never permanently
// exit: a new turn appended to the transcript must still slide in.
if (text != lastText) {
lastText = text
val segs = segmentFlowLines(text, FLOW_MAX_CHARS)
// Add new lines (they slide in); update a changed tail in place.
// Lines PERSIST — older ones simply scroll up within the bounded,
// scrollable viewport and dissolve at the top fade edge.
segs.forEachIndexed { i, s ->
val existing = flowLines.firstOrNull { it.key == i }
if (existing == null) {
if (i > maxKeyAdded) {
flowLines.add(FlowLine(key = i, initialText = s))
maxKeyAdded = i
}
} else if (existing.text != s) {
existing.text = s
}
} else if (existing.text != s) {
existing.text = s
}
// Memory guard: drop the oldest lines once well past the viewport.
while (flowLines.size > FLOW_BUFFER_MAX) flowLines.removeAt(0)
}
// Memory guard: drop the oldest lines once well past the viewport
// (already scrolled above the fade — invisible to the user).
while (flowLines.size > FLOW_BUFFER_MAX) flowLines.removeAt(0)
// Nothing left to do once the turn ended and every segment is in.
if (!isStreamingNow && maxKeyAdded >= segs.lastIndex) return@LaunchedEffect
delay(FLOW_TICK_MS)
}
}
@@ -362,7 +364,9 @@ fun AgentTextFlow(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.topFadeEdge()
// Fade the top edge ONLY when content is scrolled above it, so a
// reply that fits the viewport shows its first line crisply.
.topFadeEdge(fade = if (scrollState.canScrollBackward) 28.dp else 0.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.Bottom,
) {
@@ -516,11 +520,30 @@ fun CleanChatMode(
val lastAssistant = remember(messages) {
messages.lastOrNull { it.role == MessageRole.ASSISTANT }
}
val flowContent = lastAssistant?.content.orEmpty()
// Clean mode shows the recent CONVERSATION (not just the last reply) as one
// faded, scrollable flow, so scrolling up brings history into view. The flow
// is append-only across turns; user turns get a subtle "›" so the
// back-and-forth stays legible. How far back it retains is bounded by the
// flow's line buffer (FLOW_BUFFER_MAX).
val flowContent = remember(messages) {
messages
.filter { it.role == MessageRole.USER || it.role == MessageRole.ASSISTANT }
.joinToString("\n\n") { msg ->
val body = msg.content.trim()
if (msg.role == MessageRole.USER) "› $body" else body
}
}
// Stable per-conversation key so the flow buffer accumulates across turns and
// resets only on a new conversation (the oldest message's id changes).
val conversationKey = messages.firstOrNull()?.id
val flowStreaming = lastAssistant?.isStreaming == true && isStreaming
// Cap the flow at ~1/3 of the screen so lines can slide up and accumulate
// without ever climbing into / blocking the avatar above them.
val maxFlowHeight = (LocalConfiguration.current.screenHeightDp * 0.34f).dp
// The sphere + text are a vertically-centered group (equal spacers above and
// below). The sphere is a fixed size so the group grows via the TEXT: a short
// reply sits centered, and as the reply lengthens the centered group gets
// taller — sliding the sphere up toward the top third while the text fills
// down toward the composer.
val sphereHeight = (LocalConfiguration.current.screenHeightDp * 0.34f).dp
val maxFlowHeight = (LocalConfiguration.current.screenHeightDp * 0.5f).dp
BackHandler(enabled = true) { onExit() }
@@ -533,7 +556,19 @@ fun CleanChatMode(
.fillMaxSize()
// Opaque so the chat underneath is fully hidden — this is a mode,
// not a translucent overlay.
.background(RelayRefresh.Background),
.background(RelayRefresh.Background)
// Consume any pointer event the children (composer, exit button, text
// scroll) didn't handle, so stray taps/swipes in the empty areas don't
// fall through to the chat + session drawer behind this mode. Children
// run leaf-first on the same Main pass, so this only catches the gaps
// (mirrors the voice overlay's focus-mode scrim).
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent().changes.forEach { it.consume() }
}
}
},
) {
Column(
modifier = Modifier
@@ -557,12 +592,17 @@ fun CleanChatMode(
}
}
// Centered sphere — takes the slack so the flow + composer keep a
// stable bottom anchor as lines come and go.
// Flexible top spacer — with the bottom one it vertically centers the
// sphere + text group; as the text grows the spacers yield and the
// sphere rises toward the top third.
Spacer(modifier = Modifier.weight(1f))
// Bounded, centered sphere — a fixed size so the group grows via the
// text, sliding the sphere upward as the conversation lengthens.
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
.height(sphereHeight),
contentAlignment = Alignment.Center,
) {
Box(
@@ -586,8 +626,11 @@ fun CleanChatMode(
AgentTextFlow(
content = flowContent,
streaming = flowStreaming,
messageId = lastAssistant?.id,
messageId = conversationKey,
motionEnabled = textMotionEnabled,
// Content-sized reading area (capped ~half the screen) directly
// below the sphere — no gap between them. Grows + scrolls with the
// reply, which is what lifts the centered group (and the sphere).
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 560.dp)
@@ -595,6 +638,10 @@ fun CleanChatMode(
.padding(bottom = 12.dp),
)
// Flexible bottom spacer — balances the top one to keep the
// sphere + text group vertically centered.
Spacer(modifier = Modifier.weight(1f))
CleanModeComposer(
enabled = enabled,
onSend = onSend,
@@ -1480,7 +1480,7 @@ fun AgentInfoSheet(
val hostname = com.hermesandroid.relay.data.Connection
.extractDefaultLabel(connection.apiServerUrl)
val statusLine = when {
connection.pairedAt == null -> "$hostname • Vanilla Hermes"
connection.pairedAt == null -> "$hostname • Hermes"
else -> "$hostname • Paired"
}
ProfileRadioRow(
@@ -0,0 +1,161 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionSecurityLevel
import com.hermesandroid.relay.data.SurfaceSecurity
private const val LEARN_MORE_URL =
"https://codename-11.github.io/hermes-relay/architecture/connection-security.html"
/**
* Per-surface "Connection security" detail sheet — the tap target for the
* connection-security badge. Shows the rollup, the per-transport breakdown,
* and a one-line explainer of the mechanism so the at-a-glance badge never
* has to lie about a mixed connection.
*/
/**
* Self-contained badge that opens the [ConnectionSecuritySheet] on tap. Drop
* it on any surface (connection header, posture strip) without threading sheet
* state through the caller.
*/
@Composable
fun ConnectionSecurityBadgeWithSheet(
security: ConnectionSecurity,
modifier: Modifier = Modifier,
size: TransportSecuritySize = TransportSecuritySize.Chip,
) {
var show by remember { mutableStateOf(false) }
ConnectionSecurityBadge(
security = security,
modifier = modifier,
size = size,
onClick = { show = true },
)
if (show) {
ConnectionSecuritySheet(security = security, onDismiss = { show = false })
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionSecuritySheet(
security: ConnectionSecurity,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val uriHandler = LocalUriHandler.current
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = "Connection security",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
ConnectionSecurityBadge(
security = security,
size = TransportSecuritySize.Large,
)
HorizontalDivider()
if (security.surfaces.isEmpty()) {
Text(
text = "No active route yet. Connect to a server to see how each " +
"part of the connection is protected.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
security.surfaces.forEach { SurfaceSecurityRow(it) }
}
HorizontalDivider()
Text(
text = explainer(security.level),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { uriHandler.openUri(LEARN_MORE_URL) }) {
Text("Learn about connection security →")
}
}
}
}
@Composable
private fun SurfaceSecurityRow(surface: SurfaceSecurity) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SurfaceSecurityGlyph(kind = surface.kind, modifier = Modifier.size(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = surface.label,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = surface.url,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
Text(
text = surface.mechanism,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun explainer(level: ConnectionSecurityLevel): String = when (level) {
ConnectionSecurityLevel.Tls ->
"Encrypted with TLS. The server's certificate is pinned on first connect."
ConnectionSecurityLevel.Overlay ->
"Encrypted by your overlay network (e.g. Tailscale/WireGuard), not TLS. " +
"Cert pinning applies only to TLS routes."
ConnectionSecurityLevel.Mixed ->
"Some parts of this connection are encrypted and some are plain. The app " +
"prefers a secure route when one is reachable."
ConnectionSecurityLevel.Plain ->
"Not encrypted. Only safe on a network you fully trust — anyone in between " +
"could read this traffic."
ConnectionSecurityLevel.Unknown -> ""
}
@@ -119,7 +119,7 @@ private fun ConnectionRow(
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val statusLine = if (connection.pairedAt == null) {
"$hostname • Vanilla Hermes"
"$hostname • Hermes"
} else {
"$hostname • Paired"
}
@@ -99,7 +99,7 @@ import kotlinx.coroutines.withTimeout
/**
* Shared connection wizard used by both onboarding (first run) and
* Settings → Connections. Standard Hermes setup is the default path:
* Settings → Connections. Hermes setup is the default path:
* save the API URL/key, derive the dashboard URL, and verify sessions.
* Relay pairing remains available for power tools such as Terminal,
* Bridge, Relay sessions, channel grants, and relay-backed media routes.
@@ -107,7 +107,7 @@ import kotlinx.coroutines.withTimeout
* Steps:
*
* 1. **Method** — pick a setup path. Four tiles:
* - **Standard Hermes**: API URL + API key. → StandardEntry.
* - **Hermes**: API URL + API key. → StandardEntry.
* - **Scan QR**: standard convenience path for API URL/key QRs; Relay
* plugin QRs still work and route through Confirm/Relay pair.
* - **Pair Relay by code**: server already minted a code via
@@ -1013,7 +1013,7 @@ private fun MethodStep(
MethodTile(
icon = Icons.Filled.Check,
title = "Vanilla Hermes",
title = "Hermes",
subtitle = "API/dashboard setup for Chat, Manage, Skills, Cron, MCP, Profiles, Models, and Settings",
onClick = onPickStandard,
isPrimary = true,
@@ -1022,7 +1022,7 @@ private fun MethodStep(
MethodTile(
icon = Icons.Filled.QrCodeScanner,
title = "Scan setup QR",
subtitle = "Scan a QR with API URL/key for Standard; Relay QR details require the Hermes-Relay plugin",
subtitle = "Scan a QR with API URL/key for Hermes; Relay QR details require the Relay plugin",
onClick = onPickScan,
)
@@ -1039,7 +1039,7 @@ private fun MethodStep(
fontWeight = FontWeight.SemiBold,
)
Text(
text = "Terminal, Bridge, Relay sessions, and grants require the Hermes-Relay plugin.",
text = "Terminal, Bridge, Relay sessions, and grants require the Relay plugin.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1251,7 +1251,7 @@ private fun StandardEntryStep(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Vanilla Hermes",
text = "Hermes",
style = MaterialTheme.typography.headlineSmall,
)
Text(
@@ -1617,7 +1617,7 @@ private fun StandardSetupResultCard(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Vanilla Hermes connected",
text = "Hermes connected",
style = MaterialTheme.typography.titleMedium,
)
ReadinessLine(
@@ -2481,7 +2481,7 @@ private fun ConfirmStep(
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Connecting to Vanilla Hermes",
text = "Connecting to Hermes",
style = MaterialTheme.typography.titleSmall,
)
Text(
@@ -48,8 +48,11 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.SurfaceSecurityKind
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.data.isEncryptedOverlayRoute
import com.hermesandroid.relay.data.isKnownRole
import com.hermesandroid.relay.data.isTlsUrl
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
@@ -241,6 +244,7 @@ private fun EndpointRow(
text = candidate.displayLabel(),
style = MaterialTheme.typography.bodyMedium,
)
SurfaceSecurityGlyph(kind = candidate.routeSecurityKind())
if (isActive) {
ActiveChip()
} else if (isPreferred) {
@@ -498,6 +502,18 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
else -> Icons.Filled.Shield
}
/**
* Per-route security classification for the picker glyph. Keyed on the
* candidate's own scheme + role (no device-level Tailscale detection needed —
* a `tailscale`/`plugin_proxy` role is encrypted regardless), so each row can
* be classified independently before it's the active route.
*/
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
isTlsUrl(api.url) -> SurfaceSecurityKind.Tls
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
else -> SurfaceSecurityKind.Plain
}
/**
* Add/edit dialog for an extra fallback route — the manual counterpart of a
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
@@ -601,7 +617,7 @@ fun RouteEditorDialog(
errorText = null
},
label = { Text("API server URL or host") },
placeholder = { Text("100.71.8.56 or http://host:8642") },
placeholder = { Text("100.64.0.1 or http://host:8642") },
singleLine = true,
isError = errorText != null,
supportingText = {
@@ -40,9 +40,8 @@ enum class PowerFeatureGateStatus(
RequiresPairing(
label = "Requires pairing",
actionLabel = "Pair to unlock",
explanation = "This feature runs over the Hermes Relay plugin. Make sure the Relay " +
"plugin is installed and running on your Hermes server, then pair this device " +
"to unlock it.",
explanation = "This feature requires the Relay plugin. Make sure it is installed " +
"and running on your Hermes server, then pair this device to unlock it.",
),
PairingExpired(
label = "Pairing expired",
@@ -87,11 +87,11 @@ import kotlin.math.max
* ```json
* {
* "hermes": 1,
* "host": "172.16.24.250",
* "host": "192.168.1.100",
* "port": 8642,
* "key": "bearer-token",
* "tls": false,
* "relay": { "url": "ws://172.16.24.250:8767", "code": "ABCD12" }
* "relay": { "url": "ws://192.168.1.100:8767", "code": "ABCD12" }
* }
* ```
*
@@ -187,7 +187,7 @@ data class HermesPairingPayload(
* Relay connection details carried in a Hermes pairing QR.
*
* - [url] is the full WebSocket URL the phone should connect to, e.g.
* `ws://172.16.24.250:8767` for dev or `wss://relay.example.com:8767`
* `ws://192.168.1.100:8767` for dev or `wss://relay.example.com:8767`
* for a TLS-fronted relay.
* - [code] is a 6-char one-shot pairing code that the relay has already
* registered via its localhost-only `/pairing/register` endpoint. The
@@ -799,7 +799,7 @@ fun QrPairingScanner(
textAlign = TextAlign.Center
)
Text(
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Hermes-Relay plugin.",
text = "Ask Hermes: \"Generate a QR code with my API URL and API key.\" Relay pairing QRs require the Relay plugin.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@@ -33,6 +33,8 @@ fun RelayStatusStrip(
trailing: String,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
/** Optional security marker rendered just before the route label. */
securityGlyph: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier
@@ -65,6 +67,9 @@ fun RelayStatusStrip(
verticalAlignment = Alignment.CenterVertically,
) {
leadingBadge()
if (securityGlyph != null) {
securityGlyph()
}
if (routeLabel.isNotBlank()) {
Text(
text = "· $routeLabel",
@@ -79,7 +79,7 @@ fun StatsForNerds(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Analytics",
text = "Overview",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
@@ -106,10 +106,10 @@ fun StatsForNerds(
val tokensPerMsg = if (appStats.totalMessagesSent > 0)
totalTokens / appStats.totalMessagesSent else 0L
Text(
text = "${appStats.totalMessagesSent} messages | " +
text = "${appStats.totalMessagesSent} messages · " +
"${formatTokenCount(totalTokens)} tokens" +
(if (tokensPerMsg > 0) " (~${formatTokenCount(tokensPerMsg)}/msg)" else "") +
" | ${appStats.sessionCount} sessions",
" · ${appStats.sessionCount} sessions",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -8,8 +8,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
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
@@ -33,12 +35,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ToolCallEvent
import com.hermesandroid.relay.diagnostics.CheckStatus
import com.hermesandroid.relay.diagnostics.StatusCheck
import com.hermesandroid.relay.viewmodel.VoiceStats
import java.text.SimpleDateFormat
import java.util.Date
@@ -181,6 +187,234 @@ private fun LegendEntry(label: String, color: Color) {
}
}
// -----------------------------------------------------------------------------
// Status-check timeline (Diagnostics)
// -----------------------------------------------------------------------------
/**
* Vertical timeline of derived [StatusCheck]s for the Diagnostics screen.
*
* Shares the dot + colour-legend visual language of [TimelineView] above, but
* adds a connecting rail between dots and renders each check's failure
* [StatusCheck.reason] inline — the whole point of the screen. Rows whose check
* carries a concrete log entry ([StatusCheck.timestampMs] != null) are tappable
* so the host can open the full diagnostic detail.
*/
@Composable
fun StatusCheckTimeline(
checks: List<StatusCheck>,
modifier: Modifier = Modifier,
onCheckClick: (StatusCheck) -> Unit = {},
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Status checks",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = statusSummary(checks),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(10.dp))
StatusCheckLegend()
Spacer(modifier = Modifier.height(12.dp))
if (checks.isEmpty()) {
Text(
text = "No checks yet — connect to a server to populate diagnostics.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
checks.forEachIndexed { index, check ->
StatusCheckRow(
check = check,
isFirst = index == 0,
isLast = index == checks.lastIndex,
onClick = { onCheckClick(check) },
)
}
}
}
}
}
@Composable
private fun StatusCheckLegend() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
LegendEntry("Pass", CheckStatus.Pass.statusColor())
LegendEntry("Warn", CheckStatus.Warn.statusColor())
LegendEntry("Fail", CheckStatus.Fail.statusColor())
LegendEntry("Unknown", CheckStatus.Unknown.statusColor())
}
}
@Composable
private fun StatusCheckRow(
check: StatusCheck,
isFirst: Boolean,
isLast: Boolean,
onClick: () -> Unit,
) {
val dotColor = check.status.statusColor()
val railColor = MaterialTheme.colorScheme.outlineVariant
// Only rows backed by a concrete log entry (timestamp captured) open a
// deep-detail view — keeps the "tap for detail" affordance honest.
val hasDetail = check.timestampMs != null
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.then(if (hasDetail) Modifier.clickable(onClick = onClick) else Modifier),
) {
// Rail gutter: a vertical connecting line through the column with the
// status dot punched over it. Drawn in a draw-scope so dp→px and the
// first/last segment trimming stay self-contained.
Box(
modifier = Modifier
.fillMaxHeight()
.width(22.dp)
.drawBehind {
val cx = size.width / 2f
val dotCenterY = 12.dp.toPx()
val dotRadius = 5.dp.toPx()
val lineWidth = 2.dp.toPx()
if (!isFirst) {
drawLine(
color = railColor,
start = Offset(cx, 0f),
end = Offset(cx, dotCenterY),
strokeWidth = lineWidth,
)
}
if (!isLast) {
drawLine(
color = railColor,
start = Offset(cx, dotCenterY),
end = Offset(cx, size.height),
strokeWidth = lineWidth,
)
}
drawCircle(
color = dotColor,
radius = dotRadius,
center = Offset(cx, dotCenterY),
)
},
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 4.dp, bottom = 14.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = check.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
StatusPill(check.status)
}
check.reason?.let { reason ->
Text(
text = reason,
style = MaterialTheme.typography.bodySmall,
color = if (check.status == CheckStatus.Fail) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
if (hasDetail) {
Text(
text = "Tap for log detail",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@Composable
private fun StatusPill(status: CheckStatus) {
val color = status.statusColor()
val label = when (status) {
CheckStatus.Pass -> "PASS"
CheckStatus.Warn -> "WARN"
CheckStatus.Fail -> "FAIL"
CheckStatus.Unknown -> "UNKNOWN"
}
Surface(
shape = RoundedCornerShape(50),
color = color.copy(alpha = 0.16f),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = color,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp),
)
}
}
/** One-line "N failing · N warning · N passing" summary for the header. */
private fun statusSummary(checks: List<StatusCheck>): String {
if (checks.isEmpty()) return "no checks"
val fail = checks.count { it.status == CheckStatus.Fail }
val warn = checks.count { it.status == CheckStatus.Warn }
val pass = checks.count { it.status == CheckStatus.Pass }
return buildList {
if (fail > 0) add("$fail failing")
if (warn > 0) add("$warn warning")
add("$pass passing")
}.joinToString(" · ")
}
/** Dot/pill colour per [CheckStatus]: green / amber / error-red / gray. */
@Composable
private fun CheckStatus.statusColor(): Color = when (this) {
CheckStatus.Pass -> Color(0xFF4CAF50)
CheckStatus.Warn -> Color(0xFFFFB300)
CheckStatus.Fail -> MaterialTheme.colorScheme.error
CheckStatus.Unknown -> MaterialTheme.colorScheme.onSurfaceVariant
}
@Composable
private fun TimelineRow(
bucket: TimelineBucket,
@@ -2,6 +2,7 @@ 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.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -22,6 +23,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionSecurityLevel
import com.hermesandroid.relay.data.SurfaceSecurityKind
/**
* Visual badge for the current relay transport security posture.
@@ -294,3 +298,123 @@ fun isUrlSecure(url: String?): Boolean {
val lower = url.trim().lowercase()
return lower.startsWith("wss://") || lower.startsWith("https://")
}
// ---------------------------------------------------------------------------
// ConnectionSecurity-driven badge (single source of truth — see
// data/ConnectionSecurity.kt). Mechanism-first copy: a Tailscale/WireGuard
// route reads "Encrypted · Tailscale", NOT "Secure — TLS". Both TLS and
// overlay are green; only true plaintext-without-overlay warns.
// ---------------------------------------------------------------------------
private data class ConnSecAppearance(
val label: String,
val icon: ImageVector,
val bg: Color,
val fg: Color,
)
@Composable
private fun connSecAppearance(security: ConnectionSecurity): ConnSecAppearance {
val green = Color(0xFF2E7D32)
val amber = Color(0xFFF9A825)
val red = MaterialTheme.colorScheme.error
return when (security.level) {
ConnectionSecurityLevel.Tls -> ConnSecAppearance(
label = "Encrypted · TLS",
icon = Icons.Filled.Lock,
bg = green.copy(alpha = 0.14f),
fg = green,
)
ConnectionSecurityLevel.Overlay -> ConnSecAppearance(
label = "Encrypted · ${security.mechanism}",
icon = Icons.Filled.Shield,
bg = green.copy(alpha = 0.14f),
fg = green,
)
ConnectionSecurityLevel.Mixed -> ConnSecAppearance(
label = "Mixed routes",
icon = Icons.Filled.Shield,
bg = amber.copy(alpha = 0.16f),
fg = amber,
)
ConnectionSecurityLevel.Plain -> ConnSecAppearance(
label = if (security.mechanism.isNotBlank() && security.mechanism != "Plain") {
"Not encrypted · ${security.mechanism}"
} else {
"Not encrypted"
},
icon = Icons.Filled.LockOpen,
bg = red.copy(alpha = 0.16f),
fg = red,
)
ConnectionSecurityLevel.Unknown -> ConnSecAppearance(
label = "Checking…",
icon = Icons.Filled.Shield,
bg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
fg = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* The connection-level security badge every surface should use. Renders the
* rollup from [ConnectionSecurity]; tap (when [onClick] is set) opens the
* per-surface detail sheet. Renders nothing while the verdict is Unknown.
*/
@Composable
fun ConnectionSecurityBadge(
security: ConnectionSecurity,
modifier: Modifier = Modifier,
size: TransportSecuritySize = TransportSecuritySize.Chip,
onClick: (() -> Unit)? = null,
) {
if (security.level == ConnectionSecurityLevel.Unknown) return
val a = connSecAppearance(security)
RenderBadge(
label = a.label,
bg = a.bg,
fg = a.fg,
icon = a.icon,
size = size,
modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier,
)
}
/** Icon-only security marker for tight spots (chat status strip). */
@Composable
fun ConnectionSecurityGlyph(
security: ConnectionSecurity,
modifier: Modifier = Modifier,
) {
if (security.level == ConnectionSecurityLevel.Unknown) return
val a = connSecAppearance(security)
Icon(
imageVector = a.icon,
contentDescription = a.label,
tint = a.fg,
modifier = modifier.size(14.dp),
)
}
/** Per-route security glyph for the route picker (one [SurfaceSecurityKind]). */
@Composable
fun SurfaceSecurityGlyph(
kind: SurfaceSecurityKind,
modifier: Modifier = Modifier,
) {
val green = Color(0xFF2E7D32)
val amber = Color(0xFFF9A825)
val (icon, tint, desc) = when (kind) {
SurfaceSecurityKind.Tls -> Triple(Icons.Filled.Lock, green, "Encrypted (TLS)")
SurfaceSecurityKind.Overlay -> Triple(Icons.Filled.Shield, green, "Encrypted")
// Per-route plaintext is amber (informational), not red — a secure
// route may exist alongside it.
SurfaceSecurityKind.Plain -> Triple(Icons.Filled.LockOpen, amber, "Not encrypted")
}
Icon(
imageVector = icon,
contentDescription = desc,
tint = tint,
modifier = modifier.size(14.dp),
)
}
@@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.components.StatsForNerds
import com.hermesandroid.relay.ui.components.TimelineView
@@ -70,14 +71,24 @@ fun AnalyticsScreen(
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
// Stats for Nerds section
Text(
text = "Stats for Nerds",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
// Section intro — names the grouping and clarifies these are local,
// on-device metrics (distinct from the TopAppBar "Analytics" title
// and each card's own header below).
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = "Stats for Nerds",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Local, on-device performance and usage metrics.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatsForNerds(
voiceStats = voiceStats,
@@ -1852,7 +1852,7 @@ fun ChatScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Chat needs a Vanilla Hermes API connection.",
text = "Chat needs a Hermes API connection.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1860,7 +1860,7 @@ fun ChatScreen(
onClick = onNavigateToConnect,
modifier = Modifier.fillMaxWidth(),
) {
Text("Connect Vanilla Hermes")
Text("Connect Hermes")
}
}
}
@@ -207,7 +207,7 @@ fun ConnectionsSettingsScreen(
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "Tap Add connection to connect to Vanilla Hermes.",
text = "Tap Add connection to connect to Hermes.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -868,7 +868,7 @@ private fun ConnectionCard(
SectionHeader(text = "Advanced")
SectionCaption(
text = "Manual setup — most people don't need this " +
"after Vanilla Hermes setup.",
"after Hermes setup.",
)
// Advanced expander: manual URL config + insecure toggle
@@ -0,0 +1,378 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.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.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.diagnostics.CheckStatus
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.StatusCheck
import com.hermesandroid.relay.network.shared.ConnectivityObserver
import com.hermesandroid.relay.network.upstream.ServerCapabilities
import com.hermesandroid.relay.ui.components.DiagnosticDetailDialog
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
import com.hermesandroid.relay.ui.components.StatusCheckTimeline
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
/**
* Dedicated Diagnostics screen — replaces the old modal bottom sheet. Hosts a
* vertical timeline of subsystem **status checks** (with failure reasons) at
* the top, then the existing "Recent diagnostics" activity log below it.
*
* The checks are derived **read-only** from the flows [ConnectionViewModel]
* already exposes (network / API health, capability snapshot, auth + relay
* readiness, voice readiness) plus the recent [DiagnosticsLog] — no new probing
* is started here, so the screen stays an honest snapshot of current state.
* A failing check whose reason came from a logged error is tappable and opens
* that entry's full [DiagnosticDetailDialog].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiagnosticsScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
) {
val network by connectionViewModel.networkStatus.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val apiUrl by connectionViewModel.apiServerUrl.collectAsState()
val capabilities by connectionViewModel.serverCapabilities.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val chatReady by connectionViewModel.chatReady.collectAsState()
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
val relayHealth by connectionViewModel.relayServerHealth.collectAsState()
val relayReady by connectionViewModel.relayReady.collectAsState()
val voiceReady by connectionViewModel.voiceReady.collectAsState()
val relayVoiceReady by connectionViewModel.relayVoiceReady.collectAsState()
val entries by DiagnosticsLog.entries.collectAsState()
val checks = remember(
network, apiHealth, apiUrl, capabilities, authState, chatReady,
relayConfigured, relayHealth, relayReady, voiceReady, relayVoiceReady, entries,
) {
buildStatusChecks(
network = network,
apiHealth = apiHealth,
apiUrl = apiUrl,
capabilities = capabilities,
authState = authState,
chatReady = chatReady,
relayConfigured = relayConfigured,
relayHealth = relayHealth,
relayReady = relayReady,
voiceReady = voiceReady,
relayVoiceReady = relayVoiceReady,
recentEntries = entries,
)
}
// Tapping a check backed by a concrete log entry opens its full detail.
var selectedEntry by remember { mutableStateOf<DiagnosticLogEntry?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Diagnostics") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Status",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
StatusCheckTimeline(
checks = checks,
onCheckClick = { check ->
selectedEntry = entries.lastOrNull { entry ->
check.category != null &&
entry.category == check.category &&
(check.timestampMs == null || entry.timestampMs == check.timestampMs)
}
},
)
Text(
text = "Recent diagnostics",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "Recent app-level connection and voice events. Secrets and raw " +
"payloads are hidden.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
DiagnosticsLogPanel(
title = "Activity log",
limit = 80,
showCategory = true,
showClear = true,
showSeverityFilter = true,
)
}
}
selectedEntry?.let { entry ->
DiagnosticDetailDialog(entry = entry, onDismiss = { selectedEntry = null })
}
}
// -----------------------------------------------------------------------------
// Read-only check derivation
// -----------------------------------------------------------------------------
/**
* Derive the status-check list from a snapshot of connection state + the recent
* [DiagnosticsLog]. Pure and side-effect free (no probing) so it is trivially
* testable and re-runs cheaply whenever any input flow emits.
*
* When a check fails or warns and a matching-category error sits in
* [recentEntries], that entry's message becomes the reason and its timestamp is
* stamped onto the check — which is what makes the row tappable for full detail.
*/
internal fun buildStatusChecks(
network: ConnectivityObserver.Status,
apiHealth: ConnectionViewModel.HealthStatus,
apiUrl: String,
capabilities: ServerCapabilities,
authState: AuthState,
chatReady: Boolean,
relayConfigured: Boolean,
relayHealth: ConnectionViewModel.HealthStatus,
relayReady: Boolean,
voiceReady: Boolean,
relayVoiceReady: Boolean,
recentEntries: List<DiagnosticLogEntry>,
): List<StatusCheck> {
// Most recent ERROR for a category (entries are oldest -> newest).
fun recentError(category: DiagnosticCategory): DiagnosticLogEntry? =
recentEntries.lastOrNull {
it.category == category && it.severity == DiagnosticSeverity.Error
}
fun DiagnosticLogEntry.message(): String = detail ?: title
val checks = mutableListOf<StatusCheck>()
// 1) Network reachability.
checks += when (network) {
ConnectivityObserver.Status.Available ->
StatusCheck("Network", CheckStatus.Pass, reason = "Device is online")
ConnectivityObserver.Status.Lost ->
StatusCheck(
"Network", CheckStatus.Fail,
reason = "Network connection lost",
category = DiagnosticCategory.Endpoint,
)
ConnectivityObserver.Status.Unavailable ->
StatusCheck(
"Network", CheckStatus.Warn,
reason = "No active network detected",
category = DiagnosticCategory.Endpoint,
)
}
// 2) API server reachability.
val host = DiagnosticsLog.sanitizeUrl(apiUrl)
val apiErr = recentError(DiagnosticCategory.Api)
checks += when (apiHealth) {
ConnectionViewModel.HealthStatus.Reachable ->
StatusCheck(
"API server", CheckStatus.Pass,
reason = host?.let { "Reachable at $it" } ?: "Reachable",
category = DiagnosticCategory.Api,
)
ConnectionViewModel.HealthStatus.Unreachable ->
StatusCheck(
"API server", CheckStatus.Fail,
reason = apiErr?.message() ?: (host?.let { "Not reachable at $it" } ?: "Not reachable"),
category = DiagnosticCategory.Api,
timestampMs = apiErr?.timestampMs,
)
ConnectionViewModel.HealthStatus.Probing ->
StatusCheck(
"API server", CheckStatus.Unknown,
reason = "Probing…",
category = DiagnosticCategory.Api,
)
ConnectionViewModel.HealthStatus.Unknown ->
StatusCheck(
"API server", CheckStatus.Unknown,
reason = "Not checked yet",
category = DiagnosticCategory.Api,
)
}
// 3) Server capabilities (which chat surfaces the server advertises).
checks += when {
!capabilities.healthy ->
StatusCheck(
"Server capabilities", CheckStatus.Unknown,
reason = "Not probed — no healthy server yet",
category = DiagnosticCategory.Api,
)
capabilities.sessionsChatStream ->
StatusCheck(
"Server capabilities", CheckStatus.Pass,
reason = "Native session streaming available",
category = DiagnosticCategory.Api,
)
capabilities.sessionsApi || capabilities.runs || capabilities.portable ->
StatusCheck(
"Server capabilities", CheckStatus.Warn,
reason = "No session SSE — falling back to ${capabilities.preferredChatEndpoint()}",
category = DiagnosticCategory.Api,
)
else ->
StatusCheck(
"Server capabilities", CheckStatus.Fail,
reason = "No usable chat endpoint advertised",
category = DiagnosticCategory.Api,
)
}
// 4) Chat transport readiness.
val chatErr = recentError(DiagnosticCategory.Session) ?: recentError(DiagnosticCategory.Api)
checks += if (chatReady) {
StatusCheck(
"Chat transport", CheckStatus.Pass,
reason = "Ready · ${capabilities.preferredChatEndpoint()}",
category = DiagnosticCategory.Session,
)
} else {
val degraded = apiHealth == ConnectionViewModel.HealthStatus.Reachable
StatusCheck(
"Chat transport",
if (degraded) CheckStatus.Warn else CheckStatus.Fail,
reason = chatErr?.message() ?: "Not ready — no usable streaming endpoint",
category = DiagnosticCategory.Session,
timestampMs = chatErr?.timestampMs,
)
}
// 5) Relay / pairing auth.
val authErr = recentError(DiagnosticCategory.Auth)
checks += when (authState) {
is AuthState.Paired ->
StatusCheck(
"Pairing / auth", CheckStatus.Pass,
reason = "Relay session active",
category = DiagnosticCategory.Auth,
)
is AuthState.Pairing ->
StatusCheck(
"Pairing / auth", CheckStatus.Warn,
reason = "Pairing in progress…",
category = DiagnosticCategory.Auth,
)
is AuthState.Failed ->
StatusCheck(
"Pairing / auth", CheckStatus.Fail,
reason = authState.reason,
category = DiagnosticCategory.Auth,
timestampMs = authErr?.timestampMs,
)
is AuthState.Unpaired ->
StatusCheck(
"Pairing / auth", CheckStatus.Unknown,
reason = "Not paired — vanilla Hermes path doesn't require pairing",
category = DiagnosticCategory.Auth,
)
}
// 6) Relay server (optional — Unknown when not paired/configured).
val relayErr = recentError(DiagnosticCategory.Relay)
checks += when {
!relayConfigured ->
StatusCheck(
"Relay server", CheckStatus.Unknown,
reason = "Not paired — relay features are optional",
category = DiagnosticCategory.Relay,
)
relayReady ->
StatusCheck(
"Relay server", CheckStatus.Pass,
reason = "Connected",
category = DiagnosticCategory.Relay,
)
relayHealth == ConnectionViewModel.HealthStatus.Reachable ->
StatusCheck(
"Relay server", CheckStatus.Warn,
reason = relayErr?.message() ?: "Reachable but session not ready",
category = DiagnosticCategory.Relay,
timestampMs = relayErr?.timestampMs,
)
else ->
StatusCheck(
"Relay server", CheckStatus.Fail,
reason = relayErr?.message() ?: "Configured but not reachable",
category = DiagnosticCategory.Relay,
timestampMs = relayErr?.timestampMs,
)
}
// 7) Voice readiness.
val voiceErr = recentError(DiagnosticCategory.Voice)
checks += if (voiceReady) {
StatusCheck(
"Voice", CheckStatus.Pass,
reason = if (relayVoiceReady) "Relay voice ready" else "Standard voice ready",
category = DiagnosticCategory.Voice,
)
} else {
StatusCheck(
"Voice",
if (voiceErr != null) CheckStatus.Fail else CheckStatus.Unknown,
reason = voiceErr?.message() ?: "Not configured or unavailable",
category = DiagnosticCategory.Voice,
timestampMs = voiceErr?.timestampMs,
)
}
return checks
}
@@ -138,7 +138,7 @@ fun PermissionsStatusScreen(
PermissionsIntroCard()
PermissionSection(
title = "Vanilla Hermes",
title = "Hermes",
subtitle = "Chat and Manage use your configured Hermes API/dashboard connection.",
) {
PermissionStatusRow(
@@ -51,7 +51,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@@ -60,7 +59,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -87,7 +85,6 @@ import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.ui.components.AgentAvatarFace
import com.hermesandroid.relay.ui.components.AgentInfoSheet
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.gradientBorder
@@ -143,6 +140,7 @@ fun SettingsScreen(
onNavigateToMediaSettings: () -> Unit,
onNavigateToAppearanceSettings: () -> Unit,
onNavigateToAnalytics: () -> Unit,
onNavigateToDiagnostics: () -> Unit,
onNavigateToVoiceSettings: () -> Unit,
onNavigateToNotificationCompanion: () -> Unit,
onNavigateToPermissions: () -> Unit,
@@ -271,13 +269,11 @@ fun SettingsScreen(
// the sheet renders inline over Settings so closing drops the user
// back where they started.
var showAgentSheet by remember { mutableStateOf(false) }
var showDiagnosticsSheet by remember { mutableStateOf(false) }
var showProfileLockDialog by remember { mutableStateOf(false) }
// What's New / Changelog — opens the full release history as a
// self-contained full-screen Dialog (no nav route). Always available, not
// gated on the post-update "seen" state that drives the auto dialog.
var showChangelog by remember { mutableStateOf(false) }
val diagnosticsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// Profile lock state — this card/dialog is the ONE surface that always
// lists every profile, so it does NOT gate on isProfileLocked.
@@ -521,8 +517,8 @@ fun SettingsScreen(
SettingsCategoryRow(
icon = Icons.Filled.Info,
title = "Diagnostics",
subtitle = "Recent API, relay, session, and voice activity",
onClick = { showDiagnosticsSheet = true },
subtitle = "Status checks, plus recent API, relay, session, and voice activity",
onClick = onNavigateToDiagnostics,
isDarkTheme = isDarkTheme,
)
@@ -571,36 +567,6 @@ fun SettingsScreen(
)
}
if (showDiagnosticsSheet) {
ModalBottomSheet(
onDismissRequest = { showDiagnosticsSheet = false },
sheetState = diagnosticsSheetState,
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Diagnostics",
style = MaterialTheme.typography.titleLarge,
)
Text(
text = "Recent app-level connection and voice events. Secrets and raw payloads are hidden.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
DiagnosticsLogPanel(
limit = 80,
showCategory = true,
showClear = true,
)
}
}
}
if (showProfileLockDialog) {
ProfileLockDialog(
profiles = agentProfiles,
@@ -614,7 +580,8 @@ fun SettingsScreen(
// Full-screen changelog. Hosted as a self-contained Dialog (no nav route)
// so it stacks over Settings and dismisses back here — mirroring the
// showAgentSheet / showDiagnosticsSheet inline-surface pattern above.
// showAgentSheet inline-surface pattern above. (Diagnostics moved to its
// own nav route — see Screen.Diagnostics.)
if (showChangelog) {
Dialog(
onDismissRequest = { showChangelog = false },
@@ -562,22 +562,22 @@ private fun VoiceForThisProfileCard(
val relayStatus = if (relayVoiceReady) "Ready" else "Relay not configured"
val autoStatus = when {
relayVoiceReady -> "Ready — using Relay"
standardOk -> "Ready — using standard Hermes"
standardOk -> "Ready — using Hermes"
else -> "No route available yet"
}
listOf(
RouteOption(
route = VoiceAudioRoute.Auto,
label = "Auto",
detail = "Relay when paired; otherwise the standard Hermes dashboard. Recommended.",
detail = "Relay when paired; otherwise the Hermes dashboard. Recommended.",
status = autoStatus,
statusOk = relayVoiceReady || standardOk,
),
RouteOption(
route = VoiceAudioRoute.Standard,
label = "Vanilla Hermes",
label = "Hermes",
detail = "The dashboard audio path Hermes Desktop uses — works on a " +
"vanilla Hermes install, no Relay plugin required.",
"Hermes install, no Relay plugin required.",
status = standardStatus,
statusOk = standardOk,
),
@@ -2104,7 +2104,7 @@ private fun TestCurrentEngineCard(
).joinToString(" / ").ifBlank { "loading..." },
)
} else {
ProviderRow(label = "Route", value = "standard Hermes")
ProviderRow(label = "Route", value = "Hermes")
ProviderRow(label = "Voice", value = "server-configured TTS")
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
@@ -1051,6 +1051,17 @@ class ChatViewModel : ViewModel() {
profileSessionLister = lister
}
/**
* Deletes a session scoped to the active profile on gateway connections
* (dashboard `DELETE /api/sessions/{id}?profile=`). The write twin of
* [profileSessionLister]: a non-default profile's row lives in that profile's
* own DB, so the unscoped api_server delete leaves it behind and the next
* profile-scoped list resurrects it. Returns `true` on success. Wired from
* RelayApp to
* [com.hermesandroid.relay.viewmodel.ConnectionViewModel.deleteProfileScopedSession].
*/
var profileSessionDeleter: (suspend (String) -> Boolean)? = null
/**
* Loads a session's transcript scoped to the active profile (dashboard
* `/api/sessions/{id}/messages?profile=`). Twin of [profileSessionLister]:
@@ -1819,8 +1830,24 @@ class ChatViewModel : ViewModel() {
}
viewModelScope.launch {
val success = client.deleteSession(sessionId)
if (!success && removedSession != null) {
// On the gateway, the session lives in the ACTIVE PROFILE's own
// state.db, so it must be deleted through the dashboard
// `/api/sessions/{id}?profile=` surface — the same scoping
// refreshSessions() uses for the listing. The unscoped api_server
// delete leaves a non-default profile's row intact and the next
// profile-scoped list resurrects it. Off the gateway (one shared
// api_server DB, no profiles) the plain delete is correct; the
// deleter is also null until RelayApp wires it, so fall back then.
val success = if (streamingEndpoint == "gateway") {
profileSessionDeleter?.invoke(sessionId) ?: client.deleteSession(sessionId)
} else {
client.deleteSession(sessionId)
}
if (success) {
// Re-fetch so a server that still has the row can't leave it
// resurrected in the drawer; mirrors session create's refresh.
refreshSessions()
} else if (removedSession != null) {
// Restore on failure
handler.addSession(removedSession)
}
@@ -18,6 +18,7 @@ import com.hermesandroid.relay.ui.components.avatar.PetLoader
import com.hermesandroid.relay.ui.components.avatar.SphereAvatar
import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.auth.PairedSession
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.DataManager
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.displayLabel
@@ -25,8 +26,10 @@ import com.hermesandroid.relay.data.MediaSettingsRepository
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.ConnectionValidation
import com.hermesandroid.relay.data.computeConnectionSecurity
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.SessionTransport
@@ -1037,8 +1040,35 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
suspend fun loadProfileScopedMessages(sessionId: String): Result<List<MessageItem>>? =
profileController.loadProfileScopedMessages(sessionId)
/**
* Delete a session scoped to the ACTIVE PROFILE via the dashboard
* `DELETE /api/sessions/{id}?profile=` surface — the write twin of
* [listProfileScopedSessions]. A non-default profile's sessions live in that
* profile's own `state.db`, so the unscoped api_server delete leaves the row
* intact and the next profile-scoped list resurrects it. Resolves the active
* connection + dashboard URL + profile name exactly as the lister does;
* returns `false` when there's no dashboard surface so the caller can fall
* back to the shared api_server delete.
*/
suspend fun deleteProfileScopedSession(sessionId: String): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrl() ?: return false
val profileName = AgentDisplay.profileRequestName(profileController.selectedProfile.value?.name)
return upstreamTransport.dashboardClientFor(connectionId, dashboardUrl)
.deleteSession(sessionId, profileName)
.isSuccess
}
val selectedProfile: StateFlow<Profile?> get() = profileController.selectedProfile
/**
* True once the active connection's persisted profile selection has settled,
* so cold-start profile-scoped reads (e.g. the session drawer + restored
* session context) don't race the restore and load the server-default
* profile. See [ProfileController.selectionSettled].
*/
val profileSelectionSettled: StateFlow<Boolean> get() = profileController.selectionSettled
val profileDisplayAlias: StateFlow<String?> get() = profileController.profileDisplayAlias
fun setProfileDisplayAlias(alias: String?) = profileController.setProfileDisplayAlias(alias)
@@ -1094,6 +1124,33 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
)
val isTailscaleDetected: StateFlow<Boolean> = tailscaleDetector.isTailscaleDetected
/**
* Single source of truth for the connection-security indicator (chat
* status chip, connection header, route picker, detail sheet). Rolls up
* the per-surface scheme of API / dashboard / relay against the active
* route — overlay transports (Tailscale/WireGuard/proxy) count as
* encrypted, not just TLS. Declared after [isTailscaleDetected] because it
* reads it. See `data/ConnectionSecurity.kt`.
*/
val connectionSecurity: StateFlow<ConnectionSecurity> = combine(
effectiveApiServerUrl,
effectiveDashboardUrl,
effectiveRelayUrl,
relayConfigured,
activeEndpoint,
) { api, dashboard, relay, relayCfg, endpoint ->
arrayOf(api, dashboard, relay, relayCfg, endpoint)
}.combine(isTailscaleDetected) { values, tailscale ->
computeConnectionSecurity(
apiUrl = values[0] as String,
dashboardUrl = values[1] as String,
relayUrl = values[2] as String,
relayConfigured = values[3] as Boolean,
activeEndpoint = values[4] as EndpointCandidate?,
isTailscaleDetected = tailscale,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, ConnectionSecurity.UNKNOWN)
// What's New tracking
private val _showWhatsNew = MutableStateFlow(false)
val showWhatsNew: StateFlow<Boolean> = _showWhatsNew.asStateFlow()
@@ -4568,7 +4625,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
)
if (candidate == null) {
onResult(
"Enter the API server URL — e.g. 100.71.8.56 or " +
"Enter the API server URL — e.g. 100.64.0.1 or " +
"http://host:8642 (http/https only; port defaults to 8642)",
)
return@launch
@@ -5338,21 +5395,14 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
override fun onCleared() {
super.onCleared()
connectionManager.shutdown()
// ViewModel.onCleared runs on the main thread and viewModelScope
// is already being cancelled — fire-and-forget the client
// shutdown on a plain background Thread so
// ConnectionPool.evictAll doesn't trip
// onCleared runs on the main thread, but every client's shutdown()
// routes ConnectionPool.evictAll() (a synchronous TLS socket close /
// network write) off the main thread internally via
// shutdownOffMainThread, so these direct calls can't trip
// NetworkOnMainThreadException on live SSL sockets.
_apiClient.value?.let { client ->
Thread({ runCatching { client.shutdown() } }, "HermesApiClient-shutdown").start()
}
profileChatApiClient?.let { client ->
Thread(
{ runCatching { client.shutdown() } },
"HermesProfileApiClient-shutdown",
).start()
}
connectionManager.shutdown()
_apiClient.value?.shutdown()
profileChatApiClient?.shutdown()
tailscaleDetector.shutdown()
// Release the cached VirtualDisplay + ImageReader + HandlerThread
// built by ScreenCapture on the first /screenshot call. Without
@@ -106,6 +106,43 @@ class ProfileController(
private val _pendingSelectedProfileConnectionId = MutableStateFlow<String?>(null)
private val _pendingSelectedProfileName = MutableStateFlow<String?>(null)
/**
* True once the active connection's persisted profile selection has SETTLED
* — i.e. profile-scoped reads (session drawer, transcript restore, voice
* prefs) can run without racing the cold-start restore and wrongly loading
* the SERVER-DEFAULT profile. Settled when any of these hold:
* - there's no active connection yet (nothing profile-scoped to gate), or
* - the selection has resolved into [selectedProfile], or
* - no NON-default profile is pending for the active connection (server
* default / nothing to wait for), or
* - the agent-profile list has arrived, so resolution has been ATTEMPTED —
* a genuinely-missing profile then falls back to server default rather
* than gating forever.
*
* False only in the cold-start window where a non-default profile name is
* persisted but the profile list hasn't landed yet to resolve it — exactly
* when an unscoped read would load the server-default profile by mistake.
*/
val selectionSettled: StateFlow<Boolean> = combine(
activeConnectionId,
selectedProfile,
_pendingSelectedProfileConnectionId,
_pendingSelectedProfileName,
agentProfiles,
) { connId, selected, pendingConnId, pendingName, profiles ->
when {
connId == null -> true
selected != null -> true
// Pending state still points at a previous connection mid-switch —
// hold until this connection's restore re-stamps the pending name.
pendingConnId != connId -> false
pendingName == null || AgentDisplay.isServerDefaultAlias(pendingName) -> true
// Non-default name pending: settled once the profile list is present
// (resolution attempted), even if the name turns out to be gone.
else -> profiles.isNotEmpty()
}
}.stateIn(scope, SharingStarted.Eagerly, false)
/**
* DataStore-backed persistence for the selected profile keyed by
* connection id. Public so the ViewModel's connection-lifecycle
@@ -0,0 +1,120 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Locks the connection-security rollup (the single source of truth behind the
* in-app indicator). The headline correctness property: a plaintext route over
* an overlay network (Tailscale/WireGuard) is **encrypted**, not "insecure".
*/
class ConnectionSecurityTest {
private fun endpoint(role: String, security: String? = null) = EndpointCandidate(
role = role,
api = ApiEndpoint(host = "h", port = 8642),
relay = RelayEndpoint(url = "ws://h:8767"),
security = security,
)
@Test
fun allTlsSurfaces_rollUpToTls() {
val result = computeConnectionSecurity(
apiUrl = "https://h:8642",
dashboardUrl = "https://h:9119",
relayUrl = "wss://h:8767",
relayConfigured = true,
activeEndpoint = endpoint("public"),
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Tls, result.level)
assertEquals("TLS", result.mechanism)
assertEquals(3, result.surfaces.size)
}
@Test
fun plaintextOverTailscale_isEncryptedOverlay_notPlain() {
val result = computeConnectionSecurity(
apiUrl = "http://100.71.0.1:8642",
dashboardUrl = "http://100.71.0.1:9119",
relayUrl = "ws://100.71.0.1:8767",
relayConfigured = true,
activeEndpoint = endpoint("tailscale"),
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Overlay, result.level)
assertEquals("Tailscale", result.mechanism)
// The whole point: overlay counts as encrypted.
assertEquals(true, result.isEncrypted)
}
@Test
fun someTlsSomePlain_isMixed() {
val result = computeConnectionSecurity(
apiUrl = "https://h:8642",
dashboardUrl = "https://h:9119",
relayUrl = "ws://h:8767",
relayConfigured = true,
activeEndpoint = endpoint("lan"),
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Mixed, result.level)
assertEquals(false, result.isEncrypted)
}
@Test
fun allPlainLan_isPlain_withRoleMechanism() {
val result = computeConnectionSecurity(
apiUrl = "http://192.168.1.10:8642",
dashboardUrl = "http://192.168.1.10:9119",
relayUrl = "ws://192.168.1.10:8767",
relayConfigured = true,
activeEndpoint = endpoint("lan"),
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Plain, result.level)
assertEquals("LAN", result.mechanism)
}
@Test
fun relayNotConfigured_excludesRelaySurface() {
val result = computeConnectionSecurity(
apiUrl = "https://h:8642",
dashboardUrl = "https://h:9119",
relayUrl = "ws://h:8767", // plain, but relay not configured → ignored
relayConfigured = false,
activeEndpoint = endpoint("public"),
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Tls, result.level)
assertEquals(2, result.surfaces.size)
}
@Test
fun noSurfaces_isUnknown() {
val result = computeConnectionSecurity(
apiUrl = "",
dashboardUrl = "",
relayUrl = "",
relayConfigured = false,
activeEndpoint = null,
isTailscaleDetected = false,
)
assertEquals(ConnectionSecurityLevel.Unknown, result.level)
assertEquals(ConnectionSecurity.UNKNOWN, result)
}
@Test
fun deviceTailscaleDetected_withSecurityHint_classifiesOverlay() {
val result = computeConnectionSecurity(
apiUrl = "http://host:8642",
dashboardUrl = "http://host:9119",
relayUrl = "ws://host:8767",
relayConfigured = false,
activeEndpoint = endpoint(role = "custom", security = "tailscale-magicdns"),
isTailscaleDetected = true,
)
assertEquals(ConnectionSecurityLevel.Overlay, result.level)
assertEquals("Tailscale", result.mechanism)
}
}
@@ -18,8 +18,8 @@ class ConnectionUrlInputNormalizationTest {
@Test
fun bareIp_getsSchemeAndDefaultPort() {
assertEquals(
"http://100.71.8.56:8642",
Connection.normalizeApiUrlInput("100.71.8.56"),
"http://100.64.0.1:8642",
Connection.normalizeApiUrlInput("100.64.0.1"),
)
}
@@ -60,8 +60,8 @@ class ConnectionUrlInputNormalizationTest {
@Test
fun whitespaceAndTrailingSlash_areTrimmed() {
assertEquals(
"http://100.71.8.56:8642",
Connection.normalizeApiUrlInput(" 100.71.8.56/ "),
"http://100.64.0.1:8642",
Connection.normalizeApiUrlInput(" 100.64.0.1/ "),
)
}
@@ -95,7 +95,7 @@ class ConnectionUrlInputNormalizationTest {
// End-to-end: the exact user journey from the bug report — typing a
// bare Tailscale IP must yield a plain-HTTP (tls=false) candidate on
// port 8642 with the tailscale role inferred.
val normalized = Connection.normalizeApiUrlInput("100.71.8.56")
val normalized = Connection.normalizeApiUrlInput("100.64.0.1")
val candidate = Connection.endpointCandidateFromApiUrl(
role = "",
priority = 1,
@@ -103,7 +103,7 @@ class ConnectionUrlInputNormalizationTest {
relayUrl = "",
)
assertEquals("tailscale", candidate?.role)
assertEquals("100.71.8.56", candidate?.api?.host)
assertEquals("100.64.0.1", candidate?.api?.host)
assertEquals(8642, candidate?.api?.port)
assertEquals(false, candidate?.api?.tls)
}
@@ -0,0 +1,63 @@
package com.hermesandroid.relay.network
import android.os.Looper
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* Guards the fix for the `NetworkOnMainThreadException` crash (issues #70 /
* #118 / #124): client `shutdown()` reaches `ConnectionPool.evictAll()`, which
* closes live TLS sockets with a synchronous network write. The teardown block
* must never execute on the main thread.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NetworkShutdownTest {
@Test
fun whenCalledOnMainThread_runsTeardownOffTheMainThread() {
// Robolectric drives the test body on the main looper — the same place
// a viewModelScope (Dispatchers.Main.immediate) coroutine resumes and
// shuts a dashboard/API client down in a `finally` block.
assertSame(Looper.myLooper(), Looper.getMainLooper())
val mainThread = Looper.getMainLooper().thread
val ranOn = AtomicReference<Thread>()
val latch = CountDownLatch(1)
shutdownOffMainThread("test-shutdown") {
ranOn.set(Thread.currentThread())
latch.countDown()
}
assertTrue("teardown block never ran", latch.await(5, TimeUnit.SECONDS))
assertNotSame(
"evictAll must not run on the main thread",
mainThread,
ranOn.get(),
)
}
@Test
fun whenCalledOffMainThread_runsTeardownInline() {
val ranOn = AtomicReference<Thread>()
val latch = CountDownLatch(1)
val worker = Thread {
shutdownOffMainThread("test-shutdown") { ranOn.set(Thread.currentThread()) }
latch.countDown()
}
worker.start()
assertTrue(latch.await(5, TimeUnit.SECONDS))
// Off the main thread the block runs inline (no extra hop), preserving
// the blocking awaitTermination semantics for callers already off main.
assertSame(worker, ranOn.get())
}
}
@@ -10,16 +10,16 @@ class RelayUrlDeriverTest {
@Test
fun derivesPlainLanRelayUrlFromApiUrl() {
assertEquals(
"ws://172.16.24.250:8767",
RelayUrlDeriver.deriveFromApiUrl("http://172.16.24.250:8642"),
"ws://192.168.1.100:8767",
RelayUrlDeriver.deriveFromApiUrl("http://192.168.1.100:8642"),
)
}
@Test
fun derivesTlsRelayUrlFromApiUrl() {
assertEquals(
"wss://docker-server.tailnet.ts.net:8767",
RelayUrlDeriver.deriveFromApiUrl("https://docker-server.tailnet.ts.net:8642"),
"wss://hermes-host.tailnet.ts.net:8767",
RelayUrlDeriver.deriveFromApiUrl("https://hermes-host.tailnet.ts.net:8642"),
)
}
@@ -9,10 +9,10 @@ class ProfileApiUrlResolverTest {
@Test
fun resolveForConnection_rewritesLoopbackProfileHostToBaseHost() {
assertEquals(
"http://172.16.24.250:8647",
"http://192.168.1.100:8647",
ProfileApiUrlResolver.resolveForConnection(
profileApiUrl = "http://127.0.0.1:8647",
baseApiUrl = "http://172.16.24.250:8642",
baseApiUrl = "http://192.168.1.100:8642",
),
)
}
@@ -20,10 +20,10 @@ class ProfileApiUrlResolverTest {
@Test
fun resolveForConnection_rewritesZeroBindHostToBaseHost() {
assertEquals(
"https://docker-server.tailnet.ts.net:8646",
"https://hermes-host.tailnet.ts.net:8646",
ProfileApiUrlResolver.resolveForConnection(
profileApiUrl = "http://0.0.0.0:8646/",
baseApiUrl = "https://docker-server.tailnet.ts.net:8642/",
baseApiUrl = "https://hermes-host.tailnet.ts.net:8642/",
),
)
}
@@ -34,7 +34,7 @@ class ProfileApiUrlResolverTest {
"http://192.168.1.50:8647",
ProfileApiUrlResolver.resolveForConnection(
profileApiUrl = "http://192.168.1.50:8647",
baseApiUrl = "http://172.16.24.250:8642",
baseApiUrl = "http://192.168.1.100:8642",
),
)
}
@@ -55,7 +55,7 @@ class ProfileApiUrlResolverTest {
assertNull(
ProfileApiUrlResolver.resolveForConnection(
profileApiUrl = " ",
baseApiUrl = "http://172.16.24.250:8642",
baseApiUrl = "http://192.168.1.100:8642",
),
)
}
@@ -8,8 +8,11 @@ import com.hermesandroid.relay.data.RealtimeTurnTrace
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoiceIntentTrace
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
import com.hermesandroid.relay.network.upstream.models.SessionItem
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -1196,6 +1199,128 @@ class ChatHandlerTest {
assertNull(handler.messages.value[0].voiceIntent)
}
// --- Relay typed stream.event rendering ---
@Test
fun applyRelayStreamEvent_rendersAssistantDeltaToolLifecycleAndDone() {
handler.addPlaceholderMessage(
ChatMessage(
id = "assist-relay",
role = MessageRole.ASSISTANT,
content = "",
timestamp = 1L,
isStreaming = true,
)
)
handler.applyRelayStreamEvent(
"assist-relay",
RelayStreamEventEnvelope(
sessionId = "sess-1",
runId = "run-1",
seq = 1,
event = "assistant.delta",
payload = buildJsonObject { put("delta", "Hello") },
),
)
handler.applyRelayStreamEvent(
"assist-relay",
RelayStreamEventEnvelope(
sessionId = "sess-1",
runId = "run-1",
seq = 2,
event = "tool.started",
payload = buildJsonObject {
put("tool_name", "terminal")
put("call_id", "call-1")
},
),
)
handler.applyRelayStreamEvent(
"assist-relay",
RelayStreamEventEnvelope(
sessionId = "sess-1",
runId = "run-1",
seq = 3,
event = "tool.completed",
payload = buildJsonObject {
put("tool_name", "terminal")
put("call_id", "call-1")
put("result_preview", "ok")
},
),
)
handler.applyRelayStreamEvent(
"assist-relay",
RelayStreamEventEnvelope(
sessionId = "sess-1",
runId = "run-1",
seq = 4,
event = "done",
payload = buildJsonObject { put("state", "final") },
),
)
val msg = handler.messages.value.single()
assertEquals("Hello", msg.content)
assertFalse(msg.isStreaming)
assertEquals(1, msg.toolCalls.size)
assertEquals("terminal", msg.toolCalls[0].name)
assertTrue(msg.toolCalls[0].isComplete)
assertEquals("ok", msg.toolCalls[0].result)
}
@Test
fun applyRelayStreamEvent_rendersProgressArtifactAndErrorStates() {
handler.addPlaceholderMessage(
ChatMessage(
id = "assist-relay",
role = MessageRole.ASSISTANT,
content = "",
timestamp = 1L,
isStreaming = true,
)
)
handler.applyRelayStreamEvent(
"assist-relay",
RelayStreamEventEnvelope(
sessionId = "sess-1",
runId = "run-1",
seq = 1,
event = "tool.progress",
payload = buildJsonObject { put("delta", "Thinking...") },
),
)
handler.applyRelayStreamEvent(
"assist-relay",
RelayStreamEventEnvelope(
sessionId = "sess-1",
runId = "run-1",
seq = 2,
event = "artifact.created",
payload = buildJsonObject { put("url", "https://example.invalid/artifact") },
),
)
handler.applyRelayStreamEvent(
"assist-relay",
RelayStreamEventEnvelope(
sessionId = "sess-1",
runId = "run-1",
seq = 3,
event = "error",
payload = buildJsonObject { put("message", "boom") },
),
)
val msg = handler.messages.value.single()
assertTrue(msg.thinkingContent.contains("Thinking..."))
assertTrue(msg.thinkingContent.contains("Artifact:"))
assertTrue(msg.badges.contains("Error"))
assertEquals("boom", handler.error.value)
}
// --- Helper ---
private fun createUserMessage(id: String, content: String) = ChatMessage(
@@ -25,7 +25,7 @@ class DashboardManageDiskCacheTest {
}
private fun sampleEntries(): Map<String, PersistedDashboardPayload> = mapOf(
"conn-1|http://100.71.8.56:9119|/api/skills" to PersistedDashboardPayload(
"conn-1|http://100.64.0.1:9119|/api/skills" to PersistedDashboardPayload(
status = DashboardStatus(
authRequired = true,
authProviders = listOf("password"),
+7 -7
View File
@@ -143,11 +143,11 @@ In the tray app, open **Pair** and paste the `hermes-relay://pair?...` URL into
hermes-relay pair --pair-qr 'hermes-relay://pair?payload=...' --grant-tools
# ✓ Paired. Token stored in ~/.hermes/remote-sessions.json
# Server: 0.6.0
# Relay: ws://172.16.24.250:8767
# Relay: ws://192.168.1.100:8767
```
Manual URL + six-character code pairing still works with
`hermes-relay pair --remote ws://172.16.24.250:8767`, but the invite URL is
`hermes-relay pair --remote ws://192.168.1.100:8767`, but the invite URL is
the preferred path because it carries endpoint candidates and the correct
relay one-shot code.
@@ -195,7 +195,7 @@ Herm uses `bun add -g herm-tui` when Bun is available and falls back to `npm ins
If you plan to run `daemon` (headless tool serving), tack `--grant-tools` onto `pair` to capture the per-URL desktop-tool consent in the same step. That removes the historical `pair` → `shell` (consent prompt) → `daemon` dance:
```sh
hermes-relay pair --remote ws://172.16.24.250:8767 --grant-tools
hermes-relay pair --remote ws://192.168.1.100:8767 --grant-tools
# ...prompts for code, then prompts for tool consent, stamps it on the stored session.
hermes-relay daemon
@@ -206,7 +206,7 @@ For non-interactive provisioning (CI, install scripts, automated boxes) use `--a
```sh
HERMES_RELAY_CODE=F3W7EY hermes-relay pair \
--remote ws://172.16.24.250:8767 --auto-grant-tools --non-interactive
--remote ws://192.168.1.100:8767 --auto-grant-tools --non-interactive
```
The two flags are deliberately separate so consent is never implicit — `--grant-tools` means "ask me", `--auto-grant-tools` means "I've already decided". Plain `pair` (no flag) leaves consent untouched, matching the original behavior.
@@ -326,7 +326,7 @@ hermes-relay
```
```
Connecting to ws://172.16.24.250:8767...
Connecting to ws://192.168.1.100:8767...
Connected (server 0.6.0).
Session 4a3c1f2e… on claude-opus-4-7
@@ -374,7 +374,7 @@ hermes-relay tools
```
```
Server: ws://172.16.24.250:8767
Server: ws://192.168.1.100:8767
Version: 0.6.0
Toolsets: 18 (12 enabled)
@@ -433,7 +433,7 @@ $ hermes-relay daemon status
hermes-relay daemon
state: ● connected
pid: 48213
relay: ws://172.16.24.250:8767
relay: ws://192.168.1.100:8767
uptime: 3h 12m
updated: 4s ago
server: 1.2.0
+2 -2
View File
@@ -222,7 +222,7 @@ Flags:
Examples:
# First time: pair with the relay (one-time code from \`hermes-pair\` on the server)
hermes-relay pair --remote ws://172.16.24.250:8767
hermes-relay pair --remote ws://192.168.1.100:8767
# ...prompts for code, stores a token in ~/.hermes/remote-sessions.json
# REPL — reuses the tray-selected active relay or stored token
@@ -248,7 +248,7 @@ Examples:
hermes-relay plugins launch herm
# Two-command bring-up: pair with consent, then run headless. No \`shell\` round-trip.
hermes-relay pair --remote ws://172.16.24.250:8767 --grant-tools
hermes-relay pair --remote ws://192.168.1.100:8767 --grant-tools
hermes-relay daemon
Config files:
+5
View File
@@ -482,6 +482,11 @@ export class RelayTransport extends EventEmitter implements Transport {
payload.ttl_seconds = this.cfg.ttlSeconds
}
payload.supports = {
typed_stream_events: true,
event_schema_version: 1
}
this.sendEnvelope('system', 'auth', payload)
}
+9 -3
View File
@@ -438,7 +438,7 @@ The bare-path fetch is therefore safe as long as operators treat the allowed-roo
**Decision:** Replace the minimal pairing model (one-shot code → fixed-30-day session token → no channel separation → `EncryptedSharedPreferences` storage) with a layered architecture built around four ideas:
1. **User chooses session TTL at pair time** — 1 day / 7 days / 30 days / 90 days / 1 year / **never expire**. The Android TTL picker dialog always opens on QR scan so the user explicitly confirms. Defaults depend on transport: wss or Tailscale → 30d; plain ws → 7d. Never-expire is ALWAYS selectable with an inline warning — per operator direction, trust the user's intent rather than gating on secure-transport detection.
1. **User chooses session TTL at pair time** — 1 day / 7 days / 30 days / 90 days / 1 year / **never expire**. The Android TTL picker dialog always opens on QR scan so the user explicitly confirms. Defaults depend on transport: wss or Tailscale → 30d; plain ws → 7d. (Both `wss` and Tailscale are treated as *secure transports* here — but for different reasons: `wss` is TLS, while Tailscale's security comes from WireGuard end-to-end encryption, not TLS. See [`user-docs/architecture/connection-security.md`](../user-docs/architecture/connection-security.md).) Never-expire is ALWAYS selectable with an inline warning — per operator direction, trust the user's intent rather than gating on secure-transport detection.
2. **Per-channel grants** — one session token, separate expiries for `chat` / `terminal` / `bridge`; later releases added `tui` and split voice grants (`voice:config`, `voice:stt`, `voice:tts`). Blast-radius-heavy channels can have shorter caps, and all grants are clamped to the session lifetime. Chat runs through the hermes-agent API server rather than the relay, so the chat grant is informational only (used by the phone UI to show scope).
3. **Hardware-backed token storage with graceful fallback** — `KeystoreTokenStore` requests StrongBox-backed keys via `setRequestStrongBoxBacked(true)` on Android 9+ devices that advertise `FEATURE_STRONGBOX_KEYSTORE`. Falls back to the existing `LegacyEncryptedPrefsTokenStore` (TEE-backed `EncryptedSharedPreferences`) on older devices or when the Keystore path throws. Migration is one-shot and lossless — users never lose a session to an app upgrade.
4. **TOFU cert pinning with explicit reset on re-pair** — `CertPinStore` records SHA-256 SPKI fingerprints per `host:port` on the first successful wss connect. Subsequent connects build an OkHttp `CertificatePinner` from the stored pin. A user-initiated QR re-pair (`applyServerIssuedCodeAndReset(code, relayUrl)`) wipes the pin for the target host — re-pair is explicit consent to potentially-new cert material. Plaintext ws:// short-circuits pinning entirely.
@@ -1077,8 +1077,14 @@ priority-0 candidate from the top-level fields when `endpoints` is absent.
phone falls through to the next candidate in priority order.
- **TTL defaults by role** (informational — operator can override at
pair time): `lan` → 7 days, `tailscale` → 30 days, `public` → 30 days,
unknown role → 7 days (conservative). Plaintext-`ws://` consent still
gates any candidate with `transport_hint = "ws"`.
unknown role → 7 days (conservative). The longer `tailscale` default
reflects that the tailnet is a *secure transport* (WireGuard end-to-end
encryption + device identity) — not that the link is TLS. A
`tailscale` candidate can carry a plain `transport_hint = "ws"` and
still be encrypted; that's WireGuard, not TLS. See
[`user-docs/architecture/connection-security.md`](../user-docs/architecture/connection-security.md).
Plaintext-`ws://` consent still gates any candidate with
`transport_hint = "ws"`.
**Canonicalization for the HMAC signature:** `canonicalize()` in
`plugin/relay/qr_sign.py` uses `json.dumps(sort_keys=True,
+11
View File
@@ -0,0 +1,11 @@
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":1,"event":"session.created","ts":"2026-06-05T00:00:00Z","payload":{"title":"Typed stream fixture"}}
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":2,"event":"run.started","ts":"2026-06-05T00:00:01Z","payload":{"user_message":{"id":"user_1","role":"user","content":"Run a command"}}}
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":3,"event":"message.started","ts":"2026-06-05T00:00:02Z","payload":{"message":{"id":"msg_1","role":"assistant"}}}
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":4,"event":"tool.progress","ts":"2026-06-05T00:00:03Z","payload":{"delta":"Preparing terminal command"}}
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":5,"event":"tool.started","ts":"2026-06-05T00:00:04Z","payload":{"tool_name":"terminal","call_id":"call_1","preview":"echo ok","args":{"cmd":"echo ok"}}}
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":6,"event":"tool.completed","ts":"2026-06-05T00:00:05Z","payload":{"tool_name":"terminal","call_id":"call_1","result_preview":"ok","success":true}}
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":7,"event":"artifact.created","ts":"2026-06-05T00:00:06Z","payload":{"title":"terminal-log.txt","url":"https://preview.example.invalid/artifacts/terminal-log.txt"}}
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":8,"event":"assistant.delta","ts":"2026-06-05T00:00:07Z","payload":{"message_id":"msg_1","delta":"Command completed successfully."}}
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":9,"event":"assistant.completed","ts":"2026-06-05T00:00:08Z","payload":{"message_id":"msg_1","completed":true,"partial":false,"interrupted":false}}
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":10,"event":"run.completed","ts":"2026-06-05T00:00:09Z","payload":{"completed":true,"partial":false,"interrupted":false}}
{"type":"stream.event","schema_version":1,"session_id":"sess_fixture","run_id":"run_fixture","seq":11,"event":"done","ts":"2026-06-05T00:00:10Z","payload":{"state":"final"}}
@@ -1014,7 +1014,7 @@
<div class="sphere"></div>
<div class="thread">
<div class="bubble user">Can you check the relay sessions and keep the terminal nearby?</div>
<div class="bubble tool">terminal.attach · session: docker-server</div>
<div class="bubble tool">terminal.attach · session: hermes-host</div>
<div class="bubble">I found 3 active grants. Terminal is attached and ready.</div>
</div>
<div class="quick-rail">
@@ -1278,7 +1278,7 @@
<span>rail</span>
</div>
<div class="tile-list">
<div class="tile primary"><div class="mini-icon">⌁</div><div><h4>Terminal</h4><p>docker-server</p></div><div></div></div>
<div class="tile primary"><div class="mini-icon">⌁</div><div><h4>Terminal</h4><p>hermes-host</p></div><div></div></div>
<div class="tile"><div class="mini-icon">▤</div><div><h4>Notifications</h4><p>2 apps shared</p></div><div></div></div>
<div class="tile"><div class="mini-icon">☤</div><div><h4>Profile</h4><p>Victor memory</p></div><div></div></div>
</div>
+4 -4
View File
@@ -12,7 +12,7 @@
> - [`ROADMAP.md`](../../ROADMAP.md) — where this pass sits in the broader arc
> - [`docs/plans/2026-04-13-bridge-feature-expansion.md`](./2026-04-13-bridge-feature-expansion.md) — precedent for plan-file format
> - [`DEVLOG.md`](../../DEVLOG.md) — append session entry on completion
> - Upstream reference: `~/.hermes/hermes-agent/tools/tts_tool.py` on hermes-host (`bailey@172.16.24.250`)
> - Upstream reference: `~/.hermes/hermes-agent/tools/tts_tool.py` on hermes-host (`you@hermes-host`)
## How to use this file
@@ -83,11 +83,11 @@ cd ../hermes-android-voice
**Implementation notes.**
- `eleven_flash_v2_5` costs fewer characters per dollar than `multilingual_v2` — net win on billing too.
- Voice ID stays at `XZEfcFyBnzsNJrdvkWdI` (Bailey's current custom voice).
- Voice ID stays at `<your-voice-id>` (Bailey's current custom voice).
- Rollback: revert the config line, restart gateway. Zero risk.
**Agent brief.**
> This is an operator-executed step, not an agent task. The orchestrator should SSH to `bailey@172.16.24.250` (key auth), edit `~/.hermes/config.yaml`, restart `hermes-gateway.service`, and verify with a single voice request. Document the before/after impression in the session commit message of Wave 1.
> This is an operator-executed step, not an agent task. The orchestrator should SSH to `you@hermes-host` (key auth), edit `~/.hermes/config.yaml`, restart `hermes-gateway.service`, and verify with a single voice request. Document the before/after impression in the session commit message of Wave 1.
**Dependencies.** None.
@@ -210,7 +210,7 @@ cd ../hermes-android-voice
- Keep the PR scope narrow — one function + one config key + one docs page. Don't bundle other voice improvements.
**Agent brief.**
> SSH to `bailey@172.16.24.250`. Work in `~/.hermes/hermes-agent/` on the `Codename-11/hermes-agent` fork. Create branch `feat/elevenlabs-voice-settings`. Patch `tools/tts_tool.py::_generate_elevenlabs` to accept `voice_settings` from the elevenlabs config block and pass through a `VoiceSettings(...)` object (defaults: stability=0.65, similarity_boost=0.8, style=0.0, use_speaker_boost=True). Add a graceful degradation for older elevenlabs SDKs. Update the upstream `docs/configuration/tts.md` reference. Run the project's test suite. Push the branch, open a PR against `NousResearch/hermes-agent` — cross-reference PR #8556 for style. Then apply the patch locally on the running `axiom` branch and restart `hermes-gateway` so V2/V3/V4 can benefit during testing. Report back the PR URL and the locally-applied confirmation.
> SSH to `you@hermes-host`. Work in `~/.hermes/hermes-agent/` on the `Codename-11/hermes-agent` fork. Create branch `feat/elevenlabs-voice-settings`. Patch `tools/tts_tool.py::_generate_elevenlabs` to accept `voice_settings` from the elevenlabs config block and pass through a `VoiceSettings(...)` object (defaults: stability=0.65, similarity_boost=0.8, style=0.0, use_speaker_boost=True). Add a graceful degradation for older elevenlabs SDKs. Update the upstream `docs/configuration/tts.md` reference. Run the project's test suite. Push the branch, open a PR against `NousResearch/hermes-agent` — cross-reference PR #8556 for style. Then apply the patch locally on the running `axiom` branch and restart `hermes-gateway` so V2/V3/V4 can benefit during testing. Report back the PR URL and the locally-applied confirmation.
**Dependencies.** Benefits from Cfg1 being done first (both ship settings for ElevenLabs — makes config review cleaner).
+3 -3
View File
@@ -7,7 +7,7 @@
## Goal
`hermes --remote wss://docker-server.ts.net:8767` on Windows (or any machine with Node ≥ 20) opens the Hermes TUI with full feature parity — approvals, tool cards, image paste, voice, session resume — against a remote `hermes-agent` brain. No SSH, no X11 forwarding, no `mosh`. WSS transport, bearer auth, TOFU cert pinning, reconnect-safe.
`hermes --remote wss://hermes-host.tailnet.ts.net:8767` on Windows (or any machine with Node ≥ 20) opens the Hermes TUI with full feature parity — approvals, tool cards, image paste, voice, session resume — against a remote `hermes-agent` brain. No SSH, no X11 forwarding, no `mosh`. WSS transport, bearer auth, TOFU cert pinning, reconnect-safe.
This is the **v0.1 / Option C (hybrid)** shipping target from the high-level design. All tools still execute **server-side**. Per-tool client-side routing (Option B) is explicitly v2.
@@ -95,7 +95,7 @@ Confirm exact invocation by reading `hermes_cli/main.py:1034` area (agent will d
- `~/.hermes/remote-sessions.json` storage (mirror Android `SessionTokenStore` semantics — fail closed, atomic write).
- Desktop cert-pin store (JSON file, SHA-256 SPKI per `host:port`, first-seen TOFU).
- Reconnect/backoff logic in `RelayTransport`.
- **End-to-end test:** from this Docker-Server → start relay → paste code → from Windows → `hermes --remote wss://docker-server.ts.net:8767` → full session works (prompt, tool use, image paste, approval modal).
- **End-to-end test:** from this hermes-host → start relay → paste code → from Windows → `hermes --remote wss://hermes-host.tailnet.ts.net:8767` → full session works (prompt, tool use, image paste, approval modal).
### Phase 4 — Polish + docs *(post-smoke)*
- Update `~/.hermes/hermes-relay/README.md` with desktop install section.
@@ -136,7 +136,7 @@ Confirm exact invocation by reading `hermes_cli/main.py:1034` area (agent will d
## Success Metric
From a fresh Windows machine: install Node 20, `npm install -g @codename-11/hermes-tui-remote` (or equivalent), `hermes --remote wss://docker-server.ts.net:8767 --pair ABC123`, and do a full interactive session (prompt → tool use → image paste → approval) without a single "feature missing" or "protocol mismatch" error.
From a fresh Windows machine: install Node 20, `npm install -g @codename-11/hermes-tui-remote` (or equivalent), `hermes --remote wss://hermes-host.tailnet.ts.net:8767 --pair ABC123`, and do a full interactive session (prompt → tool use → image paste → approval) without a single "feature missing" or "protocol mismatch" error.
## Follow-up Ideas (explicit non-MVP)
@@ -243,7 +243,7 @@ python -m unittest plugin.tests.test_realtime_voice_routes plugin.tests.test_voi
1. Land capability guard first if OpenAI remains selectable before native support is ready.
2. Implement the OpenAI provider behind tests.
3. Register OpenAI as native only after adapter and broker tests pass.
4. Deploy relay-only files to `bailey@docker-server.local`.
4. Deploy relay-only files to `you@hermes-host`.
5. Restart `hermes-relay.service`.
6. Verify `/health`.
7. Test Android Realtime Agent with `openai_realtime`, including:
@@ -0,0 +1,199 @@
# Connection Security Indicator — Surfacing, Wording & Docs Plan
**Status:** Draft for review (no implementation yet — placement decisions pending)
**Date:** 2026-06-24
**Owner surface:** Android app (UI), user docs, engineering docs
**Companion to:** [`docs/plans/2026-06-18-native-secure-routes.md`](2026-06-18-native-secure-routes.md) (Features-vs-Routes split + plugin secure proxy mechanics). That plan owns *how routes work*; **this plan owns how security is communicated** to the user across every surface.
**Goal:** Let a user tell, at a glance and without ambiguity, whether their connection to Hermes is encrypted — and by what (TLS, Tailscale/WireGuard, or not at all) — without the app lying or scaring people who are already secure.
---
## Bottom line
Users keep asking "is this secure?" The honest answer today is *"yes, but the app barely tells you, and where it does, it sometimes lies."* The security **model already exists** in code — it's just (a) buried in `Manage → Connections → Advanced`, (b) mislabelled (a Tailscale route is reported as **"Secure — TLS"** when it's actually WireGuard, not TLS), and (c) absent from every at-a-glance surface (the chat status chip, the connection header, the route picker).
This is a **surfacing + wording + docs** task, not a greenfield feature. We promote the existing computation to a single source of truth, correct the copy, place a glanceable badge on the high-traffic surfaces, add a tap-through "Connection security" explainer, and fix the docs that conflate "Tailscale" with "TLS."
---
## What already exists (do not rebuild)
| Asset | File | What it does |
|---|---|---|
| Tri-state model | `ui/components/TransportSecurityBadge.kt` — `TransportSecurityState { AllSecure, Mixed, AllInsecure }` | Badge with lock/shield/lock-open icons + 3 size variants. |
| Overlay-aware "is this route encrypted" | `ActiveConnectionSections.kt:1233` `isSelectedRouteUrlSecure()` → `isEncryptedOverlayRoute()` (`:1242`) | **Already** treats `role=="tailscale"`, `plugin_proxy`, WireGuard/HTTPS security hints, and `hasSecureProxy()` as encrypted — not just `wss`/`https`. |
| Security posture strip | `ActiveConnectionSections.kt:1109` `ActiveCardSecurityPosture` | Renders the badge + "Tailscale detected" + "hardware keystore" + relay-sessions row. **Buried** under the Advanced section. |
| Insecure consent | `ui/components/InsecureConnectionAckDialog.kt`; `ConnectionManager.kt:156–325` (`insecureMode`/`isInsecureConnection`, ws:// block) | Threat-model dialog + reason picker; blocks `ws://` unless insecure mode is on. |
| TOFU cert pinning | `auth/CertPinStore.kt` (TLS-only, per `host:port`) | Pins on first `wss`/`https` connect. Not surfaced to users. |
| Per-endpoint label | `data/Endpoint.kt:136` `displayLabel()` | "LAN" / "Tailscale" / "HTTPS" / "Plugin proxy". |
**The three concrete defects to fix:**
1. **Buried** — the only real security readout lives below `Advanced` on the Manage tab. Most users never see it.
2. **The "TLS lie"** — `resolveStateAppearance(AllSecure)` hardcodes the label **"Secure — TLS"** even when the secure-ness comes from Tailscale/WireGuard (`isEncryptedOverlayRoute` returned true for a `ws://` Tailscale route). Saying "TLS" for a non-TLS link is wrong and erodes trust.
3. **No glanceable surface** — the chat status chip (`RelayApp.kt` ~`920–975`, `ChatTransportStatusBadge.kt`), the connection card header, and the route picker (`EndpointsCard.kt`) show the *route name* but never its *security*.
---
## The hard question: 3 transports → is "secure" even well-defined?
**You asked: does having 3 potential transports make this hard to call "secure"? Yes — and that's the core design problem.** A single paired connection fans out to several surfaces, each with an **independent** scheme (confirmed in `Endpoint.kt:37–94` + `ConnectionViewModel.kt:746–820`):
| Surface | Client | Scheme source | Can be plain while others are TLS? |
|---|---|---|---|
| Gateway chat (`/api/ws`) | `GatewayChatClient` | dashboard URL scheme | yes |
| API / sessions (SSE) | `HermesApiClient` | `endpoint.api.tls` | yes |
| Dashboard (Manage/voice) | `DashboardApiClient` | `endpoint.dashboard.url` ∨ derived from `api.tls` | yes |
| Relay (terminal/bridge/tools) | `ConnectionManager` | `endpoint.relay.url` (`ws`/`wss`) | yes |
So **a connection is not uniformly secure** — relay can be `ws://` while the API is `https://`. (Concretely: one paired connection can carry API `https://host:8642`, dashboard derived to `https://host:9119`, and relay `ws://host:8767` — secure chat/Manage, plain relay — at the same time.) A single binary "Secure" badge would lie. The existing `AllSecure / Mixed / AllInsecure` rollup is the right instinct; we keep it but make it **honest and overlay-aware**.
**Decision (proposed):** show a **connection-level rollup for the glance, per-surface truth on tap.**
- **Glance badge** = worst-case across the surfaces *actually in use*: all encrypted → secure; some plain → "Mixed"; all plain with no overlay → "Not encrypted."
- **Tap → detail sheet** = the per-surface breakdown (Chat/API: 🔒, Relay: ⚠️, …) so power users get the truth without the chip having to.
- Crucially, **"encrypted" includes overlay transports** (Tailscale/WireGuard/plugin proxy), not just TLS — because for the user those *are* secure end-to-end.
---
## The wording model (the part that fixes the trust problem)
Reframe from a binary "Secure/Insecure" to **mechanism-first, 4 outcomes**. The key correction: **Tailscale is secure** — WireGuard gives end-to-end encryption + device identity, arguably stronger than TOFU-pinned TLS. Telling a Tailscale user they're "insecure/plain" is both wrong and the likely reason they keep asking.
| State | When | Icon | Chip copy | Tone |
|---|---|---|---|---|
| **TLS** | every in-use surface is `wss`/`https` | 🔒 Lock | `Encrypted · TLS` | green |
| **Private network** | plain scheme, but route is Tailscale / WireGuard / plugin proxy | 🛡️ Shield | `Encrypted · Tailscale` (or `· WireGuard` / `· Proxy`) | green |
| **Mixed** | some surfaces encrypted, some plain (a secure fallback exists) | 🛡️ Shield | `Mixed routes` | amber |
| **Not encrypted** | plain `ws`/`http`, no overlay | ⚠️ Lock-open | `Not encrypted · LAN` | amber→red by context |
Notes:
- Both 🔒 and 🛡️ are **green/"secure"** — only true plaintext-without-overlay is a warning. This is the single most important copy change.
- Keep "Plain"/"Not encrypted" (never a blank); avoid the word "Insecure" in the chip (reserve it for the consent dialog where the threat model is explained).
- The detail sheet spells out the distinction in one line each: *"TLS — encrypted to this server's certificate (pinned on first connect)."* / *"Tailscale — encrypted by your tailnet (WireGuard), not TLS."* / *"Not encrypted — only safe on a network you fully trust."*
- **Code change:** replace the hardcoded `"Secure — TLS"` label (`TransportSecurityBadge.kt:219`) with mechanism-derived copy, and split `AllSecure` into `Tls` vs `Overlay` so the badge can say which.
---
## Placement audit & recommendation
Full surface inventory in the appendix. Recommended placements, highest-traffic first:
### P1 — Chat bottom status chip (the one everyone sees)
`RelayApp.kt` ~`920–975`, beside `ChatTransportStatusBadge` + route label. Today: `⚡ Gateway · Tailscale gpt-5.5 / profile: default`. Add a leading security glyph:
```
┌─────────────────────────────────────────────────────────┐
│ ⚡ Gateway 🛡️ Tailscale gpt-5.5 / profile: default │ ← encrypted via Tailscale (green shield)
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ⚡ Gateway 🔒 TLS gpt-5.5 / profile: default │ ← encrypted via TLS (green lock)
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ⚡ Gateway ⚠️ Not encrypted gpt-5.5 / profile: default │ ← plain LAN, no overlay (amber)
└─────────────────────────────────────────────────────────┘
```
Glyph replaces/precedes the bare route word so "Tailscale" now reads as *secure-Tailscale*. Tap the chip → **Connection security** detail sheet.
### P2 — Connection card header (Manage → Connections)
`ActiveConnectionSections.kt` card header — add the same badge next to the `Active` pill so the connection list communicates security without expanding Advanced. Promotes the existing `ActiveCardSecurityPosture` logic up out of the Advanced fold.
### P3 — Route picker (`EndpointsCard.kt`)
Per-route security glyph on each candidate row, so when a user switches routes they see which are encrypted *before* committing:
```
○ LAN ⚠️ Not encrypted 192.168.x.x · Probe ✓
● Tailscale 🛡️ Encrypted 100.x.y.z · Active
○ Public 🔒 TLS <host>.ts.net · Probe ✓
```
### P4 — "Connection security" detail sheet (new, the tap target for P1/P2)
A small bottom sheet that is the single place the per-surface truth + the explainer lives:
```
Connection security — <your server>
────────────────────────────────────
Overall 🛡️ Encrypted (Tailscale)
Chat (gateway) 🛡️ Tailscale http://100.x.y.z:9119
API / sessions 🛡️ Tailscale http://100.x.y.z:8642
Relay tools 🛡️ Tailscale ws://100.x.y.z:8767
────────────────────────────────────
🛡️ Tailscale encrypts this with WireGuard (not TLS).
Cert pinning applies only to TLS routes.
[ Learn about connection security → ] (docs link)
```
> **For your review:** P1 + P4 are the must-haves (glance + truth-on-tap). P2/P3 are high-value but optional for a first cut. The detail sheet is also the natural home for the **TOFU pin** ("Server identity pinned ✓") and the hardware-keystore line that currently sit in the buried posture strip.
---
## Secure proxy: status and how it fits
The **plugin secure proxy** (the "Secure proxy — Not advertised" row) is a **stub today**: the Android side models it (`Endpoint.kt` `ProxyEndpoint`, `plugin_proxy` role, `hasSecureProxy()`), and `isEncryptedOverlayRoute()` already treats it as encrypted — but **the relay has no proxy-forward implementation, pairing never emits a `plugin_proxy` candidate, and no cert/pin is generated.** Enabling it end-to-end is the unbuilt **Phase 4** of `2026-06-18-native-secure-routes.md` (relay HTTP-forward routes + `RELAY_SSL_*` cert + pairing emission; ~2–3 wk).
**Implication for this plan:** the indicator must **not block** on the proxy. We design the wording/placement so that *when* a `plugin_proxy` route is advertised it slots in as a 🔒 **TLS (pinned)** route automatically (it already would, via `isEncryptedOverlayRoute`). Until then it stays honestly "Not advertised." Recommend a separate spike to stand it up + test on the server (tracked in `TODO.md`), independent of this UX work.
---
## Documentation plan
The docs currently **conflate "Tailscale" with "TLS/secure"** in several places — fixing this is half the user-facing win.
**New page:** `user-docs/architecture/connection-security.md` — "Is my connection secure?" Covers: `ws`/`wss` & `http`/`https`; what Tailscale actually does (WireGuard VPN, encrypted + identity, *plus* optional Serve-HTTPS); TLS + TOFU pinning; the per-surface model; how to read the in-app badge; how to get a TLS route (Tailscale Serve `--https`, reverse proxy, or the future plugin proxy). Add to the `/architecture/` sidebar after `security.md`. (Pairs 1:1 with the in-app detail-sheet "Learn more" link.)
**Conflation fixes (call out "WireGuard ≠ TLS, both are secure"):**
- `docs/decisions.md` §15 (`:441` TTL `wss or Tailscale → 30d`) and §24 — annotate that Tailscale's security is WireGuard, separate from `wss`.
- `user-docs/architecture/security.md:62–69` — split "Tailscale (VPN + optional managed TLS)" from "reverse proxy (TLS only)."
- `user-docs/guide/remote-access.md:27–32, 70–84` — distinguish *who terminates TLS* from *Tailscale provides the network*.
- Document TOFU pinning for users for the first time (currently code-only).
---
## Open decisions (your call before implementation)
1. **Is "plain over Tailscale" green or amber?** Recommendation: **green 🛡️ "Encrypted · Tailscale"** (WireGuard is genuinely secure). This is the crux of the trust fix. (Alternative: amber, treating only TLS as fully green — more conservative, but keeps confusing Tailscale users.)
2. **Glance scope:** connection-rollup badge + per-surface on tap (recommended), vs. always show per-surface inline (busier).
3. **First-cut scope:** P1 (chat chip) + P4 (detail sheet) + wording fix + docs page — vs. also P2/P3 in the same PR.
4. **Word choice:** "Encrypted" vs "Secure" vs "Private" for the overlay state. Recommendation: **"Encrypted · <mechanism>"** (concrete, non-marketing).
5. **Proxy:** confirm we keep it out of scope here (separate Phase-4 spike).
---
## Implementation tiers (after decisions land)
> Scope: **A** = ship-now UX · **Doc** = docs · effort **S/M/L**.
### A1 — Single source of truth: `ConnectionSecurity` model · M
Lift `isSelectedRouteUrlSecure`/`isEncryptedOverlayRoute` + the per-surface URL scheme reads into a ViewModel-exposed `StateFlow<ConnectionSecurity>` (`{ overall: Tls|Overlay|Mixed|Plain, perSurface: Map<Surface, SecurityKind>, mechanism: String }`). Every surface reads this one flow.
**Files:** new `viewmodel/ConnectionSecurity.kt`; `ConnectionViewModel.kt`; refactor `ActiveConnectionSections.kt:1109–1254`.
### A2 — Fix the wording / split `AllSecure` into Tls vs Overlay · S
Replace hardcoded `"Secure — TLS"`; mechanism-derived copy; new state for overlay. Pure `TransportSecurityBadge.kt` change + tests.
### A3 — P1 chat status chip glyph · S
Add the security glyph to the chat bottom strip; tap → detail sheet. **Files:** `RelayApp.kt`, `ChatTransportStatusBadge.kt`.
### A4 — P4 "Connection security" detail sheet · M
New bottom sheet; per-surface rows + explainer + TOFU/keystore lines + docs link. **Files:** new `ui/components/ConnectionSecuritySheet.kt`.
### A5 — P2 header badge + P3 route-picker glyphs · M (optional first cut)
**Files:** `ActiveConnectionSections.kt`, `EndpointsCard.kt`.
### Doc1 — `connection-security.md` + conflation fixes · M
New user-docs page + the four conflation edits + TOFU documentation.
### Spike — stand up & test the plugin secure proxy · L (separate, not blocking)
Phase 4 of `2026-06-18-native-secure-routes.md`. Tracked in `TODO.md`.
---
## Appendix — full UI surface inventory
| Surface | File:area | Shows today | Security data available |
|---|---|---|---|
| Chat status chip | `RelayApp.kt` ~920–975; `ChatTransportStatusBadge.kt` | transport tier + route + model | route role + per-surface URL schemes |
| Connection card header | `ActiveConnectionSections.kt` (card header) | name + Active + route summary | full per-surface |
| Status rows (API/Dashboard/Relay/Session) | `ActiveConnectionSections.kt:108–219` | reachable/connected + "Connected · Tailscale" | role known, security not rendered |
| Feature rows (incl. "Secure proxy") | `ActiveConnectionSections.kt:227–380` | Ready/Configured/Not advertised | proxy advertise flag |
| Security posture strip | `ActiveConnectionSections.kt:1109–1231` | **the existing badge** (buried under Advanced) | full (this is the source to promote) |
| Route picker | `EndpointsCard.kt:79–194` | per-route role + health | per-candidate scheme |
| Insecure toggle + ack | `ActiveConnectionSections.kt:806–866`; `InsecureConnectionAckDialog.kt` | warning + reason picker | `isInsecureConnection` |
| Connection info sheet | `ConnectionInfoSheet.kt` | session state | session relay URL |
| Pair wizard confirm | `OnboardingScreen.kt` / pairing flow | route candidates | candidate schemes (good place for per-route glyph at commit time) |
+2 -6
View File
@@ -83,13 +83,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.2.1 — Polish & control.
v1.2.3 — Connection crash fix.
• Lock the app to a single agent profile and hide the rest.
• In-app "What's New" with current and past release notes.
• Diagnostics with clean error titles and one-tap reporting.
• A tasteful, dismissable "update available" nudge.
• Voice fixes: Stop halts speech instantly, steadier hold-to-talk, a more readable overlay, and chosen voices apply in Auto mode.
• Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS). Connecting over a secured connection is now stable. Plain local-network connections were never affected.
```
## Category
+11 -11
View File
@@ -985,7 +985,7 @@ quota.
## Tested Relay Command Flow
These commands were used on 2026-05-18 to verify the relay-owned output path
against the Docker-server deployment target.
against the hermes-host deployment target.
```powershell
# Local repo checks
@@ -996,20 +996,20 @@ python -m compileall plugin\relay plugin\voice_lab
.\gradlew.bat :app:installSideloadDebug
# Remote relay checks
ssh bailey@172.16.24.250 "cd ~/.hermes/hermes-relay && ~/.hermes/hermes-agent/venv/bin/python -m compileall plugin/relay plugin/voice_lab"
ssh bailey@172.16.24.250 "cd ~/.hermes/hermes-relay && ~/.hermes/hermes-agent/venv/bin/python -m unittest plugin.tests.test_voice_output_routes plugin.tests.test_voice_lab -v"
ssh bailey@172.16.24.250 "systemctl --user restart hermes-relay.service"
ssh bailey@172.16.24.250 "curl -fsS http://127.0.0.1:8767/health"
ssh you@hermes-host "cd ~/.hermes/hermes-relay && ~/.hermes/hermes-agent/venv/bin/python -m compileall plugin/relay plugin/voice_lab"
ssh you@hermes-host "cd ~/.hermes/hermes-relay && ~/.hermes/hermes-agent/venv/bin/python -m unittest plugin.tests.test_voice_output_routes plugin.tests.test_voice_lab -v"
ssh you@hermes-host "systemctl --user restart hermes-relay.service"
ssh you@hermes-host "curl -fsS http://127.0.0.1:8767/health"
```
Live relay smoke used the configured server token without printing secrets:
```bash
set -a
. /home/bailey/.hermes/.env >/dev/null 2>&1 || true
. $HOME/.hermes/.env >/dev/null 2>&1 || true
set +a
cd /home/bailey/.hermes/hermes-relay
/home/bailey/.hermes/hermes-agent/venv/bin/python - <<'PY'
cd $HOME/.hermes/hermes-relay
$HOME/.hermes/hermes-agent/venv/bin/python - <<'PY'
import asyncio, base64, json, os, time, aiohttp
BASE = "http://127.0.0.1:8767"
@@ -1056,7 +1056,7 @@ PY
Expected shape for the successful xAI/Grok TTS smoke is
`provider=xai_tts`, `model=xai-tts`, `voice=eve`, nonzero `audio_bytes`, and
`voice.response.done`. The 2026-05-18 Docker-server smoke produced first audio
`voice.response.done`. The 2026-05-18 hermes-host smoke produced first audio
in roughly 330 ms and completed a short tool-status phrase in roughly 605 ms.
### Live Phone Smoke
@@ -1081,7 +1081,7 @@ On the phone:
1. Unlock the device.
2. Open Hermes Relay sideload build `0.8.0-sideload`.
3. Confirm it reconnects to the active relay route, for example
`ws://172.16.24.250:8767/ws` or the configured Tailscale route.
`ws://192.168.1.100:8767/ws` or the configured Tailscale route.
4. Start tap-to-talk and say: `check the relay status`.
5. Wait for the spoken status/tool narration and assistant reply.
6. Interrupt while it is speaking to verify barge-in cancellation/resume.
@@ -1105,7 +1105,7 @@ adb logcat -d -v time |
Select-String -Pattern "VoiceViewModel|RelayVoiceClient|voice/output|voice.response|voice.audio|voice.session|voice.replay|session.resume|replayed|RealtimePcmPlayer|BargeIn|transcribe|synthesize|auth.ok|sendMessage|tool|ConnectionManager|endpoint fallback|probeAndReconnect" |
Select-Object -Last 260
ssh bailey@docker-server.local 'journalctl --user -u hermes-relay.service --since "10 minutes ago" --no-pager | grep -E "Client connected|Client disconnected|voice.output|voice/output|voice/realtime|voice.session|voice.replay|session.resume|detached|resumed|resume_failed|voice/config|voice/transcribe|voice/synthesize|ERROR|Traceback" | tail -160'
ssh you@hermes-host 'journalctl --user -u hermes-relay.service --since "10 minutes ago" --no-pager | grep -E "Client connected|Client disconnected|voice.output|voice/output|voice/realtime|voice.session|voice.replay|session.resume|detached|resumed|resume_failed|voice/config|voice/transcribe|voice/synthesize|ERROR|Traceback" | tail -160'
```
Pass criteria:
+110 -5
View File
@@ -41,7 +41,11 @@ Source: `plugin/relay/server.py:2649-2889` (`handle_ws`, `_authenticate`).
"device_id": "android-device-uuid",
"ttl_seconds": 2592000,
"grants": {"chat": 2592000, "terminal": 604800, "bridge": 604800, "voice:stt": 2592000},
"session_token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
"session_token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"supports": {
"typed_stream_events": true,
"event_schema_version": 1
}
}
}
```
@@ -53,6 +57,7 @@ Source: `plugin/relay/server.py:2649-2889` (`handle_ws`, `_authenticate`).
- `device_id` — unique persistent identifier.
- `ttl_seconds` — requested session lifetime; `0` means never expire. Ignored if pairing code carried pre-set metadata from host.
- `grants` — per-channel seconds-from-now. Keys include `chat`, `terminal`, `bridge`, `tui`, `voice:config`, `voice:stt`, `voice:tts`, and `voice:realtime`.
- `supports` — optional capability negotiation. `typed_stream_events: true` with `event_schema_version: 1` opts the client into first-class `chat`/`stream.event` envelopes (§3.3.2). Omit it or set it false for legacy text/final-response mode.
Source: `plugin/relay/server.py:2804-2850`.
@@ -211,9 +216,109 @@ Sources: `plugin/relay/channels/bridge.py`, `app/src/main/kotlin/.../network/han
### 3.3 Chat
**Note:** Chat does **not** traverse the relay. It rides the vanilla upstream Hermes surfaces — the dashboard `/api/ws` gateway transport (live thinking) when Manage auth is ready, falling back to the API server's SSE routes.
**Purpose:** Native chat turn streaming and session listing.
**Direction:** Client → Server (`chat.send`, `chat.sessions.list`); Server → Client (legacy chat envelopes or typed stream events).
**Handler:** `plugin/relay/channels/chat.py`.
Relay involvement is limited to session management routes (`/api/sessions/*`) for create/list/delete/extend. See hermes-relay CLAUDE.md §"Upstream Hermes API Reference" for the endpoint catalog.
Modern Android/Desktop usually talk directly to Hermes dashboard/API-server for chat, but Relay also exposes a chat channel for paired native clients that need a single WSS route. Relay proxies `/api/sessions/{id}/chat/stream` SSE and preserves old text-first behavior unless the client explicitly advertises typed stream support in `system/auth.payload.supports`.
#### 3.3.1 Legacy chat envelopes
Clients that do not send `supports.typed_stream_events=true` receive the historical flattened messages:
| Type | Payload |
|------|---------|
| `chat.session` | `{session_id,title,model}` after Relay creates a session |
| `chat.delta` | `{session_id,message_id,delta}` assistant text only |
| `chat.progress` | `{session_id,message_id,delta}` subdued thinking/progress text |
| `chat.tool.started` | `{tool_name,tool_call_id?,preview,args}` |
| `chat.tool.completed` | `{tool_name,tool_call_id?,result_preview,success}` |
| `chat.tool.failed` | `{tool_name,tool_call_id?,error}` |
| `chat.turn.completed` | one assistant turn finished but run may continue |
| `chat.completed` | whole run/stream finished |
| `chat.error` | `{message}` |
This mode deliberately drops unknown/informational Hermes SSE events so older clients continue to work without UI changes.
#### 3.3.2 Typed stream.event mode
Capability negotiation:
```json
{
"channel": "system",
"type": "auth",
"payload": {
"session_token": "...",
"supports": {
"typed_stream_events": true,
"event_schema_version": 1
}
}
}
```
When negotiated, each Hermes/API-server SSE event is forwarded on the chat channel as a Relay envelope whose payload is the versioned stream envelope:
```json
{
"channel": "chat",
"type": "stream.event",
"id": "<uuid>",
"payload": {
"type": "stream.event",
"schema_version": 1,
"session_id": "sess_123",
"run_id": "run_123",
"seq": 42,
"event": "tool.started",
"ts": "2026-06-05T00:00:00Z",
"payload": {
"tool_name": "terminal",
"call_id": "call_123",
"preview": "npm test"
}
}
}
```
Stable top-level fields:
| Field | Stability | Notes |
|-------|-----------|-------|
| `type` | stable | always `stream.event` |
| `schema_version` | stable | v1 for this document. New incompatible shapes must increment. |
| `session_id` | stable | Hermes chat session id, copied from upstream or Relay-created session |
| `run_id` | stable nullable | upstream run id when present |
| `seq` | stable | monotonic per Relay stream (`session_id + run_id + request id`) when upstream does not supply one; clients use it for order/de-dupe |
| `event` | stable | event family below |
| `ts` | stable | ISO-8601 UTC string; Relay-generated when upstream omits timestamp |
| `payload` | event-specific | JSON object; unknown fields are preview-only unless documented by the upstream Hermes SSE contract |
Stable event families forwarded by Relay v1:
`session.created`, `run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.pending`, `tool.started`, `tool.completed`, `tool.failed`, `memory.updated`, `skill.loaded`, `artifact.created`, `assistant.completed`, `run.completed`, `error`, `done`.
Relay-specific events must be namespaced: `relay.connection.*`, `relay.resume.*`, `relay.client_ack`.
Rendering guidance:
| Event | Native rendering |
|-------|------------------|
| `assistant.delta` | append to assistant bubble incrementally |
| `tool.progress` | subdued progress/thinking row, not assistant text |
| `tool.pending`/`tool.started`/`tool.completed`/`tool.failed` | collapsible tool card lifecycle |
| `artifact.created` | tappable/downloadable attachment row; payload may contain `url`, `path`, `title`, or a preview |
| `memory.updated`/`skill.loaded` | low-noise timeline chip/badge |
| `assistant.completed` | finish current assistant turn; run may continue |
| `run.completed`/`done` | explicit terminal completion state |
| `error` | explicit error/partial/interrupted affordance |
Reconnect/resume v1: Relay preserves in-order delivery on a live WebSocket and emits sequence numbers. Guaranteed replay/resume is not implemented for chat v1; clients should de-dupe by `(run_id || session_id, seq)` after reconnect and treat missing sequence gaps as best-effort live-stream loss. Future guaranteed resume belongs under `relay.resume.*`.
Payload safety: Relay redacts common secret-shaped keys (`token`, `api_key`, `authorization`, `password`, `secret`) and truncates large result-like fields to previews before sending typed events. Native clients must still treat payloads as previews, not as an authority for full tool results.
Golden fixture: `docs/fixtures/typed-stream-v1.jsonl` contains an ordered tool-using stream for native renderer tests and manual smoke.
### 3.4 Terminal
@@ -717,12 +822,12 @@ Top-level `key` is the Hermes API bearer used for direct chat/session HTTP; the
```json
{
"hermes": 3,
"host": "172.16.24.250",
"host": "192.168.1.100",
"port": 8642,
"key": "<api_key>",
"tls": false,
"relay": {
"url": "ws://172.16.24.250:8767",
"url": "ws://192.168.1.100:8767",
"code": "ABC123",
"ttl_seconds": 604800,
"transport_hint": "ws"
+1 -1
View File
@@ -42,7 +42,7 @@ API_SERVER_PORT=8645
API_SERVER_KEY=<same key as the paired Android connection>
```
Use a distinct port per running profile, then start that profile's Hermes gateway/API service with your normal Hermes service manager, for example `hermes -p mizu gateway start` or the equivalent container/supervisor entry. Set `RELAY_WEBAPI_URL` on the relay service to the phone-reachable base Hermes API URL, for example `http://172.16.24.250:8642`; this lets the relay rewrite local profile binds (`127.0.0.1`, `localhost`, `0.0.0.0`, `::1`) to that same host/scheme while preserving the profile API port. Android also defensively rewrites loopback profile URLs against the active Connection API URL so stale or host-local profile payloads do not make the phone dial its own `127.0.0.1`.
Use a distinct port per running profile, then start that profile's Hermes gateway/API service with your normal Hermes service manager, for example `hermes -p mizu gateway start` or the equivalent container/supervisor entry. Set `RELAY_WEBAPI_URL` on the relay service to the phone-reachable base Hermes API URL, for example `http://192.168.1.100:8642`; this lets the relay rewrite local profile binds (`127.0.0.1`, `localhost`, `0.0.0.0`, `::1`) to that same host/scheme while preserving the profile API port. Android also defensively rewrites loopback profile URLs against the active Connection API URL so stale or host-local profile payloads do not make the phone dial its own `127.0.0.1`.
If a profile does not advertise a running API server, Android can still select it, but the behavior is compatibility fallback: the app sends that profile's `model` and `SOUL.md` as request overrides on the active Connection API server. That fallback does not isolate profile memory, sessions, tools, provider auth, or cron jobs.
+2 -2
View File
@@ -230,12 +230,12 @@ Biometric gate on the app side for terminal access (fingerprint/face) remains pl
```json
{
"hermes": 3,
"host": "172.16.24.250",
"host": "192.168.1.100",
"port": 8642,
"key": "api-bearer-token",
"tls": false,
"relay": {
"url": "ws://172.16.24.250:8767",
"url": "ws://192.168.1.100:8767",
"code": "ABCD12",
"ttl_seconds": 2592000,
"grants": { "terminal": 2592000, "bridge": 604800 },
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.2.1"
appVersionCode = "15"
appVersionName = "1.2.3"
appVersionCode = "17"
agp = "9.2.1"
kotlin = "2.4.0"
compose-bom = "2026.06.00"
+1 -1
View File
@@ -708,7 +708,7 @@ else
# Was the service already running? If yes we MUST `restart` it
# explicitly — `enable --now` is a no-op on already-active services
# and the editable-install code refresh would never reach the live
# process. (Spent way too long debugging this on Docker-Server
# process. (Spent way too long debugging this on hermes-host
# 2026-04-12 — every install.sh run looked successful but the live
# relay kept serving stale code from before the last git pull.)
if systemctl --user is-active hermes-relay.service >/dev/null 2>&1; then
+1 -1
View File
@@ -245,7 +245,7 @@ async def mint_pairing(body: dict[str, Any] = Body(default_factory=dict)) -> Any
"""Mint a fresh pairing code + return a signed QR payload.
Body (all fields optional — relay fills them from its config):
- host: "172.16.24.250" API server host the phone will hit
- host: "192.168.1.100" API server host the phone will hit
(defaults to RelayConfig.webapi_url host,
resolved to a LAN-routable IP)
- port: 8642 API server port
+229 -8
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import json
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
import aiohttp
@@ -23,8 +24,22 @@ logger = logging.getLogger(__name__)
# The WebAPI may use different names than our protocol — this layer
# absorbs that difference.
_SSE_TYPE_MAP: dict[str, str] = {
# Current Hermes API-server event families
"assistant.delta": "chat.delta",
"tool.progress": "chat.progress",
"tool.pending": "chat.tool.started",
"tool.started": "chat.tool.started",
"tool.completed": "chat.tool.completed",
"tool.failed": "chat.tool.failed",
"assistant.completed": "chat.turn.completed",
"run.completed": "chat.completed",
"done": "chat.completed",
"error": "chat.error",
# Historical aliases
"content_delta": "chat.delta",
"delta": "chat.delta",
"thinking_delta": "chat.progress",
"reasoning_delta": "chat.progress",
"tool_start": "chat.tool.started",
"tool_started": "chat.tool.started",
"tool_result": "chat.tool.completed",
@@ -32,9 +47,29 @@ _SSE_TYPE_MAP: dict[str, str] = {
"content_complete": "chat.completed",
"complete": "chat.completed",
"completed": "chat.completed",
"error": "chat.error",
}
_TYPED_STREAM_EVENT_NAMES: set[str] = {
"session.created",
"run.started",
"message.started",
"assistant.delta",
"tool.progress",
"tool.pending",
"tool.started",
"tool.completed",
"tool.failed",
"memory.updated",
"skill.loaded",
"artifact.created",
"assistant.completed",
"run.completed",
"error",
"done",
}
_STABLE_EVENT_FIELDS = {"type", "event", "session_id", "run_id", "seq", "ts", "timestamp"}
def _make_envelope(
msg_type: str,
@@ -52,6 +87,83 @@ def _make_envelope(
)
def _iso_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _json_preview(value: Any, *, max_chars: int = 1600) -> Any:
"""Return a compact JSON-serializable preview suitable for native clients.
Typed relay stream payloads deliberately avoid forwarding unbounded raw tool
results. Dict/list structure is preserved where it is small; otherwise the
payload falls back to a truncated string preview.
"""
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
return value if len(value) <= max_chars else value[: max_chars - 1] + "…"
if isinstance(value, dict):
out: dict[str, Any] = {}
for key, item in value.items():
if not isinstance(key, str):
continue
lowered = key.lower()
if lowered in {"api_key", "authorization", "password", "refresh_token", "session_token", "token", "secret"}:
out[key] = "[REDACTED]"
elif key in {"result", "output", "stdout", "stderr"}:
out[f"{key}_preview"] = _json_preview(item, max_chars=max_chars)
else:
out[key] = _json_preview(item, max_chars=max_chars)
return out
if isinstance(value, list):
return [_json_preview(item, max_chars=max_chars) for item in value[:20]]
return _json_preview(str(value), max_chars=max_chars)
def _normalize_typed_payload(raw: dict[str, Any]) -> dict[str, Any]:
return {
key: _json_preview(value)
for key, value in raw.items()
if key not in _STABLE_EVENT_FIELDS
}
def _typed_stream_envelope(
*,
event: str,
payload: dict[str, Any],
session_id: str,
run_id: str | None,
seq: int,
msg_id: str | None = None,
ts: str | None = None,
) -> str:
"""Build the Relay WS wrapper around the versioned stream.event payload."""
stream_payload: dict[str, Any] = {
"type": "stream.event",
"schema_version": 1,
"session_id": session_id,
"run_id": run_id,
"seq": seq,
"event": event,
"ts": ts or _iso_now(),
"payload": payload,
}
return _make_envelope("stream.event", stream_payload, msg_id)
def _supports_typed_stream_events(capabilities: dict[str, Any] | None) -> bool:
if not isinstance(capabilities, dict):
return False
supports = capabilities.get("supports")
if not isinstance(supports, dict):
supports = capabilities
if supports.get("typed_stream_events") is not True:
return False
version = supports.get("event_schema_version", supports.get("typed_stream_event_schema_version", 1))
return version in (1, "1")
class ChatHandler:
"""Handles all ``chat.*`` messages from the phone.
@@ -62,6 +174,30 @@ class ChatHandler:
def __init__(self, webapi_url: str = "http://localhost:8642") -> None:
self._webapi_url = webapi_url.rstrip("/")
self._http: aiohttp.ClientSession | None = None
self._client_capabilities: dict[int, dict[str, Any]] = {}
self._stream_seq: dict[str, int] = {}
def set_client_capabilities(
self, ws: web.WebSocketResponse, capabilities: dict[str, Any] | None
) -> None:
"""Record negotiated client capabilities for this websocket."""
if capabilities:
self._client_capabilities[id(ws)] = capabilities
else:
self._client_capabilities.pop(id(ws), None)
def detach_ws(self, ws: web.WebSocketResponse) -> None:
"""Forget per-websocket stream state after disconnect."""
ws_id = id(ws)
self._client_capabilities.pop(ws_id, None)
prefix = f"{ws_id}:"
for key in [key for key in self._stream_seq if key.startswith(prefix)]:
self._stream_seq.pop(key, None)
def _next_seq(self, stream_key: str) -> int:
seq = self._stream_seq.get(stream_key, 0) + 1
self._stream_seq[stream_key] = seq
return seq
async def _get_http(self) -> aiohttp.ClientSession:
"""Return (and lazily create) the shared HTTP client session."""
@@ -79,8 +215,15 @@ class ChatHandler:
# ── Dispatcher ───────────────────────────────────────────────────────
async def handle(self, ws: web.WebSocketResponse, envelope: dict[str, Any]) -> None:
async def handle(
self,
ws: web.WebSocketResponse,
envelope: dict[str, Any],
client_capabilities: dict[str, Any] | None = None,
) -> None:
"""Route an incoming chat-channel envelope to the right handler."""
if client_capabilities is not None:
self.set_client_capabilities(ws, client_capabilities)
msg_type = envelope.get("type", "")
payload = envelope.get("payload", {})
msg_id = envelope.get("id")
@@ -313,24 +456,42 @@ class ChatHandler:
try:
data = json.loads(data_str)
except json.JSONDecodeError:
# Some events may be plain text (e.g. "[DONE]")
# Some events may be plain text (notably "[DONE]"). Typed-capable
# clients still receive a first-class final done event; legacy
# clients keep the historical behavior (ignore bare [DONE]).
if data_str.strip() == "[DONE]":
if _supports_typed_stream_events(self._client_capabilities.get(id(ws))):
await self._emit_typed_stream_event(
ws,
raw_type="done",
data={"state": "final"},
session_id=session_id,
msg_id=msg_id,
)
return
logger.debug("Non-JSON SSE data: %s", data_str[:100])
return
# Determine the Hermes SSE event type. Try:
# 1. The explicit ``event:`` line from SSE
# 2. A ``type`` field inside the JSON data
raw_type = sse_event_type or data.get("type", "")
# 2. A ``type`` or ``event`` field inside the JSON data
raw_type = sse_event_type or data.get("type") or data.get("event") or ""
# Map to our protocol type
if _supports_typed_stream_events(self._client_capabilities.get(id(ws))):
await self._emit_typed_stream_event(
ws, raw_type=raw_type, data=data, session_id=session_id, msg_id=msg_id
)
return
# Map to our legacy protocol type for text-only clients.
proto_type = _SSE_TYPE_MAP.get(raw_type)
if proto_type is None:
# If we don't recognize the type, log it and skip
# Unknown informational events are intentionally dropped in legacy
# text mode to preserve old clients' behavior. Typed-capable clients
# receive them losslessly above.
logger.debug(
"Unmapped SSE event type %r — forwarding raw data", raw_type
"Unmapped SSE event type %r — skipped for legacy chat client", raw_type
)
return
@@ -342,6 +503,41 @@ class ChatHandler:
except ConnectionResetError:
logger.warning("WebSocket closed while sending %s", proto_type)
async def _emit_typed_stream_event(
self,
ws: web.WebSocketResponse,
*,
raw_type: str,
data: dict[str, Any],
session_id: str,
msg_id: str | None,
) -> None:
event = raw_type or data.get("type") or data.get("event") or "assistant.delta"
if event not in _TYPED_STREAM_EVENT_NAMES and not event.startswith("relay."):
logger.debug("Forwarding preview typed stream event: %s", event)
run_id = data.get("run_id") if isinstance(data.get("run_id"), str) else None
stream_key = f"{id(ws)}:{session_id}:{msg_id or 'no-msg'}"
seq = data.get("seq")
if not isinstance(seq, int):
seq = self._next_seq(stream_key)
ts_raw = data.get("ts") or data.get("timestamp")
ts = ts_raw if isinstance(ts_raw, str) else None
payload = _normalize_typed_payload(data)
try:
await ws.send_str(
_typed_stream_envelope(
event=event,
payload=payload,
session_id=str(data.get("session_id") or session_id),
run_id=run_id,
seq=seq,
msg_id=msg_id,
ts=ts,
)
)
except ConnectionResetError:
logger.warning("WebSocket closed while sending typed stream event %s", event)
@staticmethod
def _build_payload(
proto_type: str,
@@ -366,12 +562,37 @@ class ChatHandler:
if proto_type == "chat.tool.completed":
return {
"tool_name": data.get("tool_name", data.get("name", "")),
"tool_call_id": data.get("tool_call_id", data.get("call_id", "")),
"result_preview": data.get(
"result_preview", data.get("result", "")
),
"success": data.get("success", True),
}
if proto_type == "chat.tool.failed":
return {
"tool_name": data.get("tool_name", data.get("name", "")),
"tool_call_id": data.get("tool_call_id", data.get("call_id", "")),
"error": data.get("error", data.get("message", "Tool failed")),
}
if proto_type == "chat.progress":
return {
"session_id": session_id,
"message_id": data.get("message_id", data.get("id", "")),
"delta": data.get("delta", data.get("thinking_delta", data.get("text", ""))),
}
if proto_type == "chat.turn.completed":
return {
"session_id": session_id,
"message_id": data.get("message_id", data.get("id", "")),
"content": data.get("content", ""),
"completed": data.get("completed", True),
"partial": data.get("partial", False),
"interrupted": data.get("interrupted", False),
}
if proto_type == "chat.completed":
return {
"session_id": session_id,
+42 -5
View File
@@ -123,6 +123,11 @@ class RelayServer:
# In-flight tasks per client (for cancellation on disconnect)
self._client_tasks: dict[web.WebSocketResponse, set[asyncio.Task[Any]]] = {}
# Negotiated websocket capabilities per connected client. Kept outside
# Session so reconnects can renegotiate independently and older stored
# tokens do not accidentally opt in to a new protocol.
self._client_capabilities: dict[web.WebSocketResponse, dict[str, Any]] = {}
@property
def client_count(self) -> int:
return len(self._clients)
@@ -326,8 +331,8 @@ async def handle_pairing_mint(request: web.Request) -> web.Response:
```json
{
"hermes": 2,
"host": "172.16.24.250", "port": 8642, "key": "<api-key>", "tls": false,
"relay": {"url": "ws://172.16.24.250:8767", "code": "ABC123",
"host": "192.168.1.100", "port": 8642, "key": "<api-key>", "tls": false,
"relay": {"url": "ws://192.168.1.100:8767", "code": "ABC123",
"ttl_seconds": 604800, "transport_hint": "ws"}
}
```
@@ -340,7 +345,7 @@ async def handle_pairing_mint(request: web.Request) -> web.Response:
POST /pairing/mint
body (all optional — fall back to RelayConfig / local Hermes defaults):
- host: "172.16.24.250" API server host override (LAN IP)
- host: "192.168.1.100" API server host override (LAN IP)
- port: 8642 API server port override
- tls: false API server TLS override
- api_key: "<token>" API bearer token override (goes in
@@ -3222,6 +3227,8 @@ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
session_token = await _authenticate(ws, server, remote_ip, request)
except _AuthFailed as exc:
logger.info("Auth failed from %s: %s", remote_ip, exc)
server._client_capabilities.pop(ws, None)
server.chat.detach_ws(ws)
# WebSocket was already sent an auth.fail message
if not ws.closed:
await ws.close()
@@ -3283,6 +3290,25 @@ def _detect_transport_hint(request: web.Request) -> str:
return "unknown"
def _extract_client_capabilities(payload: dict[str, Any]) -> dict[str, Any]:
"""Normalize optional client capability negotiation from auth payload."""
supports = payload.get("supports")
if not isinstance(supports, dict):
supports = {}
typed = supports.get("typed_stream_events") is True
version = supports.get("event_schema_version", supports.get("typed_stream_event_schema_version", 1))
try:
version_int = int(version)
except (TypeError, ValueError):
version_int = 0
return {
"supports": {
"typed_stream_events": bool(typed and version_int == 1),
"event_schema_version": 1 if typed and version_int == 1 else version_int,
}
}
def _build_auth_ok_payload(
session: Session, server: RelayServer
) -> dict[str, Any]:
@@ -3352,6 +3378,9 @@ async def _authenticate(
))
payload = envelope.get("payload", {})
if not isinstance(payload, dict):
payload = {}
server._client_capabilities[ws] = _extract_client_capabilities(payload)
pairing_code = payload.get("pairing_code", "")
session_token_attempt = payload.get("session_token", "")
refresh_token_attempt = str(payload.get("refresh_token", "") or "").strip()
@@ -3510,8 +3539,14 @@ async def _on_message(
if channel == "system":
await _handle_system(ws, server, envelope)
elif channel == "chat":
# Run chat handling as a tracked task so we can cancel on disconnect
task = asyncio.create_task(server.chat.handle(ws, envelope))
# Run chat handling as a tracked task so we can cancel on disconnect.
# Capability negotiation happens at system/auth; the chat channel uses
# it to decide typed stream.event passthrough vs legacy text envelopes.
task = asyncio.create_task(
server.chat.handle(
ws, envelope, server._client_capabilities.get(ws)
)
)
_track_task(server, ws, task)
elif channel == "terminal":
task = asyncio.create_task(server.terminal.handle(ws, envelope))
@@ -3611,6 +3646,8 @@ async def _on_disconnect(
"""Clean up after a client disconnects."""
token = server._clients.pop(ws, None)
tasks = server._client_tasks.pop(ws, set())
server._client_capabilities.pop(ws, None)
server.chat.detach_ws(ws)
# === PHASE3-bridge-server: fail in-flight bridge commands on phone disconnect ===
# If this ws was the currently-latched phone, detach_ws flips phone_ws
+119
View File
@@ -0,0 +1,119 @@
"""Typed chat stream passthrough tests for the Relay chat channel."""
from __future__ import annotations
import json
import unittest
from typing import Any
from plugin.relay.channels.chat import ChatHandler
class FakeWebSocket:
def __init__(self) -> None:
self.closed = False
self.sent: list[str] = []
async def send_str(self, data: str) -> None:
self.sent.append(data)
class ChatTypedStreamTests(unittest.IsolatedAsyncioTestCase):
async def test_typed_client_receives_ordered_stream_event_envelopes(self) -> None:
handler = ChatHandler()
ws = FakeWebSocket()
handler.set_client_capabilities(
ws, # type: ignore[arg-type]
{"supports": {"typed_stream_events": True, "event_schema_version": 1}},
)
await handler._emit_sse_event( # type: ignore[attr-defined]
ws, # type: ignore[arg-type]
json.dumps(
{
"type": "assistant.delta",
"session_id": "sess-1",
"run_id": "run-1",
"delta": "Hello",
}
),
"sess-1",
"msg-1",
None,
)
await handler._emit_sse_event( # type: ignore[attr-defined]
ws, # type: ignore[arg-type]
json.dumps(
{
"type": "tool.started",
"session_id": "sess-1",
"run_id": "run-1",
"tool_name": "terminal",
"call_id": "call-1",
"args": {"cmd": "echo ok", "api_key": "secret-value"},
}
),
"sess-1",
"msg-1",
None,
)
await handler._emit_sse_event( # type: ignore[attr-defined]
ws, # type: ignore[arg-type]
"[DONE]",
"sess-1",
"msg-1",
None,
)
envelopes = [json.loads(item) for item in ws.sent]
self.assertEqual([env["type"] for env in envelopes], ["stream.event", "stream.event", "stream.event"])
payloads: list[dict[str, Any]] = [env["payload"] for env in envelopes]
self.assertEqual([p["event"] for p in payloads], ["assistant.delta", "tool.started", "done"])
self.assertEqual([p["seq"] for p in payloads], [1, 2, 3])
self.assertEqual(payloads[0]["schema_version"], 1)
self.assertEqual(payloads[0]["session_id"], "sess-1")
self.assertEqual(payloads[1]["run_id"], "run-1")
self.assertEqual(payloads[1]["payload"]["args"]["api_key"], "[REDACTED]")
self.assertEqual(payloads[2]["payload"], {"state": "final"})
async def test_legacy_client_keeps_flattened_text_mode(self) -> None:
handler = ChatHandler()
ws = FakeWebSocket()
await handler._emit_sse_event( # type: ignore[attr-defined]
ws, # type: ignore[arg-type]
json.dumps({"type": "assistant.delta", "delta": "Hi"}),
"sess-legacy",
"msg-legacy",
None,
)
await handler._emit_sse_event( # type: ignore[attr-defined]
ws, # type: ignore[arg-type]
json.dumps({"type": "artifact.created", "url": "https://example.invalid/a"}),
"sess-legacy",
"msg-legacy",
None,
)
await handler._emit_sse_event( # type: ignore[attr-defined]
ws, # type: ignore[arg-type]
json.dumps({"type": "tool.failed", "tool_name": "terminal", "error": "boom"}),
"sess-legacy",
"msg-legacy",
None,
)
await handler._emit_sse_event( # type: ignore[attr-defined]
ws, # type: ignore[arg-type]
"[DONE]",
"sess-legacy",
"msg-legacy",
None,
)
envelopes = [json.loads(item) for item in ws.sent]
self.assertEqual([env["type"] for env in envelopes], ["chat.delta", "chat.tool.failed"])
self.assertEqual(envelopes[0]["payload"]["delta"], "Hi")
self.assertEqual(envelopes[1]["payload"]["error"], "boom")
if __name__ == "__main__": # pragma: no cover
unittest.main()
+8 -8
View File
@@ -235,9 +235,9 @@ class PairingMintSchemaTests(AioHTTPTestCase):
{
"role": "lan",
"priority": 0,
"api": {"host": "172.16.24.250", "port": 8642, "tls": False},
"api": {"host": "192.168.1.100", "port": 8642, "tls": False},
"relay": {
"url": "ws://172.16.24.250:8767",
"url": "ws://192.168.1.100:8767",
"transport_hint": "ws",
},
},
@@ -245,12 +245,12 @@ class PairingMintSchemaTests(AioHTTPTestCase):
"role": "tailscale",
"priority": 1,
"api": {
"host": "docker-server.tail6f460.ts.net",
"host": "hermes-host.tailnet.ts.net",
"port": 8642,
"tls": True,
},
"relay": {
"url": "wss://docker-server.tail6f460.ts.net:8767",
"url": "wss://hermes-host.tailnet.ts.net:8767",
"transport_hint": "wss",
},
},
@@ -260,8 +260,8 @@ class PairingMintSchemaTests(AioHTTPTestCase):
"plugin.pair._tailscale_status",
return_value={
"available": True,
"hostname": "docker-server.tail6f460.ts.net",
"tailscale_ip": "100.71.8.56",
"hostname": "hermes-host.tailnet.ts.net",
"tailscale_ip": "100.64.0.1",
"serve_ports": [],
},
):
@@ -270,9 +270,9 @@ class PairingMintSchemaTests(AioHTTPTestCase):
tailscale = qr["endpoints"][1]
self.assertEqual(qr["hermes"], 3)
self.assertEqual(tailscale["api"]["host"], "100.71.8.56")
self.assertEqual(tailscale["api"]["host"], "100.64.0.1")
self.assertFalse(tailscale["api"]["tls"])
self.assertEqual(tailscale["relay"]["url"], "ws://100.71.8.56:8767")
self.assertEqual(tailscale["relay"]["url"], "ws://100.64.0.1:8767")
self.assertEqual(tailscale["relay"]["transport_hint"], "ws")
self.assertEqual(result.get("endpoints"), qr["endpoints"])
+2 -2
View File
@@ -539,7 +539,7 @@ class VoiceRoutesTests(AioHTTPTestCase):
async def test_voice_config_returns_providers(self) -> None:
sys.modules["tools.tts_tool"]._load_tts_config = lambda: {
"provider": "elevenlabs",
"voice_id": "XZEfcFyBnzsNJrdvkWdI",
"voice_id": "<your-voice-id>",
"model": "eleven_turbo_v2_5",
}
sys.modules["tools.transcription_tools"]._load_stt_config = lambda: {
@@ -559,7 +559,7 @@ class VoiceRoutesTests(AioHTTPTestCase):
body = await resp.json()
self.assertTrue(body["success"])
self.assertEqual(body["tts"]["provider"], "elevenlabs")
self.assertEqual(body["tts"]["voice_id"], "XZEfcFyBnzsNJrdvkWdI")
self.assertEqual(body["tts"]["voice_id"], "<your-voice-id>")
self.assertTrue(body["tts"]["enabled"])
self.assertEqual(body["stt"]["provider"], "openai")
self.assertEqual(body["stt"]["model"], "whisper-1")
+1 -1
View File
@@ -26,7 +26,7 @@ apply(plugin = "com.meta.spatial.plugin")
android {
namespace = "com.axiomlabs.hermesquest"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "com.axiomlabs.hermesquest"
+1 -1
View File
@@ -5,7 +5,7 @@ plugins {
android {
namespace = "com.axiomlabs.hermesrelay.core"
compileSdk = 36
compileSdk = 37
defaultConfig {
minSdk = 26
+1 -1
View File
@@ -5,7 +5,7 @@ plugins {
android {
namespace = "com.axiomlabs.hermesrelay.ui"
compileSdk = 36
compileSdk = 37
defaultConfig {
minSdk = 26
+1 -1
View File
@@ -1,5 +1,5 @@
param(
[string]$RemoteHost = "bailey@docker-server.local",
[string]$RemoteHost = "you@hermes-host",
[string]$Package = "com.axiomlabs.hermesrelay.sideload",
[string]$OutputRoot = "voice-lab-runs\phone-smoke",
[int]$SinceMinutes = 15,
@@ -251,7 +251,7 @@ Expect the agent to render:
}
```
The key signal: `stdout` contains **your local hostname**, not the server's. That proves the call routed from hermes → relay → WSS → this machine → shell exec → response back. If the agent instead sees the server's hostname (e.g., `Docker-Server`), the tool is running on the server — check consent and `/desktop/_ping` from the host:
The key signal: `stdout` contains **your local hostname**, not the server's. That proves the call routed from hermes → relay → WSS → this machine → shell exec → response back. If the agent instead sees the server's hostname (e.g., `hermes-host`), the tool is running on the server — check consent and `/desktop/_ping` from the host:
```bash
# On the host
+1
View File
@@ -137,6 +137,7 @@ export default defineConfig({
{ text: 'Flavor Differences', link: '/architecture/flavor-differences' },
{ text: 'Decisions', link: '/architecture/decisions' },
{ text: 'Security', link: '/architecture/security' },
{ text: 'Is my connection secure?', link: '/architecture/connection-security' },
{ text: 'Privacy', link: '/architecture/privacy' },
],
},
@@ -0,0 +1,172 @@
# Is my connection secure?
Short answer: **probably yes** — and Hermes-Relay now tells you at a glance, without
overstating or understating it.
This page explains what "encrypted" actually means for your connection, why a Tailscale
link is genuinely secure even when it looks like plain `http://`, and how to read the
in-app security indicator. If you just want the quick version, jump to
[How to read the indicator](#how-to-read-the-in-app-indicator).
## Plain vs. encrypted: `ws://` vs `wss://`, `http://` vs `https://`
Every connection uses a URL scheme, and the scheme tells you whether the link is
encrypted by **TLS** (the same technology the lock icon in your browser refers to):
| Scheme | What it is | Privacy |
|---|---|---|
| `http://` / `ws://` | **Plaintext.** No TLS. | Anyone on the network path can read the traffic. |
| `https://` / `wss://` | **TLS-encrypted.** | The traffic is encrypted to the server's certificate. |
On its own, a plain `http://` or `ws://` link is readable by anyone between your phone
and the server — your home router, the coffee-shop Wi-Fi, an upstream ISP. That's why
plaintext is only safe on a network you fully trust.
**But scheme isn't the whole story.** A plain `http://` link can still be fully encrypted
if it rides inside an encrypted overlay network like Tailscale. That's the part people
get wrong — including, until now, our own docs.
## What Tailscale actually is
[Tailscale](https://tailscale.com/) is a **WireGuard-based VPN**. When your phone and your
Hermes host are both on your tailnet, every byte between them is **encrypted and
authenticated end-to-end by WireGuard** — before it ever touches the URL scheme. This is
genuinely secure transport: strong modern encryption plus device identity (only enrolled
devices on your tailnet can talk to each other).
So a connection to `http://100.x.y.z:8642` **over your tailnet is encrypted** — by
WireGuard, not by TLS. It is *not* plaintext-on-the-wire even though the scheme says
`http`. **Tailscale plaintext is secure; it's just not TLS.** Hermes-Relay treats it as a
green/secure route, and never labels a Tailscale route "insecure."
Tailscale can *also*, separately, terminate TLS for you. Running
`tailscale serve --https=<port>` puts a real TLS certificate in front of a service, so the
same connection becomes `https://<host>.ts.net:<port>` — now you have **both** WireGuard
encryption *and* TLS. You don't need the TLS layer for the link to be secure over a
tailnet, but it's there if you want a `wss://`/`https://` route (some tools and proxies
expect one).
::: tip The one thing to remember
WireGuard encryption ≠ TLS, but **both are secure transports.** A Tailscale route is
encrypted whether or not TLS is also in play. The app's 🛡️ shield means "encrypted by your
private network" — it is a green/secure state, not a warning.
:::
## TLS + certificate pinning (TOFU)
When Hermes-Relay connects over a TLS route (`wss://`/`https://`) for the **first** time,
it records a fingerprint of the server's certificate — its SHA‑256 SPKI. This is
**trust-on-first-use (TOFU) pinning**: every later connection to that same host must
present the *same* certificate, or the app refuses to connect.
What this buys you:
- After the first connect, a man-in-the-middle can't swap in a different certificate to
intercept your traffic — the pin won't match.
- The pin is per `host:port`, stored on-device.
- Re-pairing the device (scanning a fresh QR) intentionally resets the pin for that host,
because re-pairing is explicit consent to potentially new certificate material.
Two honest caveats:
- **Pinning only applies to TLS routes.** A Tailscale-over-`http` route has no TLS
certificate to pin — its security comes from WireGuard instead, which provides its own
device identity.
- **TOFU can't protect the very first connect.** By definition it trusts whatever
certificate is present on the initial handshake, so do your first connection over a path
you trust (LAN, Tailscale, or VPN). It protects every connection after that.
## Why one connection has several security states
A single paired connection isn't one pipe — it fans out to several **surfaces**, and each
one can independently be TLS, overlay-encrypted, or plain:
| Surface | What it carries | Typical port |
|---|---|---|
| **Chat (gateway)** | Live chat, thinking/reasoning | dashboard `:9119` |
| **API / sessions** | Chat fallback, session history | API `:8642` |
| **Dashboard (Manage + voice)** | Settings, model config, vanilla voice | dashboard `:9119` |
| **Relay tools** | Terminal, bridge, device control | relay `:8767` |
Because each surface has its own URL, **a connection can be partly encrypted and partly
plain at the same time** — for example chat and Manage on `https://`, but relay tools on
plain `ws://`. There's no single true/false answer to "is it secure," so a single binary
badge would lie.
Hermes-Relay handles this with a **rollup at a glance, the full truth on tap**:
- The **glance badge** reflects the worst case across the surfaces actually in use.
- **Tapping it** opens a per-surface breakdown so you can see exactly which routes are
encrypted and how.
## How to read the in-app indicator
The security glyph appears next to the route on the chat status chip and the connection
card. There are four outcomes:
| Indicator | Meaning | Tone |
|---|---|---|
| 🔒 `Encrypted · TLS` | Every in-use surface is `wss`/`https`, pinned on first connect. | **Secure (green)** |
| 🛡️ `Encrypted · Tailscale` | Plain scheme, but the route is Tailscale / WireGuard / a secure proxy — encrypted by the overlay. | **Secure (green)** |
| 🛡️ `Mixed routes` | Some surfaces are encrypted, some are plain (a secure fallback exists). | Amber — review the breakdown |
| ⚠️ `Not encrypted` | Plain `ws`/`http` with no overlay. | Warning — only safe on a network you fully trust |
The key idea: **both 🔒 and 🛡️ are green/secure.** Only true plaintext with no overlay is a
warning. Tap the indicator for the per-surface detail, where each route is spelled out in
one line:
- *TLS — encrypted to this server's certificate (pinned on first connect).*
- *Tailscale — encrypted by your tailnet (WireGuard), not TLS.*
- *Not encrypted — only safe on a network you fully trust.*
If you see ⚠️ **Not encrypted**, you're on a plain `ws://`/`http://` route with nothing
wrapping it. That's fine on a home LAN or a trusted VPN, but you should add an encrypted
route before using it over public Wi-Fi or the open internet.
## How to get a TLS (or otherwise encrypted) route
You have a few ways to make a connection secure. Pick whichever fits your setup:
### 1. Tailscale (recommended)
Putting both devices on a tailnet gives you WireGuard encryption immediately — you're
secure (🛡️) with no certificates to manage. If you also want TLS-fronted `https://`/`wss://`
routes, run Tailscale Serve:
```bash
tailscale serve --https=<port> http://127.0.0.1:<port>
```
The `hermes-relay-tailscale` helper fronts the two relay-owned services for you — relay
(`:8767`) and the Hermes API server (`:8642`):
```bash
hermes-relay-tailscale enable
```
The **dashboard** (`:9119`, used for Manage and vanilla voice) is **not** fronted by the
helper — if you want a TLS route to the dashboard, you front it yourself with
`tailscale serve --https=9119 http://127.0.0.1:9119`. Without that, your dashboard surface
rides plain `http` over the tailnet — which is still WireGuard-encrypted and secure, just
not TLS.
### 2. A public reverse proxy
A proxy like **Caddy**, **nginx**, or **Cloudflare** can terminate TLS in front of your
services and expose `https://`/`wss://` routes to the open internet. The proxy holds the
certificate; your phone pins it on first connect. This is the path to use when you're
exposing Hermes beyond a private network — never expose plain `ws://`/`http://` ports
directly.
### 3. The plugin secure proxy *(not yet available)*
A future relay-built secure proxy will mint and front its own TLS for the relay surfaces,
so you get a pinned `wss://` route without standing up Tailscale Serve or an external
proxy. It is **not implemented yet** — when it ships, the app will slot it in
automatically as a 🔒 TLS (pinned) route. Until then, use Tailscale or a reverse proxy.
## See also
- [Security](./security.md) — full security model: key storage, auth flow, the bridge safety gate.
- [Remote access](../guide/remote-access.md) — step-by-step Tailscale and reverse-proxy setup.
- [Privacy](./privacy.md) — what data the app stores and where.
+13 -2
View File
@@ -103,8 +103,19 @@ Every command is logged to the Bridge tab's activity log (timestamp, status, res
## Recommendations
1. **Use HTTPS** in production — the network security config enforces it by default
1. **Use an encrypted route** in production. Two independent ways to get there, both secure:
- **TLS** (`https://`/`wss://`) — via a reverse proxy (Caddy/nginx/Cloudflare) or
`tailscale serve --https`. Pinned on first connect (TOFU).
- **Tailscale / WireGuard** — even a plain `http://`/`ws://` route over your tailnet is
**encrypted end-to-end by WireGuard**. This is *not* TLS, but it *is* secure transport;
a Tailscale link is not "plaintext on the wire."
Don't conflate the two: TLS and WireGuard are different mechanisms that both make a
connection secure. See [Is my connection secure?](./connection-security.md) for how the
app reports each (🔒 TLS vs 🛡️ Tailscale, both green).
2. **Rotate API keys** periodically in your Hermes server config
3. **Disconnect when idle** — especially if bridge is enabled (or let the auto-disable timer handle it)
4. **Avoid public WiFi** for relay connections without additional encryption
4. **Avoid plaintext on untrusted networks** — a plain `ws://`/`http://` route with no
Tailscale/WireGuard or TLS wrapping it is readable on public Wi-Fi. The app shows
⚠️ **Not encrypted** for exactly this case.
5. **Keep the app updated** — security patches ship with new releases
+1 -1
View File
@@ -125,7 +125,7 @@ Beyond the terminal, the tray adds GUI surfaces the headless CLI can't: a **Gran
They're not the same thing:
- **`shell`** pipes the host's actual `hermes` CLI through a PTY. You see exactly what `ssh bailey@hermes-host hermes` would show — same banner, same skin, same slash commands. Best for interactive use.
- **`shell`** pipes the host's actual `hermes` CLI through a PTY. You see exactly what `ssh you@hermes-host hermes` would show — same banner, same skin, same slash commands. Best for interactive use.
- **`chat`** speaks the relay's structured `tui` channel (JSON-RPC-over-WSS), renders events as plain lines. Scriptable, pipeable, survives non-TTY environments. Best for automation / CI / one-shot queries.
Use `shell` when you want to drive interactively; use `chat --json` from scripts. Chat mode is maintained for automation — it isn't where new features land, and it isn't a desktop chat app (that's [hermes-desktop](https://github.com/NousResearch/hermes-agent)'s job).
+1 -1
View File
@@ -146,7 +146,7 @@ hermes-relay status --json --reveal-tokens # full tokens (careful — don't pa
Output per URL:
```
ws://172.16.24.250:8767
ws://192.168.1.100:8767
server: 0.6.0
paired: 2h ago
token: 79d2cf41…8d8c
+1 -1
View File
@@ -178,7 +178,7 @@ If the agent says "desktop_terminal is not available" or calls time out immediat
```bash
# On the server, verify the channel sees your client
ssh bailey@<host> curl -s "http://127.0.0.1:8767/desktop/_ping?tool=desktop_terminal"
ssh you@<host> curl -s "http://127.0.0.1:8767/desktop/_ping?tool=desktop_terminal"
```
Expected (the default-advertised set — `desktop_computer_*` appears only when the client runs with `--experimental-computer-use`):
+19 -6
View File
@@ -1,6 +1,6 @@
# Remote Access
Hermes-Relay can keep one paired phone connected as it moves between LAN, Tailscale, a VPN, and a public reverse proxy. The recommended path is Tailscale because it works behind CGNAT, gives you managed TLS, and keeps access inside your tailnet ACLs.
Hermes-Relay can keep one paired phone connected as it moves between LAN, Tailscale, a VPN, and a public reverse proxy. The recommended path is Tailscale because it works behind CGNAT, encrypts traffic end-to-end (WireGuard), keeps access inside your tailnet ACLs, and can *optionally* front TLS for you. (Note: the WireGuard encryption is what makes a tailnet link secure — TLS via `tailscale serve --https` is a separate, optional layer on top. See [Is my connection secure?](../architecture/connection-security.md).)
## What Uses Which Connection
@@ -24,7 +24,7 @@ hermes-relay-tailscale enable
hermes pair --mode auto --prefer tailscale
```
The Tailscale helper publishes both required loopback services:
The Tailscale helper publishes both required loopback services, fronting each with TLS:
```bash
tailscale serve --bg --https=8767 http://127.0.0.1:8767
@@ -33,6 +33,15 @@ tailscale serve --bg --https=8642 http://127.0.0.1:8642
Port `8767` carries relay WSS and relay HTTP routes. Port `8642` carries the Hermes API server for chat, API-key voice auth, and endpoint health probes. If only `8767` is served, terminal/bridge may work while chat and API-key voice still fail remotely.
::: tip Two layers, both optional-to-stack
Your tailnet is already encrypted by WireGuard, so even a plain `http://100.x.y.z` route is
secure over Tailscale. `tailscale serve --https` adds a *separate* TLS layer on top, giving
you a `wss://`/`https://` route fronted by a real certificate (the dashboard on `:9119` is
not fronted by the helper — front it yourself if you want TLS there). See
[Is my connection secure?](../architecture/connection-security.md) for which the app reports
as 🔒 TLS vs 🛡️ Tailscale (both secure).
:::
Check the served ports with:
```bash
@@ -65,8 +74,8 @@ You can also override from the phone: **Settings -> Connections -> active connec
Route fields want the **API server** (port `8642` by default) — never the
dashboard (`9119`) or relay (`8767`); those are derived from the host
automatically. You can type just a host or IP: `100.71.8.56` is saved as
`http://100.71.8.56:8642`, and the editor previews the exact URL before you
automatically. You can type just a host or IP: `100.64.0.1` is saved as
`http://100.64.0.1:8642`, and the editor previews the exact URL before you
save.
Pick the scheme by how the server is reached:
@@ -75,10 +84,14 @@ Pick the scheme by how the server is reached:
The Hermes API server speaks plain HTTP; an `https://` route against it
fails its TLS handshake on every probe and never wins. This also requires
the API server to listen beyond loopback (`0.0.0.0:8642` or the tailnet
interface).
interface). Note that an `http://` route over a raw Tailscale IP is **not
plaintext on the wire** — WireGuard encrypts it end-to-end. It's secure
transport, just not TLS (the app reports it as 🛡️ Tailscale, not ⚠️ Not
encrypted). A plain LAN IP, by contrast, has no such wrapping.
- **`*.ts.net` hostname fronted by `hermes-relay-tailscale enable`** →
`https://` — Tailscale terminates TLS for the MagicDNS hostname (the cert
is only valid for that name, not for the raw `100.x` IP).
is only valid for that name, not for the raw `100.x` IP). This adds TLS
*on top of* the WireGuard encryption you already had over the tailnet.
- **Public reverse proxy** → `https://` with whatever host/port the proxy
exposes.