Compare commits
23
Commits
+24
-2
@@ -20,6 +20,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 +368,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
|
||||
|
||||
|
||||
@@ -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
@@ -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**.
|
||||
|
||||
@@ -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 >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).
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -115,6 +115,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 +271,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 +397,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 +649,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 +668,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,
|
||||
@@ -1740,6 +1762,9 @@ fun RelayApp() {
|
||||
onNavigateToAnalytics = {
|
||||
navController.navigate(Screen.Analytics.route)
|
||||
},
|
||||
onNavigateToDiagnostics = {
|
||||
navController.navigate(Screen.Diagnostics.route)
|
||||
},
|
||||
onNavigateToVoiceSettings = {
|
||||
navController.navigate(Screen.VoiceSettings.route)
|
||||
},
|
||||
@@ -2068,6 +2093,12 @@ fun RelayApp() {
|
||||
chatViewModel = chatViewModel,
|
||||
)
|
||||
}
|
||||
composable(Screen.Diagnostics.route) {
|
||||
DiagnosticsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.DeveloperSettings.route) {
|
||||
DeveloperSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
|
||||
+6
-6
@@ -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
|
||||
@@ -330,7 +330,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 +344,7 @@ fun ActiveCardFeaturesSection(
|
||||
)
|
||||
CapabilityDivider()
|
||||
CapabilityRow(
|
||||
label = "Vanilla Hermes voice",
|
||||
label = "Hermes voice",
|
||||
value = voiceValue,
|
||||
tone = voiceTone,
|
||||
onClick = if (standardVoiceAvailability ==
|
||||
@@ -623,7 +623,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 +665,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 +710,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"}"
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -601,7 +601,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -1037,8 +1038,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)
|
||||
@@ -4568,7 +4596,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 +5366,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
|
||||
|
||||
+6
-6
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -26,7 +26,7 @@ apply(plugin = "com.meta.spatial.plugin")
|
||||
|
||||
android {
|
||||
namespace = "com.axiomlabs.hermesquest"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.axiomlabs.hermesquest"
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.axiomlabs.hermesrelay.core"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.axiomlabs.hermesrelay.ui"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`):
|
||||
|
||||
@@ -65,8 +65,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:
|
||||
|
||||
Reference in New Issue
Block a user