Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee0591457b | ||
|
|
26811f0eb8 | ||
|
|
eafdb4efe2 | ||
|
|
802385c65c | ||
|
|
ec05643b6b | ||
|
|
984d9a2e63 | ||
|
|
65f22e21d9 | ||
|
|
36b05b637e | ||
|
|
0dfc581117 | ||
|
|
08a4efdceb | ||
|
|
92adfafc81 | ||
|
|
45326b377e | ||
|
|
889273aa85 | ||
|
|
206d182704 | ||
|
|
440f34080e | ||
|
|
6552566159 | ||
|
|
c3098a951e | ||
|
|
85c70338dc | ||
|
|
c9fa8f722b | ||
|
|
1dca285cd6 | ||
|
|
894b70ef62 | ||
|
|
80ea95db1c | ||
|
|
ed0b32e246 | ||
|
|
41037a3897 | ||
|
|
50c5fd8373 | ||
|
|
788d2abcb5 | ||
|
|
3ec432cd8b | ||
|
|
ef5bae7ca5 | ||
|
|
9be6422941 | ||
|
|
3d0b090a64 | ||
|
|
f972284dee | ||
|
|
a0bb195d4d | ||
|
|
038a2a472b | ||
|
|
f8141a6a91 | ||
|
|
c83f85745d | ||
|
|
674d2e34a2 | ||
|
|
7531065bdf | ||
|
|
0b922538f0 | ||
|
|
3166139f9e | ||
|
|
39cafc20c1 | ||
|
|
8b15c6d357 | ||
|
|
c869733069 | ||
|
|
7deb3efa88 | ||
|
|
f0e135c153 | ||
|
|
f6b965a97c | ||
|
|
0aa1b38a18 | ||
|
|
a22bdd9488 | ||
|
|
26e4a054d2 | ||
|
|
9f568e12cb | ||
|
|
a0b4d3715c | ||
|
|
e0a2a59957 | ||
|
|
738256238f | ||
|
|
d1820fb606 | ||
|
|
11274ce51b | ||
|
|
6fb15ddc9c | ||
|
|
15dcd6d637 | ||
|
|
42d262bc79 | ||
|
|
b977b6b02a | ||
|
|
d411764935 |
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security guidance
|
||||
url: https://github.com/Codename-11/hermes-relay/blob/main/docs/security.md
|
||||
about: Review the security model before posting sensitive vulnerability details publicly.
|
||||
- name: Report a security vulnerability (private)
|
||||
url: https://github.com/Codename-11/hermes-relay/security/advisories/new
|
||||
about: Report privately via GitHub Security Advisories — do not open a public issue. See SECURITY.md for the full policy.
|
||||
- name: User documentation
|
||||
url: https://codename-11.github.io/hermes-relay/
|
||||
about: Read setup, pairing, remote access, and troubleshooting docs.
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
# Pipeline: lint, build, and focused tests run concurrently. PRs build debug
|
||||
# APKs before merge; dev pushes keep lint/tests only to avoid duplicate
|
||||
# post-merge packaging. Main pushes keep APK artifacts.
|
||||
#
|
||||
# A release-build smoke (bundleRelease assembleRelease) runs on dev/main pushes
|
||||
# and on the dev→main release PR so release-only breakage (R8/minify rules,
|
||||
# resource shrinking, bundletool OOM) is caught BEFORE the android-v* tag,
|
||||
# instead of mid-release. It is debug-signed, so it needs no signing secrets.
|
||||
|
||||
name: CI — Android
|
||||
|
||||
@@ -152,3 +157,38 @@ jobs:
|
||||
name: test-reports
|
||||
path: app/build/reports/tests/
|
||||
retention-days: 7
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Release build smoke — exercises the release variant the android-v* tag
|
||||
# build runs (./gradlew bundleRelease assembleRelease, both flavors), so
|
||||
# release-only breakage (R8/minify, resource shrinking, bundletool OOM) is
|
||||
# caught BEFORE the tag instead of mid-release. Debug-signed — no secrets,
|
||||
# so it also runs on fork PRs. Runs on dev/main pushes (early signal after
|
||||
# each merge) and on the dev→main release PR (hard pre-tag gate); skipped on
|
||||
# dev-targeted feature PRs to avoid re-running a ~12-min build per iteration.
|
||||
# ──────────────────────────────────────────────
|
||||
release-smoke:
|
||||
name: Release build smoke (Android)
|
||||
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
# Mirrors release-android.yml's build step. No keystore is provided here,
|
||||
# so app/build.gradle.kts falls back to debug signing — fine for a build
|
||||
# smoke; the goal is to exercise the build, not to produce a shippable AAB.
|
||||
- name: Build release bundles + APKs (both flavors, debug-signed)
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
@@ -38,7 +38,11 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
# Node 24 ships npm 11, matching the npm that generates
|
||||
# user-docs/package-lock.json. On npm 10 (Node 20), `npm ci` rejects
|
||||
# the lock over the optional `search-insights` peer dep of bundled
|
||||
# docsearch. Keep this aligned with the npm used to write the lock.
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: user-docs/package-lock.json
|
||||
|
||||
|
||||
@@ -147,10 +147,13 @@ jobs:
|
||||
- name: Smoke-test tray exe launch
|
||||
shell: pwsh
|
||||
run: |
|
||||
$home = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
|
||||
New-Item -ItemType Directory -Force -Path $home | Out-Null
|
||||
$env:USERPROFILE = $home
|
||||
$env:HOME = $home
|
||||
# $HOME is a read-only automatic variable in PowerShell (names are
|
||||
# case-insensitive), so use a distinct scratch name; only the
|
||||
# $env:HOME / $env:USERPROFILE environment vars are writable.
|
||||
$smokeHome = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
|
||||
New-Item -ItemType Directory -Force -Path $smokeHome | Out-Null
|
||||
$env:USERPROFILE = $smokeHome
|
||||
$env:HOME = $smokeHome
|
||||
$proc = Start-Process -FilePath tray/src-tauri/target/release/hermes-relay-desktop.exe -WindowStyle Hidden -PassThru
|
||||
Start-Sleep -Seconds 5
|
||||
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
|
||||
|
||||
+60
-2
@@ -6,6 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Desktop CLI: `hermes-relay audit`.** Shows what the remote agent has actually run on this machine through the desktop tools — tool, status, and a short detail per call — read from a local log, no network or auth. Answers "what did the agent just do?" at a glance.
|
||||
- **Desktop CLI: `hermes-relay relay`.** Inspect the relay server itself: `relay info` (version, uptime, sessions — on the relay host), `relay security` (runtime auth toggles), and `relay context` (audit the system-prompt context the relay injects into the agent, which works from a remote machine with your session).
|
||||
- **Desktop CLI: background daemon.** `hermes-relay daemon start` runs the headless tool router in the background (no console window, survives closing the terminal), with `daemon stop` and `daemon status` to manage it. `daemon status` reports state, uptime, relay, and advertised-tool count; bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
|
||||
- **Desktop CLI: per-command help.** Every subcommand now answers `--help`, and `devices`/`sessions`/`plugins`/`voice`/`relay` print their own usage (sub-commands, flags, examples) instead of a terse "unknown sub-verb".
|
||||
- **Desktop CLI: startup banner.** A slim "Hermes Relay" wordmark shows atop `--help`, the first-run welcome, and the chat REPL — and `hermes-relay logo` prints it on demand. Suppressed for piped/`--json`/`--no-color` output.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Desktop CLI: visual + ergonomics refresh.** A single color theme across the CLI, aligned tables for `devices`/`sessions`, status dots for on/off states, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
|
||||
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
|
||||
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
|
||||
|
||||
## [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
|
||||
|
||||
- **Profile lock.** Settings → Profile lock pins the app to a single agent profile and hides the rest from the pickers; the lock screen stays the one place that lists every profile, with a clear notice if the locked profile isn't on the current server.
|
||||
- **In-app What's New & changelog.** A new Settings entry shows the current and past release notes any time — not just the post-update popup.
|
||||
- **Diagnostics: tap for detail + report.** Logged errors now carry clean titles and open a detail view with Copy / Share / Create-GitHub-issue (the same flow as crash reports); classified errors across voice, chat, and connection are captured centrally.
|
||||
- **Update-available nudge.** A dismissable in-app banner when a newer version is live — Google Play In-App Update on Play installs, GitHub Releases on sideload. Per-version dismissal, throttled, never nags.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Crash reports can be shared without GitHub.** The crash dialog now has a **Share** action alongside Copy and Report, handing the full report to the system share sheet (email, chat apps, notes, Drive). This covers users without a GitHub account and sideload installs that Play vitals never sees. Every outbound path stays user-initiated — nothing is sent automatically.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice override applies in Auto mode.** A chosen per-profile/enhanced voice now takes effect when the engine is on Auto with the relay paired — previously only "Relay" mode applied it. Per-profile voice settings are also namespaced by connection.
|
||||
- **Realtime voice "Stop" stops immediately.** Tapping Stop while the agent is speaking now halts realtime playback at once; over-chatty spoken status is throttled; and long background tasks no longer time out the turn (relay keeps the session alive while the task runs).
|
||||
- **Realtime Agent: brokered Hermes turns no longer fail (relay).** When the Realtime Agent reached back to Hermes for context or tool work, a session-namespace mismatch could make the API Server reject the turn with `session_not_found`. The relay now mints or reuses a valid API Server session and retries once, and reads the API Server's current nested create-session response. Provider-native turns are unaffected.
|
||||
- **Hold-to-talk no longer releases on accidental drift.** The mic button holds until the finger genuinely lifts, instead of cancelling when it drifts off the button.
|
||||
- **Voice overlay is readable.** The voice dropdown panel and its status bubbles are opaque (no bleed-through), and the Focus/Overlay/Exit labels no longer wrap to two lines; invalid engine/route combinations are no longer selectable.
|
||||
- **Connection status overlay clears faster.** Resolved (error/warning) connection toasts auto-dismiss within ~5s instead of lingering.
|
||||
|
||||
## [1.2.0] - 2026-06-20
|
||||
|
||||
### Added
|
||||
@@ -310,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
|
||||
|
||||
|
||||
@@ -282,7 +282,17 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
|
||||
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
|
||||
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat` |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat`; command-scoped `--help` falls through to each command |
|
||||
| `desktop/src/lib/theme.ts` | Shared ANSI palette + `colorEnabled()` + `Theme` (semantic helpers, `statusDot`) — single visual language; `--no-color`/`NO_COLOR`/TTY aware |
|
||||
| `desktop/src/lib/table.ts` | Zero-dep column-aligned table renderer (ANSI-width aware, last column flexes to terminal width) — used by devices/sessions/audit |
|
||||
| `desktop/src/lib/spinner.ts` | Stderr braille spinner for slow ops (pair probe, gateway connect); no-op when piped/quiet/json |
|
||||
| `desktop/src/lib/usage.ts` | `UsageSpec` + `renderUsage`/`printUsage`/`unknownSubcommand` — per-subcommand `--help` + self-documenting sub-verb fallback |
|
||||
| `desktop/src/lib/hints.ts` | `suggestedFix(err, ctx)` → next-step command (re-pair on auth fail, etc.); `formatError` renders error + hint |
|
||||
| `desktop/src/lib/logo.ts` | Slim box-drawing "Hermes Relay" wordmark; shown atop `--help`, first-run welcome, REPL header, and `hermes-relay logo`; theme/no-color aware |
|
||||
| `desktop/src/lib/auditLog.ts` | Local desktop-tool audit JSONL (`~/.hermes/desktop-audit.jsonl`); router appends per dispatch; backs `audit` command (relay's ring is loopback-only) |
|
||||
| `desktop/src/lib/daemonStatus.ts` | Daemon heartbeat file (`~/.hermes/daemon-status.json`) + `isPidAlive` liveness; backs `daemon --status` |
|
||||
| `desktop/src/commands/audit.ts` | `hermes-relay audit` — tails the local audit log into a table (WHEN/TOOL/STATUS/DETAIL); `--limit`, `--json` |
|
||||
| `desktop/src/commands/relay.ts` | `hermes-relay relay info/security/context` — relay-server management surface; info/security loopback-only, context works remote with bearer |
|
||||
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
|
||||
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
|
||||
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
|
||||
|
||||
+11
-22
@@ -1,36 +1,25 @@
|
||||
# Hermes-Relay-CLI v__VERSION__
|
||||
|
||||
**Release Date:** <!-- YYYY-MM-DD -->
|
||||
**Since the previous CLI release:** <!-- one line: the theme of this release -->
|
||||
**Release Date:** 2026-06-21
|
||||
**Since the previous CLI release:** a first-class command surface — activity audit, relay inspection, a background daemon, a polished visual layer, and v1.2.0 server parity.
|
||||
|
||||
<!-- One short paragraph: what this desktop/CLI release is about and who should care. -->
|
||||
|
||||
<!--
|
||||
═══ RELEASE-PREP CHECKLIST (delete this comment block when done) ═══
|
||||
• This file is the GitHub Release body for `cli-v*` tags. The release workflow
|
||||
substitutes __VERSION__ (bare, e.g. 0.3.0) and __TAG__ (full, e.g. cli-v0.3.0) —
|
||||
leave those tokens in the Install section; do NOT hardcode versions there.
|
||||
• Rewrite the Summary + the Added/Changed/Fixed groups from the CLI/desktop-relevant
|
||||
bullets in CHANGELOG.md's promoted version block.
|
||||
• Keep-a-Changelog rules: include only the groups that have entries; delete empty ones.
|
||||
• Keep the "Experimental phase" notice until the CLI reaches GA.
|
||||
• Scrub for public distribution (RELEASE.md §2): no personal names, no private infra,
|
||||
no fork-branch plumbing, no AI self-narration.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
This is a broad CLI uplift: new commands for seeing what the agent did and inspecting the relay, a daemon you can run in the background, and a consistent themed interface with per-command help. Everything is additive — existing commands, flags, and scripts keep working.
|
||||
|
||||
**Experimental phase.** Assets are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
-
|
||||
- **`hermes-relay audit`** — see what the remote agent has run on this machine through the desktop tools (tool, status, detail), read from a local log. No network, no auth; works whether the relay is local or remote.
|
||||
- **`hermes-relay relay`** — inspect the relay server: `relay context` audits the system-prompt context the relay injects into the agent (works from any paired machine), and `relay info` / `relay security` report server state for operators on the relay host.
|
||||
- **Background daemon.** `hermes-relay daemon start` runs the headless tool router in the background — no console window, survives closing the terminal — with `daemon stop` and `daemon status` to manage it. Bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
|
||||
- **Per-command help.** Every subcommand answers `--help`, and `devices` / `sessions` / `plugins` / `voice` / `relay` print their own usage (sub-commands, flags, examples) instead of a terse "unknown sub-verb".
|
||||
- **Startup banner.** A slim "Hermes Relay" wordmark shows atop `--help`, the first-run welcome, and the chat REPL; `hermes-relay logo` prints it on demand. Suppressed for piped / `--json` / `--no-color` output.
|
||||
|
||||
### Changed
|
||||
-
|
||||
|
||||
### Fixed
|
||||
-
|
||||
- **Visual + ergonomics refresh.** One consistent color theme across the CLI, aligned tables for `devices` / `sessions`, on/off status dots, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
|
||||
- **Smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
|
||||
- **Voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Code of Conduct
|
||||
|
||||
Hermes-Relay adopts the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/),
|
||||
version 2.1, as its code of conduct. The canonical, full text lives at that
|
||||
link; the summary below states what it means for this project.
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and maintainers pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Behavior that helps create a positive environment includes:
|
||||
|
||||
- Showing empathy and kindness toward others.
|
||||
- Being respectful of differing opinions, viewpoints, and experiences.
|
||||
- Giving and gracefully accepting constructive feedback.
|
||||
- Taking responsibility, apologizing to those affected by our mistakes, and
|
||||
learning from the experience.
|
||||
- Focusing on what is best for the overall community, not just ourselves.
|
||||
|
||||
Behavior that is not acceptable includes:
|
||||
|
||||
- Harassment, intimidation, or discrimination in any form.
|
||||
- Personal or political attacks, insults, or derogatory comments.
|
||||
- Unwelcome advances or attention, including of a romantic or sexual nature.
|
||||
- Publishing others' private information (such as a physical or email address)
|
||||
without their explicit permission.
|
||||
- Other conduct that could reasonably be considered inappropriate in a
|
||||
professional setting.
|
||||
|
||||
For the complete, canonical list of standards and examples, see the
|
||||
[Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing these standards
|
||||
and will take appropriate and fair corrective action in response to any behavior
|
||||
they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Maintainers have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, issues, and other contributions that are not aligned
|
||||
with this Code of Conduct, and will communicate reasons for moderation decisions
|
||||
when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces — the repository, issues,
|
||||
pull requests, discussions, and the documentation site — and also applies when
|
||||
an individual is officially representing the project in public spaces.
|
||||
|
||||
## Reporting & Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported privately to the maintainers at **`conduct@codename-11.dev`**. All
|
||||
complaints will be reviewed and investigated promptly and fairly. Maintainers
|
||||
are obligated to respect the privacy and security of the reporter of any
|
||||
incident.
|
||||
|
||||
For the **Enforcement Guidelines** (the tiered Correction → Warning →
|
||||
Temporary Ban → Permanent Ban ladder maintainers use to determine consequences),
|
||||
see the corresponding section of the
|
||||
[Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/#enforcement-guidelines).
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||
@@ -1,5 +1,88 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 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.
|
||||
|
||||
## 2026-06-21 — Realtime Agent API Server session handoff (issue #101)
|
||||
|
||||
**Why.** The Realtime Agent's brokered Hermes path (`hermes_run_task`) could fail two ways when reaching back to the API Server. (1) A caller-supplied `chat_session_id` that originated in a different session namespace (the gateway/client session store) was passed straight to `POST /api/sessions/{id}/chat/stream`, which the API Server rejects with `404 session_not_found`. (2) `_create_session()` only read a flat `id`/`session_id`, but the current API Server returns the created session nested under `{"object":"hermes.session","session":{"id":"api_…"}}` — so creation raised "Hermes API created a session without an id."
|
||||
|
||||
**Verified against upstream first.** `gateway/platforms/api_server.py` confirms the contract: create-session returns the nested `session` object at status 201 (`_session_response`, line ~1426); `_get_existing_session_or_404` emits `{"error":{"code":"session_not_found"}}` at 404 (line ~1349). Coded to the verified shapes, not the docs.
|
||||
|
||||
- **`hermes_tool_broker.py` — nested create-session parse.** Extracted `_session_id_from_create_response()` that accepts top-level `id`/`session_id` *and* nested `session.id`/`session.session_id`, preferring the flat form for back-compat with older/partial builds. `_create_session()` now delegates to it.
|
||||
- **`hermes_tool_broker.py` — `session_not_found` handoff + single retry.** `stream_task()` tracks whether it owns the API Server session (`api_session_owned`). When a caller-supplied id 404s with `session_not_found` (matched by `_is_session_not_found()`, structured-or-substring), the broker mints a fresh API Server session, emits a second `hermes.session.bound` event with `reason: "session_not_found_handoff"` (so the orchestrator rebinds `session.chat_session_id`), and retries the chat/stream POST once. A session the broker created itself, or a second failure, is not retried — no loop. The 404 is raised before any SSE bytes stream, so the retry never double-emits chat content. Valid existing API sessions are reused untouched.
|
||||
- **Tests.** New `plugin/tests/test_hermes_tool_broker.py` (13): pure-function coverage for both parsers (nested/flat/precedence/empty, 404-only `session_not_found` detection) plus end-to-end `stream_task` against a local aiohttp `TestServer` fake API Server — no-id-creates-session, existing-session-reused, namespace-mismatch handoff+retry, and single-retry-then-give-up. `aioresponses` isn't installed, so the tests drive the real aiohttp client path against a local server (the repo's existing pattern).
|
||||
|
||||
**Verification.** `python -m unittest plugin.tests.test_hermes_tool_broker` → 13/13 green. `plugin.tests.test_realtime_agent_routes` → 34/34 green (no regression). Server-side only; no Android/CLI changes.
|
||||
|
||||
## 2026-06-21 — Profile lock + voice fixes (orchestration batch)
|
||||
|
||||
**Why.** User-requested batch (TODO User-Added) covering the profile-lock setting and the concrete voice TODOs. Investigated and implemented via a planning→implementation orchestration pass: four read-only investigators, then three disjoint file-ownership implementation lanes. All changes are client-side Kotlin; the server-side realtime-voice half is deferred to TODO. **Unbuilt at time of writing — pending Studio build + `./gradlew lint`.**
|
||||
|
||||
- **Profile lock (new).** Per-connection "lock to one profile": `data/ProfileLockStore.kt` (twin of `ProfileSelectionStore`, same `profile_selections` DataStore; `__server_default__` sentinel via `AgentDisplay`); `ProfileController` gains `lockedProfileName`/`isProfileLocked` + `lockProfile`/`unlockProfile`, with `selectProfile` no-op'd when locked and `resolvePendingProfileFrom` preferring (and holding on missing) the locked target; `ConnectionViewModel` delegations + lock-clear at reset/remove sites + a lock-flow observer; `ConnectionInfoSheet` collapses the picker to a static "Locked to <name>" row when locked; `SettingsScreen` adds the `ProfileLockCard` + dialog — the one surface that still lists all profiles, with a "not found on this server" banner.
|
||||
- **Voice override in 'auto' (fix).** `VoiceViewModel.shouldPreferRealtimeVoice()` gated on `.route` (configured) instead of `.effectiveRoute` (resolved), so 'auto'+relay-ready never engaged the override-capable relay path and fell back to the host-global Standard `/api/audio/speak` (no override slot) — hence only 'Relay' applied the chosen voice. Switched to `effectiveRoute`. Also wired `connectionId` for per-profile voice-prefs namespacing (`RelayApp` calls `setVoicePrefsConnection(activeConnectionId)` and passes `connectionId` to `VoiceSettingsScreen`, which now takes the param and feeds `setActiveScope`).
|
||||
- **Realtime voice (fix, client half).** Stall: `RelayVoiceClient.awaitRealtimeAgentCompletion` relaxes the 90s idle watchdog once a `hermes.run.promoted`/long run is seen, keeping the 5-min max-turn backstop. Over-chatty status: per-turn throttle in `VoiceViewModel.emitStatus` (≥22s gap, ≤3 spoken/turn). Waveform: realtime `outputAudioActive` now gates on real playback-start (`RealtimePcmPlayer` head-move/`playbackAmplitude`) instead of decoded-byte RMS, matching the basic-TTS path.
|
||||
- **Voice UI.** Profile icon now shows in the floating overlay header pill (`VoiceModeOverlay` reads `LocalAgentIconPath`; sphere/pet stays the fallback). Voice Settings: invalid engine/route combos made unreachable (RealtimeAgent disabled without relay, unavailable routes disabled, `coerceAudioRoute` auto-corrects on engine switch / relay loss); long dropdown/provider labels get `maxLines=1`+ellipsis.
|
||||
- **Method.** Disjoint file-ownership lanes (1: VoiceViewModel/RelayVoiceClient/RelayApp; 2: VoiceSettingsScreen/VoiceModeOverlay; 3: ProfileController/ConnectionViewModel/ConnectionInfoSheet/SettingsScreen/ProfileLockStore) so parallel implementers never touched the same file, and `ChatScreen.kt` was avoided (owned by a concurrent session). Pure helpers (`coerceAudioRoute`, `shouldSpeakStatusNow`, `shouldMarkRealtimeOutputActive`) extracted for unit-testing.
|
||||
- **Deferred.** See TODO.md "Orchestration batch (2026-06-21)": streaming-path override question, upstream per-profile Standard voice, ChatScreen lock glyph, export decision, CHANGELOG entries, on-device verification.
|
||||
- **Follow-up (same day).** Built + deployed to device as **1.2.1 / versionCode 15** (`:app:assembleSideloadDebug`, clean). Server-side realtime half implemented in `broker.py` (heartbeat-while-task-running + calmer spoken-status cadence) with `plugin/tests/test_realtime_heartbeat.py` (11) + promotion regression (5) green — **deployed**: committed `d1820fb` → pushed to `origin/dev` → server `~/.hermes/hermes-relay` fast-forwarded + `hermes-relay` restarted (active, clean startup on ws://…:8767). New Kotlin unit suite green: `ProfileLockStoreTest` (9, in-memory DataStore harness), `ProfileControllerLockTest` (8, Robolectric), `CoerceAudioRouteTest` (7), `VoiceStatusGatesTest` (12) — 36/36 via `:app:testSideloadDebugUnitTest`.
|
||||
|
||||
## 2026-06-21 — Desktop CLI first-class pass (audit-driven)
|
||||
|
||||
**Why.** The desktop CLI hadn't had feature work since 2026-05-19 while the relay plugin shipped a full v1.2.0 wave (relay-management surface, enhanced voice, context injection). A four-axis audit (command UX/visuals, pairing, desktop-tools, plugin parity) found the CLI surfaced ~⅓ of current plugin capability with an ad-hoc visual layer and weak discoverability. This pass closes those gaps; all changes are confined to `desktop/` (no Android, no Python).
|
||||
|
||||
- **Shared zero-dep UI foundation (`desktop/src/lib/`).** `theme.ts` (one ANSI palette + `colorEnabled` + `Theme` with `statusDot`/semantic helpers, extracted from `renderer.ts`'s pattern), `table.ts` (ANSI-width-aware column renderer, last column flexes to terminal width), `spinner.ts` (stderr braille spinner, no-op when piped/quiet/json), `hints.ts` (`suggestedFix(err)` → next-step command + `formatError`), `usage.ts` (`UsageSpec` → per-subcommand `--help` + self-documenting unknown-sub-verb), `logo.ts` (slim box-drawing wordmark).
|
||||
- **Discoverability.** Fixed the `cli.ts` dispatch so command-scoped `--help` reaches the command (was always short-circuiting to global help). Added `--help` + usage specs across `devices`/`sessions`/`status`/`tools`/`plugins`/`voice`/`relay`/`pair`/`daemon`/`doctor`/`workspace`/`paste`; ported list output (`devices`/`sessions`) to aligned tables + status dots; routed command failures through `formatError` (actionable hints); replaced `doctor`'s inconsistent `!!` warning markers with themed `⚠` lines.
|
||||
- **Pairing.** Threaded an `onProbe` callback into `probeCandidatesByPriority` so `pair` shows per-endpoint progress + latency during the multi-endpoint race; `credentials.ts` warns (TTY-only) when a stored token is near/at expiry with the exact re-pair command; `relayUrlPrompt.normalizeRelayUrl` defaults a bare `ws://host` to `:8767` (scoped to `ws://` so `wss://` proxy fronts on :443 aren't broken), surfaced not silent.
|
||||
- **Desktop tools first-class.** New `hermes-relay audit` backed by a local JSONL (`~/.hermes/desktop-audit.jsonl`) the `DesktopToolRouter` appends per dispatch — the relay's ring buffer is loopback-only, so the client (the executor) is the right source of truth and this works against a remote relay with no auth. Consent prompt rewritten to state persistence + point at `audit`; computer-use's observe→grant→act flow documented in `--help`.
|
||||
- **Daemon observability + background run.** `daemon` writes a heartbeat file (`~/.hermes/daemon-status.json`) on each lifecycle transition + a 30s tick; `daemon status` reads it, cross-checks pid liveness (`process.kill(pid,0)`), and exits non-zero when stale. Added `daemon start` (detached spawn — `detached:true` + `windowsHide:true` + stdio→`~/.hermes/daemon.log` + `unref`, no console window, survives terminal close) and `daemon stop` (kills the status-file pid + clears it); bare `daemon` still runs foreground. Validated start→status→stop on Windows against the live relay. A true OS service (reboot/login auto-start) remains the deferred follow-up.
|
||||
- **Dev loop.** Added `desktop/scripts/dev-install.mjs` + `npm run dev:install` — builds the bun binary for the current platform and drops it over the curl-installed `~/.hermes/bin/` binary (backs the old one up as `.bak`, surfaces EBUSY as "stop the daemon first"). Closes the gap where local changes could only be exercised via `npx tsx`, never as the real global binary.
|
||||
- **Plugin v1.2.0 parity.** `voice` now renders the `/voice/config` `enhanced` block (Gemini tone-tags/persona, xAI speech-tags). New `hermes-relay relay info|security|context` over the relay-management surface — `context` (the injected-system-prompt audit) works remote with a bearer; `info`/`security` are loopback-only and say so on a remote 403. Deliberately did **not** add a CLI-vs-server "version skew" warning — the two are on independent release tracks, so it would be a false alarm.
|
||||
- **Logo.** Slim box-drawing "Hermes Relay" wordmark atop `--help`, the first-run welcome, the chat REPL, and a `logo` command; theme/no-color aware, never on piped/`--json` stdout.
|
||||
- **Verification.** `npm run type-check` and `npm run build` (tsc) green. Runtime-smoked via `npx tsx src/cli.ts` (NO_COLOR): `--help`, `logo`, `devices --help`/`devices bogus` (usage fallback), `audit` (empty-state), `daemon --status` (no-daemon), `doctor`, `workspace`. Docs: CHANGELOG `[Unreleased]`, `desktop/README.md` (audit/relay/daemon-status sections), CLAUDE.md desktop Key Files refreshed. Version bump (`alpha.18`→`alpha.19`) left to the operator — not cutting a CLI release this cycle.
|
||||
|
||||
## 2026-06-20 — Release-prep: android-v1.2.0 + plugin-v1.2.0
|
||||
|
||||
**Why.** Cut a combined 1.2.0 across both lockstep surfaces (both were at 1.1.0). The accumulated `[Unreleased]` block had captured the major feature arcs but a second wave had landed undocumented — audited every commit since the `*-v1.1.0` tags and backfilled the changelog before promoting it.
|
||||
@@ -521,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).
|
||||
|
||||
|
||||
+5
-15
@@ -1,25 +1,15 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
|
||||
**Release Date:** June 20, 2026
|
||||
**Since the previous plugin release:** A new, removable **enhancement layer** that lets the relay teach the agent things only the relay knows — starting with sensitive-media classification — plus provider-aware enhanced voice and an isolated, TUI-tuned tmux for relay terminals.
|
||||
**Release Date:** June 22, 2026
|
||||
**Since the previous plugin release:** Reliability fixes for the Realtime Agent voice path — brokered Hermes turns no longer drop with `session_not_found`, and long-running Hermes work no longer times out a live voice session.
|
||||
|
||||
This release adds a clean way for the relay to extend the agent without forking or touching the user's soul/memory. The first use is **sensitive-media classification**: the relay appends a small, auditable system-prompt block teaching the agent to mark private/NSFW media so the paired phone can blur it — with sensitivity staying model-emitted. It's on by default for relay installs (installing the relay is the opt-in), reversible from the dashboard or an env flag, fully visible over a new audit route, and a complete no-op on vanilla upstream. Voice gains provider-aware controls for Gemini and xAI, and relay terminals now run on a dedicated, correctly-configured tmux.
|
||||
This is a focused patch for the relay's Realtime Agent. When a spoken turn reached back into Hermes for context or tool work, a session-namespace mismatch could make the API Server reject the turn, and long background tasks could let the voice session lapse mid-run. Both paths are now resilient. Provider-native voice turns and vanilla upstream (no plugin) are unaffected.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
- **Relay enhancement layer + agent-context injection.** A reusable, removable layer that injects auditable, fenced blocks into the agent's system prompt at plugin-load. Fail-open at every step (seam absent / block build throws ⇒ base prompt unchanged), config-gated, and a byte-for-byte no-op on vanilla upstream. Built to be retired per-surface as upstream adds a context hook — the same pattern as the bootstrap route shims. See `docs/plans/2026-06-20-relay-enhancement-layer.md`.
|
||||
- **Sensitive-media classification (first block).** Teaches the agent to mark private/NSFW media with the client's spoiler convention so the phone blurs it per the user's setting. **On by default for relay installs**; opt out with `RELAY_AGENT_CONTEXT_ENABLED=0` or the dashboard toggle. Sensitivity stays model-emitted — no relay-side or on-device classifier. No soul/memory is touched.
|
||||
- **`GET /context/injected` audit route.** The relay exposes exactly what it would inject (loopback-open, bearer-gated remotely), so the injection is never hidden — surfaced in the Android chat "What the agent sees" sheet as "Relay context (server-side)".
|
||||
- **Dashboard Agent-context controls.** The Relay management tab gained a master toggle and per-block toggles (labeled experimental / server-side / removable), shown on-by-default for relay installs.
|
||||
- **Provider-aware enhanced voice (Gemini + xAI).** `/voice/synthesize` accepts per-request overrides so a paired client can steer a Gemini voice/model with expressive tone tags, or an xAI voice with expressive speech tags, without changing the server's global voice config.
|
||||
|
||||
### Changed
|
||||
- **Relay terminals run on an isolated, TUI-tuned tmux.** Sessions spawn on a dedicated tmux server/socket with a generated config — `escape-time 0`, truecolor `tmux-256color`, `mouse`/`focus-events` on, `status off` — so editors and full-screen tools behave correctly without touching the user's personal tmux.
|
||||
|
||||
### Fixed
|
||||
- **Relay voice synthesis no longer leaves temporary audio files behind** on the server.
|
||||
- **Clearer voice errors.** Standard voice rejects an over-long recording before uploading and returns a helpful message for audio the server can't read, instead of a generic HTTP error.
|
||||
- **Brokered Hermes turns no longer fail with `session_not_found`.** When the Realtime Agent reached back to Hermes for context or tool work, it could hand the API Server a session id from a different session namespace (the gateway/client store), which the API Server rejected. The broker now mints a valid API Server session and retries the turn once when that happens, reuses an existing API Server session when the id is already valid, and reads the API Server's current nested `{"session": {"id": …}}` create-session response (previously only the legacy flat shape) so session creation no longer errors with "created a session without an id."
|
||||
- **Realtime voice survives long Hermes runs.** A heartbeat now keeps the realtime voice session alive while a long-running Hermes task is in flight, so the turn no longer times out before the work finishes.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -392,6 +392,14 @@ the new app version and a higher `appVersionCode`.
|
||||
3. Skim the new versioned block and tighten / reorder if needed —
|
||||
Keep-a-Changelog grouping (`Added` / `Changed` / `Fixed`) should
|
||||
already be in place from the accumulator phase.
|
||||
4. **Per-surface split.** `[Unreleased]` accumulates entries from *all
|
||||
three* surfaces (Android + CLI + plugin), but releases are
|
||||
per-surface. Move only the entries for the surface you're cutting into
|
||||
the new versioned block, and leave the other surfaces' entries under
|
||||
the fresh `[Unreleased]` for their own `cli-v*` / `plugin-v*` cut.
|
||||
(Those tracks' GitHub-Release bodies come from `CLI_RELEASE_NOTES.md` /
|
||||
`PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's
|
||||
historical record.)
|
||||
- `RELEASE_NOTES.md` — body of the GitHub Release for this version
|
||||
(rewritten each release; the workflow uses this as-is). This is the
|
||||
operator-facing summary, not the CHANGELOG mirror. Keep the
|
||||
|
||||
+14
-45
@@ -1,22 +1,22 @@
|
||||
# Hermes-Relay-Android v1.2.0
|
||||
# Hermes-Relay-Android v1.2.3
|
||||
|
||||
**Release Date:** June 20, 2026
|
||||
**Since v1.1.0:** A big personalization release — app themes, swappable sphere skins, and animated agent **pets** — paired with a transparency pass (see which transport you're on and exactly what the agent is told), a much faster cold start, in-app crash reporting, and a broad reliability sweep.
|
||||
**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.0 is about making Hermes-Relay feel like *yours* and making it honest about what it's doing. Dress the app in one of eight themes, swap the agent orb for a hand-picked or AI-generated **pet** that reacts to what the agent is doing, and give each profile its own icon. At the same time, the chat status strip now names the actual streaming path (⚡ Gateway, 📡 Sessions, …), a "What the agent sees" sheet shows the exact extra context prepended to your next turn, and cold start is roughly three times faster. If something does go wrong, the app now catches the crash and offers a one-tap, pre-filled bug report.
|
||||
v1.2.3 is a focused fix for anyone connecting over Tailscale or public TLS. Plain-LAN connections were never affected.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
v1.2.0 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.0-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.2.0-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.2.0-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.2.0-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,43 +24,12 @@ Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload
|
||||
|
||||
## Highlights
|
||||
|
||||
### Make it yours
|
||||
|
||||
- **App themes.** A theme picker in Settings → Appearance ships eight looks — the signature Hermes Relay brand (full light/dark) plus ports of the Nous Hermes baselines: Hermes Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, and Rosé. The whole app follows your choice; Light/Dark/Auto applies to themes that ship both modes.
|
||||
- **Agent pets — a living avatar.** Replace the orb with an animated pet that reacts to the agent: idle / thinking / writing / speaking / listening, a distinct **working** pose during tool calls, one-shot **greet** and **celebrate** reactions, and a loop that speeds up as output streams. Add or remove pets right in Appearance (no `adb`), preview each state, tune playback speed, and toggle frame auto-stabilization. Pets are pure data — an AI authoring kit and JSON schema let you generate one from sprite art.
|
||||
- **Hot-swappable sphere skins + per-profile icons.** Keep the orb but reskin it (Adaptive, Classic, Aurora, Solar, Mono, or your own JSON skin), and give each agent profile its own small icon beside its name — all client-side, never sent to Hermes.
|
||||
|
||||
### See what's actually happening
|
||||
|
||||
- **Transport path is visible.** The chat status strip now shows which streaming path is in use — ⚡ Gateway (live thinking), 📡 Sessions, Completions, or Runs — and Chat Settings adds a basic→best tier ladder explaining the active path and its fallback.
|
||||
- **"What the agent sees" sheet.** Tap the context meter to see the exact extra context prepended to your next turn — persona/profile, phone status, any per-turn voice hint, and (when paired) the relay's own server-side context. The audit is honest about what the phone sends versus what's applied on the server.
|
||||
- **Spoken-turn badges + voice render-path visibility.** Voice and Realtime Agent replies carry a chip in the scrollback, and Voice Settings shows whether speech is rendering over the streaming or basic path.
|
||||
|
||||
### Privacy
|
||||
|
||||
- **Sensitive-media blur.** When paired to the relay, the agent can mark private/NSFW media and the phone blurs it per your setting — sensitivity stays model-emitted (no on-device or relay-side classifier), and the exact instruction is visible in the "What the agent sees" sheet. Vanilla Hermes (no plugin) is unaffected.
|
||||
|
||||
### Faster, calmer, more honest
|
||||
|
||||
- **~3× faster cold start.** The app was building several hardware-keystore-encrypted stores at launch, serializing on a process-global lock and stalling the chat header for seconds. It now builds a single keyset shared with the dashboard cookies, cutting measured time-to-connected from ~2.9 s to ~1 s. Existing sign-ins migrate automatically.
|
||||
- **Honest loading, never stale.** Model, personality, and approvals show a brief "checking…" state and fade in once the server confirms them; standard controls (Model, YOLO, Fast, reasoning effort) always appear — live when ready, "checking…" while loading, or cleanly disabled with the reason — instead of being hidden or showing a maybe-wrong value.
|
||||
|
||||
### More reliable
|
||||
|
||||
- **In-app crash reporting.** A force-close now surfaces a clean dialog on next launch with the stack trace — Copy it, or **Report** to open a pre-filled GitHub issue from the bug template. The handler re-raises so Play vitals still record the crash.
|
||||
- **QR pairing hardened for foldables.** On devices where the camera can't initialize, the scanner shows a "camera unavailable — pair manually" card instead of force-closing.
|
||||
- **Crash fixes.** No more crash opening a chat with a server-local image, and the PDF viewer no longer crashes when a document closes mid-render.
|
||||
- **Chat correctness.** In-chat model picks now actually apply — both on a new chat and mid-conversation — server-side turn errors stay on screen as an error bubble, per-reply token counts and provenance badges survive the post-turn reload, and server steering markers (`[System: …]`) no longer appear as chat bubbles.
|
||||
|
||||
### Voice & terminal polish
|
||||
|
||||
- **Enhanced voice control (Gemini & xAI).** When the relay uses a Gemini or xAI voice provider, Voice Settings can pick a voice/model and turn on expressive tone/speech tags. Vanilla Hermes voice stays configured server-side.
|
||||
- **Leaner terminal.** A scrollable, fully-legible key bar, TUI-correct arrows and bracketed paste, a compact single-row header, and relay sessions on an isolated, TUI-tuned tmux so editors and full-screen tools behave.
|
||||
### 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
|
||||
|
||||
- Cold-start speedup migrates the encrypted credential and dashboard-cookie stores automatically on first launch; in rare cases Manage/voice may ask for a one-time re-login (cookies are re-obtainable).
|
||||
- App themes, sphere skins, and pets are available on **both** flavors — they're client-side and need no Device Control.
|
||||
- `appVersionCode` is **14**.
|
||||
- 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**.
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Security Policy
|
||||
|
||||
Hermes-Relay can give a remote AI agent real control of a phone and, via the
|
||||
CLI, of a paired desktop. We take security reports seriously and welcome
|
||||
responsible disclosure.
|
||||
|
||||
For the architecture, threat model, and the `googlePlay` vs. `sideload`
|
||||
capability boundary, see [`docs/security.md`](docs/security.md). This document
|
||||
covers **how to report a problem**.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public issue, discussion, or pull request for a security
|
||||
vulnerability.** Public reports expose users before a fix is available.
|
||||
|
||||
Use one of these private channels instead:
|
||||
|
||||
1. **GitHub Private Vulnerability Reporting (preferred).** Go to the
|
||||
repository's **Security** tab → **Report a vulnerability**, or
|
||||
[open a draft advisory directly](https://github.com/Codename-11/hermes-relay/security/advisories/new).
|
||||
This keeps the whole exchange private and threaded with the code.
|
||||
2. **Email** — `security@codename-11.dev`. Use this if you can't use GitHub.
|
||||
If you'd like to encrypt the report, say so in a first contact message and
|
||||
we'll arrange a key.
|
||||
|
||||
### What to include
|
||||
|
||||
A good report lets us reproduce and assess impact quickly:
|
||||
|
||||
- The affected surface — **Android app** (and which flavor, `googlePlay` or
|
||||
`sideload`), **relay plugin / server**, **desktop CLI**, or the **docs site**.
|
||||
- Affected version(s) — app version/code, plugin version, or CLI version.
|
||||
- A clear description of the issue and its security impact.
|
||||
- Step-by-step reproduction, a proof of concept, or a minimal example.
|
||||
- Any suggested remediation, if you have one.
|
||||
|
||||
> ⚠️ **Scrub secrets before sending.** Remove API keys, relay session tokens,
|
||||
> pairing codes, real hostnames/IPs, and personal data from logs, traces, and
|
||||
> screenshots.
|
||||
|
||||
## What to Expect
|
||||
|
||||
This is an indie, open-source project, so timelines are best-effort rather than
|
||||
contractual:
|
||||
|
||||
- **Acknowledgement** of your report — typically within **5 business days**.
|
||||
- An initial **assessment and severity triage** after we can reproduce it.
|
||||
- **Coordinated disclosure:** we'll work with you on a fix and a disclosure
|
||||
timeline, and credit you in the advisory and release notes if you'd like
|
||||
(or keep you anonymous if you prefer).
|
||||
- A public GitHub Security Advisory and a `CHANGELOG.md` entry once a fix ships.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope** — vulnerabilities in code this project ships:
|
||||
|
||||
- The Android app (`app/`) on either flavor.
|
||||
- The relay plugin and server (`plugin/`).
|
||||
- The desktop CLI (`desktop/`).
|
||||
- The pairing, auth, transport, media, and tool-routing surfaces.
|
||||
|
||||
**Out of scope** — please report these to the right place instead:
|
||||
|
||||
- **Your own Hermes server configuration** (missing TLS, an exposed dashboard,
|
||||
weak provider keys). The relay connects only to endpoints you configure; how
|
||||
you deploy and secure your Hermes host is outside this app. See
|
||||
[`docs/security.md`](docs/security.md) and the relay-server docs for hardening
|
||||
guidance.
|
||||
- **Upstream [hermes-agent](https://github.com/NousResearch/hermes-agent)**
|
||||
issues — report those to the upstream project (a heads-up to us is welcome if
|
||||
it affects how Hermes-Relay should behave).
|
||||
- **Third-party dependencies** — report upstream; if a dependency issue affects
|
||||
Hermes-Relay users, tell us so we can pin or patch.
|
||||
- Findings that require a **rooted device, a physical-access attacker, or a
|
||||
malicious app already granted Accessibility/overlay permissions** — these are
|
||||
outside the model documented in `docs/security.md`, though we'll still read
|
||||
the report.
|
||||
|
||||
## Safe Harbor
|
||||
|
||||
We consider security research conducted in good faith under this policy to be
|
||||
authorized. We will not pursue or support legal action against researchers who:
|
||||
|
||||
- Make a good-faith effort to avoid privacy violations, data destruction, and
|
||||
service disruption.
|
||||
- Test only against **their own devices, installs, and Hermes servers** — never
|
||||
another person's data or infrastructure.
|
||||
- Report promptly and give us a reasonable chance to remediate before any
|
||||
public disclosure.
|
||||
|
||||
Thank you for helping keep Hermes-Relay and its users safe.
|
||||
@@ -8,12 +8,41 @@ 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.
|
||||
- [ ] Look into the voice-settings profile specific capabilities - confirm approach is sound - verify as I noticed that in 'auto' mode it didn't work, it still used the system default despite config despite override voice chosen being displayed to user in voice config in voice setting in app UI. Only switching to 'Relay' specifically allowed the user-override to work/apply.
|
||||
- [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.*
|
||||
- [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.
|
||||
- [x] **Per-profile agent icon + static-image avatar (shipped 2026-06-20 —** `d827e46`**, see DEVLOG).** Per-profile icon: client-side `ProfileIconStore` (per `(connection, profile)`, never sent to Hermes; stores a copied-file path) → small Coil image beside the agent name in `MessageBubble` via `LocalAgentIconPath`; picker is `AgentIconRow` under the local-name row in `ConnectionInfoSheet`. Static image: "Add a pet" accepts a single image (magic-byte detect → one-frame static pet). Scope shipped: small name-adjacent icon only; big avatar stays global. Follow-ups: on-device smoke (import an image as a pet; set a profile icon, confirm it shows by the name + persists across restart); optionally also show the icon in the profile picker.
|
||||
|
||||
## Orchestration batch (2026-06-22) — deferred follow-ups
|
||||
|
||||
Four User-Added items resolved via a 4-worker orchestration pass (disjoint file ownership, coordinator-serialized commits): clean-chat viewport (`1dca285`), connections reframe (`c9fa8f7`), diagnostics/analytics (`c3098a9`), session-delete fix (`6552566`). Plus a follow-on profile-isolation fix raised mid-session: cold-start session-drawer hydration (`889273a`). **Committed to `dev`, NOT built/linted/verified.** Remaining:
|
||||
|
||||
- **Build + lint + on-device verify all five (Studio).** Run `./gradlew lint` and a Studio build before pushing `dev` (workers couldn't run gradle). Then confirm on device: clean-chat shows a noticeably taller text area that scrolls; deleting a session on a *non-default* profile sticks (no resurrection after the drawer re-fetches); the Diagnostics screen renders honest per-check status + failure reasons and opens detail on a failing tappable row; connections/voice/permissions copy reads "Hermes"/"Relay"; **and on a cold start while a non-default profile is selected, the session drawer loads that profile's sessions directly with no flash of the server-default list.**
|
||||
- **Profile isolation — broader sweep (cold-start race).** The session drawer + restored session context are now gated on `ProfileController.selectionSettled` (`889273a`), so they no longer load the server-default profile before the persisted profile resolves. Other profile-scoped surfaces read the *live* `selectedProfile.value` and self-correct when it resolves but aren't gated: voice prefs (`VoiceViewModel.onProfileChanged` at the `RelayApp` voice effect), `profileDisplayAlias`, `profileIcon`. They re-seed on resolution (no visible content-flash like the drawer), but if any shows a wrong-profile beat on cold start, gate its first use on `profileSelectionSettled` the same way. Also: `selectionSettled`'s decision logic is unit-testable (pure over connId/selected/pending/profiles) — add a `ProfileControllerSettledTest` when convenient.
|
||||
- **Diagnostics: no live re-probe trigger.** The status checks reflect the *last* probe state (read-only snapshot). A "Re-run checks" button would need `ConnectionViewModel` to expose probe methods — deferred so the diagnostics work didn't have to edit a concurrently-owned VM.
|
||||
- **Diagnostics: Pass checks lack a last-checked timestamp/duration.** `StatusCheck` carries `timestampMs`/`durationMs`, but the VM doesn't expose probe timing, so passing rows show no "checked Ns ago". Wire when/if the VM surfaces probe timestamps.
|
||||
- **Connections reframe — out-of-scope occurrences left intentionally.** `ConnectionViewModel.kt`, `VoiceAudioClient.kt`, `VoiceViewModel.kt`, `BridgeCoreScreen.kt`, and `RelayApp.kt` still contain "Standard"/"Vanilla" in code identifiers/log strings; only user-facing display copy was reframed. Revisit if any of those surface to users.
|
||||
|
||||
## Orchestration batch (2026-06-21) — deferred follow-ups
|
||||
|
||||
Client-side profile-lock + voice fixes (the items marked above) landed via a planning→implementation orchestration pass, **built + deployed to device as 1.2.1 (versionCode 15)**; new unit suite green (36 Kotlin + 11 Python). On-device behaviour verification still pending. Remaining from that batch:
|
||||
|
||||
- **Realtime voice: server-side half (Python) — DONE + DEPLOYED 2026-06-21.** `plugin/relay/realtime_agent/broker.py`: `_send_hermes_run_progress` now heartbeats while `session.hermes_task` is unfinished (helper `_should_continue_heartbeat`), closing the 90s stall at the source; spoken-status repeat raised 30s→90s and gated on a *coarse* status change (`_coarse_spoken_status_key` / `_should_repeat_spoken_status`) so tool-message churn no longer re-narrates. `plugin/tests/test_realtime_heartbeat.py` 11/11; `test_realtime_promotion` regression 5/5. Deployed: committed `d1820fb` → pushed to `origin/dev` → server `~/.hermes/hermes-relay` fast-forwarded + `hermes-relay` restarted (active, clean startup) — both client + server halves now live end-to-end (re-pair the phone after the relay restart). Optional follow-up: flip `promotion_enabled` default to True so long runs detach.
|
||||
- **Voice override on the streaming path (open question).** The `.route`→`.effectiveRoute` fix makes 'auto'+relay engage the override-capable path, but the streaming `/voice/output` renderer reads the relay's server-saved `voice_output:` config, not the UI `enhancedVoice` override. Decide whether the override card should also push to `updateVoiceOutputConfig`, or whether an override should force the basic `/voice/synthesize` path.
|
||||
- **Per-profile voice on Standard (upstream).** `/api/audio/*` is host-global/text-only; the Standard surface still can't carry a per-request voice. Needs the upstream profile-voice / `/v1/audio/*` PR. Until then the client prefers the relay path; consider surfacing an honest "override needs Relay" state when Standard is the effective surface.
|
||||
- **Profile lock: ChatScreen glyph + export.** The optional lock glyph on the chat-header avatar was skipped (`ChatScreen.kt` is owned by a concurrent session). Decide whether the per-connection lock belongs in settings export/import (it rides the `profile_selections` DataStore).
|
||||
- **Unit tests — DONE 2026-06-21 (36/36 pass via `:app:testSideloadDebugUnitTest`).** `ProfileLockStoreTest` (9 — uses an in-memory `DataStore` harness; the file-backed factory hits a Windows write-rename/instance race), `ProfileControllerLockTest` (8, Robolectric), `CoerceAudioRouteTest` (7), `VoiceStatusGatesTest` (12).
|
||||
- **CHANGELOG.** Add `[Unreleased]` entries (Profile lock → Added; voice override + realtime → Fixed) at build-verify/PR time.
|
||||
- **On-device verification.** Override applies in 'auto'+relay; realtime survives a >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
|
||||
|
||||
@@ -153,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).
|
||||
|
||||
---
|
||||
@@ -164,7 +193,7 @@ Triggered by a Play Store review: app "keeps crashing" during setup on a Samsung
|
||||
|
||||
Follow-ups:
|
||||
|
||||
- **Confirm the actual crash from Play vitals.** Pull the top crash cluster for Galaxy Z Fold7 / version code 13 (Quality → Android vitals → Crashes & ANRs) to verify the camera path is the real cause vs. another setup-path throw. The hardening is correct regardless, but the trace closes the loop.
|
||||
- **Confirm the actual crash from Play vitals.** Pull the top crash cluster for Galaxy Z Fold7 / version code 13 (Quality → Android vitals → Crashes & ANRs) to verify the camera path is the real cause vs. another setup-path throw. The hardening is correct regardless, but the trace closes the loop.
|
||||
- **Portrait lock is moot on large screens under SDK 36.** `android:screenOrientation="portrait"` is largely ignored by Android 16's mandatory large-screen orientation override on foldables/tablets. Decide whether to keep the lock (it still applies on phones) or make it conditional; either way it does not *cause* the crash.
|
||||
- **Foldable camera lifecycle races (from the 2026-06-20 audit, not yet fixed).** `QrPairingScanner` can still hit bind/unbind races on rapid fold/unfold recomposition (the `DisposableEffect` `unbindAll()` vs. an in-flight `addListener` bind), and `mapBoxToViewport` runs on possibly-stale `viewportSizePx` during a fold transition. Not crash-fatal after the try/catch hardening (logged + skipped), but worth a fold-aware guard if foldable adoption grows.
|
||||
- **Optional: surface crash history in Settings.** The reporter keeps only the most recent crash (`files/crash/last-crash.json`, consumed on view). If repeat-crash diagnosis becomes common, keep a small ring of recent reports + a Settings entry to view/copy them.
|
||||
@@ -204,12 +233,13 @@ Follow-ups:
|
||||
- **Part-A chat polish (optional bundle).** Per-code-block copy + horizontal scroll, visible copy affordance, mid-stream stall feedback, profile/skill-aware empty-state chips, the ~40-flow recomposition hotspot at the top of `ChatScreen`. (Sphere `contentDescription`/reduced-motion was handled by the clean-mode a11y work.)
|
||||
- **Pet hot-load + in-app add/remove (shipped 2026-06-20).** Pets now live-refresh: an `avatarsRefreshTick` keys the avatar `produceState` in `RelayApp`, and Appearance re-scans `pets/` on open and after in-app import/delete — no app restart. Appearance gained "Add a pet" (SAF `.zip` import via `PetImporter`, zip-slip/zip-bomb guarded + validated through `toAvatar`) and an "Installed pets" list with per-pet remove (`PetLoader.deletePet`, confirm dialog, Sphere fallback). Remaining:
|
||||
- **Sphere-skin parity.** Skins are still process-scoped + `adb push` only — the live tick and the importer cover pets, not skins. Extend the tick to `loadUserSkins` and add a `.json` skin import if hot-loading/adding skins in-app is wanted.
|
||||
- **`adb push` into `Android/data` hangs on Samsung scoped storage.** Confirmed: pushing a pet pack to `/sdcard/Android/data/<pkg>/files/pets/` stalls (no bytes written) although `adb shell ls` of the dir works. In-app `.zip` import is the supported path; `/sdcard/Download` pushes fine. Consider softening `docs/pet-spec.md` + user-docs to lead with in-app import over adb.
|
||||
- `**adb push` into `Android/data` hangs on Samsung scoped storage.** Confirmed: pushing a pet pack to `/sdcard/Android/data/<pkg>/files/pets/` stalls (no bytes written) although `adb shell ls` of the dir works. In-app `.zip` import is the supported path; `/sdcard/Download` pushes fine. Consider softening `docs/pet-spec.md` + user-docs to lead with in-app import over adb.
|
||||
- **On-device import/delete smoke.** Import `/sdcard/Download/lucy.zip` via Add a pet → confirm Lucy appears, selects, and animates all states; then remove it and confirm the avatar falls back to the Sphere.
|
||||
- **Pet state-change re-decode can flash one blank frame.** When the agent state switches clips, the first frame of the new clip may briefly be blank during decode; prewarm/hold-last-frame to smooth it. Root cause is the same as the next item: `PetAvatar.Render` re-decodes from disk on every clip change.
|
||||
- **Pet frame-sequence memory: no cap or downsample (audit 2026-06-19).** `decodeClip` decodes every frame of the selected clip into `List<ImageBitmap>` at full resolution with no `inSampleSize` downscale to the display size and no frame-count/dimension ceiling — a long sequence of large PNGs can use a lot of RAM and a single very large image can OOM `BitmapFactory`. Add `inSampleSize` downsampling to the avatar's draw size and/or a documented hard cap. Spec now warns authors (prefer sprite sheets), but the renderer doesn't enforce it.
|
||||
- **Pet decoded-clip cache (audit 2026-06-19).** `PetAvatar.Render` keys `produceState` on `clip`, so idle→thinking→speaking→idle within one turn re-runs `BitmapFactory.decodeFile` from disk each transition (repeated I/O + GC churn, and the blank-frame flash above). Add a small per-avatar `Map<SphereState, PetFrames>` decode cache.
|
||||
- **Pet behavior model — richer state association (spec'd 2026-06-19, `docs/pet-spec.md` "Agent states & pet behavior").** Shipped: the honesty clamp (declared reactivity ∩ `PET_RENDERER_CAPABILITIES`), the friendly `writing` alias, the `**working`/tool-use overlay** (pet-local sub-state from `toolCallBurst`; opt-in `working` clip drives both the swap and the Tools badge), the **one-shot reaction layer** (`greet`/`wake` on appear, `done`/`celebrate` on turn-finish — opt-in, play-once-then-revert, transition-derived; `ONE_SHOT_MAX_MS` backstop), and `**intensity` modulation** (opt-in `reactive.intensity` → live playback speedup ≤1.6× via `rememberUpdatedState`; un-clamps the Activity badge). Voice · Tools · Activity reactivity is now complete. Remaining:
|
||||
- **Pet behavior model — richer state association (spec'd 2026-06-19, `docs/pet-spec.md` "Agent states & pet behavior").** Shipped: the honesty clamp (declared reactivity ∩ `PET_RENDERER_CAPABILITIES`), the friendly `writing` alias, the `**working`/tool-use overlay** (pet-local sub-state from `toolCallBurst`; opt-in `working` clip drives both the swap and the Tools badge), the **one-shot reaction layer** (`greet`/`wake` on appear, `done`/`celebrate` on turn-finish — opt-in, play-once-then-revert, transition-derived; `ONE_SHOT_MAX_MS` backstop), and `**intensity` modulation** (opt-in `reactive.intensity` → live playback speedup ≤1.6× via `rememberUpdatedState`; un-clamps the Activity badge). Voice · Tools · Activity reactivity is now complete. Remaining:
|
||||
- `**attention` one-shot (only deferred behavior).** A reaction on notification arrival — needs a host event the avatar doesn't yet receive (unlike `greet`/`done`, which ride state transitions). Would plumb a notification edge into `AvatarRenderState` (or a side channel) + a `PetOneShot.Attention`. Low priority: the avatar is rarely on-screen when notifications land (backgrounded) — see the value analysis; revisit only if the avatar becomes an always-on surface (persistent overlay / Quest port).
|
||||
- **On-device verification (working + one-shots + intensity).** Best seen in clean mode (`AgentTextFlow` feeds `toolCallBurst` + `streamingIntensity` + state transitions). Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Watch for the known clip re-decode flash on each swap (separate TODO — decoded-clip cache).
|
||||
- **Undecodable-but-present image appears valid (audit 2026-06-19).** A file that exists but isn't a decodable image passes the loader's `isFile` check, so the pet shows in the picker but renders blank. Documented as a caveat; consider a cheap header sniff at load time if false-valid pets become a support issue.
|
||||
- **Undecodable-but-present image appears valid (audit 2026-06-19).** A file that exists but isn't a decodable image passes the loader's `isFile` check, so the pet shows in the picker but renders blank. Documented as a caveat; consider a cheap header sniff at load time if false-valid pets become a support issue.
|
||||
|
||||
|
||||
+11
-2
@@ -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
|
||||
@@ -254,6 +254,15 @@ dependencies {
|
||||
// Bundled ONNX Silero model (~2.2 MB); pulled from JitPack.
|
||||
implementation(libs.android.vad.silero)
|
||||
|
||||
// Google Play In-App Update — googlePlay flavor ONLY (FLEXIBLE flow).
|
||||
// Scoped via the `googlePlayImplementation` configuration so it never
|
||||
// ships in the sideload APK, which updates via the GitHub-releases
|
||||
// UpdateChecker instead. The `app/src/googlePlay/.../update/` impl
|
||||
// references AppUpdateManager; the `app/src/sideload/.../update/` impl
|
||||
// never touches this library.
|
||||
"googlePlayImplementation"(libs.play.app.update)
|
||||
"googlePlayImplementation"(libs.play.app.update.ktx)
|
||||
|
||||
// Markdown rendering
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer.code)
|
||||
@@ -311,6 +320,6 @@ dependencies {
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.43.1")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.2.1")
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
}
|
||||
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.android.play.core.appupdate.AppUpdateInfo
|
||||
import com.google.android.play.core.appupdate.AppUpdateManager
|
||||
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
|
||||
import com.google.android.play.core.appupdate.AppUpdateOptions
|
||||
import com.google.android.play.core.install.InstallState
|
||||
import com.google.android.play.core.install.InstallStateUpdatedListener
|
||||
import com.google.android.play.core.install.model.AppUpdateType
|
||||
import com.google.android.play.core.install.model.InstallStatus
|
||||
import com.google.android.play.core.install.model.UpdateAvailability
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* === update (googlePlay flavor): factory ===
|
||||
*
|
||||
* Backs [UpdateAvailabilitySource] onto Google Play's In-App Update API,
|
||||
* FLEXIBLE flow. Mirrors `voice/VoiceBridgeIntentFactory`'s flavor-split
|
||||
* factory pattern: both flavors export this exact function signature +
|
||||
* package, so the UI layer has one static call site and no reflection / no
|
||||
* `#if` gating.
|
||||
*/
|
||||
fun createUpdateAvailabilitySource(context: Context): UpdateAvailabilitySource =
|
||||
PlayUpdateAvailabilitySource(context.applicationContext)
|
||||
|
||||
private const val TAG = "PlayUpdate"
|
||||
|
||||
/**
|
||||
* Google Play FLEXIBLE in-app update source.
|
||||
*
|
||||
* - [check] queries `AppUpdateManager.appUpdateInfo`. If Play reports
|
||||
* `UPDATE_AVAILABLE` and FLEXIBLE is allowed, returns [UpdateStatus.Available]
|
||||
* (or [UpdateStatus.Downloaded] / [UpdateStatus.Downloading] if a previously
|
||||
* started flexible update is already mid-flight). Anything else →
|
||||
* [UpdateStatus.UpToDate].
|
||||
* - [startUpdate] launches Play's FLEXIBLE consent + background download and
|
||||
* registers an [InstallStateUpdatedListener] so DOWNLOADED is reported back
|
||||
* asynchronously via [onStatusChanged].
|
||||
* - [completeUpdate] calls `AppUpdateManager.completeUpdate()` which restarts
|
||||
* the app to install the staged APK.
|
||||
*
|
||||
* Robustness: every Play interaction is wrapped in try/catch. On any failure
|
||||
* (no Play services, sideloaded "googlePlay" build on an AOSP device, RESULT
|
||||
* errors) it degrades to [UpdateStatus.UpToDate] / [UpdateStatus.Unsupported]
|
||||
* — the banner just never shows. Play is never a crash surface.
|
||||
*/
|
||||
private class PlayUpdateAvailabilitySource(
|
||||
private val appContext: Context,
|
||||
) : UpdateAvailabilitySource {
|
||||
|
||||
override var onStatusChanged: ((UpdateStatus) -> Unit)? = null
|
||||
|
||||
private val manager: AppUpdateManager? = runCatching {
|
||||
AppUpdateManagerFactory.create(appContext)
|
||||
}.getOrNull()
|
||||
|
||||
/** Cached label/code from the last [check] so async listener events can label themselves. */
|
||||
@Volatile private var lastVersionCode: Long? = null
|
||||
|
||||
private val installListener = InstallStateUpdatedListener { state: InstallState ->
|
||||
when (state.installStatus()) {
|
||||
InstallStatus.DOWNLOADING ->
|
||||
onStatusChanged?.invoke(
|
||||
UpdateStatus.Downloading(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
// bytesDownloaded()/totalBytesToDownload() are base
|
||||
// app-update InstallState methods (Long); no ktx import.
|
||||
bytesDownloaded = state.bytesDownloaded(),
|
||||
totalBytes = state.totalBytesToDownload(),
|
||||
)
|
||||
)
|
||||
InstallStatus.DOWNLOADED ->
|
||||
onStatusChanged?.invoke(
|
||||
UpdateStatus.Downloaded(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
)
|
||||
else -> Unit // INSTALLING / INSTALLED / FAILED / CANCELED → no banner change
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var listenerRegistered = false
|
||||
|
||||
override suspend fun check(): UpdateStatus {
|
||||
val mgr = manager ?: return UpdateStatus.Unsupported
|
||||
return try {
|
||||
val info = mgr.awaitAppUpdateInfo()
|
||||
lastVersionCode = info.availableVersionCode().toLong()
|
||||
when {
|
||||
// A previously started FLEXIBLE update already finished downloading.
|
||||
info.installStatus() == InstallStatus.DOWNLOADED -> {
|
||||
ensureListener(mgr)
|
||||
UpdateStatus.Downloaded(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
}
|
||||
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
|
||||
info.installStatus() == InstallStatus.DOWNLOADING -> {
|
||||
ensureListener(mgr)
|
||||
UpdateStatus.Downloading(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
}
|
||||
info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
|
||||
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) ->
|
||||
UpdateStatus.Available(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
openUrl = null,
|
||||
)
|
||||
else -> UpdateStatus.UpToDate
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "appUpdateInfo check failed; treating as up-to-date", t)
|
||||
UpdateStatus.UpToDate
|
||||
}
|
||||
}
|
||||
|
||||
override fun startUpdate(activity: Activity?): Boolean {
|
||||
val mgr = manager ?: return false
|
||||
if (activity == null) return false
|
||||
return try {
|
||||
ensureListener(mgr)
|
||||
mgr.appUpdateInfo
|
||||
.addOnSuccessListener { info: AppUpdateInfo ->
|
||||
val canStart = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
|
||||
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
|
||||
val resuming = info.updateAvailability() ==
|
||||
UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
|
||||
if (canStart || resuming) {
|
||||
runCatching {
|
||||
mgr.startUpdateFlow(
|
||||
info,
|
||||
activity,
|
||||
AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(),
|
||||
)
|
||||
}.onFailure { Log.w(TAG, "startUpdateFlow failed", it) }
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { Log.w(TAG, "startUpdate appUpdateInfo failed", it) }
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startUpdate failed", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun completeUpdate() {
|
||||
val mgr = manager ?: return
|
||||
runCatching { mgr.completeUpdate() }
|
||||
.onFailure { Log.w(TAG, "completeUpdate failed", it) }
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
val mgr = manager ?: return
|
||||
if (listenerRegistered) {
|
||||
runCatching { mgr.unregisterListener(installListener) }
|
||||
listenerRegistered = false
|
||||
}
|
||||
onStatusChanged = null
|
||||
}
|
||||
|
||||
private fun ensureListener(mgr: AppUpdateManager) {
|
||||
if (!listenerRegistered) {
|
||||
runCatching { mgr.registerListener(installListener) }
|
||||
.onSuccess { listenerRegistered = true }
|
||||
.onFailure { Log.w(TAG, "registerListener failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Play exposes only the numeric versionCode, not a marketing version
|
||||
// string, so the banner copy stays generic ("A new version"). The code is
|
||||
// still carried on the status for per-version dismissal keying.
|
||||
private fun labelFor(@Suppress("UNUSED_PARAMETER") code: Long?): String = "A new version"
|
||||
}
|
||||
|
||||
// === END update (googlePlay) ===
|
||||
|
||||
/**
|
||||
* `await()` for Play's [AppUpdateInfo] task without pulling in
|
||||
* `kotlinx-coroutines-play-services`. Named `await…` (not the ktx
|
||||
* `requestAppUpdateInfo`) to avoid any overload ambiguity with the
|
||||
* `app-update-ktx` suspend extension. Resumable + cancels cleanly if the
|
||||
* coroutine is torn down.
|
||||
*/
|
||||
private suspend fun AppUpdateManager.awaitAppUpdateInfo(): AppUpdateInfo =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
appUpdateInfo
|
||||
.addOnSuccessListener { info -> if (cont.isActive) cont.resume(info) }
|
||||
.addOnFailureListener { e -> if (cont.isActive) cont.cancel(e) }
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
v1.2.0 — Make it yours.
|
||||
v1.2.3 — Connection crash fix.
|
||||
|
||||
• Eight app themes, swappable sphere skins, and animated agent "pets" that react to what your agent is doing.
|
||||
• See which streaming path you're on, plus a "What the agent sees" sheet showing the agent's exact context.
|
||||
• ~3× faster cold start and honest loading states.
|
||||
• In-app crash reporting with one-tap bug reports.
|
||||
• Fixes: QR pairing on foldables, server-image & PDF crashes, in-chat model picks now apply.
|
||||
• 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.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"title": "Connection crash fix",
|
||||
"date": "2026-06-23",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"title": "Multi-profile polish",
|
||||
"date": "2026-06-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Profiles that behave",
|
||||
"bullets": [
|
||||
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
|
||||
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Clearer diagnostics",
|
||||
"bullets": [
|
||||
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Small touches",
|
||||
"bullets": [
|
||||
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
|
||||
"Distraction-free chat mode gives its text a taller, scrollable area."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.1",
|
||||
"title": "Polish & control",
|
||||
"date": "2026-06-21",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Yours to control",
|
||||
"bullets": [
|
||||
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Find your way back",
|
||||
"bullets": [
|
||||
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "When something breaks",
|
||||
"bullets": [
|
||||
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
|
||||
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice fixes",
|
||||
"bullets": [
|
||||
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
|
||||
"Realtime turns that reach back to Hermes no longer drop with a session error."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"title": "Make it yours",
|
||||
"date": "2026-06-20",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Personalize",
|
||||
"bullets": [
|
||||
"Eight app themes in Settings → Appearance — the Hermes Relay brand plus ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rosé), with light/dark.",
|
||||
"Swap the agent orb for an animated pet that reacts to what the agent is doing — add, preview, and tune pets right in the app, or generate one from sprite art with the AI authoring kit.",
|
||||
"Reskin the sphere, and give each agent profile its own icon."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See what's happening",
|
||||
"bullets": [
|
||||
"The chat status strip names the actual streaming path (Gateway, Sessions, Completions, Runs), with a basic→best tier ladder in Chat Settings.",
|
||||
"Tap the context meter for a \"What the agent sees\" sheet — the exact extra context prepended to your next turn.",
|
||||
"Voice and Realtime turns are badged in the scrollback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Privacy",
|
||||
"bullets": [
|
||||
"When paired to the relay, the agent can mark private media and the phone blurs it per your setting — sensitivity stays model-emitted."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Faster & more reliable",
|
||||
"bullets": [
|
||||
"Cold start is about 3× faster, and model/personality/approvals load honestly instead of showing a maybe-wrong value.",
|
||||
"In-app crash reporting offers a one-tap, pre-filled bug report.",
|
||||
"QR pairing no longer force-closes on unusual cameras (foldables); fixed crashes opening server images and PDFs; in-chat model picks now apply."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice & terminal",
|
||||
"bullets": [
|
||||
"Enhanced voice control for Gemini and xAI providers.",
|
||||
"Leaner terminal with TUI-correct input and an isolated, tuned tmux."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"title": "Release plumbing & polish",
|
||||
"date": "2026-06-16",
|
||||
"sections": [
|
||||
{
|
||||
"header": "New",
|
||||
"bullets": [
|
||||
"Automated Play Console upload when a release tag ships (a human still starts the rollout).",
|
||||
"/relay slash commands — status, devices, and pair from any platform — plus a relay-status badge in the dashboard header.",
|
||||
"The relay plugin prompts for its optional voice-provider keys on install, and a tools-only native install path."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Improved",
|
||||
"bullets": [
|
||||
"Settings overhaul: status pills are now exception-only, Power tools shows a single Plugin active/required/offline badge, and Connections moved to the top.",
|
||||
"Release names and notes are now split per surface (Android, plugin, CLI)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Fixed",
|
||||
"bullets": [
|
||||
"No more force-close on connect when the stored credential keyset was corrupt — it now heals in place.",
|
||||
"The installer works on uv-managed Hermes hosts, and the dashboard relay panel buttons are readable again."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"title": "Stable launch",
|
||||
"date": "2026-06-14",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Gateway chat with live thinking",
|
||||
"bullets": [
|
||||
"Chat can ride the upstream dashboard gateway — the only vanilla-upstream path that streams reasoning live, so the Thinking block and sphere light up during generation. \"Auto\" prefers it and falls back to the SSE endpoints per turn.",
|
||||
"Desktop parity: native image/PDF/file attachments, mid-turn steering, edit & resend, approval/clarify/sudo/secret cards, live subagent lanes, a context-window meter, server slash commands, and turn-complete notifications.",
|
||||
"Warm-start and an opt-in Keep connected in background toggle so long-backgrounded conversations resume instantly."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Agents, Manage & media",
|
||||
"bullets": [
|
||||
"Switch agent profiles per conversation — model, SOUL, personality, and skills — with the selection bound to the session, never changing the server default for other clients.",
|
||||
"Manage parity with the desktop dashboard: change models, manage provider keys, edit profiles and SOUL.md, and browse/install skills.",
|
||||
"Open and save chat images and attachments — full-screen viewer with pinch-zoom, plus an Open/Share/Save menu."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Standard path is first-class",
|
||||
"bullets": [
|
||||
"Chat, Manage, and voice all work against an unmodified upstream Hermes agent; the relay plugin is now purely additive.",
|
||||
"Seamless connection UX — LAN↔Tailscale handoffs and reconnects no longer reload the chat, and status shows as in-theme slide-down toasts.",
|
||||
"Persistent Realtime Agent voice that keeps one session across turns, with long runs promoted to tracked background tasks."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,32 +1,6 @@
|
||||
v1.2.0 - Make it yours
|
||||
v1.2.3 - Connection crash fix
|
||||
|
||||
Personalize
|
||||
* Eight app themes in Settings → Appearance — the Hermes Relay brand plus
|
||||
ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono,
|
||||
Cyberpunk, Rosé), with light/dark.
|
||||
* Swap the agent orb for an animated pet that reacts to what the agent is
|
||||
doing — add, preview, and tune pets right in the app, or generate one
|
||||
from sprite art with the AI authoring kit.
|
||||
* Reskin the sphere, and give each agent profile its own icon.
|
||||
|
||||
See what's happening
|
||||
* The chat status strip names the actual streaming path (Gateway, Sessions,
|
||||
Completions, Runs), with a basic→best tier ladder in Chat Settings.
|
||||
* Tap the context meter for a "What the agent sees" sheet — the exact extra
|
||||
context prepended to your next turn.
|
||||
* Voice and Realtime turns are badged in the scrollback.
|
||||
|
||||
Privacy
|
||||
* When paired to the relay, the agent can mark private media and the phone
|
||||
blurs it per your setting — sensitivity stays model-emitted.
|
||||
|
||||
Faster & more reliable
|
||||
* Cold start is about 3× faster, and model/personality/approvals load
|
||||
honestly instead of showing a maybe-wrong value.
|
||||
* In-app crash reporting offers a one-tap, pre-filled bug report.
|
||||
* QR pairing no longer force-closes on unusual cameras (foldables); fixed
|
||||
crashes opening server images and PDFs; in-chat model picks now apply.
|
||||
|
||||
Voice & terminal
|
||||
* Enhanced voice control for Gemini and xAI providers.
|
||||
* Leaner terminal with TUI-correct input and an isolated, tuned tmux.
|
||||
Stability
|
||||
* Fixed a crash that could close the app right after connecting over an
|
||||
encrypted link (Tailscale or HTTPS). Securing your connection no longer
|
||||
force-closes the app. Plain-LAN connections were never affected.
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
@@ -702,6 +703,18 @@ class AuthManager(
|
||||
pendingEndpoints = endpoints?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability negotiation advertised in the first system/auth envelope.
|
||||
* Older relays ignore this object; newer relays use it to send versioned
|
||||
* `chat:stream.event` payloads instead of flattening Hermes SSE into text.
|
||||
*/
|
||||
private fun JsonObjectBuilder.putRelayClientSupports() {
|
||||
put("supports", buildJsonObject {
|
||||
put("typed_stream_events", true)
|
||||
put("event_schema_version", 1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send auth envelope when connection is established.
|
||||
*
|
||||
@@ -737,6 +750,7 @@ class AuthManager(
|
||||
}
|
||||
put("device_id", deviceId)
|
||||
put("device_name", android.os.Build.MODEL)
|
||||
putRelayClientSupports()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -752,6 +766,7 @@ class AuthManager(
|
||||
put("pairing_code", codeToSend)
|
||||
put("device_id", deviceId)
|
||||
put("device_name", android.os.Build.MODEL)
|
||||
putRelayClientSupports()
|
||||
pendingTtlSeconds?.let { put("ttl_seconds", it) }
|
||||
pendingGrants?.let { grants ->
|
||||
val obj = buildJsonObject {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Per-connection persisted "profile lock" — pins the app to ONE Hermes
|
||||
* profile so the profile pickers/switchers across the app collapse to a
|
||||
* single locked state. A dedicated Settings control is the only surface that
|
||||
* still lists every profile (to choose the lock target or unlock).
|
||||
*
|
||||
* Twin of [ProfileSelectionStore]: this deliberately rides the SAME
|
||||
* [profileSelectionsDataStore] ("profile_selections") so the lock and the
|
||||
* selection clear and migrate together — a per-connection wipe or a wholesale
|
||||
* reset takes out both, and there is no second DataStore file to keep in sync.
|
||||
*
|
||||
* Value semantics (distinct from "selection", which is just a name or absent):
|
||||
* - **absent key** → unlocked. The flow emits `null`. This is distinct from
|
||||
* "locked to Server default", so we can tell "no lock" apart from "lock to
|
||||
* the server's own default profile".
|
||||
* - [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY] sentinel → locked to **Server
|
||||
* default** (the null-profile context). Reusing the existing sentinel keeps
|
||||
* the server-default identity consistent with [AgentDisplay.profileSessionKey].
|
||||
* - any other string → locked to that profile `name`.
|
||||
*
|
||||
* The caller ([com.hermesandroid.relay.viewmodel.connection.ProfileController])
|
||||
* resolves the locked name against the current server-advertised profile list;
|
||||
* if the locked profile no longer exists it HOLDS (selection null) and surfaces
|
||||
* a banner rather than silently switching.
|
||||
*/
|
||||
class ProfileLockStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileSelectionsDataStore)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Preference-key factory. Per-connection so every connection gets its
|
||||
* own lock slot — profiles are server-scoped, so a lock pinned on one
|
||||
* server must not leak onto another.
|
||||
*/
|
||||
private fun keyFor(connectionId: String) =
|
||||
stringPreferencesKey("locked_profile_$connectionId")
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the lock for [connectionId].
|
||||
* - `null` → **unlock**: removes the key (converges with fresh-install
|
||||
* "no key" state).
|
||||
* - any non-null [profileName] → lock to that profile name. Callers lock
|
||||
* to Server default by passing [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY].
|
||||
*/
|
||||
suspend fun setLockedProfile(connectionId: String, profileName: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId)
|
||||
if (profileName == null) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = profileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the locked profile name for [connectionId], or `null` when no lock
|
||||
* is stored (unlocked). The sentinel
|
||||
* [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY] means "locked to Server default".
|
||||
*/
|
||||
fun lockedProfileFlow(connectionId: String): Flow<String?> {
|
||||
val key = keyFor(connectionId)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the persisted lock for [connectionId]. Called from the connection
|
||||
* removal path alongside the selection clear so a removed connection's lock
|
||||
* pointer goes with it.
|
||||
*/
|
||||
suspend fun clear(connectionId: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.remove(keyFor(connectionId))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,50 @@ data class DiagnosticLogEntry(
|
||||
val endpointRole: String? = null,
|
||||
val url: String? = null,
|
||||
val elapsedMs: Long? = null,
|
||||
/**
|
||||
* Full (multi-KB) redacted stacktrace for the detail page. Kept OUT of the
|
||||
* 180-char [detail] truncation — the list still shows the short title/detail,
|
||||
* the detail view shows this. Null for non-error / manually-recorded entries.
|
||||
*/
|
||||
val stacktrace: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Current health of a single subsystem on the Diagnostics status timeline.
|
||||
*
|
||||
* Distinct from [DiagnosticSeverity], which classifies a *logged event* after
|
||||
* the fact. A [CheckStatus] is the *live* state of a subsystem, derived
|
||||
* read-only from connection state + the recent [DiagnosticsLog]. [Unknown] is
|
||||
* a first-class, honest state — "not checked / not applicable" — never an
|
||||
* implied pass or fail.
|
||||
*/
|
||||
enum class CheckStatus { Pass, Warn, Fail, Unknown }
|
||||
|
||||
/**
|
||||
* One row on the Diagnostics status timeline: a named subsystem check with its
|
||||
* current [status] and, when not [CheckStatus.Pass], a human [reason] — the
|
||||
* whole point of the screen is answering "why is this failing?".
|
||||
*
|
||||
* [category] links the check back to a [DiagnosticCategory]; when [timestampMs]
|
||||
* is non-null the reason came from a concrete [DiagnosticLogEntry], so the row
|
||||
* is tappable and the UI can open that entry's full detail.
|
||||
*/
|
||||
data class StatusCheck(
|
||||
val name: String,
|
||||
val status: CheckStatus,
|
||||
val reason: String? = null,
|
||||
val category: DiagnosticCategory? = null,
|
||||
val timestampMs: Long? = null,
|
||||
val durationMs: Long? = null,
|
||||
)
|
||||
|
||||
object DiagnosticsLog {
|
||||
private const val MAX_ENTRIES = 200
|
||||
private const val MAX_TEXT_LENGTH = 180
|
||||
|
||||
/** Cap for the full stacktrace kept on an error entry — a few KB is plenty. */
|
||||
private const val MAX_TRACE_LENGTH = 8000
|
||||
|
||||
private val lock = Any()
|
||||
private val _entries = MutableStateFlow<List<DiagnosticLogEntry>>(emptyList())
|
||||
val entries: StateFlow<List<DiagnosticLogEntry>> = _entries.asStateFlow()
|
||||
@@ -46,6 +84,7 @@ object DiagnosticsLog {
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
stacktrace: String? = null,
|
||||
) {
|
||||
val entry = DiagnosticLogEntry(
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
@@ -56,12 +95,51 @@ object DiagnosticsLog {
|
||||
endpointRole = clean(endpointRole),
|
||||
url = sanitizeUrl(url),
|
||||
elapsedMs = elapsedMs,
|
||||
stacktrace = redactTrace(stacktrace),
|
||||
)
|
||||
synchronized(lock) {
|
||||
_entries.value = (_entries.value + entry).takeLast(MAX_ENTRIES)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an [DiagnosticSeverity.Error] entry from a classified failure. The
|
||||
* list keeps showing the clean [title] (+ short [detail]); the detail page
|
||||
* shows the full redacted stacktrace.
|
||||
*
|
||||
* Called centrally from [com.hermesandroid.relay.util.classifyError] as a
|
||||
* side effect, so every classified error lands here with no per-call-site
|
||||
* churn. The flow is one-way (classify -> record); nothing here re-enters
|
||||
* the classifier, so there is no recursion.
|
||||
*
|
||||
* @param title clean, human title (e.g. [com.hermesandroid.relay.util.HumanError.title]).
|
||||
* @param detail short one-line summary shown in the list row (truncated to 180).
|
||||
* @param throwable source error — its stacktrace is captured, redacted, and capped.
|
||||
*/
|
||||
fun recordError(
|
||||
category: DiagnosticCategory,
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
throwable: Throwable? = null,
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
) {
|
||||
record(
|
||||
category = category,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = title,
|
||||
detail = detail ?: throwable?.message,
|
||||
endpointRole = endpointRole,
|
||||
url = url,
|
||||
elapsedMs = elapsedMs,
|
||||
stacktrace = throwable?.let { stackTraceText(it) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun stackTraceText(t: Throwable): String =
|
||||
java.io.StringWriter().also { t.printStackTrace(java.io.PrintWriter(it)) }.toString().trim()
|
||||
|
||||
fun recent(
|
||||
categories: Set<DiagnosticCategory>? = null,
|
||||
limit: Int = 30,
|
||||
@@ -101,10 +179,26 @@ object DiagnosticsLog {
|
||||
|
||||
private fun clean(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return trimmed
|
||||
.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
|
||||
"${it.groupValues[1]}=[hidden]"
|
||||
}
|
||||
.take(MAX_TEXT_LENGTH)
|
||||
return redact(trimmed).take(MAX_TEXT_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Same secret redaction as [clean] but WITHOUT the 180-char list truncation —
|
||||
* for the full stacktrace shown on the detail page. Still capped at
|
||||
* [MAX_TRACE_LENGTH] so a runaway trace can't bloat the ring.
|
||||
*/
|
||||
private fun redactTrace(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val redacted = redact(trimmed)
|
||||
return if (redacted.length > MAX_TRACE_LENGTH) {
|
||||
redacted.take(MAX_TRACE_LENGTH) + "\n… (truncated)"
|
||||
} else {
|
||||
redacted
|
||||
}
|
||||
}
|
||||
|
||||
private fun redact(value: String): String =
|
||||
value.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
|
||||
"${it.groupValues[1]}=[hidden]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.os.Looper
|
||||
|
||||
/**
|
||||
* Run an OkHttp teardown [block] without ever performing a network write on
|
||||
* the main thread.
|
||||
*
|
||||
* [okhttp3.ConnectionPool.evictAll] closes pooled sockets synchronously. For
|
||||
* a live `https`/`wss` keep-alive connection that close drains the SSL output
|
||||
* queue — a real network write (`SSLOutputStream.writeInternal`) — which trips
|
||||
* StrictMode's [android.os.NetworkOnMainThreadException]. Reported as a hard
|
||||
* crash on connect over TLS/Tailscale (issues #70 / #118 / #124): a
|
||||
* `viewModelScope` (i.e. `Dispatchers.Main.immediate`) coroutine resumes on the
|
||||
* main thread and shuts a dashboard/API client down in a `finally` block.
|
||||
*
|
||||
* Client shutdown is fire-and-forget cleanup, so when the caller is on the main
|
||||
* thread we hand [block] to a short-lived daemon thread. Off the main thread
|
||||
* (already on `Dispatchers.IO` or a background thread) we run it inline so
|
||||
* callers that deliberately moved off main keep their ordering and any blocking
|
||||
* `awaitTermination` waits stay where the caller put them.
|
||||
*/
|
||||
internal fun shutdownOffMainThread(threadName: String, block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
Thread({ runCatching(block) }, threadName).apply { isDaemon = true }.start()
|
||||
} else {
|
||||
block()
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1270,6 +1270,13 @@ class RelayVoiceClient(
|
||||
// True while a turn is awaiting its response. In persistent mode the idle
|
||||
// guard only applies while a turn is active; between-turn idle is normal.
|
||||
val activeTurn = AtomicBoolean(true)
|
||||
// W3: set true once a turn is known to be a long/background Hermes run
|
||||
// (e.g. `hermes.run.promoted`). The relay can legitimately go quiet for
|
||||
// minutes while such a run executes, so the 90s idle guard would kill an
|
||||
// otherwise-healthy turn. When set, the idle check is paused the same way
|
||||
// persistent between-turn idle is — REALTIME_AGENT_MAX_TURN_MS remains
|
||||
// the absolute backstop. Reset at every turn boundary.
|
||||
val longRunningTurn = AtomicBoolean(false)
|
||||
val inputChunks = buildList {
|
||||
var offset = 0
|
||||
var chunkId = 1L
|
||||
@@ -1306,6 +1313,7 @@ class RelayVoiceClient(
|
||||
turnStartedAtMs.set(System.currentTimeMillis())
|
||||
lastEventAtMs.set(System.currentTimeMillis())
|
||||
activeTurn.set(true)
|
||||
longRunningTurn.set(false)
|
||||
}
|
||||
fun activateSocket(webSocket: WebSocket, generation: Long): Boolean {
|
||||
while (true) {
|
||||
@@ -1440,6 +1448,13 @@ class RelayVoiceClient(
|
||||
lastPlayedAudioEventId.updateAndGet { current -> maxOf(current, playedAudioEventId) }
|
||||
}
|
||||
onEvent(event, control)
|
||||
// W3: a promoted (background) Hermes run can legitimately
|
||||
// leave the socket quiet for minutes. Flag the turn so the
|
||||
// idle guard relaxes; MAX_TURN_MS still bounds it.
|
||||
if (event.type == "hermes.run.promoted") {
|
||||
longRunningTurn.set(true)
|
||||
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
|
||||
}
|
||||
if (event.isAudioDelta) {
|
||||
audioChunks += 1
|
||||
val byteCount = event.byteCount ?: 0
|
||||
@@ -1468,6 +1483,7 @@ class RelayVoiceClient(
|
||||
// Turn boundary, not session boundary: keep the socket
|
||||
// open for the next utterance.
|
||||
activeTurn.set(false)
|
||||
longRunningTurn.set(false)
|
||||
onTurnComplete(summary)
|
||||
} else {
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
@@ -1588,14 +1604,26 @@ class RelayVoiceClient(
|
||||
if (turnElapsedMs >= REALTIME_AGENT_MAX_TURN_MS) {
|
||||
throw IOException("Realtime agent exceeded the turn limit")
|
||||
}
|
||||
if (idleElapsedMs >= REALTIME_AGENT_IDLE_TIMEOUT_MS) {
|
||||
// W3: for a known long/background run the relay can go quiet
|
||||
// for minutes — pause the idle guard the same way persistent
|
||||
// between-turn idle is paused, keeping only the MAX_TURN_MS
|
||||
// backstop above.
|
||||
val idleGuardActive = !longRunningTurn.get()
|
||||
if (idleGuardActive && idleElapsedMs >= REALTIME_AGENT_IDLE_TIMEOUT_MS) {
|
||||
throw IOException("Realtime agent stalled waiting for relay events")
|
||||
}
|
||||
val waitMs = minOf(
|
||||
REALTIME_AGENT_WAIT_SLICE_MS,
|
||||
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
|
||||
REALTIME_AGENT_IDLE_TIMEOUT_MS - idleElapsedMs,
|
||||
).coerceAtLeast(1L)
|
||||
val waitMs = if (idleGuardActive) {
|
||||
minOf(
|
||||
REALTIME_AGENT_WAIT_SLICE_MS,
|
||||
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
|
||||
REALTIME_AGENT_IDLE_TIMEOUT_MS - idleElapsedMs,
|
||||
).coerceAtLeast(1L)
|
||||
} else {
|
||||
minOf(
|
||||
REALTIME_AGENT_WAIT_SLICE_MS,
|
||||
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
|
||||
).coerceAtLeast(1L)
|
||||
}
|
||||
withTimeoutOrNull(waitMs) {
|
||||
finished.await()
|
||||
}?.let { return it }
|
||||
@@ -1626,6 +1654,7 @@ class RelayVoiceClient(
|
||||
turnStartedAtMs.set(System.currentTimeMillis())
|
||||
lastEventAtMs.set(System.currentTimeMillis())
|
||||
activeTurn.set(true)
|
||||
longRunningTurn.set(false)
|
||||
} else {
|
||||
sendTurnPcm(ws, turn.inputPcm, turn.sampleRate)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,10 +90,9 @@ import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
|
||||
import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusStrip
|
||||
import com.hermesandroid.relay.ui.components.UnattendedGlobalBanner
|
||||
import com.hermesandroid.relay.ui.components.UpdateBanner
|
||||
import com.hermesandroid.relay.ui.components.UpdateAvailableBanner
|
||||
import com.hermesandroid.relay.ui.components.rememberUpdateAvailability
|
||||
import com.hermesandroid.relay.ui.components.resolveChatTransportStatus
|
||||
import com.hermesandroid.relay.update.UpdateCheckResult
|
||||
import com.hermesandroid.relay.viewmodel.UpdateViewModel
|
||||
import com.hermesandroid.relay.ui.components.WhatsNewDialog
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BridgePreferencesRepository
|
||||
@@ -116,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
|
||||
@@ -271,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)
|
||||
@@ -327,7 +328,6 @@ fun RelayApp() {
|
||||
val chatViewModel: ChatViewModel = viewModel()
|
||||
val terminalViewModel: TerminalViewModel = viewModel()
|
||||
val voiceViewModel: VoiceViewModel = viewModel()
|
||||
val updateViewModel: UpdateViewModel = viewModel()
|
||||
|
||||
// Composition-scoped coroutine scope for firing connection-store suspend
|
||||
// writes off of UI click handlers (rename/revoke/remove) —
|
||||
@@ -397,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()
|
||||
@@ -648,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 ->
|
||||
@@ -662,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,
|
||||
@@ -681,7 +701,12 @@ fun RelayApp() {
|
||||
chatViewModel.refreshSessions()
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedProfile?.name) {
|
||||
LaunchedEffect(activeConnectionId, selectedProfile?.name) {
|
||||
// WP-V2: namespace per-profile voice prefs by BOTH the active connection
|
||||
// and the profile so two connections exposing a same-named profile don't
|
||||
// collide. Set the connection id first so onProfileChanged re-seeds from
|
||||
// the correctly-scoped keys.
|
||||
voiceViewModel.setVoicePrefsConnection(activeConnectionId)
|
||||
voiceViewModel.onProfileChanged(
|
||||
AgentDisplay.profileRequestName(selectedProfile?.name)
|
||||
)
|
||||
@@ -1221,11 +1246,12 @@ fun RelayApp() {
|
||||
!suppressGlobalChrome &&
|
||||
!showStartupSphere &&
|
||||
!voiceUiState.voiceMode
|
||||
// Sideload-only update availability (UpdateViewModel short-circuits on
|
||||
// googlePlay). Hoisted to the outer scope so the update toast can render
|
||||
// in the floating Box overlay below alongside the connection toast.
|
||||
val updateBannerState by updateViewModel.bannerState.collectAsState()
|
||||
val availableUpdate = (updateBannerState as? UpdateCheckResult.Available)?.update
|
||||
// Update availability (unified): googlePlay = Play In-App Update FLEXIBLE,
|
||||
// sideload = GitHub releases. The handle filters dismissed versions +
|
||||
// throttles checks internally, exposing a surfaceable status for the
|
||||
// floating overlay (mirrors the connection toast treatment).
|
||||
val updateHandle = rememberUpdateAvailability()
|
||||
val availableUpdateStatus by updateHandle.visibleStatus
|
||||
|
||||
// Content-identity key so a swipe-up dismiss sticks for THIS status but
|
||||
// a genuinely new status (different title/tone/phase) re-shows.
|
||||
@@ -1736,6 +1762,9 @@ fun RelayApp() {
|
||||
onNavigateToAnalytics = {
|
||||
navController.navigate(Screen.Analytics.route)
|
||||
},
|
||||
onNavigateToDiagnostics = {
|
||||
navController.navigate(Screen.Diagnostics.route)
|
||||
},
|
||||
onNavigateToVoiceSettings = {
|
||||
navController.navigate(Screen.VoiceSettings.route)
|
||||
},
|
||||
@@ -1772,6 +1801,7 @@ fun RelayApp() {
|
||||
VoiceSettingsScreen(
|
||||
voiceViewModel = voiceViewModel,
|
||||
voiceClient = voiceClient,
|
||||
connectionId = activeConnectionId,
|
||||
selectedProfile = selectedProfile,
|
||||
standardVoiceAvailability = standardVoiceAvailability,
|
||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||
@@ -2063,6 +2093,12 @@ fun RelayApp() {
|
||||
chatViewModel = chatViewModel,
|
||||
)
|
||||
}
|
||||
composable(Screen.Diagnostics.route) {
|
||||
DiagnosticsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.DeveloperSettings.route) {
|
||||
DeveloperSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
@@ -2191,15 +2227,17 @@ fun RelayApp() {
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = availableUpdate != null && !suppressGlobalChrome &&
|
||||
visible = availableUpdateStatus != null && !suppressGlobalChrome &&
|
||||
!showStartupSphere && !voiceUiState.voiceMode,
|
||||
enter = slideInVertically(tween(220)) { -it } + fadeIn(tween(180)),
|
||||
exit = slideOutVertically(tween(200)) { -it } + fadeOut(tween(160)),
|
||||
) {
|
||||
availableUpdate?.let { upd ->
|
||||
UpdateBanner(
|
||||
update = upd,
|
||||
onDismiss = { updateViewModel.dismiss(upd.latestVersion) },
|
||||
availableUpdateStatus?.let { status ->
|
||||
UpdateAvailableBanner(
|
||||
status = status,
|
||||
onUpdate = updateHandle.onUpdateClick,
|
||||
onDismiss = updateHandle.onDismiss,
|
||||
includeStatusBarPadding = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -645,6 +646,11 @@ fun AgentInfoSheet(
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
// Profile lock — when set, the picker below collapses to a single static
|
||||
// "Locked to <name>" row. Only the dedicated Settings control still lists
|
||||
// every profile (to change the lock target or unlock).
|
||||
val isProfileLocked by connectionViewModel.isProfileLocked.collectAsState()
|
||||
val lockedProfileName by connectionViewModel.lockedProfileName.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val personalityNames by chatViewModel.personalityNames.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
@@ -836,6 +842,26 @@ fun AgentInfoSheet(
|
||||
?: "Server default",
|
||||
) {
|
||||
|
||||
if (isProfileLocked) {
|
||||
// Pinned to one profile — collapse the whole radio list to a
|
||||
// single static, non-interactive row. The lock target is the
|
||||
// raw stored token: the sentinel means Server default, any
|
||||
// other value is a profile name (resolved to its display name).
|
||||
val lockedDisplayName = when {
|
||||
lockedProfileName == null ->
|
||||
"Server default"
|
||||
AgentDisplay.isServerDefaultAlias(lockedProfileName) ||
|
||||
lockedProfileName == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY ->
|
||||
"Server default"
|
||||
else ->
|
||||
agentProfiles
|
||||
.firstOrNull { it.name == lockedProfileName }
|
||||
?.let { AgentDisplay.profileDisplayName(it) }
|
||||
?: lockedProfileName!!.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
LockedProfileRow(lockedDisplayName = lockedDisplayName)
|
||||
} else {
|
||||
|
||||
val defaultDotColor = serverDefaultProfile?.let { profile ->
|
||||
if (profile.gatewayRunning) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
@@ -1063,6 +1089,7 @@ fun AgentInfoSheet(
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
|
||||
)
|
||||
}
|
||||
} // end else (not locked)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
@@ -1453,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(
|
||||
@@ -1884,6 +1911,42 @@ private fun ProfileRadioRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single static, non-interactive row shown in place of the profile radio list
|
||||
* when the connection is locked to one profile. There is intentionally no
|
||||
* onSelect — the only way to change the target or unlock is the dedicated
|
||||
* "Profile lock" control in Settings, which always lists every profile.
|
||||
*/
|
||||
@Composable
|
||||
private fun LockedProfileRow(lockedDisplayName: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Locked to $lockedDisplayName",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = "Manage the lock in Settings → Profile lock",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lines a collapsed (truncated) [ProfileRadioRow] description shows before its
|
||||
* tap-to-expand affordance reveals the rest. Two keeps the badge FlowRow on
|
||||
* screen even when the description is long. */
|
||||
|
||||
+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(
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -49,6 +46,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.util.CrashReport
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -150,26 +148,45 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Row(
|
||||
// FlowRow so the actions wrap instead of clipping on narrow /
|
||||
// foldable cover screens now that a fourth (Share) action exists.
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
copyToClipboard(context, reportText)
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Crash report copied")
|
||||
},
|
||||
) { Text("Copy") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// Universal, GitHub-free path: hand the full report to the
|
||||
// system share sheet (email, chat apps, notes, Drive…). The
|
||||
// user picks the destination, so nothing leaves the device
|
||||
// until they choose to send it — same privacy posture as Copy.
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
"Hermes-Relay crash report — ${report.shortTitle()}",
|
||||
reportText,
|
||||
chooserTitle = "Share crash report",
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Report copied — no app found to share to")
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text("Share") }
|
||||
Button(
|
||||
onClick = {
|
||||
// Copy the FULL report first; the URL only carries the
|
||||
// head of the trace, so the user can paste the rest.
|
||||
copyToClipboard(context, reportText)
|
||||
val opened = openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
toast(
|
||||
context,
|
||||
if (opened) "Full report copied — paste into the issue if it's truncated"
|
||||
@@ -184,20 +201,6 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToClipboard(context: Context, text: String) {
|
||||
runCatching {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Hermes-Relay crash report", text))
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUrl(context: Context, url: String): Boolean = runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun toast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
|
||||
/**
|
||||
* Self-contained, full-detail view for a single [DiagnosticLogEntry], opened
|
||||
* from a tapped row in [DiagnosticsLogPanel]. Renders the clean title, category,
|
||||
* severity, timestamp, sanitized route/url, elapsed, and the full redacted
|
||||
* stacktrace/detail in a monospace selectable block.
|
||||
*
|
||||
* It is a plain [Dialog] driven entirely by the panel's own state — there is NO
|
||||
* nav route and nothing to wire in RelayApp. Visual pattern mirrors
|
||||
* [CrashReportDialog]; the Copy / Export(share) / Create-GitHub-issue actions
|
||||
* all route through the shared [IssueReport] helper.
|
||||
*/
|
||||
@Composable
|
||||
fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val plainText = remember(entry) { entry.toPlainText() }
|
||||
val severityName = entry.severity.name
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DiagnosticSeverityChip(entry.severity)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = entry.category.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
// Metadata rows — only render the ones that are present.
|
||||
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
|
||||
MetaRow("Severity", severityName)
|
||||
MetaRow("Category", entry.category.label)
|
||||
entry.endpointRole?.let { MetaRow("Route", it) }
|
||||
entry.url?.let { MetaRow("URL", it) }
|
||||
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
val body = entry.stacktrace ?: entry.detail
|
||||
if (body != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 320.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
RoundedCornerShape(12.dp),
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = body,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "No further detail captured for this entry.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
toast(context, "Diagnostic copied")
|
||||
},
|
||||
) { Text("Copy") }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
subject = "Hermes-Relay diagnostic — ${entry.title}",
|
||||
text = plainText,
|
||||
chooserTitle = "Export diagnostic",
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
toast(context, "Copied — no app found to share to")
|
||||
}
|
||||
},
|
||||
) { Text("Export") }
|
||||
Button(
|
||||
onClick = {
|
||||
// Copy full text first; the GitHub URL only carries the
|
||||
// head of long traces, so the user can paste the rest.
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
val opened = IssueReport.openUrl(
|
||||
context,
|
||||
IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: ${entry.title}",
|
||||
bodyMarkdown = entry.toIssueBody(),
|
||||
labels = "bug",
|
||||
),
|
||||
)
|
||||
toast(
|
||||
context,
|
||||
if (opened) "Full diagnostic copied — paste it into the issue if truncated"
|
||||
else "Copied — no browser found to open GitHub",
|
||||
)
|
||||
},
|
||||
) { Text("Report") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(78.dp),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DiagnosticSeverityChip(severity: DiagnosticSeverity) {
|
||||
val (bg, fg) = when (severity) {
|
||||
DiagnosticSeverity.Info ->
|
||||
MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
|
||||
DiagnosticSeverity.Warning ->
|
||||
MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
|
||||
DiagnosticSeverity.Error ->
|
||||
MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(50), color = bg) {
|
||||
Text(
|
||||
text = severity.name.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = fg,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(context: android.content.Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
/** Full, copy/share-ready plain-text rendering of a single diagnostic entry. */
|
||||
private fun DiagnosticLogEntry.toPlainText(): String = buildString {
|
||||
appendLine("Hermes-Relay diagnostic")
|
||||
appendLine("Title: $title")
|
||||
appendLine("Category: ${category.label}")
|
||||
appendLine("Severity: ${severity.name}")
|
||||
appendLine("Time: ${DateFormat.format("yyyy-MM-dd HH:mm:ss", timestampMs)}")
|
||||
appendLine("App: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE}) ${BuildConfig.FLAVOR}")
|
||||
endpointRole?.let { appendLine("Route: $it") }
|
||||
url?.let { appendLine("URL: $it") }
|
||||
elapsedMs?.let { appendLine("Elapsed: ${it}ms") }
|
||||
detail?.let {
|
||||
appendLine()
|
||||
appendLine("Detail:")
|
||||
appendLine(it)
|
||||
}
|
||||
stacktrace?.let {
|
||||
appendLine()
|
||||
appendLine("Stacktrace:")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown issue body mirroring the crash-report issue format: environment block
|
||||
* + the captured entry. Trace is capped so the prefilled GitHub URL stays within
|
||||
* browser limits (full text is on the clipboard).
|
||||
*/
|
||||
private const val MAX_TRACE_FOR_URL = 3000
|
||||
|
||||
private fun DiagnosticLogEntry.toIssueBody(): String {
|
||||
val trace = (stacktrace ?: detail).orEmpty().let {
|
||||
if (it.length > MAX_TRACE_FOR_URL) {
|
||||
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — full diagnostic copied to your clipboard)"
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
val surface = if (BuildConfig.FLAVOR.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play"
|
||||
return buildString {
|
||||
appendLine(
|
||||
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
|
||||
"or personal data from the detail below.",
|
||||
)
|
||||
appendLine()
|
||||
appendLine("### Affected area")
|
||||
appendLine("Android app")
|
||||
appendLine()
|
||||
appendLine("### What happened?")
|
||||
appendLine("Captured diagnostic from the in-app activity log.")
|
||||
appendLine()
|
||||
appendLine("### Environment")
|
||||
appendLine("- Hermes-Relay version/tag: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE})")
|
||||
appendLine("- Install surface: $surface")
|
||||
appendLine("- Connection mode: LAN / Tailscale / public TLS / other")
|
||||
appendLine()
|
||||
appendLine("### Diagnostic")
|
||||
appendLine("- Title: $title")
|
||||
appendLine("- Category: ${category.label}")
|
||||
appendLine("- Severity: ${severity.name}")
|
||||
endpointRole?.let { appendLine("- Route: $it") }
|
||||
url?.let { appendLine("- URL: $it") }
|
||||
elapsedMs?.let { appendLine("- Elapsed: ${it}ms") }
|
||||
if (trace.isNotBlank()) {
|
||||
appendLine()
|
||||
appendLine("```")
|
||||
appendLine(trace)
|
||||
appendLine("```")
|
||||
}
|
||||
appendLine()
|
||||
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -13,6 +15,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -21,6 +24,9 @@ import androidx.compose.material3.TextButton
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -42,11 +48,20 @@ fun DiagnosticsLogPanel(
|
||||
limit: Int = 8,
|
||||
showCategory: Boolean = false,
|
||||
showClear: Boolean = false,
|
||||
showSeverityFilter: Boolean = false,
|
||||
) {
|
||||
val entries by DiagnosticsLog.entries.collectAsState()
|
||||
|
||||
// Self-contained detail-view state — tapping a row opens DiagnosticDetailDialog.
|
||||
// No nav route; nothing to wire in RelayApp.
|
||||
var selected by remember { mutableStateOf<DiagnosticLogEntry?>(null) }
|
||||
// Optional severity filter, local to the panel (null = all severities).
|
||||
var severityFilter by remember { mutableStateOf<DiagnosticSeverity?>(null) }
|
||||
|
||||
val visible = entries
|
||||
.asReversed()
|
||||
.filter { categories == null || it.category in categories }
|
||||
.filter { severityFilter == null || it.severity == severityFilter }
|
||||
.take(limit.coerceAtLeast(0))
|
||||
|
||||
Column(
|
||||
@@ -70,6 +85,23 @@ fun DiagnosticsLogPanel(
|
||||
}
|
||||
}
|
||||
|
||||
if (showSeverityFilter) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = severityFilter == null,
|
||||
onClick = { severityFilter = null },
|
||||
label = { Text("All") },
|
||||
)
|
||||
DiagnosticSeverity.entries.forEach { sev ->
|
||||
FilterChip(
|
||||
selected = severityFilter == sev,
|
||||
onClick = { severityFilter = if (severityFilter == sev) null else sev },
|
||||
label = { Text(sev.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visible.isEmpty()) {
|
||||
Text(
|
||||
text = "No recent activity",
|
||||
@@ -89,6 +121,7 @@ fun DiagnosticsLogPanel(
|
||||
showCategory = showCategory,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { selected = entry }
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
)
|
||||
if (index != visible.lastIndex) {
|
||||
@@ -99,6 +132,10 @@ fun DiagnosticsLogPanel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selected?.let { entry ->
|
||||
DiagnosticDetailDialog(entry = entry, onDismiss = { selected = null })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -141,6 +178,9 @@ private fun DiagnosticLogRow(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (entry.severity != DiagnosticSeverity.Info) {
|
||||
DiagnosticSeverityChip(entry.severity)
|
||||
}
|
||||
Text(
|
||||
text = DateFormat.format("HH:mm:ss", entry.timestampMs).toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.SystemUpdate
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import com.hermesandroid.relay.update.UpdateAvailabilitySource
|
||||
import com.hermesandroid.relay.update.UpdateDismissalPreferences
|
||||
import com.hermesandroid.relay.update.UpdateStatus
|
||||
import com.hermesandroid.relay.update.createUpdateAvailabilitySource
|
||||
import com.hermesandroid.relay.update.dismissKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Auto-check interval — both flavors. App cold-starts / resumes more often
|
||||
* than this don't need a fresh Play/GitHub round-trip.
|
||||
*/
|
||||
private const val AUTO_CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Debug-only injected status for previewing [UpdateAvailableBanner] from
|
||||
* Developer options — the real Play / GitHub sources can't be triggered without
|
||||
* an actual new release. Honoured by [rememberUpdateAvailability] ONLY in debug
|
||||
* builds, and cleared the moment the previewed banner is actioned or dismissed.
|
||||
* Never read in release builds.
|
||||
*/
|
||||
object UpdateDebugOverride {
|
||||
val flow = MutableStateFlow<UpdateStatus?>(null)
|
||||
|
||||
/** Cycle the preview: off → Available → Downloaded → off. */
|
||||
fun cycle() {
|
||||
flow.value = when (flow.value) {
|
||||
null -> UpdateStatus.Available(versionLabel = "9.9.9", versionCode = 999_999L)
|
||||
is UpdateStatus.Available -> UpdateStatus.Downloaded(versionLabel = "9.9.9", versionCode = 999_999L)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
flow.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle returned by [rememberUpdateAvailability] for the host
|
||||
* (`RelayApp.kt`) to drive the banner. The scaffold renders
|
||||
* [UpdateAvailableBanner] when [visibleStatus] is a surfaceable status, and
|
||||
* calls [onUpdateClick] / [onDismiss] from the banner's actions.
|
||||
*
|
||||
* `visibleStatus` is already filtered through the per-version dismiss
|
||||
* preference: a dismissed [UpdateStatus.Available] reads as null here, but a
|
||||
* [UpdateStatus.Downloaded] (FLEXIBLE finished while in-app) is intentionally
|
||||
* NOT suppressible — "restart to finish" should always be offered.
|
||||
*/
|
||||
class UpdateAvailabilityHandle internal constructor(
|
||||
val visibleStatus: State<UpdateStatus?>,
|
||||
/**
|
||||
* Primary banner action. For [UpdateStatus.Downloaded] this completes +
|
||||
* restarts (Play); otherwise it starts the update (Play FLEXIBLE flow /
|
||||
* sideload browser open). The hosting Activity is captured internally by
|
||||
* [rememberUpdateAvailability] — the coordinator just calls this.
|
||||
*/
|
||||
val onUpdateClick: () -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Lifecycle-bound entry point the coordinator wires once from inside the
|
||||
* `RelayApp` composable. Builds the per-flavor [UpdateAvailabilitySource]
|
||||
* (googlePlay = Play In-App Update FLEXIBLE; sideload = GitHub releases),
|
||||
* throttle-checks on first composition + every ON_RESUME, listens for the
|
||||
* async Play DOWNLOADED transition, and exposes a [UpdateAvailabilityHandle].
|
||||
*
|
||||
* Wiring (host side, NOT done here):
|
||||
* ```
|
||||
* val update = rememberUpdateAvailability()
|
||||
* val status by update.visibleStatus
|
||||
* // inside the top overlay Column, alongside ConnectionStatusToast:
|
||||
* AnimatedVisibility(visible = status != null && !suppressGlobalChrome && …) {
|
||||
* status?.let { UpdateAvailableBanner(
|
||||
* status = it,
|
||||
* onUpdate = { update.onUpdateClick(activity) },
|
||||
* onDismiss = update.onDismiss,
|
||||
* ) }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Place the call near the other `viewModel()` hoists at the top of `RelayApp`;
|
||||
* render the banner in the existing floating top-overlay Column so it slides
|
||||
* over content without resizing it (same treatment as the connection toast).
|
||||
*/
|
||||
@Composable
|
||||
fun rememberUpdateAvailability(): UpdateAvailabilityHandle {
|
||||
val context = LocalContext.current
|
||||
val appContext = context.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// Resolve the hosting Activity for the Play FLEXIBLE consent dialog.
|
||||
// Tracked live so a config-change recomposition re-binds the new Activity.
|
||||
val activityState = rememberUpdatedState(context.findActivity())
|
||||
|
||||
val source = remember(appContext) { createUpdateAvailabilitySource(appContext) }
|
||||
|
||||
// Raw, unfiltered status from the source (check result + async listener).
|
||||
var rawStatus by remember { mutableStateOf<UpdateStatus>(UpdateStatus.UpToDate) }
|
||||
|
||||
// Per-version dismissal. dismissedKey is observed so a fresh dismiss takes
|
||||
// effect immediately; a strictly-newer offer re-shows automatically.
|
||||
val dismissedKey by UpdateDismissalPreferences
|
||||
.dismissedKey(appContext)
|
||||
.collectAsState(initial = null)
|
||||
|
||||
// Debug-only preview override (Developer options → Test harness). Forced to
|
||||
// null in release builds so production never surfaces a fake banner.
|
||||
val debugOverride by UpdateDebugOverride.flow.collectAsState()
|
||||
val debugOverrideState = rememberUpdatedState(if (BuildConfig.DEBUG) debugOverride else null)
|
||||
|
||||
// Visible status = raw, but Available/Downloading suppressed when dismissed.
|
||||
// Downloaded is never suppressed (restart prompt must always show).
|
||||
// derivedStateOf tracks both snapshot inputs (rawStatus + the collected
|
||||
// dismissedKey) so the handle (built once) reads live updates. The
|
||||
// dismiss check is a pure function (no I/O), safe inside the derivation.
|
||||
val dismissedKeyState = rememberUpdatedState(dismissedKey)
|
||||
val visibleStatus = remember {
|
||||
derivedStateOf {
|
||||
val dbg = debugOverrideState.value
|
||||
if (dbg != null) {
|
||||
dbg
|
||||
} else {
|
||||
when (val raw = rawStatus) {
|
||||
UpdateStatus.UpToDate, UpdateStatus.Unsupported -> null
|
||||
is UpdateStatus.Downloaded -> raw
|
||||
is UpdateStatus.Available, is UpdateStatus.Downloading ->
|
||||
if (UpdateDismissalPreferences.isDismissed(raw, dismissedKeyState.value)) {
|
||||
null
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Async Play listener (DOWNLOADED / DOWNLOADING) feeds rawStatus directly.
|
||||
DisposableEffect(source) {
|
||||
source.onStatusChanged = { newStatus -> rawStatus = newStatus }
|
||||
onDispose { source.dispose() }
|
||||
}
|
||||
|
||||
// Throttled check: once on first composition, then on every ON_RESUME. The
|
||||
// throttle (maybeCheck) no-ops unless the auto-check interval has elapsed,
|
||||
// so the initial check + resume checks don't double-hit Play/GitHub.
|
||||
val sourceState = rememberUpdatedState(source)
|
||||
LaunchedEffect(source) {
|
||||
maybeCheck(appContext, sourceState.value) { rawStatus = it }
|
||||
}
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
scope.launch { maybeCheck(appContext, sourceState.value) { rawStatus = it } }
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
return remember(source) {
|
||||
UpdateAvailabilityHandle(
|
||||
visibleStatus = visibleStatus,
|
||||
onUpdateClick = {
|
||||
if (UpdateDebugOverride.flow.value != null) {
|
||||
// Preview mode — the action just dismisses the fake banner.
|
||||
UpdateDebugOverride.clear()
|
||||
} else {
|
||||
val current = visibleStatus.value
|
||||
if (current is UpdateStatus.Downloaded) {
|
||||
source.completeUpdate()
|
||||
} else {
|
||||
source.startUpdate(activityState.value)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
if (UpdateDebugOverride.flow.value != null) {
|
||||
UpdateDebugOverride.clear()
|
||||
} else {
|
||||
visibleStatus.value?.dismissKey?.let { key ->
|
||||
scope.launch { UpdateDismissalPreferences.dismiss(appContext, key) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire a check iff the throttle window has elapsed; records the time on success. */
|
||||
private suspend fun maybeCheck(
|
||||
context: Context,
|
||||
source: UpdateAvailabilitySource,
|
||||
onResult: (UpdateStatus) -> Unit,
|
||||
) {
|
||||
val last = UpdateDismissalPreferences.lastCheckAtMs(context).first()
|
||||
val overdue = (System.currentTimeMillis() - last) > AUTO_CHECK_INTERVAL_MS
|
||||
if (!overdue) return
|
||||
val result = source.check()
|
||||
onResult(result)
|
||||
UpdateDismissalPreferences.markChecked(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up the ContextWrapper chain to the hosting Activity. Needed by the Play
|
||||
* FLEXIBLE flow (`startUpdateFlow` hosts its consent dialog on an Activity);
|
||||
* `LocalContext.current` inside a ComponentActivity is the activity, but the
|
||||
* direct cast can silently fail behind theme/inflater wrappers. Mirrors
|
||||
* `BridgeScreen.findActivity()`.
|
||||
*/
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a [UpdateStatus] versionLabel for display. The sideload track passes
|
||||
* a raw semver string (e.g. "1.3.0") → "v1.3.0"; the Play track passes a
|
||||
* generic phrase (e.g. "A new version", since Play exposes only a versionCode)
|
||||
* → shown verbatim. Heuristic: prefix "v" only when the label begins with a
|
||||
* digit.
|
||||
*/
|
||||
private fun displayVersion(label: String): String =
|
||||
if (label.firstOrNull()?.isDigit() == true) "v$label" else label
|
||||
|
||||
/**
|
||||
* Shared, dismissable Material 3 update banner — serves both flavors.
|
||||
*
|
||||
* Visual treatment matches [ConnectionStatusToast]: an opaque Surface
|
||||
* (tinted container composited over the theme surface so content doesn't bleed
|
||||
* through), rounded 16dp, shadow elevation, status-bar inset. Render it inside
|
||||
* the host's floating top-overlay Box so it slides over content instead of
|
||||
* resizing it.
|
||||
*
|
||||
* Copy + primary action key off [status]:
|
||||
* - [UpdateStatus.Available] → "Update available" + "Update" (Play flow /
|
||||
* browser) + dismiss (X).
|
||||
* - [UpdateStatus.Downloading] → "Downloading update…" + progress bar, no
|
||||
* action button (Play is working); dismiss still available.
|
||||
* - [UpdateStatus.Downloaded] → "Update ready — restart" + "Restart"
|
||||
* (completeUpdate). No dismiss — finishing the install is the only sane
|
||||
* next step, and Play has already staged the APK.
|
||||
*
|
||||
* [UpdateStatus.UpToDate] / [Unsupported] render nothing (caller should gate
|
||||
* on a non-null visible status, but this guards defensively).
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateAvailableBanner(
|
||||
status: UpdateStatus,
|
||||
onUpdate: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = true,
|
||||
) {
|
||||
val surface = MaterialTheme.colorScheme.surface
|
||||
val containerColor = MaterialTheme.colorScheme.primaryContainer.compositeOver(surface)
|
||||
val contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
val title: String
|
||||
val subtitle: String?
|
||||
val actionLabel: String?
|
||||
val showDismiss: Boolean
|
||||
val downloading = status as? UpdateStatus.Downloading
|
||||
|
||||
when (status) {
|
||||
is UpdateStatus.Available -> {
|
||||
title = "Update available"
|
||||
subtitle = "${displayVersion(status.versionLabel)} is ready to install."
|
||||
actionLabel = "Update"
|
||||
showDismiss = true
|
||||
}
|
||||
is UpdateStatus.Downloading -> {
|
||||
title = "Downloading update…"
|
||||
subtitle = displayVersion(status.versionLabel)
|
||||
actionLabel = null
|
||||
showDismiss = true
|
||||
}
|
||||
is UpdateStatus.Downloaded -> {
|
||||
title = "Update ready — restart"
|
||||
subtitle = "${displayVersion(status.versionLabel)} downloaded. Restart to finish."
|
||||
actionLabel = "Restart"
|
||||
showDismiss = false
|
||||
}
|
||||
UpdateStatus.UpToDate, UpdateStatus.Unsupported -> return
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
shadowElevation = 8.dp,
|
||||
tonalElevation = 2.dp,
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 24.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (downloading != null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = contentColor,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SystemUpdate,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor.copy(alpha = 0.82f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (actionLabel != null) {
|
||||
Button(
|
||||
onClick = onUpdate,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 14.dp,
|
||||
vertical = 4.dp,
|
||||
),
|
||||
) {
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
if (showDismiss) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = "Dismiss",
|
||||
tint = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (downloading != null && downloading.totalBytes > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = {
|
||||
(downloading.bytesDownloaded.toFloat() /
|
||||
downloading.totalBytes.toFloat()).coerceIn(0f, 1f)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp),
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
trackColor = contentColor.copy(alpha = 0.16f),
|
||||
)
|
||||
} else if (downloading != null) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp),
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
trackColor = contentColor.copy(alpha = 0.16f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -61,7 +60,9 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -81,7 +82,9 @@ import com.hermesandroid.relay.viewmodel.PermissionDeniedCallout
|
||||
import com.hermesandroid.relay.viewmodel.VoiceHandoffStatus
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import coil3.compose.AsyncImage
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Full-screen voice-mode overlay. Renders the MorphingSphere in its voiceMode
|
||||
@@ -608,9 +611,17 @@ private fun VoiceMicButton(
|
||||
val gestureModifier = when (uiState.interactionMode) {
|
||||
InteractionMode.HoldToTalk -> Modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown()
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
currentOnHoldPress()
|
||||
waitForUpOrCancellation()
|
||||
// Hold until the finger genuinely lifts. Don't use
|
||||
// waitForUpOrCancellation(): it ends the hold on ANY cancel — a
|
||||
// consumed move event or the finger drifting just off the small
|
||||
// circle — which made the button feel like it released by
|
||||
// accident. Loop until no pointer is still pressed so drift and
|
||||
// minor consumption don't cut the recording short.
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
} while (event.changes.any { it.pressed })
|
||||
currentOnHoldRelease()
|
||||
}
|
||||
}
|
||||
@@ -729,7 +740,10 @@ private fun VoiceSessionPill(
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.98f),
|
||||
// Fully opaque panel — the overlay floats over live chat/sphere, so a
|
||||
// translucent surface let the background bleed through and made the
|
||||
// dropdown text hard to read.
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 5.dp,
|
||||
shadowElevation = 7.dp,
|
||||
) {
|
||||
@@ -741,12 +755,29 @@ private fun VoiceSessionPill(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
// Show the active profile's local icon (if set) as the leading
|
||||
// glyph — same circular avatar treatment chat uses in
|
||||
// MessageBubble. Falls back to the equalizer icon when there's
|
||||
// no profile icon; the sphere/pet remains the no-icon fallback.
|
||||
val agentIconPath = LocalAgentIconPath.current
|
||||
if (!agentIconPath.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = File(agentIconPath),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.testTag("voiceOverlayProfileIcon")
|
||||
.size(18.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Voice",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
@@ -857,7 +888,11 @@ private fun VoiceSessionPill(
|
||||
onClick = { onFocusModeChange(!focusMode) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(if (focusMode) "Compact" else "Focus")
|
||||
Text(
|
||||
if (focusMode) "Compact" else "Focus",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
@@ -866,13 +901,13 @@ private fun VoiceSessionPill(
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Overlay")
|
||||
Text("Overlay", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onExit,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Exit")
|
||||
Text("Exit", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
// Settings link (4c): exit voice mode before navigating
|
||||
// so the overlay isn't left floating over the Voice
|
||||
@@ -991,7 +1026,8 @@ private fun VoiceControlChip(
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f)
|
||||
// Opaque — translucent chips over the floating overlay were hard to read.
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (selected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
@@ -1021,9 +1057,11 @@ private fun StatusPill(
|
||||
modifier = modifier.height(24.dp),
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = if (emphasized) {
|
||||
MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f)
|
||||
// Opaque — translucent status bubbles over the floating overlay were
|
||||
// hard to read against the sphere/chat behind them.
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.62f)
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (emphasized) {
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.ui.components
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -19,20 +20,37 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* "What's New" sheet, shown automatically on a version bump (RelayApp) and from
|
||||
* the About screen. Parses [whats_new.txt]'s tiny markup — a version line,
|
||||
* blank-separated sections with a plain-text header, `*` bullets with indented
|
||||
* continuation lines — into styled Compose instead of pasting the raw text
|
||||
* (which showed literal `*` and gave headers no emphasis).
|
||||
* the About screen.
|
||||
*
|
||||
* As of the multi-version changelog work, the single source of truth is the
|
||||
* bundled [changelog.json] asset (see [ChangelogStore]). This dialog renders the
|
||||
* *latest* entry; the full version history lives in `ChangelogScreen`, which
|
||||
* reuses [VersionNotesBlock] for per-version rendering so the styling stays in
|
||||
* lockstep.
|
||||
*
|
||||
* For resilience the dialog still falls back to the legacy [whats_new.txt]
|
||||
* tiny-markup format (a version line, blank-separated sections with a plain-text
|
||||
* header, `*` bullets) when the JSON asset is missing or unparseable — that file
|
||||
* is also what `gradle-play-publisher`-adjacent tooling expects to find, so it's
|
||||
* kept current alongside the JSON.
|
||||
*/
|
||||
@Composable
|
||||
fun WhatsNewDialog(
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val notes = remember { parseWhatsNew(loadWhatsNew(context)) }
|
||||
// Prefer the structured changelog's latest entry; fall back to the legacy
|
||||
// text asset so a missing/garbled JSON never leaves the dialog empty.
|
||||
val notes = remember {
|
||||
ChangelogStore.loadLatestAsNotes(context)
|
||||
?: parseWhatsNew(loadWhatsNew(context))
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -62,34 +80,7 @@ fun WhatsNewDialog(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
notes.groups.forEachIndexed { index, group ->
|
||||
group.header?.let { header ->
|
||||
Text(
|
||||
text = header,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = if (index == 0) 0.dp else 6.dp),
|
||||
)
|
||||
}
|
||||
group.bullets.forEach { bullet ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = bullet,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
VersionNotesBody(notes.groups)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -100,16 +91,167 @@ fun WhatsNewDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/** One section: an optional header plus its bullets. */
|
||||
private data class WhatsNewGroup(val header: String?, val bullets: List<String>)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Shared rendering — used by both this dialog and ChangelogScreen so a tweak
|
||||
// to bullet/header styling lands in one place.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parsed release notes: the leading version line plus styled sections. */
|
||||
private data class WhatsNewNotes(
|
||||
/** One section: an optional header plus its bullets. */
|
||||
data class WhatsNewGroup(val header: String?, val bullets: List<String>)
|
||||
|
||||
/** Parsed release notes for a single version: the version subtitle + sections. */
|
||||
data class WhatsNewNotes(
|
||||
val version: String?,
|
||||
val groups: List<WhatsNewGroup>,
|
||||
val fallback: String?,
|
||||
val fallback: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Renders the body of one version's notes — its section headers and bullet
|
||||
* lists — without any surrounding chrome (no title, no scroll container). The
|
||||
* caller owns the [Column] so this can be dropped into a dialog or a screen.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.VersionNotesBody(groups: List<WhatsNewGroup>) {
|
||||
groups.forEachIndexed { index, group ->
|
||||
group.header?.let { header ->
|
||||
Text(
|
||||
text = header,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = if (index == 0) 0.dp else 6.dp),
|
||||
)
|
||||
}
|
||||
group.bullets.forEach { bullet ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = bullet,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained version block — a version subtitle (version · title · date)
|
||||
* followed by [VersionNotesBody]. Used by ChangelogScreen for each release.
|
||||
*/
|
||||
@Composable
|
||||
fun VersionNotesBlock(entry: ChangelogVersion) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = entry.subtitle(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
VersionNotesBody(entry.toGroups())
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Structured changelog model + loader (kotlinx.serialization).
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One bullet group within a version: an optional header and its bullets. */
|
||||
@Serializable
|
||||
data class ChangelogSection(
|
||||
val header: String? = null,
|
||||
val bullets: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** A single released version's user-facing notes. */
|
||||
@Serializable
|
||||
data class ChangelogVersion(
|
||||
val version: String,
|
||||
val title: String? = null,
|
||||
val date: String? = null,
|
||||
val sections: List<ChangelogSection> = emptyList(),
|
||||
) {
|
||||
/** "v1.2.0 — Make it yours · 2026-06-20" (each token optional but version). */
|
||||
fun subtitle(): String {
|
||||
val head = "v$version"
|
||||
val titlePart = title?.takeIf { it.isNotBlank() }?.let { " — $it" } ?: ""
|
||||
val datePart = date?.takeIf { it.isNotBlank() }?.let { " · $it" } ?: ""
|
||||
return head + titlePart + datePart
|
||||
}
|
||||
|
||||
fun toGroups(): List<WhatsNewGroup> =
|
||||
sections.map { WhatsNewGroup(it.header?.takeIf { h -> h.isNotBlank() }, it.bullets) }
|
||||
|
||||
/** Adapt this version into the dialog's [WhatsNewNotes] shape. */
|
||||
fun toNotes(): WhatsNewNotes = WhatsNewNotes(
|
||||
version = subtitle(),
|
||||
groups = toGroups(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Top-level shape of [changelog.json]: newest version first. */
|
||||
@Serializable
|
||||
data class Changelog(
|
||||
@SerialName("versions") val versions: List<ChangelogVersion> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses + loads the bundled [changelog.json]. Parsing is a pure function
|
||||
* ([parse]) so it can be unit-tested off-device; only [load] touches the
|
||||
* Android asset stream.
|
||||
*/
|
||||
object ChangelogStore {
|
||||
|
||||
/** Tolerant of upstream additions — unknown keys are ignored. */
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
const val ASSET_NAME: String = "changelog.json"
|
||||
|
||||
/**
|
||||
* Parse raw changelog JSON into the model, preserving file order
|
||||
* (authored newest-first). Returns an empty [Changelog] on blank input or
|
||||
* any deserialization error — callers fall back to the legacy text asset.
|
||||
*/
|
||||
fun parse(raw: String): Changelog {
|
||||
if (raw.isBlank()) return Changelog()
|
||||
return try {
|
||||
json.decodeFromString(Changelog.serializer(), raw)
|
||||
} catch (_: Exception) {
|
||||
Changelog()
|
||||
}
|
||||
}
|
||||
|
||||
/** Read + parse the bundled asset (IO is cheap — a few KB, done once). */
|
||||
fun load(context: Context): Changelog {
|
||||
val raw = try {
|
||||
context.assets.open(ASSET_NAME).bufferedReader().readText()
|
||||
} catch (_: Exception) {
|
||||
return Changelog()
|
||||
}
|
||||
return parse(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest (first) entry rendered into the dialog's notes shape, or null
|
||||
* when the changelog can't be loaded so the caller can fall back to
|
||||
* [whats_new.txt].
|
||||
*/
|
||||
fun loadLatestAsNotes(context: Context): WhatsNewNotes? =
|
||||
load(context).versions.firstOrNull()?.toNotes()
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Legacy whats_new.txt fallback parser (kept for resilience + Play tooling).
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Classify each line of the [whats_new.txt] format:
|
||||
* - line 0 (non-bullet) → version subtitle (`-` upgraded to an em dash),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.components.ChangelogStore
|
||||
import com.hermesandroid.relay.ui.components.ChangelogVersion
|
||||
import com.hermesandroid.relay.ui.components.VersionNotesBody
|
||||
|
||||
/**
|
||||
* Full release history, sourced from the bundled `changelog.json` (the same
|
||||
* single source the auto post-update [com.hermesandroid.relay.ui.components.WhatsNewDialog]
|
||||
* renders the latest entry from).
|
||||
*
|
||||
* The newest version is expanded by default; every older version is a
|
||||
* collapsible card. Per-version rendering reuses [VersionNotesBody] so the
|
||||
* header/bullet styling matches the auto dialog exactly.
|
||||
*
|
||||
* This is a self-contained screen meant to be hosted inside a full-screen
|
||||
* `Dialog` from Settings — it owns its own [Scaffold] + close affordance and
|
||||
* has no nav dependency.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChangelogScreen(
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val versions = remember { ChangelogStore.load(context).versions }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("What's New") },
|
||||
actions = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Close",
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
if (versions.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No release notes available.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
versions.forEachIndexed { index, entry ->
|
||||
// Latest version is expanded; older ones start collapsed.
|
||||
ChangelogVersionCard(
|
||||
entry = entry,
|
||||
initiallyExpanded = index == 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One version's collapsible card. The header row (version · title · date) is
|
||||
* always visible and toggles the body; the body reuses [VersionNotesBody].
|
||||
*/
|
||||
@Composable
|
||||
private fun ChangelogVersionCard(
|
||||
entry: ChangelogVersion,
|
||||
initiallyExpanded: Boolean,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "v${entry.version}" +
|
||||
(entry.title?.takeIf { it.isNotBlank() }?.let { " — $it" } ?: ""),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
entry.date?.takeIf { it.isNotBlank() }?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) {
|
||||
Icons.Filled.ExpandLess
|
||||
} else {
|
||||
Icons.Filled.ExpandMore
|
||||
},
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
VersionNotesBody(entry.toGroups())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material.icons.filled.Science
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -48,6 +49,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.ui.components.UpdateDebugOverride
|
||||
import com.hermesandroid.relay.update.UpdateStatus
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -392,6 +398,107 @@ fun DeveloperSettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test harness — debug builds only. Triggers for surfaces that
|
||||
// unit tests can't reach and that don't occur on demand (a crash, a
|
||||
// logged error, a live update). Gated by isDevBuild so it never
|
||||
// ships in a release APK.
|
||||
if (FeatureFlags.isDevBuild) {
|
||||
Text(
|
||||
text = "Test harness",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
isDarkTheme = isDarkTheme,
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TestHarnessRow(
|
||||
title = "Emit sample diagnostics",
|
||||
subtitle = "Push Info / Warning / Error entries into the diagnostics log",
|
||||
icon = Icons.Filled.Science,
|
||||
onClick = {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Api,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Sample info diagnostic",
|
||||
detail = "Emitted from the Developer options test harness.",
|
||||
)
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Sample warning diagnostic",
|
||||
detail = "Relay reachability degraded (synthetic).",
|
||||
)
|
||||
DiagnosticsLog.recordError(
|
||||
category = DiagnosticCategory.Voice,
|
||||
title = "Sample error diagnostic",
|
||||
detail = "Synthetic failure for the detail view.",
|
||||
throwable = RuntimeException(
|
||||
"Sample stacktrace — Developer options test harness",
|
||||
),
|
||||
)
|
||||
Toast.makeText(context, "3 sample diagnostics emitted", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
TestHarnessRow(
|
||||
title = "Preview update banner",
|
||||
subtitle = "Cycle the in-app update banner: Available → Downloaded → off",
|
||||
icon = Icons.Filled.Science,
|
||||
onClick = {
|
||||
UpdateDebugOverride.cycle()
|
||||
val state = when (UpdateDebugOverride.flow.value) {
|
||||
is UpdateStatus.Available -> "Available"
|
||||
is UpdateStatus.Downloaded -> "Downloaded"
|
||||
else -> "off"
|
||||
}
|
||||
Toast.makeText(context, "Update banner preview: $state", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
TestHarnessRow(
|
||||
title = "Show What's New",
|
||||
subtitle = "Open the What's New dialog now",
|
||||
icon = Icons.Filled.Science,
|
||||
onClick = {
|
||||
connectionViewModel.showWhatsNewNow()
|
||||
onBack()
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
TestHarnessRow(
|
||||
title = "Force a test crash",
|
||||
subtitle = "Throws an uncaught exception — the crash report shows on next launch",
|
||||
icon = Icons.Filled.Warning,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = {
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
throw RuntimeException("Test crash from Developer options")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,3 +588,30 @@ fun DeveloperSettingsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestHarnessRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
onClick: () -> Unit,
|
||||
tint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.tertiary,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(imageVector = icon, contentDescription = title, tint = tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -14,11 +14,13 @@ 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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -37,21 +39,26 @@ import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
// === END PHASE3-safety-rails ===
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.NewReleases
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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
|
||||
import androidx.compose.material3.Switch
|
||||
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
|
||||
@@ -61,19 +68,23 @@ import androidx.compose.runtime.remember
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
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
|
||||
@@ -129,6 +140,7 @@ fun SettingsScreen(
|
||||
onNavigateToMediaSettings: () -> Unit,
|
||||
onNavigateToAppearanceSettings: () -> Unit,
|
||||
onNavigateToAnalytics: () -> Unit,
|
||||
onNavigateToDiagnostics: () -> Unit,
|
||||
onNavigateToVoiceSettings: () -> Unit,
|
||||
onNavigateToNotificationCompanion: () -> Unit,
|
||||
onNavigateToPermissions: () -> Unit,
|
||||
@@ -257,8 +269,16 @@ 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) }
|
||||
val diagnosticsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
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) }
|
||||
|
||||
// Profile lock state — this card/dialog is the ONE surface that always
|
||||
// lists every profile, so it does NOT gate on isProfileLocked.
|
||||
val isProfileLocked by connectionViewModel.isProfileLocked.collectAsState()
|
||||
val lockedProfileName by connectionViewModel.lockedProfileName.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -333,6 +353,28 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
// ── Profile lock ───────────────────────────────────────────
|
||||
// Pin the app to ONE profile. When locked, the profile pickers
|
||||
// elsewhere collapse to a single locked row; this card's dialog
|
||||
// is the only surface that still lists every profile.
|
||||
val lockedDisplayName: String? = when {
|
||||
!isProfileLocked -> null
|
||||
lockedProfileName == null ||
|
||||
AgentDisplay.isServerDefaultAlias(lockedProfileName) ||
|
||||
lockedProfileName == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY ->
|
||||
"Server default"
|
||||
else ->
|
||||
agentProfiles
|
||||
.firstOrNull { it.name == lockedProfileName }
|
||||
?.let { AgentDisplay.profileDisplayName(it) }
|
||||
?: lockedProfileName!!.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
ProfileLockCard(
|
||||
lockedDisplayName = lockedDisplayName,
|
||||
onClick = { showProfileLockDialog = true },
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
// (The "Active Connection quick-look card" that used to live
|
||||
// here — showing API / Relay / Session status rows with a
|
||||
// clickable shortcut into a separate singular-connection
|
||||
@@ -475,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,
|
||||
)
|
||||
|
||||
@@ -490,6 +532,14 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.NewReleases,
|
||||
title = "What's New",
|
||||
subtitle = "Release notes and full changelog",
|
||||
onClick = { showChangelog = true },
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Info,
|
||||
title = "About",
|
||||
@@ -517,32 +567,28 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showDiagnosticsSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showDiagnosticsSheet = false },
|
||||
sheetState = diagnosticsSheetState,
|
||||
if (showProfileLockDialog) {
|
||||
ProfileLockDialog(
|
||||
profiles = agentProfiles,
|
||||
isLocked = isProfileLocked,
|
||||
lockedProfileName = lockedProfileName,
|
||||
onLock = { profile -> connectionViewModel.lockProfile(profile) },
|
||||
onUnlock = { connectionViewModel.unlockProfile() },
|
||||
onDismiss = { showProfileLockDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
// Full-screen changelog. Hosted as a self-contained Dialog (no nav route)
|
||||
// so it stacks over Settings and dismisses back here — mirroring the
|
||||
// showAgentSheet inline-surface pattern above. (Diagnostics moved to its
|
||||
// own nav route — see Screen.Diagnostics.)
|
||||
if (showChangelog) {
|
||||
Dialog(
|
||||
onDismissRequest = { showChangelog = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
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,
|
||||
)
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
ChangelogScreen(onClose = { showChangelog = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -659,6 +705,247 @@ private fun ActiveAgentCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry card for the per-connection profile lock. Subtitle reflects the live
|
||||
* lock state: the locked profile's display name when pinned, or the generic
|
||||
* "Pin the app to one agent profile" prompt when unlocked. Tapping opens
|
||||
* [ProfileLockDialog].
|
||||
*/
|
||||
@Composable
|
||||
private fun ProfileLockCard(
|
||||
lockedDisplayName: String?,
|
||||
onClick: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
isDarkTheme = isDarkTheme,
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = if (lockedDisplayName != null) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Profile lock",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = lockedDisplayName?.let { "Locked to $it" }
|
||||
?: "Pin the app to one agent profile",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one surface that ALWAYS lists every profile (it never gates on the lock
|
||||
* state — it's how the user picks the target or unlocks). A master "Lock to a
|
||||
* profile" toggle reveals a radio list of "Server default" + every advertised
|
||||
* profile. When the stored lock target isn't present in the list, a banner
|
||||
* names the missing profile with an inline Unlock affordance.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProfileLockDialog(
|
||||
profiles: List<Profile>,
|
||||
isLocked: Boolean,
|
||||
lockedProfileName: String?,
|
||||
onLock: (Profile?) -> Unit,
|
||||
onUnlock: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Selectable rows: a synthetic "Server default" sentinel + the advertised
|
||||
// profiles, minus the synthetic "default" alias (folded into Server default).
|
||||
val selectableProfiles = profiles.filterNot { AgentDisplay.isServerDefaultAlias(it.name) }
|
||||
|
||||
// Is the stored lock target Server default (sentinel / "default" alias / null)?
|
||||
val lockedIsServerDefault = lockedProfileName == null ||
|
||||
AgentDisplay.isServerDefaultAlias(lockedProfileName) ||
|
||||
lockedProfileName == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
val lockedProfile = if (lockedIsServerDefault) {
|
||||
null
|
||||
} else {
|
||||
selectableProfiles.firstOrNull { it.name == lockedProfileName }
|
||||
}
|
||||
// Locked to a named profile the server no longer advertises.
|
||||
val lockedProfileMissing = isLocked && !lockedIsServerDefault && lockedProfile == null
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Profile lock") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 420.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Pin the app to one agent profile. While locked, the " +
|
||||
"profile pickers elsewhere collapse to a single locked row.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (lockedProfileMissing) {
|
||||
Surface(
|
||||
color = RelayRefresh.Amber.copy(alpha = 0.15f),
|
||||
contentColor = RelayRefresh.Amber,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, RelayRefresh.Amber.copy(alpha = 0.5f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Locked profile '" +
|
||||
(lockedProfileName ?: "") +
|
||||
"' not found on this server.",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
TextButton(
|
||||
onClick = onUnlock,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text("Unlock")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Master toggle. Off = unlocked; flipping on locks to the
|
||||
// current effective target (Server default by default, or the
|
||||
// already-stored target when it still resolves).
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Lock to a profile",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = isLocked,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
// Lock to the existing target if it still
|
||||
// resolves, else Server default.
|
||||
onLock(lockedProfile)
|
||||
} else {
|
||||
onUnlock()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLocked) {
|
||||
HorizontalDivider()
|
||||
// Server default option.
|
||||
ProfileLockOptionRow(
|
||||
label = "Server default",
|
||||
secondary = "Use this connection's default profile",
|
||||
selected = lockedIsServerDefault,
|
||||
onSelect = { onLock(null) },
|
||||
)
|
||||
selectableProfiles.forEach { profile ->
|
||||
ProfileLockOptionRow(
|
||||
label = AgentDisplay.profileDisplayName(profile)
|
||||
?: profile.name.replaceFirstChar { it.uppercase() },
|
||||
secondary = profile.model.takeIf { it.isNotBlank() },
|
||||
selected = !lockedIsServerDefault &&
|
||||
lockedProfileName == profile.name,
|
||||
onSelect = { onLock(profile) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Done") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileLockOptionRow(
|
||||
label: String,
|
||||
secondary: String?,
|
||||
selected: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.selectable(
|
||||
selected = selected,
|
||||
role = Role.RadioButton,
|
||||
onClick = onSelect,
|
||||
)
|
||||
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (secondary != null) {
|
||||
Text(
|
||||
text = secondary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class SettingsStatusPillModel(
|
||||
val label: String,
|
||||
val tone: SettingsStatusTone = SettingsStatusTone.Neutral,
|
||||
|
||||
@@ -57,6 +57,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
@@ -117,6 +118,12 @@ fun VoiceSettingsScreen(
|
||||
*/
|
||||
standardVoiceSignInRouteHint: String? = null,
|
||||
relayVoiceReady: Boolean = false,
|
||||
/**
|
||||
* Active connection id used to namespace per-profile voice prefs so two
|
||||
* connections that expose a same-named profile don't share voice picks.
|
||||
* Passed from RelayApp; null degrades to profile-only namespacing.
|
||||
*/
|
||||
connectionId: String? = null,
|
||||
onOpenManage: (() -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
settingsViewModel: VoiceSettingsViewModel = viewModel(),
|
||||
@@ -141,13 +148,12 @@ fun VoiceSettingsScreen(
|
||||
|
||||
// WP-V2/V3: point the screen's prefs repo at the active (connection,
|
||||
// profile) scope so the per-profile engine/route/enhanced toggles read and
|
||||
// write the SAME namespaced keys VoiceViewModel seeds from. RelayApp never
|
||||
// wires a connection id into the voice-prefs scope today (the VM mirrors
|
||||
// ProfileSelectionStore profile-only keying), so we pass a null connection
|
||||
// id and the normalized profile name — matching VoiceViewModel exactly.
|
||||
LaunchedEffect(selectedProfile?.name) {
|
||||
// write the SAME namespaced keys VoiceViewModel seeds from. The connection
|
||||
// id (when supplied by RelayApp) disambiguates two connections that expose
|
||||
// a same-named profile; the normalized profile name matches VoiceViewModel.
|
||||
LaunchedEffect(connectionId, selectedProfile?.name) {
|
||||
prefsRepo.setActiveScope(
|
||||
connectionId = null,
|
||||
connectionId = connectionId,
|
||||
profileName = AgentDisplay.profileRequestName(selectedProfile?.name),
|
||||
)
|
||||
}
|
||||
@@ -415,6 +421,26 @@ private fun VoiceForThisProfileCard(
|
||||
onOpenManage: (() -> Unit)?,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Auto-repair when Relay disappears out from under a Relay-only selection:
|
||||
// a persisted RealtimeAgent engine (Relay-only) or Relay route can't run
|
||||
// without a paired Relay, so fall back to the always-available defaults.
|
||||
LaunchedEffect(relayVoiceReady, currentEngine, currentAudioRoute) {
|
||||
if (!relayVoiceReady) {
|
||||
if (currentEngine == VoiceEngineMode.RealtimeAgent) {
|
||||
prefsRepo.setEngineMode(VoiceEngineMode.HermesVoiceOutput)
|
||||
}
|
||||
val coerced = coerceAudioRoute(
|
||||
engine = VoiceEngineMode.HermesVoiceOutput,
|
||||
route = currentAudioRoute,
|
||||
relayVoiceReady = false,
|
||||
)
|
||||
if (coerced != currentAudioRoute) {
|
||||
prefsRepo.setAudioRoute(coerced)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "Voice for this profile") {
|
||||
Text(
|
||||
text = "Voice engine",
|
||||
@@ -434,13 +460,33 @@ private fun VoiceForThisProfileCard(
|
||||
),
|
||||
).forEach { (engine, copy) ->
|
||||
val (label, detail, experimental) = copy
|
||||
// RealtimeAgent requires a paired Relay; HermesVoiceOutput is always
|
||||
// selectable. The existing warning row below explains the disabled
|
||||
// RealtimeAgent radio.
|
||||
val engineEnabled = engine == VoiceEngineMode.HermesVoiceOutput || relayVoiceReady
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = currentEngine == engine,
|
||||
enabled = engineEnabled,
|
||||
onClick = {
|
||||
scope.launch { prefsRepo.setEngineMode(engine) }
|
||||
scope.launch {
|
||||
prefsRepo.setEngineMode(engine)
|
||||
// Switching to HermesVoiceOutput may leave a now
|
||||
// invalid persisted route (e.g. Relay while
|
||||
// unpaired) — coerce it to a reachable one.
|
||||
if (engine == VoiceEngineMode.HermesVoiceOutput) {
|
||||
val coerced = coerceAudioRoute(
|
||||
engine = engine,
|
||||
route = currentAudioRoute,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
)
|
||||
if (coerced != currentAudioRoute) {
|
||||
prefsRepo.setAudioRoute(coerced)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.padding(vertical = 6.dp),
|
||||
@@ -449,6 +495,7 @@ private fun VoiceForThisProfileCard(
|
||||
RadioButton(
|
||||
selected = currentEngine == engine,
|
||||
onClick = null,
|
||||
enabled = engineEnabled,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
@@ -456,7 +503,15 @@ private fun VoiceForThisProfileCard(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (engineEnabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
)
|
||||
if (experimental) ExperimentalBadge("Experimental")
|
||||
}
|
||||
Text(
|
||||
@@ -507,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,
|
||||
),
|
||||
@@ -535,11 +590,16 @@ private fun VoiceForThisProfileCard(
|
||||
badge = "Optional",
|
||||
),
|
||||
).forEach { option ->
|
||||
// Auto always stays selectable (it self-resolves to whatever's
|
||||
// reachable). Standard/Relay are only selectable when their live
|
||||
// availability probe says so — otherwise the radio is dimmed.
|
||||
val routeEnabled = option.route == VoiceAudioRoute.Auto || option.statusOk
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = currentAudioRoute == option.route,
|
||||
enabled = routeEnabled,
|
||||
onClick = {
|
||||
scope.launch { prefsRepo.setAudioRoute(option.route) }
|
||||
},
|
||||
@@ -550,6 +610,7 @@ private fun VoiceForThisProfileCard(
|
||||
RadioButton(
|
||||
selected = currentAudioRoute == option.route,
|
||||
onClick = null,
|
||||
enabled = routeEnabled,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
@@ -557,7 +618,15 @@ private fun VoiceForThisProfileCard(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(option.label, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
option.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (routeEnabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
)
|
||||
option.badge?.let { ExperimentalBadge(it) }
|
||||
}
|
||||
Text(
|
||||
@@ -2035,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))
|
||||
@@ -2286,6 +2355,29 @@ private data class RouteOption(
|
||||
val badge: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pure coercion of a persisted [route] to a reachable one for the given
|
||||
* [engine] / [relayVoiceReady] combination. Keeps the engine/route radios from
|
||||
* leaving a stale invalid selection persisted (e.g. a Relay route after Relay
|
||||
* was unpaired). Unit-testable; the composable just applies the result.
|
||||
*
|
||||
* - Engine == RealtimeAgent requires a paired Relay; this helper only governs
|
||||
* the audio route, so when relay isn't ready the route is forced to [Auto]
|
||||
* (the caller separately forces the engine back to HermesVoiceOutput).
|
||||
* - [VoiceAudioRoute.Relay] is only valid when [relayVoiceReady].
|
||||
* - [VoiceAudioRoute.Auto] is always valid (it self-resolves at runtime).
|
||||
* - [VoiceAudioRoute.Standard] is left as-is — its reachability is a live
|
||||
* dashboard probe the UI dims via `statusOk`, not something we can know here.
|
||||
*/
|
||||
internal fun coerceAudioRoute(
|
||||
engine: VoiceEngineMode,
|
||||
route: VoiceAudioRoute,
|
||||
relayVoiceReady: Boolean,
|
||||
): VoiceAudioRoute = when {
|
||||
route == VoiceAudioRoute.Relay && !relayVoiceReady -> VoiceAudioRoute.Auto
|
||||
else -> route
|
||||
}
|
||||
|
||||
private data class VoiceChoice(
|
||||
val value: String,
|
||||
val label: String = value,
|
||||
@@ -2687,12 +2779,18 @@ private fun VoiceChoiceDropdown(
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(choice.label)
|
||||
Text(
|
||||
choice.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
choice.detail?.takeIf { it.isNotBlank() }?.let { detail ->
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2809,6 +2907,8 @@ private fun ProviderRow(label: String, value: String) {
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(0.62f),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import android.app.Activity
|
||||
|
||||
/**
|
||||
* Flavor-agnostic "is there a newer version?" abstraction.
|
||||
*
|
||||
* Two implementations exist, one per product flavor, each exporting a
|
||||
* [createUpdateAvailabilitySource] factory with the identical signature +
|
||||
* package (mirroring `voice/VoiceBridgeIntentFactory`'s
|
||||
* `createVoiceBridgeIntentHandler` pattern):
|
||||
*
|
||||
* - **googlePlay** — backs onto Google Play's In-App Update API
|
||||
* (`AppUpdateManager`), FLEXIBLE flow. [check] reports
|
||||
* [UpdateStatus.Available] when Play has a newer build; [startUpdate]
|
||||
* kicks off the in-app download dialog; an `InstallStateUpdatedListener`
|
||||
* flips the state to [UpdateStatus.Downloaded] once the APK is staged, at
|
||||
* which point the banner offers "Restart to finish" → [completeUpdate].
|
||||
*
|
||||
* - **sideload** — wraps the existing GitHub-releases [UpdateChecker]. There
|
||||
* is no in-app download/install on this track, so [startUpdate] opens the
|
||||
* APK/release URL in the browser and the status never reaches
|
||||
* [UpdateStatus.Downloaded].
|
||||
*
|
||||
* The shared `UpdateAvailableBanner` + `rememberUpdateAvailability` entry
|
||||
* point in the UI layer drive both through this one interface.
|
||||
*
|
||||
* Threading: [check] suspends and is expected to run its own IO hop
|
||||
* internally; callers may invoke it from any dispatcher.
|
||||
*/
|
||||
interface UpdateAvailabilitySource {
|
||||
|
||||
/**
|
||||
* Probe for an update. Returns the current [UpdateStatus]. Implementations
|
||||
* MUST swallow their own transport/availability failures and degrade to
|
||||
* [UpdateStatus.UpToDate] (or [UpdateStatus.Unsupported]) rather than
|
||||
* throwing — a flaky network or a Play-less device must never crash the
|
||||
* caller. A transient error maps to [UpdateStatus.UpToDate] so the banner
|
||||
* simply stays hidden until the next check.
|
||||
*/
|
||||
suspend fun check(): UpdateStatus
|
||||
|
||||
/**
|
||||
* Begin the update.
|
||||
*
|
||||
* - googlePlay: launches the Play FLEXIBLE consent + background-download
|
||||
* flow. Needs a foreground [activity] to host Play's dialog. Returns
|
||||
* `true` if the flow was started (or already running), `false` if it
|
||||
* could not be launched (no activity / Play unavailable).
|
||||
* - sideload: opens the APK or release page in the browser. [activity]
|
||||
* may be null; returns `true` if an intent was dispatched.
|
||||
*
|
||||
* Safe to call repeatedly — implementations no-op if a flow is already in
|
||||
* flight.
|
||||
*/
|
||||
fun startUpdate(activity: Activity?): Boolean
|
||||
|
||||
/**
|
||||
* Finish a FLEXIBLE update that has finished downloading (state is
|
||||
* [UpdateStatus.Downloaded]). googlePlay calls `AppUpdateManager.
|
||||
* completeUpdate()` which restarts the app to swap in the new APK.
|
||||
* sideload is a no-op (its install is handled by the system installer
|
||||
* after the browser download).
|
||||
*/
|
||||
fun completeUpdate()
|
||||
|
||||
/**
|
||||
* Optional hook for the host to learn about asynchronous status changes
|
||||
* that happen *outside* a [check] — specifically the Play FLEXIBLE
|
||||
* download completing while the user is in the app. The googlePlay impl
|
||||
* pushes [UpdateStatus.Downloaded] (and download progress as
|
||||
* [UpdateStatus.Downloading]) here via its install-state listener; the
|
||||
* sideload impl never invokes it. Set to null to detach.
|
||||
*/
|
||||
var onStatusChanged: ((UpdateStatus) -> Unit)?
|
||||
|
||||
/**
|
||||
* Release any registered listeners / resources. The host calls this from
|
||||
* a Compose `DisposableEffect` `onDispose`. Idempotent.
|
||||
*/
|
||||
fun dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Flavor-agnostic update availability state.
|
||||
*
|
||||
* `versionLabel` is a human-readable string for the banner ("1.3.0" /
|
||||
* "android-v1.3.0"); `versionCode` is the numeric Play versionCode when known
|
||||
* (googlePlay) and null on sideload (GitHub releases are tracked by version
|
||||
* string, not code). [Available] also carries an opaque [openUrl] the sideload
|
||||
* impl uses to route `startUpdate` to the browser; googlePlay leaves it null.
|
||||
*/
|
||||
sealed class UpdateStatus {
|
||||
|
||||
/** No newer version, Play/GitHub unreachable-but-degraded, or not yet checked. */
|
||||
data object UpToDate : UpdateStatus()
|
||||
|
||||
/** This flavor/device can't surface an update at all (e.g. Play services absent). */
|
||||
data object Unsupported : UpdateStatus()
|
||||
|
||||
/** A newer version exists and the user can start the update. */
|
||||
data class Available(
|
||||
val versionLabel: String,
|
||||
val versionCode: Long? = null,
|
||||
/** Browser fallback target for sideload (APK asset or release page). Null on Play. */
|
||||
val openUrl: String? = null,
|
||||
) : UpdateStatus()
|
||||
|
||||
/**
|
||||
* googlePlay FLEXIBLE download in progress. [bytesDownloaded] /
|
||||
* [totalBytes] may be 0 before Play reports sizes; the banner shows an
|
||||
* indeterminate bar until [totalBytes] is positive.
|
||||
*/
|
||||
data class Downloading(
|
||||
val versionLabel: String,
|
||||
val versionCode: Long? = null,
|
||||
val bytesDownloaded: Long = 0,
|
||||
val totalBytes: Long = 0,
|
||||
) : UpdateStatus()
|
||||
|
||||
/**
|
||||
* googlePlay FLEXIBLE update finished downloading and is staged; calling
|
||||
* [UpdateAvailabilitySource.completeUpdate] restarts the app to install.
|
||||
*/
|
||||
data class Downloaded(
|
||||
val versionLabel: String,
|
||||
val versionCode: Long? = null,
|
||||
) : UpdateStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* The dismissal-relevant identity of an available update — the value the
|
||||
* per-version dismiss preference keys on. Play builds key on the numeric
|
||||
* versionCode (monotonic, unambiguous); sideload keys on the version string.
|
||||
* A *newer* identity than the dismissed one re-shows the banner (see
|
||||
* [UpdateDismissalPreferences]).
|
||||
*/
|
||||
val UpdateStatus.dismissKey: String?
|
||||
get() = when (this) {
|
||||
is UpdateStatus.Available -> versionCode?.toString() ?: versionLabel
|
||||
is UpdateStatus.Downloading -> versionCode?.toString() ?: versionLabel
|
||||
is UpdateStatus.Downloaded -> versionCode?.toString() ?: versionLabel
|
||||
UpdateStatus.UpToDate, UpdateStatus.Unsupported -> null
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Per-version dismissal + check-throttle state for the unified
|
||||
* (flavor-agnostic) update banner.
|
||||
*
|
||||
* Deliberately a NEW store, separate from the legacy sideload-only
|
||||
* [UpdatePreferences] (which keys dismissal by version *string* only and is
|
||||
* still consumed by the existing `UpdateViewModel` + About screen). This one
|
||||
* keys by the abstract [UpdateStatus.dismissKey]:
|
||||
* - googlePlay → numeric versionCode (monotonic, compared as Long)
|
||||
* - sideload → version string (compared with [compareVersions])
|
||||
*
|
||||
* Dismissal is **per-version, not a forever mute**: the banner reappears as
|
||||
* soon as a *strictly newer* version than the dismissed one is offered. See
|
||||
* [isDismissed].
|
||||
*
|
||||
* `lastCheckAtMs` throttles automatic checks so we don't hit Play / GitHub on
|
||||
* every cold start and resume.
|
||||
*/
|
||||
private val Context.updateBannerPrefsStore by
|
||||
preferencesDataStore(name = "hermes_relay_update_banner")
|
||||
|
||||
object UpdateDismissalPreferences {
|
||||
private val KEY_DISMISSED = stringPreferencesKey("dismissed_update_key")
|
||||
private val KEY_LAST_CHECK = longPreferencesKey("last_check_at_ms")
|
||||
|
||||
fun dismissedKey(context: Context): Flow<String?> =
|
||||
context.updateBannerPrefsStore.data.map { prefs: Preferences ->
|
||||
prefs[KEY_DISMISSED]
|
||||
}
|
||||
|
||||
fun lastCheckAtMs(context: Context): Flow<Long> =
|
||||
context.updateBannerPrefsStore.data.map { prefs: Preferences ->
|
||||
prefs[KEY_LAST_CHECK] ?: 0L
|
||||
}
|
||||
|
||||
suspend fun dismiss(context: Context, dismissKey: String) {
|
||||
context.updateBannerPrefsStore.edit { it[KEY_DISMISSED] = dismissKey }
|
||||
}
|
||||
|
||||
suspend fun markChecked(context: Context, atMs: Long = System.currentTimeMillis()) {
|
||||
context.updateBannerPrefsStore.edit { it[KEY_LAST_CHECK] = atMs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [available]'s dismiss key has already been dismissed.
|
||||
*
|
||||
* Returns `true` only when [dismissed] is non-null AND [available] is NOT
|
||||
* strictly newer than it. A newer version always re-shows (returns
|
||||
* `false`), which is the whole point of per-version dismissal.
|
||||
*
|
||||
* Pure function (no I/O) so it's unit-testable without DataStore. Both
|
||||
* keys are compared numerically when both parse as Longs (Play
|
||||
* versionCodes), otherwise via the loose [compareVersions] semver
|
||||
* comparator (sideload version strings). A mixed/unparseable pair falls
|
||||
* back to exact-string equality — conservative: only the exact dismissed
|
||||
* key stays hidden.
|
||||
*/
|
||||
fun isDismissed(available: UpdateStatus, dismissed: String?): Boolean {
|
||||
val candidate = available.dismissKey ?: return false
|
||||
if (dismissed.isNullOrBlank()) return false
|
||||
return !isStrictlyNewer(candidate, dismissed)
|
||||
}
|
||||
|
||||
/** True if [candidate] represents a strictly newer version than [reference]. */
|
||||
internal fun isStrictlyNewer(candidate: String, reference: String): Boolean {
|
||||
val c = candidate.toLongOrNull()
|
||||
val r = reference.toLongOrNull()
|
||||
if (c != null && r != null) return c > r
|
||||
// Fall back to semver string compare; if neither parses cleanly that
|
||||
// comparator still yields 0 for equal strings, so exact dupes stay
|
||||
// dismissed and any lexical/semver delta re-shows.
|
||||
return compareVersions(reference, candidate) < 0
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.net.URLEncoder
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
@@ -28,9 +27,13 @@ import kotlin.system.exitProcess
|
||||
* records the crash. We only observe; we never swallow.
|
||||
*
|
||||
* On the next launch [CrashReportGate] reads the pending report and offers the
|
||||
* user a clean copy / "report on GitHub" flow (see [CrashReportDialog]). The
|
||||
* GitHub path pre-fills our `bug_report.yml` issue form so a one-star "it keeps
|
||||
* crashing" review can become an actionable issue with a stack trace attached.
|
||||
* user three GitHub-free-friendly actions (see [CrashReportDialog]): copy the
|
||||
* full report, **share** it via the system sheet (email / chat / notes — the
|
||||
* path for users without a GitHub account and for sideload installs Play vitals
|
||||
* never sees), or open a pre-filled `bug_report.yml` issue. The GitHub path
|
||||
* turns a one-star "it keeps crashing" review into an actionable issue with a
|
||||
* stack trace attached; share/copy cover everyone else. Every outbound path is
|
||||
* user-initiated — nothing is transmitted automatically.
|
||||
*/
|
||||
object CrashReporter {
|
||||
|
||||
@@ -38,10 +41,6 @@ object CrashReporter {
|
||||
private const val DIR = "crash"
|
||||
private const val FILE = "last-crash.json"
|
||||
|
||||
/** Public issue tracker — keep in sync with the git remote. */
|
||||
private const val GITHUB_NEW_ISSUE =
|
||||
"https://github.com/Codename-11/hermes-relay/issues/new"
|
||||
|
||||
/**
|
||||
* Cap the stack trace we inline into the GitHub URL. Browsers + GitHub
|
||||
* truncate very long URLs, so we ship the head of the trace in the form and
|
||||
@@ -140,17 +139,11 @@ object CrashReporter {
|
||||
* issue. The body mirrors `bug_report.yml`'s sections in markdown so triage
|
||||
* structure is preserved without depending on the preview path.
|
||||
*/
|
||||
fun buildGithubIssueUrl(report: CrashReport): String {
|
||||
// LinkedHashMap preserves a stable, readable param order.
|
||||
val params = linkedMapOf(
|
||||
"title" to "[Bug]: Crash — ${report.shortTitle()}",
|
||||
"labels" to "bug",
|
||||
"body" to buildIssueBody(report),
|
||||
)
|
||||
return GITHUB_NEW_ISSUE + "?" + params.entries.joinToString("&") { (key, value) ->
|
||||
"$key=" + URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
}
|
||||
}
|
||||
fun buildGithubIssueUrl(report: CrashReport): String = IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: Crash — ${report.shortTitle()}",
|
||||
bodyMarkdown = buildIssueBody(report),
|
||||
labels = "bug",
|
||||
)
|
||||
|
||||
private fun buildIssueBody(report: CrashReport): String {
|
||||
val trace = report.stackTrace.let {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* One implementation of the three user-initiated outbound paths shared by the
|
||||
* crash reporter and the diagnostics detail view: build a pre-filled GitHub
|
||||
* "new issue" URL, hand text to the system share sheet, and copy text to the
|
||||
* clipboard.
|
||||
*
|
||||
* Nothing here transmits automatically — every path is triggered by an explicit
|
||||
* user tap, and the share/clipboard paths never leave the device until the user
|
||||
* chooses a destination. Both [CrashReporter]/[com.hermesandroid.relay.ui.components.CrashReportGate]
|
||||
* and the diagnostics detail dialog call these so the GitHub URL shape, share
|
||||
* intent, and clipboard label stay identical across the app.
|
||||
*/
|
||||
object IssueReport {
|
||||
|
||||
/** Public issue tracker — keep in sync with the git remote. */
|
||||
private const val GITHUB_NEW_ISSUE =
|
||||
"https://github.com/Codename-11/hermes-relay/issues/new"
|
||||
|
||||
/** Clipboard label used for every report copy in the app. */
|
||||
const val CLIP_LABEL = "Hermes-Relay report"
|
||||
|
||||
/**
|
||||
* Build a pre-filled GitHub "new issue" URL from a generic title/body/labels
|
||||
* triple.
|
||||
*
|
||||
* Uses the **stable** classic `title` + `body` + `labels` query params, NOT
|
||||
* issue-form field-`id` prefilling (`template=...&<id>=...`). The latter is a
|
||||
* GitHub public-preview feature that was observed to silently not apply (only
|
||||
* `title` carried), which is unacceptable for a reporter that fires on devices
|
||||
* we can't retry from. `blank_issues_enabled: true` in
|
||||
* `.github/ISSUE_TEMPLATE/config.yml` guarantees `?body=` opens a prefilled
|
||||
* issue.
|
||||
*
|
||||
* @param labels comma-separated GitHub labels (e.g. "bug"); omitted from the
|
||||
* query when blank.
|
||||
*/
|
||||
fun buildGithubIssueUrl(
|
||||
title: String,
|
||||
bodyMarkdown: String,
|
||||
labels: String = "bug",
|
||||
): String {
|
||||
// LinkedHashMap preserves a stable, readable param order.
|
||||
val params = linkedMapOf("title" to title)
|
||||
if (labels.isNotBlank()) params["labels"] = labels
|
||||
params["body"] = bodyMarkdown
|
||||
return GITHUB_NEW_ISSUE + "?" + params.entries.joinToString("&") { (key, value) ->
|
||||
"$key=" + URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy [text] to the system clipboard under the shared report label. */
|
||||
fun copyToClipboard(context: Context, text: String, label: String = CLIP_LABEL) {
|
||||
runCatching {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer [text] to the system share sheet (email, chat, notes, Drive…). The
|
||||
* user picks the destination, so nothing leaves the device until they choose
|
||||
* to send it — same privacy posture as [copyToClipboard]. Returns false if no
|
||||
* app can handle a plain-text share so callers can fall back to clipboard.
|
||||
*/
|
||||
fun share(context: Context, subject: String, text: String, chooserTitle: String = "Share report"): Boolean =
|
||||
runCatching {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(send, chooserTitle).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** Open [url] in the user's browser. Returns false if nothing can handle it. */
|
||||
fun openUrl(context: Context, url: String): Boolean = runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
@@ -133,14 +135,51 @@ private fun classifyIoMessage(msg: String, context: String?): HumanError? {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the caller's [classifyError] context tag to a diagnostics category so the
|
||||
* recorded error lands under the right surface in the activity log. Defaults to
|
||||
* [DiagnosticCategory.Api] for unknown/null contexts.
|
||||
*/
|
||||
private fun categoryForContext(context: String?): DiagnosticCategory = when (context) {
|
||||
"transcribe", "synthesize", "voice_config", "record" -> DiagnosticCategory.Voice
|
||||
"pair" -> DiagnosticCategory.Auth
|
||||
"save_and_test", "media_fetch" -> DiagnosticCategory.Relay
|
||||
"send_message" -> DiagnosticCategory.Api
|
||||
else -> DiagnosticCategory.Api
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an arbitrary Throwable into a user-facing [HumanError].
|
||||
*
|
||||
* **Side effect:** every classified error is also recorded to [DiagnosticsLog]
|
||||
* (Error severity, clean title + full redacted stacktrace) so the diagnostics
|
||||
* activity log captures it with zero call-site churn. The return value and all
|
||||
* existing copy are unchanged. The flow is one-way — [DiagnosticsLog.recordError]
|
||||
* never re-enters the classifier — so there is no recursion. A null throwable
|
||||
* produces a fallback but is NOT recorded (nothing actually failed).
|
||||
*
|
||||
* @param context short tag that shapes the title ("transcribe", "synthesize",
|
||||
* "voice_config", "record", "pair", "save_and_test",
|
||||
* "media_fetch", "send_message", or null for generic)
|
||||
*/
|
||||
fun classifyError(t: Throwable?, context: String? = null): HumanError {
|
||||
val human = classifyErrorInternal(t, context)
|
||||
if (t != null) {
|
||||
// Record after classification so the clean title and the raw trace both
|
||||
// reach the log. Defensive: never let logging turn a handled error fatal.
|
||||
runCatching {
|
||||
DiagnosticsLog.recordError(
|
||||
category = categoryForContext(context),
|
||||
title = human.title,
|
||||
detail = human.body,
|
||||
throwable = t,
|
||||
)
|
||||
}
|
||||
}
|
||||
return human
|
||||
}
|
||||
|
||||
private fun classifyErrorInternal(t: Throwable?, context: String?): HumanError {
|
||||
if (t == null) return nullFallback(context)
|
||||
|
||||
val msg = t.message.orEmpty().lowercase()
|
||||
|
||||
@@ -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)
|
||||
@@ -1052,6 +1080,28 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
fun selectProfile(profile: Profile?) = profileController.selectProfile(profile)
|
||||
|
||||
// --- Profile lock (per-connection pin to one profile) ------------------
|
||||
//
|
||||
// When set, the profile pickers/switchers across the app collapse to a
|
||||
// single locked state; only the dedicated Settings control still lists
|
||||
// every profile (to change the lock target or unlock). `lockedProfileName`
|
||||
// is the raw stored token (the SERVER_DEFAULT_PROFILE_KEY sentinel means
|
||||
// "locked to Server default"); `isProfileLocked` is the convenience boolean.
|
||||
|
||||
val lockedProfileName: StateFlow<String?> get() = profileController.lockedProfileName
|
||||
|
||||
val isProfileLocked: StateFlow<Boolean> get() = profileController.isProfileLocked
|
||||
|
||||
/** Lock the active connection to [profile] (null = Server default). */
|
||||
fun lockProfile(profile: Profile?) {
|
||||
viewModelScope.launch { profileController.lockProfile(profile) }
|
||||
}
|
||||
|
||||
/** Remove the active connection's profile lock. */
|
||||
fun unlockProfile() {
|
||||
viewModelScope.launch { profileController.unlockProfile() }
|
||||
}
|
||||
|
||||
// --- Paired devices list (GET /sessions) -------------------------------
|
||||
//
|
||||
// Loaded on-demand from PairedDevicesScreen. Owned by [pairingController];
|
||||
@@ -1714,9 +1764,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
connectionHandoffClearJob = viewModelScope.launch {
|
||||
delay(
|
||||
when {
|
||||
// Live, in-progress handoff: keep the spinner up as a backstop
|
||||
// until it resolves to success/error (which then clears fast).
|
||||
active -> 30_000L
|
||||
// Resolved states auto-dismiss within 5s — anything longer
|
||||
// reads as a stuck overlay.
|
||||
success -> 5_000L
|
||||
else -> 12_000L
|
||||
else -> 5_000L
|
||||
}
|
||||
)
|
||||
if (_connectionHandoffStatus.value?.updatedAtMs == now) {
|
||||
@@ -2360,6 +2414,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// ProfileSelectionStore is a separate DataStore file from
|
||||
// ConnectionStore's EncryptedSharedPrefs.
|
||||
profileController.profileSelectionStore.clear(connectionId)
|
||||
profileController.profileLockStore.clear(connectionId)
|
||||
profileController.profileSessionStore.clearConnection(connectionId)
|
||||
profileController.profileDisplayAliasStore.clearConnection(connectionId)
|
||||
profileController.profileIconStore.clearConnection(connectionId)
|
||||
@@ -2760,6 +2815,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
)
|
||||
connectionStore.removeConnection(duplicate.id)
|
||||
profileController.profileSelectionStore.clear(duplicate.id)
|
||||
profileController.profileLockStore.clear(duplicate.id)
|
||||
profileController.profileSessionStore.clearConnection(duplicate.id)
|
||||
}
|
||||
|
||||
@@ -3211,6 +3267,26 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve when the per-connection profile lock changes — locking,
|
||||
// unlocking, and the lock flow repointing after a connection switch all
|
||||
// funnel through here so the active profile always reflects the lock
|
||||
// target (or holds null + a banner when it's missing). resolvePending
|
||||
// is lock-aware, so on unlock it falls back to the persisted selection.
|
||||
viewModelScope.launch {
|
||||
profileController.lockedProfileName.collect {
|
||||
if (profileController.resolvePendingProfileFrom(
|
||||
profileController.agentProfiles.value,
|
||||
)
|
||||
) {
|
||||
profileController.refreshLastSessionForProfile(
|
||||
activeConnectionId.value,
|
||||
profileController.selectedProfile.value?.name,
|
||||
)
|
||||
rebuildChatApiClient()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cold-start restore timing: the gateway probe is async, so the first
|
||||
// refreshLastSessionForProfile at connection-activate can run while
|
||||
// availability is still Unknown — [activeSessionTransport] defers, leaving
|
||||
@@ -4520,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
|
||||
@@ -4960,6 +5036,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
// --- What's New + Version tracking ---
|
||||
|
||||
/** Dev/test hook (Developer options → Test harness): show What's New now. */
|
||||
fun showWhatsNewNow() {
|
||||
_showWhatsNew.value = true
|
||||
}
|
||||
|
||||
fun dismissWhatsNew() {
|
||||
_showWhatsNew.value = false
|
||||
viewModelScope.launch {
|
||||
@@ -5157,6 +5238,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
authManager.clearApiKey()
|
||||
dataManager.resetAppData()
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
_relayUrl.value = ""
|
||||
@@ -5284,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
|
||||
|
||||
@@ -339,6 +339,18 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private const val AUDIO_COMPLETION_MAX_SLEEP_MS = 750L
|
||||
private const val OUTPUT_AUDIO_ACTIVE_THRESHOLD = 0.012f
|
||||
|
||||
/**
|
||||
* W3 spoken-status throttle. On long / tool-heavy realtime runs the
|
||||
* agent emits many spoken status lines ("Searching.", "Still working.")
|
||||
* which becomes chatty. Independent of the per-key [spokenStatusKeys]
|
||||
* dedupe: this caps BOTH the spoken cadence (no two spoken status lines
|
||||
* within [MIN_SPOKEN_STATUS_GAP_MS]) and the per-turn spoken count
|
||||
* ([MAX_SPOKEN_STATUS_PER_TURN]). Suppressed lines still update the UI
|
||||
* + diagnostics — only the TTS enqueue is skipped.
|
||||
*/
|
||||
private const val MIN_SPOKEN_STATUS_GAP_MS = 22_000L
|
||||
private const val MAX_SPOKEN_STATUS_PER_TURN = 3
|
||||
|
||||
/**
|
||||
* Resume watchdog window (B4). After a hard barge-in interrupt, the
|
||||
* VoiceViewModel listens for user-speech silence for this many ms
|
||||
@@ -435,6 +447,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var responseText = StringBuilder()
|
||||
private var inputTranscript = StringBuilder()
|
||||
private val spokenStatusKeys = mutableSetOf<String>()
|
||||
// W3 spoken-status throttle (per turn). Reset alongside spokenStatusKeys at
|
||||
// every turn start/reset. See [shouldSpeakStatusNow] for the decision.
|
||||
private var lastSpokenStatusAtMs: Long = 0L
|
||||
private var spokenStatusCount: Int = 0
|
||||
private var voiceRelayPreflight: (suspend () -> Result<Unit>)? = null
|
||||
|
||||
// === PHASE3-voice-intents: voice→bridge intent routing ===
|
||||
@@ -542,6 +558,17 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var firstFrameWatchdogJob: Job? = null
|
||||
private var continuousLoopArmed: Boolean = false
|
||||
private var lastRealtimeAudioDeltaAtMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Set true when the user interrupts a realtime turn ([interruptSpeaking]).
|
||||
* The persistent realtime socket stays open by design, so audio deltas
|
||||
* already in flight can still arrive after we stop the player and would
|
||||
* re-create the AudioTrack — making "Stop" feel like it didn't work. While
|
||||
* suppressed, [handleRealtimeVoiceEvent] drops audio writes. Cleared when
|
||||
* the next turn is actually sent ([submitRealtimeTurn] / [runRealtimeAgentTurn]).
|
||||
*/
|
||||
@Volatile
|
||||
private var realtimeAudioSuppressed: Boolean = false
|
||||
private var listeningStartedAtMs: Long = 0L
|
||||
// 2026-04-18: silence-based auto-stop watchdog. Runs for the duration
|
||||
// of a Listening turn in TapToTalk / Continuous modes when the user has
|
||||
@@ -1526,6 +1553,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
"Interrupting speech pipeline",
|
||||
)
|
||||
cancelRealtimeAgentTurn("interrupt")
|
||||
// Drop realtime audio deltas still in flight on the open socket so a
|
||||
// stopped turn's tail can't re-create the player and resume playback.
|
||||
realtimeAudioSuppressed = true
|
||||
// B4: tear down the barge-in listener immediately so we don't
|
||||
// double-trigger on the ducking watchdog or emit another
|
||||
// bargeInDetected while the resume watchdog is deliberating.
|
||||
@@ -2193,6 +2223,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
persistentOpen: Boolean = false,
|
||||
) {
|
||||
providerRealtimeAgentTurnActive.set(true)
|
||||
// New turn requested → allow this response's audio through again.
|
||||
realtimeAudioSuppressed = false
|
||||
streamObserverJob?.cancel()
|
||||
streamObserverJob = null
|
||||
drainQueuedLocalTts()
|
||||
@@ -2227,6 +2259,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = StringBuilder()
|
||||
inputTranscript = StringBuilder()
|
||||
spokenStatusKeys.clear()
|
||||
lastSpokenStatusAtMs = 0L
|
||||
spokenStatusCount = 0
|
||||
rtUserText = userText
|
||||
rtConversationContext = chatVm.realtimeAgentContextMessages()
|
||||
rtAssistantMessageId = chatVm.startRealtimeAgentTurn(
|
||||
@@ -2257,6 +2291,30 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
}
|
||||
if (speak && (!audioSeen.get() || speakEvenAfterProviderAudio)) {
|
||||
// W3: per-turn throttle independent of the per-key dedupe above.
|
||||
// Suppress the TTS enqueue (UI state + diagnostics already
|
||||
// applied) when spoken status is too frequent or has hit the
|
||||
// per-turn cap, so long / tool-heavy runs don't over-narrate.
|
||||
val now = System.currentTimeMillis()
|
||||
if (!shouldSpeakStatusNow(
|
||||
now = now,
|
||||
lastSpokenAtMs = lastSpokenStatusAtMs,
|
||||
count = spokenStatusCount,
|
||||
gapMs = MIN_SPOKEN_STATUS_GAP_MS,
|
||||
maxCount = MAX_SPOKEN_STATUS_PER_TURN,
|
||||
)
|
||||
) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime status TTS suppressed (throttle) key=$key " +
|
||||
"count=$spokenStatusCount sinceLastMs=${
|
||||
if (lastSpokenStatusAtMs > 0L) now - lastSpokenStatusAtMs else -1L
|
||||
} line=$line",
|
||||
)
|
||||
return
|
||||
}
|
||||
lastSpokenStatusAtMs = now
|
||||
spokenStatusCount += 1
|
||||
val remainingProviderAudioMs = if (speakEvenAfterProviderAudio && audioSeen.get()) {
|
||||
300L
|
||||
} else {
|
||||
@@ -2265,6 +2323,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime status TTS queued key=$key speak=$speak " +
|
||||
"count=$spokenStatusCount " +
|
||||
"afterProviderAudio=${audioSeen.get()} delayMs=$remainingProviderAudioMs line=$line",
|
||||
)
|
||||
if (remainingProviderAudioMs > 0L) {
|
||||
@@ -2612,6 +2671,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
private fun submitRealtimeTurn(chatVm: ChatViewModel, inputPcm: ByteArray, inputSampleRate: Int) {
|
||||
val channel = realtimeTurnChannel ?: return
|
||||
// New turn requested → allow this response's audio through again.
|
||||
realtimeAudioSuppressed = false
|
||||
drainQueuedLocalTts()
|
||||
try { player?.stop() } catch (_: Exception) { /* ignore */ }
|
||||
firstFrameWatchdogJob?.cancel(); firstFrameWatchdogJob = null
|
||||
@@ -2623,6 +2684,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = StringBuilder()
|
||||
inputTranscript = StringBuilder()
|
||||
spokenStatusKeys.clear()
|
||||
lastSpokenStatusAtMs = 0L
|
||||
spokenStatusCount = 0
|
||||
rtUserText = ""
|
||||
rtConversationContext = chatVm.realtimeAgentContextMessages()
|
||||
rtAssistantMessageId = chatVm.startRealtimeAgentTurn(
|
||||
@@ -2930,7 +2993,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
voiceOutputAvailable != false &&
|
||||
realtimePcmPlayer != null &&
|
||||
voiceClient != null &&
|
||||
voiceAudioClient?.route == VoiceAudioRoute.Relay
|
||||
// Use the RESOLVED route: AutoVoiceAudioClient.effectiveRoute maps
|
||||
// Auto -> Relay when relay is ready, so in `auto` mode with relay
|
||||
// paired the override-capable relay path engages. Reading the raw
|
||||
// `route` would stay "Auto" and silently drop the chosen override.
|
||||
voiceAudioClient?.effectiveRoute == VoiceAudioRoute.Relay
|
||||
|
||||
private fun drainSentences() {
|
||||
while (true) {
|
||||
@@ -3129,6 +3196,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
decision.firstAudioMs?.let { ms ->
|
||||
Log.i(TAG, "Realtime watchdog: first audio reached speaker after ${ms}ms")
|
||||
// Flip the waveform's output gate the instant playback truly
|
||||
// starts, even if no further audio-delta byte event arrives
|
||||
// to drive handleRealtimeVoiceEvent — the unfold then lands
|
||||
// exactly at the first audible frame.
|
||||
if (_uiState.value.state == VoiceState.Speaking) {
|
||||
_uiState.update { st -> st.copy(outputAudioActive = true) }
|
||||
}
|
||||
}
|
||||
if (decision.reportStuck) {
|
||||
reportedStuck = true
|
||||
@@ -3159,6 +3233,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
bargeInStarted: AtomicBoolean,
|
||||
) {
|
||||
if (!event.isAudioDelta) return
|
||||
// After an interrupt, ignore the cancelled turn's in-flight audio tail
|
||||
// until the next turn is sent (which clears the flag). Otherwise these
|
||||
// late deltas re-create the player and playback resumes after "Stop".
|
||||
if (realtimeAudioSuppressed) return
|
||||
val encoded = event.audioBase64 ?: return
|
||||
val audio = try {
|
||||
Base64.getDecoder().decode(encoded)
|
||||
@@ -3185,11 +3263,21 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
startRealtimePlaybackWatchdog()
|
||||
}
|
||||
if (_uiState.value.state == VoiceState.Speaking) {
|
||||
// Keep feeding the visual envelope from the decoded level so the
|
||||
// waveform stays smooth, but gate `outputAudioActive` on REAL
|
||||
// playback start (head-move / head-synced amplitude) rather than on
|
||||
// the decoded RMS, which leads the audible frame by the player's
|
||||
// start prebuffer. Mirrors the basic-TTS Visualizer gating.
|
||||
speakEnvelope = applyEnvelope(speakEnvelope, level)
|
||||
val snap = pcmPlayer.snapshot()
|
||||
val playbackActive = shouldMarkRealtimeOutputActive(
|
||||
headFrames = snap.headFrames,
|
||||
playbackAmplitude = pcmPlayer.playbackAmplitude(),
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
amplitude = speakEnvelope,
|
||||
outputAudioActive = it.outputAudioActive || level > OUTPUT_AUDIO_ACTIVE_THRESHOLD,
|
||||
outputAudioActive = it.outputAudioActive || playbackActive,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5085,6 +5173,50 @@ internal fun evaluateFirstFrameWatchdog(
|
||||
return FirstFrameWatchdogDecision(null, reportStuck = stuck, keepWatching = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure gate for the realtime waveform's `outputAudioActive` flag (W3).
|
||||
*
|
||||
* The decoded-PCM RMS [level] arrives before the audio is actually audible —
|
||||
* [RealtimePcmPlayer] holds a start prebuffer, so the first few deltas decode
|
||||
* (level > 0) while the AudioTrack head is still parked at frame 0. Gating
|
||||
* `outputAudioActive` on [level] therefore unfolds the UI too early.
|
||||
*
|
||||
* Instead we gate on a playback-synced signal: the playback head has actually
|
||||
* moved ([headFrames] > 0) and/or the head-tracked [playbackAmplitude] is
|
||||
* non-zero. This mirrors the basic-TTS path, where the Visualizer only reports
|
||||
* amplitude once ExoPlayer is genuinely producing audio.
|
||||
*
|
||||
* Returns true once playback has really started so the caller may flip
|
||||
* `outputAudioActive` true (it is monotonic per turn — the caller ORs it).
|
||||
*/
|
||||
internal fun shouldMarkRealtimeOutputActive(
|
||||
headFrames: Int,
|
||||
playbackAmplitude: Float,
|
||||
): Boolean = headFrames > 0 || playbackAmplitude > 0f
|
||||
|
||||
/**
|
||||
* Pure decision for the W3 spoken-status throttle. Returns true when a spoken
|
||||
* status line should actually be enqueued for TTS right now, given the time of
|
||||
* the last spoken status ([lastSpokenAtMs], 0 = none yet this turn), how many
|
||||
* have already been spoken this turn ([count]), the minimum inter-status gap
|
||||
* ([gapMs]), and the per-turn cap ([maxCount]).
|
||||
*
|
||||
* Independent of the per-key dedupe — this caps cadence + volume so long /
|
||||
* tool-heavy runs don't narrate every step.
|
||||
*/
|
||||
internal fun shouldSpeakStatusNow(
|
||||
now: Long,
|
||||
lastSpokenAtMs: Long,
|
||||
count: Int,
|
||||
gapMs: Long,
|
||||
maxCount: Int,
|
||||
): Boolean {
|
||||
if (count >= maxCount) return false
|
||||
// First spoken status of the turn (lastSpokenAtMs == 0) is always allowed.
|
||||
if (lastSpokenAtMs > 0L && now - lastSpokenAtMs < gapMs) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Drain cross-check (#3): estimate vs. real hardware head position. */
|
||||
internal data class DrainDrift(
|
||||
val actualRemainingMs: Long,
|
||||
|
||||
+171
@@ -7,6 +7,7 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProfileDisplayAliasStore
|
||||
import com.hermesandroid.relay.data.ProfileIconStore
|
||||
import com.hermesandroid.relay.data.ProfileLockStore
|
||||
import com.hermesandroid.relay.data.ProfileSelectionStore
|
||||
import com.hermesandroid.relay.data.ProfileSessionStore
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
@@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -104,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
|
||||
@@ -113,6 +152,13 @@ class ProfileController(
|
||||
val profileSessionStore: ProfileSessionStore = ProfileSessionStore(context)
|
||||
val profileDisplayAliasStore: ProfileDisplayAliasStore = ProfileDisplayAliasStore(context)
|
||||
|
||||
/**
|
||||
* Per-connection "profile lock" persistence (twin of [profileSelectionStore],
|
||||
* sharing the same DataStore). Public so the ViewModel's connection-lifecycle
|
||||
* orchestrators can clear it alongside the selection store.
|
||||
*/
|
||||
val profileLockStore: ProfileLockStore = ProfileLockStore(context)
|
||||
|
||||
val profileDisplayAlias: StateFlow<String?> = combine(
|
||||
activeConnectionId,
|
||||
selectedProfile,
|
||||
@@ -126,6 +172,28 @@ class ProfileController(
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/**
|
||||
* The active connection's stored profile-lock target, or `null` when the
|
||||
* connection is unlocked. The value is the raw stored token: the sentinel
|
||||
* [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY] means "locked to Server default",
|
||||
* any other string is a profile name. Built by flatMapLatest on the active
|
||||
* connection id exactly like [profileDisplayAlias] so it repoints cleanly
|
||||
* across connection switches.
|
||||
*/
|
||||
val lockedProfileName: StateFlow<String?> = activeConnectionId
|
||||
.flatMapLatest { connectionId ->
|
||||
if (connectionId == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
profileLockStore.lockedProfileFlow(connectionId)
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/** True when the active connection is pinned to a single profile. */
|
||||
val isProfileLocked: StateFlow<Boolean> = lockedProfileName
|
||||
.map { it != null }
|
||||
.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
|
||||
val profileIconStore: ProfileIconStore = ProfileIconStore(context)
|
||||
|
||||
/** The active profile's local agent-icon path (twin of [profileDisplayAlias]). */
|
||||
@@ -233,13 +301,46 @@ class ProfileController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored lock-token for a (possibly null) profile. Server default —
|
||||
* including the synthetic "default" alias — maps to
|
||||
* [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY]; everything else to its name.
|
||||
* Mirrors [AgentDisplay.profileSessionKey] so the lock token and the
|
||||
* session/selection key for the same profile always agree.
|
||||
*/
|
||||
private fun lockTokenFor(profile: Profile?): String =
|
||||
AgentDisplay.profileSessionKey(profile?.name)
|
||||
|
||||
/**
|
||||
* Set (or clear, with `null`) the active profile pick. Writes through
|
||||
* to [profileSelectionStore] for the currently-active connection so
|
||||
* the selection survives process death and connection switches.
|
||||
*
|
||||
* When the connection is **locked**, a request for a profile other than
|
||||
* the locked target is ignored (the pickers are gated, but this guards the
|
||||
* programmatic paths too — e.g. voice/card dispatch). Re-selecting the
|
||||
* locked target is allowed (it's a no-op against current state anyway).
|
||||
*/
|
||||
fun selectProfile(profile: Profile?) {
|
||||
val normalizedProfile = AgentDisplay.normalizeSelection(profile)
|
||||
val locked = lockedProfileName.value
|
||||
if (locked != null && lockTokenFor(normalizedProfile) != locked) {
|
||||
// Pinned to a different profile — refuse the switch. Never silently
|
||||
// coerce to the locked target here; the resolution path already
|
||||
// holds the selection on the locked target (or null if it's gone).
|
||||
return
|
||||
}
|
||||
applyProfileSelection(normalizedProfile)
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual selection write — runs the full profile-switch machinery
|
||||
* (fresh draft via [setLastSessionId], pending-state stamp, persist,
|
||||
* chat-API rebuild, last-session restore). Bypasses the lock gate so
|
||||
* [lockProfile] can force-select the new locked target even mid-relock;
|
||||
* [selectProfile] is the gated public entry point.
|
||||
*/
|
||||
private fun applyProfileSelection(normalizedProfile: Profile?) {
|
||||
_selectedProfile.value = normalizedProfile
|
||||
setLastSessionId(null)
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
@@ -257,6 +358,15 @@ class ProfileController(
|
||||
if (_pendingSelectedProfileConnectionId.value != connectionId) {
|
||||
return false
|
||||
}
|
||||
// When the connection is locked, the lock target — NOT the pending or
|
||||
// persisted selection — decides the active profile. The sentinel means
|
||||
// Server default (selection null); any other token resolves against the
|
||||
// current list. If the locked profile isn't (yet/anymore) advertised we
|
||||
// HOLD on null so the Settings banner can explain it — never fall back.
|
||||
val locked = lockedProfileName.value
|
||||
if (locked != null) {
|
||||
return resolveLockedProfileFrom(locked, list)
|
||||
}
|
||||
val current = _selectedProfile.value
|
||||
if (current != null) {
|
||||
if (AgentDisplay.isServerDefaultAlias(current.name)) {
|
||||
@@ -290,6 +400,67 @@ class ProfileController(
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active profile against the lock [token] (already known to be
|
||||
* non-null by the caller). Returns true when the selection changed.
|
||||
*
|
||||
* - sentinel → Server default → selection null.
|
||||
* - a name present in [list] → select that profile.
|
||||
* - a name absent from [list] → HOLD on null (the locked profile is gone
|
||||
* or hasn't been advertised yet); the pending name is kept so a banner
|
||||
* can name it and so a later list arrival can recover it.
|
||||
*/
|
||||
private fun resolveLockedProfileFrom(token: String, list: List<Profile>): Boolean {
|
||||
if (AgentDisplay.isServerDefaultAlias(token) ||
|
||||
token == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
) {
|
||||
_pendingSelectedProfileName.value = null
|
||||
val changed = _selectedProfile.value != null
|
||||
_selectedProfile.value = null
|
||||
return changed
|
||||
}
|
||||
val resolved = list.firstOrNull { it.name == token }
|
||||
if (resolved != null) {
|
||||
val changed = _selectedProfile.value != resolved
|
||||
_selectedProfile.value = resolved
|
||||
_pendingSelectedProfileName.value = resolved.name
|
||||
return changed
|
||||
}
|
||||
// Locked profile not present — hold on null, keep the pending name so the
|
||||
// banner can name it and a later arrival can recover the lock.
|
||||
_pendingSelectedProfileName.value = token
|
||||
val changed = _selectedProfile.value != null
|
||||
_selectedProfile.value = null
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the active connection to [profile]. A `null` argument locks to
|
||||
* **Server default** (stored as the [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY]
|
||||
* sentinel so it's distinct from "unlocked"). Persists the lock, then forces
|
||||
* the selection to the locked target via the normal [selectProfile] path so
|
||||
* the existing profile-switch machinery (fresh draft, gateway hot-swap, chat
|
||||
* API rebuild) runs. Locking to the already-selected profile is effectively
|
||||
* a no-op for the selection but still records the lock.
|
||||
*/
|
||||
suspend fun lockProfile(profile: Profile?) {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
val normalizedProfile = AgentDisplay.normalizeSelection(profile)
|
||||
val token = lockTokenFor(normalizedProfile)
|
||||
// Persist the lock first, then force-select via the un-gated body so the
|
||||
// switch lands even when re-locking from a different target (the
|
||||
// lockedProfileName StateFlow may still hold the previous token until the
|
||||
// DataStore emission propagates).
|
||||
profileLockStore.setLockedProfile(connectionId, token)
|
||||
applyProfileSelection(normalizedProfile)
|
||||
}
|
||||
|
||||
/** Remove the lock for the active connection (back to free profile choice). */
|
||||
suspend fun unlockProfile() {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
profileLockStore.setLockedProfile(connectionId, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which transport's session slot to restore right now — or `null` when the
|
||||
* decision is still pending (the gateway probe hasn't landed). A manual
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* === update (sideload flavor): factory ===
|
||||
*
|
||||
* Backs [UpdateAvailabilitySource] onto the existing GitHub-releases
|
||||
* [UpdateChecker]. There is no in-app download/install on the sideload track:
|
||||
* [startUpdate] opens the APK asset (or release page) in the browser and the
|
||||
* status never advances past [UpdateStatus.Available]. Mirrors
|
||||
* `voice/VoiceBridgeIntentFactory`'s flavor-split factory pattern — same
|
||||
* function signature + package as the googlePlay flavor.
|
||||
*/
|
||||
fun createUpdateAvailabilitySource(context: Context): UpdateAvailabilitySource =
|
||||
GitHubUpdateAvailabilitySource(context.applicationContext)
|
||||
|
||||
private const val TAG = "SideloadUpdate"
|
||||
|
||||
private class GitHubUpdateAvailabilitySource(
|
||||
private val appContext: Context,
|
||||
) : UpdateAvailabilitySource {
|
||||
|
||||
// Sideload reports updates synchronously from [check]; there is no async
|
||||
// listener, so this is never invoked. Present for interface parity.
|
||||
override var onStatusChanged: ((UpdateStatus) -> Unit)? = null
|
||||
|
||||
/** Resolved on [check] so [startUpdate] can route to the right URL. */
|
||||
@Volatile private var pending: UpdateStatus.Available? = null
|
||||
|
||||
override suspend fun check(): UpdateStatus {
|
||||
return when (val result = UpdateChecker.check()) {
|
||||
is UpdateCheckResult.Available -> {
|
||||
val upd = result.update
|
||||
val status = UpdateStatus.Available(
|
||||
// Raw version string — doubles as the per-version dismiss
|
||||
// key (versionCode is null on this track), so it must stay
|
||||
// parseable by compareVersions. The banner formats display.
|
||||
versionLabel = upd.latestVersion,
|
||||
versionCode = null, // GitHub releases tracked by version string, not code
|
||||
openUrl = upd.apkUrl ?: upd.releasePageUrl,
|
||||
)
|
||||
pending = status
|
||||
status
|
||||
}
|
||||
// Errors degrade to UpToDate — the banner just stays hidden, the
|
||||
// About-screen "Check for updates" row still surfaces the error.
|
||||
UpdateCheckResult.Idle,
|
||||
UpdateCheckResult.Checking,
|
||||
UpdateCheckResult.UpToDate,
|
||||
is UpdateCheckResult.Error -> {
|
||||
pending = null
|
||||
UpdateStatus.UpToDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startUpdate(activity: Activity?): Boolean {
|
||||
val target = pending?.openUrl ?: return false
|
||||
return try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, target.toUri())
|
||||
.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
(activity ?: appContext).startActivity(intent)
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startUpdate (browser) failed", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** No staged install on sideload — the system installer handles the APK. */
|
||||
override fun completeUpdate() = Unit
|
||||
|
||||
override fun dispose() {
|
||||
onStatusChanged = null
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
|
||||
// === END update (sideload) ===
|
||||
+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,164 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [ProfileLockStore].
|
||||
*
|
||||
* These exercise the store's **logic** (per-connection key naming, the
|
||||
* null→remove unlock contract, the Server-default sentinel passthrough, and
|
||||
* per-connection isolation) against an in-memory [DataStore] rather than a
|
||||
* filesystem-backed [androidx.datastore.preferences.core.PreferenceDataStoreFactory].
|
||||
*
|
||||
* Why in-memory: a file-backed DataStore performs an atomic write-tmp-then-rename
|
||||
* on every `edit`, and on Windows that rename fails ("Unable to rename … multiple
|
||||
* instances of DataStore") when a prior test method's DataStore coroutine hasn't
|
||||
* released the file handle yet (scope cancellation is async). The in-memory
|
||||
* [DataStore] removes the OS dependency entirely — `edit { }`, `data.map { }`,
|
||||
* `remove`, and `clear` all behave identically, and persistence-to-disk is
|
||||
* DataStore's contract, not [ProfileLockStore]'s.
|
||||
*/
|
||||
class ProfileLockStoreTest {
|
||||
|
||||
private lateinit var store: ProfileLockStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
store = ProfileLockStore(InMemoryPreferencesDataStore())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unset_connection_emitsNull() = runBlocking {
|
||||
// Fresh store — every connection id reads as null (unlocked) until set.
|
||||
assertNull(store.lockedProfileFlow("conn-1").first())
|
||||
assertNull(store.lockedProfileFlow("conn-unknown").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setName_then_get_roundTrips() = runBlocking {
|
||||
store.setLockedProfile("conn-1", "mizu")
|
||||
assertEquals("mizu", store.lockedProfileFlow("conn-1").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setServerDefaultSentinel_roundTrips() = runBlocking {
|
||||
// Locked-to-Server-default is stored as the sentinel and must read back
|
||||
// verbatim — it is NOT null (that would mean "unlocked").
|
||||
store.setLockedProfile("conn-1", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
|
||||
val value = store.lockedProfileFlow("conn-1").first()
|
||||
assertEquals(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY, value)
|
||||
// Belt-and-suspenders: the sentinel must be distinguishable from null.
|
||||
assertEquals("__server_default__", value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setNull_unlocks_removesTheKey() = runBlocking {
|
||||
store.setLockedProfile("conn-1", "mizu")
|
||||
assertEquals("mizu", store.lockedProfileFlow("conn-1").first())
|
||||
|
||||
// Writing null removes the key — read path emits null (unlocked).
|
||||
store.setLockedProfile("conn-1", null)
|
||||
assertNull(store.lockedProfileFlow("conn-1").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setNull_afterSentinel_unlocks() = runBlocking {
|
||||
// Going from "locked to Server default" back to "unlocked" must clear the
|
||||
// sentinel, not leave it stuck.
|
||||
store.setLockedProfile("conn-1", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
assertEquals(
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
store.lockedProfileFlow("conn-1").first(),
|
||||
)
|
||||
|
||||
store.setLockedProfile("conn-1", null)
|
||||
assertNull(store.lockedProfileFlow("conn-1").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overwrite_replacesPriorValue() = runBlocking {
|
||||
store.setLockedProfile("conn-1", "mizu")
|
||||
store.setLockedProfile("conn-1", "coder")
|
||||
assertEquals("coder", store.lockedProfileFlow("conn-1").first())
|
||||
|
||||
// Name → sentinel and back, to prove neither sticks.
|
||||
store.setLockedProfile("conn-1", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
assertEquals(
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
store.lockedProfileFlow("conn-1").first(),
|
||||
)
|
||||
store.setLockedProfile("conn-1", "mizu")
|
||||
assertEquals("mizu", store.lockedProfileFlow("conn-1").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perConnectionKeys_areIndependent() = runBlocking {
|
||||
// A lock pinned on one server must not leak onto another. Mix names and
|
||||
// the sentinel across connections.
|
||||
store.setLockedProfile("conn-A", "alpha")
|
||||
store.setLockedProfile("conn-B", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
store.setLockedProfile("conn-C", "gamma")
|
||||
|
||||
assertEquals("alpha", store.lockedProfileFlow("conn-A").first())
|
||||
assertEquals(
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
store.lockedProfileFlow("conn-B").first(),
|
||||
)
|
||||
assertEquals("gamma", store.lockedProfileFlow("conn-C").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clear_removesOnlyTheGivenConnection() = runBlocking {
|
||||
store.setLockedProfile("conn-1", "mizu")
|
||||
store.setLockedProfile("conn-2", "coder")
|
||||
|
||||
store.clear("conn-1")
|
||||
|
||||
assertNull(store.lockedProfileFlow("conn-1").first())
|
||||
assertEquals("coder", store.lockedProfileFlow("conn-2").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAll_wipesEveryConnection() = runBlocking {
|
||||
store.setLockedProfile("conn-A", "alpha")
|
||||
store.setLockedProfile("conn-B", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
store.setLockedProfile("conn-C", "gamma")
|
||||
|
||||
store.clearAll()
|
||||
|
||||
assertNull(store.lockedProfileFlow("conn-A").first())
|
||||
assertNull(store.lockedProfileFlow("conn-B").first())
|
||||
assertNull(store.lockedProfileFlow("conn-C").first())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory [DataStore] of [Preferences] for unit tests — no filesystem,
|
||||
* so no atomic-rename / single-instance contention. [updateData] applies the
|
||||
* transform to the current snapshot and publishes it; [data] replays the latest
|
||||
* value to every collector (so `.first()` after a write sees the update).
|
||||
*/
|
||||
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow(emptyPreferences())
|
||||
|
||||
override val data: Flow<Preferences> = state
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences {
|
||||
val updated = transform(state.value)
|
||||
state.value = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure-JVM tests for the bundled-changelog parser ([ChangelogStore.parse]) and
|
||||
* the [ChangelogVersion] view helpers. No Android dependency — only the
|
||||
* kotlinx.serialization decode path and string formatting are exercised, so the
|
||||
* Android asset stream ([ChangelogStore.load]) is intentionally out of scope.
|
||||
*/
|
||||
class ChangelogParserTest {
|
||||
|
||||
@Test
|
||||
fun parsesVersionsInFileOrder() {
|
||||
val raw = """
|
||||
{
|
||||
"versions": [
|
||||
{"version": "1.2.0", "title": "Latest", "date": "2026-06-20",
|
||||
"sections": [{"header": "New", "bullets": ["a", "b"]}]},
|
||||
{"version": "1.1.0", "title": "Older", "date": "2026-06-16",
|
||||
"sections": [{"header": "Fixed", "bullets": ["c"]}]}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val changelog = ChangelogStore.parse(raw)
|
||||
|
||||
assertEquals(2, changelog.versions.size)
|
||||
// File order is authored newest-first and must be preserved verbatim.
|
||||
assertEquals("1.2.0", changelog.versions[0].version)
|
||||
assertEquals("1.1.0", changelog.versions[1].version)
|
||||
assertEquals("Latest", changelog.versions[0].title)
|
||||
assertEquals(listOf("a", "b"), changelog.versions[0].sections.first().bullets)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankInputYieldsEmptyChangelog() {
|
||||
assertTrue(ChangelogStore.parse("").versions.isEmpty())
|
||||
assertTrue(ChangelogStore.parse(" \n ").versions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedJsonFallsBackToEmptyInsteadOfThrowing() {
|
||||
// The dialog falls back to whats_new.txt when this returns empty, so a
|
||||
// garbled asset must never crash the parse.
|
||||
assertTrue(ChangelogStore.parse("{ this is not json").versions.isEmpty())
|
||||
assertTrue(ChangelogStore.parse("[]").versions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresUnknownTopLevelAndSectionKeys() {
|
||||
// Future authored fields (e.g. a "summary") must not break older apps.
|
||||
val raw = """
|
||||
{
|
||||
"schema": 2,
|
||||
"versions": [
|
||||
{"version": "1.0.0", "summary": "ignored",
|
||||
"sections": [{"header": "H", "bullets": ["x"], "icon": "star"}]}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val changelog = ChangelogStore.parse(raw)
|
||||
|
||||
assertEquals("1.0.0", changelog.versions.single().version)
|
||||
assertEquals(listOf("x"), changelog.versions.single().sections.single().bullets)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optionalFieldsDefaultGracefully() {
|
||||
// Only `version` is required; title/date/sections may be absent.
|
||||
val raw = """{"versions": [{"version": "0.9.0"}]}"""
|
||||
|
||||
val entry = ChangelogStore.parse(raw).versions.single()
|
||||
|
||||
assertNull(entry.title)
|
||||
assertNull(entry.date)
|
||||
assertTrue(entry.sections.isEmpty())
|
||||
assertTrue(entry.toGroups().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtitleJoinsVersionTitleAndDate() {
|
||||
val entry = ChangelogVersion(
|
||||
version = "1.2.0",
|
||||
title = "Make it yours",
|
||||
date = "2026-06-20",
|
||||
)
|
||||
assertEquals("v1.2.0 — Make it yours · 2026-06-20", entry.subtitle())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtitleOmitsMissingTokens() {
|
||||
assertEquals("v1.2.0", ChangelogVersion(version = "1.2.0").subtitle())
|
||||
assertEquals(
|
||||
"v1.2.0 — Title",
|
||||
ChangelogVersion(version = "1.2.0", title = "Title").subtitle(),
|
||||
)
|
||||
assertEquals(
|
||||
"v1.2.0 · 2026-06-20",
|
||||
ChangelogVersion(version = "1.2.0", date = "2026-06-20").subtitle(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toGroupsDropsBlankHeaders() {
|
||||
val entry = ChangelogVersion(
|
||||
version = "1.0.0",
|
||||
sections = listOf(
|
||||
ChangelogSection(header = " ", bullets = listOf("a")),
|
||||
ChangelogSection(header = "Real", bullets = listOf("b")),
|
||||
),
|
||||
)
|
||||
|
||||
val groups = entry.toGroups()
|
||||
|
||||
assertNull("blank header should normalize to null", groups[0].header)
|
||||
assertEquals("Real", groups[1].header)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toNotesUsesSubtitleAsVersionLine() {
|
||||
val notes = ChangelogVersion(
|
||||
version = "1.2.0",
|
||||
title = "Make it yours",
|
||||
date = "2026-06-20",
|
||||
sections = listOf(ChangelogSection(header = "New", bullets = listOf("a"))),
|
||||
).toNotes()
|
||||
|
||||
assertEquals("v1.2.0 — Make it yours · 2026-06-20", notes.version)
|
||||
assertEquals(1, notes.groups.size)
|
||||
assertEquals("New", notes.groups.single().header)
|
||||
assertEquals(listOf("a"), notes.groups.single().bullets)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the top-level [coerceAudioRoute] helper in
|
||||
* `VoiceSettingsScreen.kt`.
|
||||
*
|
||||
* Contract (see the helper's KDoc):
|
||||
* - [VoiceAudioRoute.Relay] with `relayVoiceReady == false` coerces to
|
||||
* [VoiceAudioRoute.Auto] (a stale Relay pick after Relay was unpaired must
|
||||
* not stay persisted).
|
||||
* - Every other (engine, route, ready) combination passes the route through
|
||||
* unchanged — the engine argument never influences the audio-route result.
|
||||
*/
|
||||
class CoerceAudioRouteTest {
|
||||
|
||||
// --- Relay + not ready → Auto -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun relayWhenNotReady_coercesToAuto_hermesEngine() {
|
||||
assertEquals(
|
||||
VoiceAudioRoute.Auto,
|
||||
coerceAudioRoute(
|
||||
engine = VoiceEngineMode.HermesVoiceOutput,
|
||||
route = VoiceAudioRoute.Relay,
|
||||
relayVoiceReady = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayWhenNotReady_coercesToAuto_realtimeEngine() {
|
||||
assertEquals(
|
||||
VoiceAudioRoute.Auto,
|
||||
coerceAudioRoute(
|
||||
engine = VoiceEngineMode.RealtimeAgent,
|
||||
route = VoiceAudioRoute.Relay,
|
||||
relayVoiceReady = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Relay + ready → Relay (unchanged) ----------------------------------
|
||||
|
||||
@Test
|
||||
fun relayWhenReady_passesThrough_hermesEngine() {
|
||||
assertEquals(
|
||||
VoiceAudioRoute.Relay,
|
||||
coerceAudioRoute(
|
||||
engine = VoiceEngineMode.HermesVoiceOutput,
|
||||
route = VoiceAudioRoute.Relay,
|
||||
relayVoiceReady = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayWhenReady_passesThrough_realtimeEngine() {
|
||||
assertEquals(
|
||||
VoiceAudioRoute.Relay,
|
||||
coerceAudioRoute(
|
||||
engine = VoiceEngineMode.RealtimeAgent,
|
||||
route = VoiceAudioRoute.Relay,
|
||||
relayVoiceReady = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Standard always passes through, regardless of readiness ------------
|
||||
|
||||
@Test
|
||||
fun standard_passesThrough_whenRelayNotReady() {
|
||||
for (engine in VoiceEngineMode.values()) {
|
||||
assertEquals(
|
||||
"Standard must never be coerced (engine=$engine, ready=false)",
|
||||
VoiceAudioRoute.Standard,
|
||||
coerceAudioRoute(engine, VoiceAudioRoute.Standard, relayVoiceReady = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standard_passesThrough_whenRelayReady() {
|
||||
for (engine in VoiceEngineMode.values()) {
|
||||
assertEquals(
|
||||
"Standard must never be coerced (engine=$engine, ready=true)",
|
||||
VoiceAudioRoute.Standard,
|
||||
coerceAudioRoute(engine, VoiceAudioRoute.Standard, relayVoiceReady = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auto always passes through (it self-resolves at runtime) -----------
|
||||
|
||||
@Test
|
||||
fun auto_passesThrough_regardlessOfReadiness() {
|
||||
for (engine in VoiceEngineMode.values()) {
|
||||
for (ready in listOf(true, false)) {
|
||||
assertEquals(
|
||||
"Auto is always valid (engine=$engine, ready=$ready)",
|
||||
VoiceAudioRoute.Auto,
|
||||
coerceAudioRoute(engine, VoiceAudioRoute.Auto, relayVoiceReady = ready),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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"),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure-logic coverage for the unified update banner's per-version dismissal +
|
||||
* the dismiss-key derivation. No DataStore / Android involved — exercises the
|
||||
* exact decision `rememberUpdateAvailability` makes to hide/show the banner.
|
||||
*/
|
||||
class UpdateDismissalTest {
|
||||
|
||||
// ── dismissKey derivation ─────────────────────────────────────────────
|
||||
|
||||
@Test fun `dismissKey prefers numeric versionCode when present (Play)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.3.0", versionCode = 17L)
|
||||
assertEquals("17", s.dismissKey)
|
||||
}
|
||||
|
||||
@Test fun `dismissKey falls back to versionLabel when no code (sideload)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.3.0", versionCode = null)
|
||||
assertEquals("1.3.0", s.dismissKey)
|
||||
}
|
||||
|
||||
@Test fun `dismissKey is null for non-actionable statuses`() {
|
||||
assertNull(UpdateStatus.UpToDate.dismissKey)
|
||||
assertNull(UpdateStatus.Unsupported.dismissKey)
|
||||
}
|
||||
|
||||
@Test fun `dismissKey covers Downloading and Downloaded`() {
|
||||
assertEquals("9", UpdateStatus.Downloading("1.1.0", 9L).dismissKey)
|
||||
assertEquals("1.2.0", UpdateStatus.Downloaded("1.2.0", null).dismissKey)
|
||||
}
|
||||
|
||||
// ── per-version dismissal: never dismissed when nothing stored ────────
|
||||
|
||||
@Test fun `not dismissed when no dismissed key stored`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.3.0", versionCode = 17L)
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(s, dismissed = null))
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(s, dismissed = ""))
|
||||
}
|
||||
|
||||
@Test fun `non-actionable status is never dismissed`() {
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(UpdateStatus.UpToDate, "17"))
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(UpdateStatus.Unsupported, "17"))
|
||||
}
|
||||
|
||||
// ── per-version dismissal: Play (numeric versionCode) ─────────────────
|
||||
|
||||
@Test fun `same versionCode stays dismissed (Play)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.3.0", versionCode = 17L)
|
||||
assertTrue(UpdateDismissalPreferences.isDismissed(s, dismissed = "17"))
|
||||
}
|
||||
|
||||
@Test fun `older offer than dismissed stays hidden (Play)`() {
|
||||
// Edge case: an older code than the one already dismissed should not
|
||||
// re-nag — only a strictly newer one re-shows.
|
||||
val s = UpdateStatus.Available(versionLabel = "1.2.0", versionCode = 16L)
|
||||
assertTrue(UpdateDismissalPreferences.isDismissed(s, dismissed = "17"))
|
||||
}
|
||||
|
||||
@Test fun `newer versionCode re-shows the banner (Play)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.4.0", versionCode = 18L)
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(s, dismissed = "17"))
|
||||
}
|
||||
|
||||
// ── per-version dismissal: sideload (version string) ──────────────────
|
||||
|
||||
@Test fun `same version string stays dismissed (sideload)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.3.0", versionCode = null)
|
||||
assertTrue(UpdateDismissalPreferences.isDismissed(s, dismissed = "1.3.0"))
|
||||
}
|
||||
|
||||
@Test fun `newer version string re-shows the banner (sideload)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.4.0", versionCode = null)
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(s, dismissed = "1.3.0"))
|
||||
}
|
||||
|
||||
@Test fun `older version string stays hidden (sideload)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.2.0", versionCode = null)
|
||||
assertTrue(UpdateDismissalPreferences.isDismissed(s, dismissed = "1.3.0"))
|
||||
}
|
||||
|
||||
@Test fun `Downloaded status respects per-version dismissal logic too`() {
|
||||
// (The UI never suppresses Downloaded, but the pure predicate is
|
||||
// consistent: a dismissed-then-downloaded same version reads dismissed.)
|
||||
val downloaded = UpdateStatus.Downloaded(versionLabel = "1.3.0", versionCode = 17L)
|
||||
assertTrue(UpdateDismissalPreferences.isDismissed(downloaded, dismissed = "17"))
|
||||
val newer = UpdateStatus.Downloaded(versionLabel = "1.4.0", versionCode = 18L)
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(newer, dismissed = "17"))
|
||||
}
|
||||
|
||||
// ── isStrictlyNewer direct coverage ───────────────────────────────────
|
||||
|
||||
@Test fun `isStrictlyNewer numeric`() {
|
||||
assertTrue(UpdateDismissalPreferences.isStrictlyNewer("18", "17"))
|
||||
assertFalse(UpdateDismissalPreferences.isStrictlyNewer("17", "17"))
|
||||
assertFalse(UpdateDismissalPreferences.isStrictlyNewer("16", "17"))
|
||||
}
|
||||
|
||||
@Test fun `isStrictlyNewer semver string`() {
|
||||
assertTrue(UpdateDismissalPreferences.isStrictlyNewer("1.4.0", "1.3.0"))
|
||||
assertFalse(UpdateDismissalPreferences.isStrictlyNewer("1.3.0", "1.3.0"))
|
||||
assertFalse(UpdateDismissalPreferences.isStrictlyNewer("1.2.0", "1.3.0"))
|
||||
}
|
||||
|
||||
@Test fun `isStrictlyNewer mixed-parse falls back to string semver`() {
|
||||
// One numeric, one not → both routed through compareVersions, which
|
||||
// tokenizes leading digits. "abc" → 0, so "1.0.0" is newer.
|
||||
assertTrue(UpdateDismissalPreferences.isStrictlyNewer("1.0.0", "abc"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import java.io.IOException
|
||||
import java.net.URLDecoder
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Covers the two new shared pieces:
|
||||
* - [IssueReport.buildGithubIssueUrl] produces a stable, properly-encoded URL.
|
||||
* - [classifyError] records the classified failure into [DiagnosticsLog] as a
|
||||
* side effect (Error severity, clean title, redacted full stacktrace).
|
||||
*
|
||||
* Both run on pure JVM — no Android framework / Robolectric needed, since
|
||||
* [IssueReport.buildGithubIssueUrl], the classifier, and the log are all plain
|
||||
* Kotlin/Java.
|
||||
*/
|
||||
class IssueReportAndDiagnosticsTest {
|
||||
|
||||
@Test
|
||||
fun buildGithubIssueUrlEncodesTitleBodyAndLabels() {
|
||||
val url = IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: Crash — NullPointerException",
|
||||
bodyMarkdown = "line one\nline two & more",
|
||||
labels = "bug",
|
||||
)
|
||||
|
||||
assertTrue(url.startsWith("https://github.com/Codename-11/hermes-relay/issues/new?"))
|
||||
// Stable param order: title, labels, body.
|
||||
assertTrue(url.indexOf("title=") < url.indexOf("labels="))
|
||||
assertTrue(url.indexOf("labels=") < url.indexOf("body="))
|
||||
// Spaces encoded as %20 (not '+'), so the URL works in a browser bar.
|
||||
assertFalse(url.contains("+"))
|
||||
assertTrue(url.contains("%20"))
|
||||
|
||||
val body = url.substringAfter("body=")
|
||||
assertEquals("line one\nline two & more", URLDecoder.decode(body, "UTF-8"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildGithubIssueUrlOmitsBlankLabels() {
|
||||
val url = IssueReport.buildGithubIssueUrl(
|
||||
title = "t",
|
||||
bodyMarkdown = "b",
|
||||
labels = "",
|
||||
)
|
||||
assertFalse(url.contains("labels="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifyErrorRecordsAnErrorEntryWithCleanTitleAndTrace() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
val human = classifyError(
|
||||
IOException("List sessions unauthorized - check your API key"),
|
||||
context = "send_message",
|
||||
)
|
||||
|
||||
val entry = DiagnosticsLog.recent().single()
|
||||
assertEquals(DiagnosticSeverity.Error, entry.severity)
|
||||
assertEquals(DiagnosticCategory.Api, entry.category)
|
||||
// Clean human title is what lands in the list — not the raw exception text.
|
||||
assertEquals(human.title, entry.title)
|
||||
assertEquals("API key rejected", entry.title)
|
||||
// Full stacktrace is captured for the detail page.
|
||||
assertNotNull(entry.stacktrace)
|
||||
assertTrue(entry.stacktrace!!.contains("IOException"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifyErrorMapsVoiceContextToVoiceCategory() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
classifyError(IOException("404 not found"), context = "voice_config")
|
||||
|
||||
val entry = DiagnosticsLog.recent().single()
|
||||
assertEquals(DiagnosticCategory.Voice, entry.category)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifyErrorWithNullThrowableRecordsNothing() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
classifyError(null, context = "send_message")
|
||||
|
||||
assertTrue(DiagnosticsLog.recent().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordErrorRedactsSecretsInTheStacktrace() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
DiagnosticsLog.recordError(
|
||||
category = DiagnosticCategory.Relay,
|
||||
title = "Boom",
|
||||
throwable = RuntimeException("rejected token=super-secret-token-value end"),
|
||||
)
|
||||
|
||||
val entry = DiagnosticsLog.recent().single()
|
||||
assertNotNull(entry.stacktrace)
|
||||
assertFalse(entry.stacktrace!!.contains("super-secret-token-value"))
|
||||
assertTrue(entry.stacktrace!!.contains("token=[hidden]"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the two pure decision helpers extracted from
|
||||
* [VoiceViewModel]: [shouldSpeakStatusNow] (the W3 spoken-status throttle) and
|
||||
* [shouldMarkRealtimeOutputActive] (the playback-synced "output is live" gate).
|
||||
*
|
||||
* Both are top-level `internal` and side-effect free, so they need no
|
||||
* ViewModel / Android scaffolding.
|
||||
*/
|
||||
class VoiceStatusGatesTest {
|
||||
|
||||
// --- shouldSpeakStatusNow ----------------------------------------------
|
||||
//
|
||||
// Contract (from the helper + its KDoc):
|
||||
// if (count >= maxCount) return false // cap wins first
|
||||
// if (lastSpokenAtMs > 0 && now - lastSpokenAtMs < gapMs) return false
|
||||
// else return true
|
||||
//
|
||||
// i.e. lastSpokenAtMs == 0 ("none yet this turn") skips the gap check, but
|
||||
// the over-count cap is still enforced ahead of it.
|
||||
|
||||
@Test
|
||||
fun firstOfTurn_isAllowed() {
|
||||
// lastSpokenAtMs == 0 → no prior status this turn → always allowed
|
||||
// (count under cap).
|
||||
assertTrue(
|
||||
shouldSpeakStatusNow(
|
||||
now = 0L,
|
||||
lastSpokenAtMs = 0L,
|
||||
count = 0,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstOfTurn_allowed_evenWhenNowIsLargeAndGapWide() {
|
||||
// The gap check is skipped entirely when lastSpokenAtMs == 0, so a huge
|
||||
// `now` against a wide gap is irrelevant.
|
||||
assertTrue(
|
||||
shouldSpeakStatusNow(
|
||||
now = 1_000_000L,
|
||||
lastSpokenAtMs = 0L,
|
||||
count = 2,
|
||||
gapMs = 10_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withinGap_isSuppressed() {
|
||||
// now - lastSpokenAtMs = 5_000 - 2_000 = 3_000 < 4_000 → suppress.
|
||||
assertFalse(
|
||||
shouldSpeakStatusNow(
|
||||
now = 5_000L,
|
||||
lastSpokenAtMs = 2_000L,
|
||||
count = 1,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overCount_isSuppressed_evenFirstOfTurn() {
|
||||
// count >= maxCount short-circuits to false BEFORE the gap/first checks,
|
||||
// so even lastSpokenAtMs == 0 cannot rescue an over-cap status.
|
||||
assertFalse(
|
||||
shouldSpeakStatusNow(
|
||||
now = 0L,
|
||||
lastSpokenAtMs = 0L,
|
||||
count = 6,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overCount_isSuppressed_pastGap() {
|
||||
// Well past the gap, but at the cap → still suppressed.
|
||||
assertFalse(
|
||||
shouldSpeakStatusNow(
|
||||
now = 100_000L,
|
||||
lastSpokenAtMs = 1_000L,
|
||||
count = 7,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pastGap_underCount_isAllowed() {
|
||||
// now - lastSpokenAtMs = 10_000 - 2_000 = 8_000 >= 4_000 gap, count < cap.
|
||||
assertTrue(
|
||||
shouldSpeakStatusNow(
|
||||
now = 10_000L,
|
||||
lastSpokenAtMs = 2_000L,
|
||||
count = 2,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactlyAtGap_isAllowed() {
|
||||
// now - lastSpokenAtMs == gapMs (4_000 == 4_000). The suppression
|
||||
// predicate is strict `<`, so being exactly at the gap is NOT suppressed.
|
||||
assertTrue(
|
||||
shouldSpeakStatusNow(
|
||||
now = 6_000L,
|
||||
lastSpokenAtMs = 2_000L,
|
||||
count = 1,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun justBelowCount_isAllowed_pastGap() {
|
||||
// count == maxCount - 1 is the last allowed slot (cap check is `>=`).
|
||||
assertTrue(
|
||||
shouldSpeakStatusNow(
|
||||
now = 10_000L,
|
||||
lastSpokenAtMs = 1_000L,
|
||||
count = 5,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- shouldMarkRealtimeOutputActive ------------------------------------
|
||||
//
|
||||
// Contract: true once headFrames > 0 OR playbackAmplitude > 0f; false only
|
||||
// at the cold (0, 0f) origin.
|
||||
|
||||
@Test
|
||||
fun coldOrigin_isInactive() {
|
||||
assertFalse(shouldMarkRealtimeOutputActive(headFrames = 0, playbackAmplitude = 0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headFramesMoved_isActive() {
|
||||
assertTrue(shouldMarkRealtimeOutputActive(headFrames = 1, playbackAmplitude = 0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun amplitudePresent_isActive() {
|
||||
assertTrue(shouldMarkRealtimeOutputActive(headFrames = 0, playbackAmplitude = 0.01f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothPresent_isActive() {
|
||||
assertTrue(shouldMarkRealtimeOutputActive(headFrames = 1, playbackAmplitude = 0.5f))
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Profile-**lock** behavior of [ProfileController].
|
||||
*
|
||||
* The controller's five persistence stores ([ProfileSelectionStore],
|
||||
* [ProfileSessionStore], [ProfileDisplayAliasStore], [ProfileLockStore],
|
||||
* [ProfileIconStore]) are built from a [Context], so this runs under
|
||||
* Robolectric (same seam as `ConnectionManagerRouteTest`) with
|
||||
* [RuntimeEnvironment.getApplication]. All other collaborators are injected as
|
||||
* lambdas/flows and are either no-ops or capturing stubs.
|
||||
*
|
||||
* Timing note: [ProfileController.lockedProfileName] is a `stateIn(...Eagerly)`
|
||||
* projection of the `ProfileLockStore` DataStore flow, so it lags a write by an
|
||||
* async hop. The controller's own [scope] therefore uses a REAL dispatcher
|
||||
* (Dispatchers.IO) — a StandardTestDispatcher would never let the DataStore
|
||||
* actor or the stateIn collectors run — and the tests `await { ... }` the lock /
|
||||
* selection StateFlows rather than reading them synchronously after a write.
|
||||
*
|
||||
* Scope of coverage: the lock semantics described on [ProfileController]:
|
||||
* - [ProfileController.lockProfile] persists the lock token + force-selects.
|
||||
* - [ProfileController.lockProfile] (null) locks to Server default.
|
||||
* - [ProfileController.selectProfile] is a no-op for a non-locked target while
|
||||
* locked, but allowed for the locked target.
|
||||
* - [ProfileController.resolvePendingProfileFrom] under a lock resolves to the
|
||||
* locked target when present and HOLDS (selection null) when it is absent,
|
||||
* then recovers on a later list arrival.
|
||||
* - [ProfileController.unlockProfile] clears the lock and re-enables selection.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ProfileControllerLockTest {
|
||||
|
||||
private val connectionId = "conn-lock-test"
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var authManager: AuthManager
|
||||
private lateinit var authManagerFlow: MutableStateFlow<AuthManager>
|
||||
private lateinit var activeConnectionId: MutableStateFlow<String?>
|
||||
private lateinit var controller: ProfileController
|
||||
|
||||
private val lastSessionIds = mutableListOf<String?>()
|
||||
|
||||
private val mizu = Profile(name = "mizu", model = "model-a")
|
||||
private val coder = Profile(name = "coder", model = "model-b")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Relay-advertised profile list — empty; tests drive the list explicitly
|
||||
// through resolvePendingProfileFrom(list).
|
||||
authManager = mockk(relaxed = true)
|
||||
every { authManager.agentProfiles } returns MutableStateFlow<List<Profile>>(emptyList()).asStateFlow()
|
||||
authManagerFlow = MutableStateFlow(authManager)
|
||||
|
||||
activeConnectionId = MutableStateFlow<String?>(connectionId)
|
||||
|
||||
controller = ProfileController(
|
||||
context = context,
|
||||
scope = scope,
|
||||
authManagerFlow = authManagerFlow,
|
||||
activeConnectionId = activeConnectionId,
|
||||
activeDashboardUrlProvider = { null },
|
||||
dashboardClientFactory = { _, _ -> mockk<DashboardApiClient>(relaxed = true) },
|
||||
// Non-"auto" so activeSessionTransport() resolves deterministically
|
||||
// (no gateway probe gating) — keeps refreshLastSessionForProfile from
|
||||
// bailing early on Unknown.
|
||||
streamingEndpointProvider = { "completions" },
|
||||
gatewayAvailabilityProvider = { GatewayAvailability.Ready },
|
||||
setLastSessionId = { lastSessionIds += it },
|
||||
legacyDefaultSessionId = { null },
|
||||
rebuildChatApiClient = { },
|
||||
)
|
||||
|
||||
// Guarantee a clean lock slot — the underlying "profile_selections"
|
||||
// DataStore is name-scoped and could carry residual state across runs in
|
||||
// the same JVM.
|
||||
runBlocking { controller.profileLockStore.clear(connectionId) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
// Start each run from clean lock state — the DataStore file is shared by
|
||||
// the app-internal store name across tests in the same JVM.
|
||||
runBlocking { controller.profileLockStore.clear(connectionId) }
|
||||
}
|
||||
|
||||
// --- await helpers ------------------------------------------------------
|
||||
|
||||
private fun <T> awaitFlow(flow: StateFlow<T>, predicate: (T) -> Boolean): T =
|
||||
runBlocking {
|
||||
withTimeout(5_000) { flow.first { predicate(it) } }
|
||||
}
|
||||
|
||||
private fun awaitLocked(token: String?) =
|
||||
awaitFlow(controller.lockedProfileName) { it == token }
|
||||
|
||||
private fun awaitSelected(name: String?) =
|
||||
awaitFlow(controller.selectedProfile) { it?.name == name }
|
||||
|
||||
// --- lockProfile(profile) -----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun lockProfile_persistsTokenAndForceSelects() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
|
||||
// Lock token == the profile name; selection forced to the locked target.
|
||||
assertEquals("mizu", awaitLocked("mizu"))
|
||||
assertEquals(mizu, awaitSelected("mizu"))
|
||||
assertTrue("should report locked", awaitFlow(controller.isProfileLocked) { it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lockProfileNull_locksToServerDefault() {
|
||||
runBlocking { controller.lockProfile(null) }
|
||||
|
||||
// Server default is stored as the sentinel (NOT null = unlocked) and the
|
||||
// selection resolves to null (the server-default context).
|
||||
assertEquals(
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
awaitLocked(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY),
|
||||
)
|
||||
assertNull(awaitSelected(null))
|
||||
assertTrue(awaitFlow(controller.isProfileLocked) { it })
|
||||
}
|
||||
|
||||
// --- selectProfile gating while locked ----------------------------------
|
||||
|
||||
@Test
|
||||
fun selectProfile_otherTarget_isNoOpWhileLocked() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
awaitLocked("mizu")
|
||||
awaitSelected("mizu")
|
||||
|
||||
// Attempt to switch to a DIFFERENT profile — must be refused.
|
||||
controller.selectProfile(coder)
|
||||
|
||||
// Selection stays on the locked target.
|
||||
assertEquals(mizu, controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectProfile_lockedTarget_isAllowedWhileLocked() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
awaitLocked("mizu")
|
||||
awaitSelected("mizu")
|
||||
|
||||
// Re-selecting the locked target is permitted (no-op against state, but
|
||||
// must not be refused outright).
|
||||
controller.selectProfile(mizu)
|
||||
assertEquals(mizu, controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
// --- resolvePendingProfileFrom under a lock -----------------------------
|
||||
|
||||
@Test
|
||||
fun resolvePending_locked_resolvesToLockedTargetWhenPresent() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
awaitLocked("mizu")
|
||||
awaitSelected("mizu")
|
||||
|
||||
// The locked profile object refreshes from the advertised list.
|
||||
val refreshedMizu = mizu.copy(model = "model-a-v2")
|
||||
val changed = controller.resolvePendingProfileFrom(listOf(refreshedMizu, coder))
|
||||
|
||||
assertTrue("resolution should report a change (model differs)", changed)
|
||||
assertEquals(refreshedMizu, controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePending_locked_holdsWhenLockedProfileAbsent_thenRecovers() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
awaitLocked("mizu")
|
||||
awaitSelected("mizu")
|
||||
|
||||
// Locked profile NOT in the advertised list → HOLD: selection cleared to
|
||||
// null, return true (changed from the previously-selected mizu).
|
||||
val held = controller.resolvePendingProfileFrom(listOf(coder))
|
||||
assertTrue("HOLD must report a change away from the locked target", held)
|
||||
assertNull("selection must hold on null while the locked profile is gone", controller.selectedProfile.value)
|
||||
|
||||
// A later list arrival that DOES contain the locked profile recovers it —
|
||||
// proves the pending lock name was retained during the HOLD.
|
||||
val recovered = controller.resolvePendingProfileFrom(listOf(mizu, coder))
|
||||
assertTrue("recovery should report a change back to the locked target", recovered)
|
||||
assertEquals(mizu, controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePending_lockedToServerDefault_resolvesToNull() {
|
||||
runBlocking { controller.lockProfile(null) }
|
||||
awaitLocked(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
awaitSelected(null)
|
||||
|
||||
// The sentinel resolves to the null (server-default) selection regardless
|
||||
// of the advertised list.
|
||||
val changed = controller.resolvePendingProfileFrom(listOf(mizu, coder))
|
||||
// Selection was already null, so no change is reported; the contract we
|
||||
// care about is that it stays null and never coerces to a list entry.
|
||||
assertFalse("server-default selection was already null — no change", changed)
|
||||
assertNull(controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
// --- unlockProfile re-enables free selection ----------------------------
|
||||
|
||||
@Test
|
||||
fun unlockProfile_clearsLock_andReenablesSelectProfile() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
awaitLocked("mizu")
|
||||
awaitSelected("mizu")
|
||||
|
||||
runBlocking { controller.unlockProfile() }
|
||||
// Lock cleared (back to unlocked / null) and isProfileLocked flips false.
|
||||
assertNull(awaitLocked(null))
|
||||
assertFalse(awaitFlow(controller.isProfileLocked) { !it })
|
||||
|
||||
// selectProfile to a different target is now honored.
|
||||
controller.selectProfile(coder)
|
||||
assertEquals(coder, controller.selectedProfile.value)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("com.android.library") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.4.0" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.0" apply false
|
||||
}
|
||||
|
||||
+63
-6
@@ -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)
|
||||
|
||||
@@ -387,6 +387,63 @@ Toolsets: 18 (12 enabled)
|
||||
|
||||
Pass `--verbose` to list every tool inside each toolset.
|
||||
|
||||
### Audit — what the agent ran on this machine
|
||||
|
||||
```sh
|
||||
hermes-relay audit # last 50 desktop-tool calls
|
||||
hermes-relay audit --limit 20
|
||||
hermes-relay audit --json
|
||||
```
|
||||
|
||||
```
|
||||
Desktop-tool activity (4 most recent)
|
||||
|
||||
WHEN TOOL STATUS DETAIL
|
||||
12s ago desktop_read_file ● ok path=C:\src\app.ts
|
||||
10s ago desktop_terminal ● ok exit 0
|
||||
8s ago desktop_write_file ✗ error EACCES: permission denied
|
||||
2s ago desktop_search ● ok pattern=TODO
|
||||
```
|
||||
|
||||
Read from a local log (`~/.hermes/desktop-audit.jsonl`) the tool router writes whenever the agent runs a `desktop_*` tool — no network, no auth, works whether the relay is local or remote.
|
||||
|
||||
### Relay — inspect the server
|
||||
|
||||
```sh
|
||||
hermes-relay relay context # what context the relay injects into the agent's prompt
|
||||
hermes-relay relay info # version, uptime, sessions (run on the relay host)
|
||||
hermes-relay relay security # runtime auth toggles (run on the relay host)
|
||||
```
|
||||
|
||||
`relay context` works from any paired machine; `relay info` / `relay security` are loopback-only (for operators on the relay host) and say so if reached remotely.
|
||||
|
||||
### Daemon — background tool router
|
||||
|
||||
```sh
|
||||
hermes-relay daemon start # run in the background (no console window)
|
||||
hermes-relay daemon status # state + uptime of the running daemon
|
||||
hermes-relay daemon stop # stop it
|
||||
hermes-relay daemon # run in the FOREGROUND (current console)
|
||||
```
|
||||
|
||||
`daemon start` detaches the headless tool router so it keeps running after you close the terminal — the agent can reach your machine any time, not just while a shell is open. It logs to `~/.hermes/daemon.log`. Bare `hermes-relay daemon` still runs in the foreground (handy for watching logs live or running under your own supervisor).
|
||||
|
||||
```
|
||||
$ hermes-relay daemon status
|
||||
hermes-relay daemon
|
||||
state: ● connected
|
||||
pid: 48213
|
||||
relay: ws://192.168.1.100:8767
|
||||
uptime: 3h 12m
|
||||
updated: 4s ago
|
||||
server: 1.2.0
|
||||
tools: 23 advertised
|
||||
```
|
||||
|
||||
`status` reads the heartbeat file a running daemon maintains and cross-checks that the pid is alive — it exits non-zero (and says "not running") when the daemon is gone, so scripts can branch on it.
|
||||
|
||||
> **Auto-start on boot/login** (survive a reboot, not just a closed terminal) needs an OS service — a Windows service, a systemd user unit, or a launchd agent. Those installers aren't shipped yet; for now `daemon start` covers "background process, this session."
|
||||
|
||||
## Flags and environment
|
||||
|
||||
| Flag | Env | Purpose |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.3.0-alpha.18",
|
||||
"version": "0.4.0-alpha.1",
|
||||
"description": "Thin-client CLI for Hermes-Relay — talk to a remote Hermes agent over WSS with pairing auth, stream-renders tool calls and responses to plain stdout.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -42,6 +42,7 @@
|
||||
"smoke": "npm run build:bin:win && node -e \"const{execFileSync}=require('child_process');const bin='./dist/bin/hermes-relay-win-x64.exe';const pkg=require('./package.json');for(const a of [['--version'],['--help'],['doctor'],['workspace']]){const out=execFileSync(bin,a,{encoding:'utf8'});if(!out||out.length<10)throw new Error('smoke FAIL: '+bin+' '+a.join(' ')+' produced no output');console.log('smoke OK: '+a.join(' ')+' ('+out.split('\\n')[0]+')')}const updOut=execFileSync(bin,['update','--check','--json'],{encoding:'utf8'});const parsed=JSON.parse(updOut);if(parsed.current!==pkg.version)throw new Error('smoke FAIL: update --check --json current='+parsed.current+' != package.json version='+pkg.version);console.log('smoke OK: update --check --json (current='+parsed.current+', up_to_date='+parsed.up_to_date+')')\"",
|
||||
"prepublishOnly": "npm run build",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"dev:install": "node scripts/dev-install.mjs",
|
||||
"type-check": "tsc --noEmit -p tsconfig.json",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
// Local dev install — build the bun binary for THIS platform and drop it over
|
||||
// the curl-installed binary at ~/.hermes/bin/, so `hermes-relay` on your PATH
|
||||
// runs your working tree. This is the "test my changes as the REAL binary"
|
||||
// loop; it is NOT the release path (that's .github/workflows/release-desktop.yml).
|
||||
//
|
||||
// npm run dev:install # build + replace the installed binary
|
||||
//
|
||||
// The previous binary is saved next to it as `.bak` (reversible). On Windows a
|
||||
// running daemon locks the .exe — this surfaces that clearly instead of EBUSY.
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||
import { arch, homedir, platform } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const plat = platform()
|
||||
const isWin = plat === 'win32'
|
||||
|
||||
// Map the current platform to the package.json build target + its artifact name.
|
||||
const target = {
|
||||
win32: { script: 'build:bin:win', out: 'hermes-relay-win-x64.exe' },
|
||||
linux: { script: 'build:bin:linux', out: 'hermes-relay-linux-x64' },
|
||||
darwin:
|
||||
arch() === 'arm64'
|
||||
? { script: 'build:bin:mac-arm', out: 'hermes-relay-darwin-arm64' }
|
||||
: { script: 'build:bin:mac-x64', out: 'hermes-relay-darwin-x64' }
|
||||
}[plat]
|
||||
|
||||
if (!target) {
|
||||
console.error(`dev-install: unsupported platform ${plat}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const binDir = join(homedir(), '.hermes', 'bin')
|
||||
const installed = join(binDir, isWin ? 'hermes-relay.exe' : 'hermes-relay')
|
||||
const backup = installed + '.bak'
|
||||
const built = join('dist', 'bin', target.out)
|
||||
|
||||
console.log(`dev-install: building ${target.script} (bun --compile)…`)
|
||||
execSync(`npm run ${target.script}`, { stdio: 'inherit' })
|
||||
|
||||
if (!existsSync(built)) {
|
||||
console.error(`dev-install: expected build artifact is missing: ${built}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
|
||||
// Move the current binary aside (reversible). On Windows you can't overwrite a
|
||||
// running .exe, so a held lock means a live daemon — say so plainly.
|
||||
if (existsSync(installed)) {
|
||||
if (existsSync(backup)) {
|
||||
rmSync(backup, { force: true })
|
||||
}
|
||||
try {
|
||||
renameSync(installed, backup)
|
||||
} catch (e) {
|
||||
console.error(`dev-install: couldn't move the current binary aside (${e.code}).`)
|
||||
if (e.code === 'EBUSY' || e.code === 'EPERM') {
|
||||
console.error(' A daemon is probably running from it. Check + stop it first:')
|
||||
console.error(' hermes-relay daemon --status')
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
copyFileSync(built, installed)
|
||||
if (!isWin) {
|
||||
execSync(`chmod +x "${installed}"`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`dev-install: copy failed (${e.code}); restoring the previous binary.`)
|
||||
if (existsSync(backup)) {
|
||||
renameSync(backup, installed)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sizeMb = (statSync(installed).size / (1024 * 1024)).toFixed(0)
|
||||
console.log(`dev-install: installed ${installed} (${sizeMb} MB)`)
|
||||
try {
|
||||
const ver = execSync(`"${installed}" --version`, { encoding: 'utf8' }).trim()
|
||||
console.log(`dev-install: ${ver}`)
|
||||
} catch {
|
||||
/* version readback is best-effort */
|
||||
}
|
||||
console.log(`dev-install: done — previous binary saved as ${backup} (delete when happy).`)
|
||||
+39
-7
@@ -3,6 +3,7 @@
|
||||
// lands in hermes_cli/main.py — the proper home for a full CLI") but with
|
||||
// subcommands because a thin client has actual verbs (pair, status, tools).
|
||||
|
||||
import { auditCommand } from './commands/audit.js'
|
||||
import { chatCommand } from './commands/chat.js'
|
||||
import { chatWorkerCommand } from './commands/chatWorker.js'
|
||||
import { daemonCommand } from './commands/daemon.js'
|
||||
@@ -11,6 +12,7 @@ import { doctorCommand } from './commands/doctor.js'
|
||||
import { pairCommand } from './commands/pair.js'
|
||||
import { pasteCommand } from './commands/paste.js'
|
||||
import { pluginsCommand } from './commands/plugins.js'
|
||||
import { relayCommand } from './commands/relay.js'
|
||||
import { sessionsCommand } from './commands/sessions.js'
|
||||
import { shellCommand } from './commands/shell.js'
|
||||
import { statusCommand } from './commands/status.js'
|
||||
@@ -18,6 +20,8 @@ import { toolsCommand } from './commands/tools.js'
|
||||
import { updateCommand } from './commands/update.js'
|
||||
import { voiceCommand } from './commands/voice.js'
|
||||
import { workspaceCommand } from './commands/workspace.js'
|
||||
import { renderLogo } from './lib/logo.js'
|
||||
import { theme as makeTheme } from './lib/theme.js'
|
||||
import { finalizePendingUpdate } from './updater.js'
|
||||
import { VERSION } from './version.js'
|
||||
|
||||
@@ -57,6 +61,8 @@ const BOOLEAN_FLAGS = new Set([
|
||||
'auto-grant-tools',
|
||||
'log-human',
|
||||
'log-json',
|
||||
'status',
|
||||
'detach',
|
||||
'allow-tools',
|
||||
'allow-computer-use',
|
||||
'experimental-computer-use',
|
||||
@@ -126,6 +132,7 @@ function parseArgs(argv: string[]): ParsedArgs {
|
||||
}
|
||||
|
||||
const KNOWN_COMMANDS = new Set([
|
||||
'audit',
|
||||
'chat',
|
||||
'chat-worker',
|
||||
'daemon',
|
||||
@@ -133,6 +140,7 @@ const KNOWN_COMMANDS = new Set([
|
||||
'doctor',
|
||||
'paste',
|
||||
'pair',
|
||||
'relay',
|
||||
'sessions',
|
||||
'shell',
|
||||
'plugins',
|
||||
@@ -156,13 +164,16 @@ Usage:
|
||||
hermes-relay sessions List / resume / create / kill TUI tmux sessions
|
||||
hermes-relay status Show stored sessions + grants + TTL
|
||||
hermes-relay tools List tools available on the server
|
||||
hermes-relay audit Show what the agent ran on this machine (desktop tools)
|
||||
hermes-relay devices List / revoke / extend server-side paired devices
|
||||
hermes-relay daemon Run headless — expose desktop tools even when no shell is open
|
||||
hermes-relay relay Inspect the relay server (info / security / injected context)
|
||||
hermes-relay daemon [start|stop|status] Headless tool router — 'start' runs it in the background
|
||||
hermes-relay doctor Diagnostic report: version, paths, sessions, daemon status
|
||||
hermes-relay update Check for and install the latest cli-v* release
|
||||
hermes-relay voice Show native Hermes voice config (STT/TTS/realtime providers)
|
||||
hermes-relay voice mode Push-to-talk in a browser tab (proxied through this CLI)
|
||||
hermes-relay workspace Print local workspace context (cwd, git, editor, shell) — --json for scripting
|
||||
hermes-relay logo Print the Hermes Relay banner
|
||||
hermes-relay help Show this help
|
||||
hermes-relay --version Print version and exit
|
||||
|
||||
@@ -184,8 +195,14 @@ Flags:
|
||||
--no-tools chat/shell: disable local tool handlers (fs, exec, search)
|
||||
--experimental-computer-use
|
||||
chat/shell/daemon: advertise experimental desktop_computer_*
|
||||
tools after normal desktop-tool consent. Host input still
|
||||
requires task grant plus visible local approval.
|
||||
tools (screenshots + mouse/keyboard). Three-stage safety:
|
||||
1. observe — desktop_computer_screenshot/status need only
|
||||
the normal desktop-tool consent (no extra approval).
|
||||
2. grant — desktop_computer_grant_request(mode=assist|control)
|
||||
pops a visible local prompt you approve (or a file-bridge
|
||||
in headless via HERMES_RELAY_GRANT_BRIDGE_DIR).
|
||||
3. act — desktop_computer_action runs only while a grant is
|
||||
live (default 15 min); desktop_computer_cancel ends it.
|
||||
Env: HERMES_RELAY_EXPERIMENTAL_COMPUTER_USE=1
|
||||
--no-computer-use Disable computer-use advertisement even if env enabled.
|
||||
--grant-tools pair: prompt for desktop-tool consent during pairing (TTY required;
|
||||
@@ -205,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
|
||||
@@ -231,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:
|
||||
@@ -253,8 +270,19 @@ export async function main(argv = process.argv): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (args.flags.help || args.command === 'help') {
|
||||
process.stdout.write(HELP)
|
||||
const noColor = !!args.flags['no-color']
|
||||
|
||||
// Global help only when there is no command (bare `--help` / `help`). When a
|
||||
// command is present, `--help` falls through to it so each subcommand can
|
||||
// print its own usage (e.g. `hermes-relay devices --help`).
|
||||
if ((args.flags.help && !args.command) || args.command === 'help') {
|
||||
process.stdout.write(renderLogo({ theme: makeTheme({ noColor }), subtitle: false }) + '\n' + HELP)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Explicit on-demand logo (handy for screenshots / docs).
|
||||
if (args.command === 'logo' || args.command === 'banner') {
|
||||
process.stdout.write(renderLogo({ theme: makeTheme({ noColor }) }))
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -278,6 +306,8 @@ export async function main(argv = process.argv): Promise<number> {
|
||||
}
|
||||
|
||||
switch (args.command) {
|
||||
case 'audit':
|
||||
return auditCommand(args)
|
||||
case 'chat':
|
||||
return chatCommand(args)
|
||||
case 'chat-worker':
|
||||
@@ -294,6 +324,8 @@ export async function main(argv = process.argv): Promise<number> {
|
||||
return pasteCommand(args)
|
||||
case 'plugins':
|
||||
return pluginsCommand(args)
|
||||
case 'relay':
|
||||
return relayCommand(args)
|
||||
case 'sessions':
|
||||
return sessionsCommand(args)
|
||||
case 'shell':
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// audit — show what the remote Hermes agent has run on THIS machine via the
|
||||
// desktop tool router. Reads the local JSONL written by DesktopToolRouter
|
||||
// (~/.hermes/desktop-audit.jsonl) — no network, no auth, works whether the
|
||||
// relay is local or remote. Answers the "what did the agent just do?" question
|
||||
// the audit flagged as the biggest desktop-tools transparency gap.
|
||||
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { auditLogPath, readRecentAudit } from '../lib/auditLog.js'
|
||||
import { renderTable } from '../lib/table.js'
|
||||
import { SYMBOLS, theme as makeTheme } from '../lib/theme.js'
|
||||
import { printUsage, type UsageSpec } from '../lib/usage.js'
|
||||
|
||||
const AUDIT_USAGE: UsageSpec = {
|
||||
name: 'audit',
|
||||
summary: 'show recent desktop-tool activity the agent ran on this machine',
|
||||
usage: ['audit [--limit <n>] [--json]'],
|
||||
flags: [
|
||||
{ flag: '--limit <n>', desc: 'How many recent entries to show (default 50)' },
|
||||
{ flag: '--json', desc: 'Emit raw audit entries as JSON' }
|
||||
],
|
||||
examples: ['hermes-relay audit', 'hermes-relay audit --limit 20']
|
||||
}
|
||||
|
||||
function humanAge(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000))
|
||||
if (s < 60) return `${s}s`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`
|
||||
if (s < 86_400) return `${Math.floor(s / 3600)}h`
|
||||
return `${Math.floor(s / 86_400)}d`
|
||||
}
|
||||
|
||||
export async function auditCommand(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (args.flags.help) {
|
||||
printUsage(AUDIT_USAGE, t)
|
||||
return 0
|
||||
}
|
||||
|
||||
const rawLimit = typeof args.flags.limit === 'string' ? parseInt(args.flags.limit, 10) : 50
|
||||
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 50
|
||||
|
||||
const entries = await readRecentAudit(limit)
|
||||
|
||||
if (args.flags.json) {
|
||||
process.stdout.write(JSON.stringify(entries, null, 2) + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
process.stdout.write(
|
||||
t.muted('No desktop-tool activity recorded on this machine yet.') + '\n' +
|
||||
t.muted(` (log: ${auditLogPath()} — written when the agent runs a desktop_* tool)`) + '\n'
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const rows = entries.map((e) => {
|
||||
const status = e.ok
|
||||
? `${t.statusDot(true)} ok`
|
||||
: e.aborted
|
||||
? `${t.warn(SYMBOLS.warn)} aborted`
|
||||
: `${t.err(SYMBOLS.err)} error`
|
||||
const detail = e.error ?? e.summary ?? e.args_preview ?? ''
|
||||
return [`${humanAge(now - e.ts)} ago`, e.tool, status, detail]
|
||||
})
|
||||
|
||||
process.stdout.write(t.bold(`Desktop-tool activity (${entries.length} most recent)`) + '\n\n')
|
||||
process.stdout.write(
|
||||
renderTable(
|
||||
[
|
||||
{ header: 'WHEN', align: 'right' },
|
||||
{ header: 'TOOL' },
|
||||
{ header: 'STATUS' },
|
||||
{ header: 'DETAIL' }
|
||||
],
|
||||
rows,
|
||||
{ theme: t }
|
||||
) + '\n'
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
export default auditCommand
|
||||
@@ -25,7 +25,9 @@ import type {
|
||||
SessionResumeResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { setupGracefulExit } from '../lib/gracefulExit.js'
|
||||
import { renderLogo } from '../lib/logo.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { theme as makeTheme } from '../lib/theme.js'
|
||||
import { deleteSession, saveSession } from '../remoteSessions.js'
|
||||
import { CliRenderer } from '../renderer.js'
|
||||
import { fetchRecentSessions, pickSession } from '../sessionPicker.js'
|
||||
@@ -549,6 +551,7 @@ export async function chatCommand(args: ParsedArgs): Promise<number> {
|
||||
}
|
||||
|
||||
// REPL mode.
|
||||
process.stderr.write('\n' + renderLogo({ theme: makeTheme({ noColor: !!args.flags['no-color'] }) }))
|
||||
process.stderr.write(
|
||||
'\nType a message. Ctrl+C to interrupt a turn, /help for slash commands, /quit to exit.\n'
|
||||
)
|
||||
|
||||
@@ -31,14 +31,25 @@
|
||||
// after the shell detaches; see roadmap for pause-while-interactive).
|
||||
// - --log-file <path>: for now, redirect stderr if you need a file.
|
||||
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { openSync, promises as fs } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { GatewayClient } from '../gatewayClient.js'
|
||||
import type { GatewayEvent, SessionCreateResponse } from '../gatewayTypes.js'
|
||||
import {
|
||||
clearDaemonStatus,
|
||||
isPidAlive,
|
||||
readDaemonStatus,
|
||||
writeDaemonStatus,
|
||||
type DaemonState,
|
||||
type DaemonStatus
|
||||
} from '../lib/daemonStatus.js'
|
||||
import { rpcErrorMessage, asRpcResult } from '../lib/rpc.js'
|
||||
import { theme as makeTheme } from '../lib/theme.js'
|
||||
import { printUsage, type UsageSpec } from '../lib/usage.js'
|
||||
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
|
||||
import { getSession } from '../remoteSessions.js'
|
||||
import {
|
||||
@@ -54,6 +65,33 @@ import { startVoiceServer, type VoiceServer } from '../voiceServer.js'
|
||||
|
||||
const VOICE_DISCOVERY_FILE = 'desktop-voice.json'
|
||||
|
||||
/** Refresh the status-file `updated_at` on this cadence so `--status` can tell
|
||||
* a live daemon from a crashed one whose file lingers. */
|
||||
const STATUS_HEARTBEAT_MS = 30_000
|
||||
|
||||
const DAEMON_USAGE: UsageSpec = {
|
||||
name: 'daemon',
|
||||
summary: 'run headless — expose desktop tools to the agent even when no shell is open',
|
||||
usage: ['daemon [run]', 'daemon start', 'daemon stop', 'daemon status'],
|
||||
subcommands: [
|
||||
{ verb: 'run', desc: 'Run in the foreground (current console; default)' },
|
||||
{ verb: 'start', desc: 'Start in the background — no console window; survives terminal close' },
|
||||
{ verb: 'stop', desc: 'Stop the background daemon' },
|
||||
{ verb: 'status', desc: 'Print state + uptime of the running daemon (alias: --status)' }
|
||||
],
|
||||
flags: [
|
||||
{ flag: '--detach', desc: 'Alias for `daemon start` — run in the background' },
|
||||
{ flag: '--remote <url>', desc: 'Relay to connect to (default: stored/active session)' },
|
||||
{ flag: '--token <token>', desc: 'Use an explicit session token (CI/provisioning)' },
|
||||
{ flag: '--allow-tools', desc: 'Skip the stored-consent gate (only with --token; implies trust)' },
|
||||
{ flag: '--no-voice', desc: 'Do not start the loopback voice server' },
|
||||
{ flag: '--log-human', desc: 'Human-readable logs (auto on a TTY)' },
|
||||
{ flag: '--log-json', desc: 'Force JSON-line logs even on a TTY' },
|
||||
{ flag: '--experimental-computer-use', desc: 'Also advertise computer-use tools (see top-level help)' }
|
||||
],
|
||||
examples: ['hermes-relay daemon start', 'hermes-relay daemon status', 'hermes-relay daemon stop']
|
||||
}
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error'
|
||||
|
||||
interface LogFields {
|
||||
@@ -95,7 +133,184 @@ function resolveRemoteOrNull(args: ParsedArgs): string | null {
|
||||
return url ? url.trim() : null
|
||||
}
|
||||
|
||||
function fmtAge(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
||||
if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`
|
||||
return `${Math.floor(seconds / 86_400)}d`
|
||||
}
|
||||
|
||||
/** `daemon --status` — read the status file and report. Exit 0 if a daemon is
|
||||
* live, 1 if the file is stale (pid gone) so scripts can branch on it. */
|
||||
async function printDaemonStatus(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
const status = await readDaemonStatus()
|
||||
if (!status) {
|
||||
process.stdout.write(
|
||||
t.muted('No daemon status file — the daemon is not running (or has never run).') + '\n'
|
||||
)
|
||||
return 1
|
||||
}
|
||||
const alive = isPidAlive(status.pid)
|
||||
if (args.flags.json) {
|
||||
process.stdout.write(JSON.stringify({ ...status, alive }, null, 2) + '\n')
|
||||
return alive ? 0 : 1
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const staleSec = Math.max(0, now - status.updated_at)
|
||||
const stale = staleSec > (STATUS_HEARTBEAT_MS / 1000) * 3
|
||||
const kv = (label: string, value: string): string => ` ${t.muted((label + ':').padEnd(9))} ${value}`
|
||||
|
||||
process.stdout.write(t.bold('hermes-relay daemon') + '\n')
|
||||
if (!alive) {
|
||||
process.stdout.write(kv('state', `${t.err('not running')} ${t.muted(`(pid ${status.pid} gone — stale file)`)}`) + '\n')
|
||||
} else {
|
||||
const label =
|
||||
status.state === 'connected'
|
||||
? t.ok('connected')
|
||||
: status.state === 'reconnecting'
|
||||
? t.warn('reconnecting')
|
||||
: status.state
|
||||
process.stdout.write(
|
||||
kv('state', `${t.statusDot(status.state === 'connected')} ${label}${stale ? t.warn(' (heartbeat stale)') : ''}`) + '\n'
|
||||
)
|
||||
}
|
||||
process.stdout.write(kv('pid', String(status.pid)) + '\n')
|
||||
process.stdout.write(kv('relay', status.url) + '\n')
|
||||
process.stdout.write(kv('uptime', fmtAge(Math.max(0, now - status.started_at))) + '\n')
|
||||
process.stdout.write(kv('updated', `${fmtAge(staleSec)} ago`) + '\n')
|
||||
if (status.server_version) {
|
||||
process.stdout.write(kv('server', status.server_version) + '\n')
|
||||
}
|
||||
if (typeof status.advertised_tools === 'number') {
|
||||
process.stdout.write(kv('tools', `${status.advertised_tools} advertised`) + '\n')
|
||||
}
|
||||
if (status.voice_url) {
|
||||
process.stdout.write(kv('voice', status.voice_url) + '\n')
|
||||
}
|
||||
return alive ? 0 : 1
|
||||
}
|
||||
|
||||
function daemonLogPath(): string {
|
||||
return path.join(os.homedir(), '.hermes', 'daemon.log')
|
||||
}
|
||||
|
||||
/** Rebuild the child argv for the foreground daemon from this invocation's
|
||||
* flags, so `daemon start --remote … --experimental-computer-use` forwards. */
|
||||
function buildDaemonChildArgs(args: ParsedArgs): string[] {
|
||||
const out: string[] = ['daemon']
|
||||
const fwdValue = (name: string) => {
|
||||
const v = args.flags[name]
|
||||
if (typeof v === 'string') {
|
||||
out.push(`--${name}`, v)
|
||||
}
|
||||
}
|
||||
const fwdBool = (name: string) => {
|
||||
if (args.flags[name] === true) {
|
||||
out.push(`--${name}`)
|
||||
}
|
||||
}
|
||||
fwdValue('remote')
|
||||
fwdValue('token')
|
||||
for (const f of [
|
||||
'allow-tools',
|
||||
'no-voice',
|
||||
'log-json',
|
||||
'log-human',
|
||||
'experimental-computer-use',
|
||||
'no-computer-use',
|
||||
'no-color'
|
||||
]) {
|
||||
fwdBool(f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** `daemon start` / `--detach` — spawn the foreground daemon as a detached
|
||||
* background process (no console window on Windows), logging to a file. */
|
||||
async function startDetachedDaemon(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
|
||||
const existing = await readDaemonStatus()
|
||||
if (existing && isPidAlive(existing.pid)) {
|
||||
process.stderr.write(
|
||||
t.warnLine(`daemon already running (pid ${existing.pid}) — stop it first: hermes-relay daemon stop`) + '\n'
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
const logPath = daemonLogPath()
|
||||
try {
|
||||
await fs.mkdir(path.dirname(logPath), { recursive: true })
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
const logFd = openSync(logPath, 'a')
|
||||
|
||||
// Compiled binary: the entry is embedded, so exe + args is enough. Running
|
||||
// via node/tsx during dev: include the script path so the child re-enters
|
||||
// the CLI (`node dist/cli.js daemon …`).
|
||||
const childArgs = buildDaemonChildArgs(args)
|
||||
const execIsNode = /node(\.exe)?$/i.test(path.basename(process.execPath))
|
||||
const spawnArgs = execIsNode ? [process.argv[1] ?? '', ...childArgs] : childArgs
|
||||
|
||||
const child = spawn(process.execPath, spawnArgs, {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
windowsHide: true
|
||||
})
|
||||
child.unref()
|
||||
|
||||
process.stdout.write(t.okLine(`daemon started in the background (pid ${child.pid})`) + '\n')
|
||||
process.stdout.write(t.muted(` logs: ${logPath}`) + '\n')
|
||||
process.stdout.write(t.muted(' status: hermes-relay daemon status') + '\n')
|
||||
process.stdout.write(t.muted(' stop: hermes-relay daemon stop') + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
/** `daemon stop` — terminate the running background daemon by its status pid. */
|
||||
async function stopDaemon(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
const status = await readDaemonStatus()
|
||||
if (!status) {
|
||||
process.stdout.write(t.muted('No daemon status file — nothing to stop.') + '\n')
|
||||
return 1
|
||||
}
|
||||
if (!isPidAlive(status.pid)) {
|
||||
await clearDaemonStatus()
|
||||
process.stdout.write(t.muted(`Daemon (pid ${status.pid}) is already gone — cleared stale status.`) + '\n')
|
||||
return 0
|
||||
}
|
||||
try {
|
||||
// Default SIGTERM; on Windows this terminates the process. The daemon's own
|
||||
// cleanup may not run on a hard Windows terminate, so we clear status here.
|
||||
process.kill(status.pid)
|
||||
} catch (e) {
|
||||
process.stderr.write(t.err(`failed to stop daemon pid ${status.pid}: ${(e as Error).message}`) + '\n')
|
||||
return 1
|
||||
}
|
||||
await clearDaemonStatus()
|
||||
process.stdout.write(t.okLine(`stopped daemon (pid ${status.pid})`) + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
export async function daemonCommand(args: ParsedArgs): Promise<number> {
|
||||
if (args.flags.help) {
|
||||
printUsage(DAEMON_USAGE, makeTheme({ noColor: !!args.flags['no-color'] }))
|
||||
return 0
|
||||
}
|
||||
const sub = args.positional[0]
|
||||
if (args.flags.status || sub === 'status') {
|
||||
return printDaemonStatus(args)
|
||||
}
|
||||
if (sub === 'stop') {
|
||||
return stopDaemon(args)
|
||||
}
|
||||
if (sub === 'start' || args.flags.detach) {
|
||||
return startDetachedDaemon(args)
|
||||
}
|
||||
// Bare `daemon` (or `daemon run`) → foreground, the existing behavior below.
|
||||
|
||||
// Default log shape: JSON-line for service-manager deploys, human if a
|
||||
// human is watching (TTY stderr) or asked for it explicitly.
|
||||
const humanFlag = !!args.flags['log-human']
|
||||
@@ -182,6 +397,22 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
|
||||
node: process.version
|
||||
})
|
||||
|
||||
// Observable status file — `hermes-relay daemon --status` reads this.
|
||||
const nowSec = () => Math.floor(Date.now() / 1000)
|
||||
const status: DaemonStatus = {
|
||||
pid: process.pid,
|
||||
url,
|
||||
state: 'starting',
|
||||
started_at: nowSec(),
|
||||
updated_at: nowSec(),
|
||||
last_event: 'starting'
|
||||
}
|
||||
const updateStatus = (partial: Partial<DaemonStatus> & { state?: DaemonState }) => {
|
||||
Object.assign(status, partial, { updated_at: nowSec() })
|
||||
void writeDaemonStatus(status)
|
||||
}
|
||||
updateStatus({})
|
||||
|
||||
const relay = new RelayTransport({
|
||||
url,
|
||||
sessionToken: token,
|
||||
@@ -197,9 +428,11 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
|
||||
? (info as { attempt?: number; delayMs?: number })
|
||||
: {}
|
||||
log.warn({ event: 'reconnecting', attempt: attempt ?? null, delay_ms: delayMs ?? null })
|
||||
updateStatus({ state: 'reconnecting', last_event: 'reconnecting' })
|
||||
})
|
||||
relay.on('reconnected', () => {
|
||||
log.info({ event: 'reconnected' })
|
||||
updateStatus({ state: 'connected', last_event: 'reconnected' })
|
||||
})
|
||||
relay.on('exit', (code: unknown) => {
|
||||
// Transport gave up (auth.fail, reconnect gate returned false, or
|
||||
@@ -228,6 +461,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
|
||||
server_version: relay.serverVersion ?? null,
|
||||
transport: relay.authMeta?.transportHint ?? null
|
||||
})
|
||||
updateStatus({ state: 'connected', server_version: relay.serverVersion ?? null, last_event: 'authed' })
|
||||
|
||||
// Signal downstream handlers that we're running headless. The router
|
||||
// also checks this env var in its detectInteractive() fallback, so any
|
||||
@@ -262,6 +496,13 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
|
||||
experimental_computer_use: computerUseEnabled,
|
||||
interactive
|
||||
})
|
||||
updateStatus({ advertised_tools: [...advertisedTools].length, last_event: 'ready' })
|
||||
|
||||
// Keep the status file's updated_at fresh so `--status` can distinguish a
|
||||
// live daemon from a crashed one whose file lingers (belt-and-suspenders
|
||||
// with the pid liveness check).
|
||||
const statusHeartbeat = setInterval(() => updateStatus({}), STATUS_HEARTBEAT_MS)
|
||||
statusHeartbeat.unref?.()
|
||||
|
||||
// ── Voice server ──────────────────────────────────────────────────
|
||||
// Hosts the same loopback HTTP voice surface that `voice mode` starts
|
||||
@@ -296,6 +537,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
|
||||
url: voiceServer.url,
|
||||
session_id: voiceSessionId.slice(0, 8)
|
||||
})
|
||||
updateStatus({ voice_url: voiceServer.url, last_event: 'voice_ready' })
|
||||
} catch (e) {
|
||||
log.warn({
|
||||
event: 'voice_unavailable',
|
||||
@@ -309,6 +551,12 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
|
||||
// (closes the WSS), then let setupGracefulExit's failsafe exit us.
|
||||
const cleanup = async () => {
|
||||
log.info({ event: 'shutdown' })
|
||||
clearInterval(statusHeartbeat)
|
||||
try {
|
||||
await clearDaemonStatus()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (voiceServer) await voiceServer.close()
|
||||
} catch {
|
||||
|
||||
+114
-61
@@ -24,10 +24,39 @@
|
||||
import { humanExpiry } from '../banner.js'
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { getActiveDesktopRelayUrl } from '../desktopConfig.js'
|
||||
import { formatError } from '../lib/hints.js'
|
||||
import { renderTable } from '../lib/table.js'
|
||||
import { theme as makeTheme } from '../lib/theme.js'
|
||||
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
|
||||
import { getSession, listSessions } from '../remoteSessions.js'
|
||||
|
||||
const DEFAULT_EXTEND_TTL_SECONDS = 24 * 3600
|
||||
|
||||
const DEVICES_USAGE: UsageSpec = {
|
||||
name: 'devices',
|
||||
summary: 'manage the devices paired with a relay (server-side sessions)',
|
||||
usage: [
|
||||
'devices [list]',
|
||||
'devices revoke <prefix>',
|
||||
'devices extend <prefix> [--ttl <seconds>]'
|
||||
],
|
||||
subcommands: [
|
||||
{ verb: 'list', desc: 'List paired devices (default)' },
|
||||
{ verb: 'revoke <prefix>', desc: 'Delete a session token by prefix' },
|
||||
{ verb: 'extend <prefix>', desc: 'Push out a session expiry (default +24h)' }
|
||||
],
|
||||
flags: [
|
||||
{ flag: '--remote <url>', desc: 'Relay to target (default: tray-active or sole stored)' },
|
||||
{ flag: '--ttl <seconds>', desc: 'extend: new TTL in seconds (default 86400)' },
|
||||
{ flag: '--json', desc: 'Machine-readable output' }
|
||||
],
|
||||
examples: [
|
||||
'hermes-relay devices',
|
||||
'hermes-relay devices revoke e35a85b2',
|
||||
'hermes-relay devices extend e35a85b2 --ttl 604800'
|
||||
]
|
||||
}
|
||||
|
||||
interface ServerSession {
|
||||
token_prefix: string
|
||||
device_name?: string
|
||||
@@ -133,6 +162,19 @@ async function jsonFetch(
|
||||
return { status: res.status, body }
|
||||
}
|
||||
|
||||
function humanAge(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
return `${Math.floor(seconds / 60)}m`
|
||||
}
|
||||
if (seconds < 86_400) {
|
||||
return `${Math.floor(seconds / 3600)}h`
|
||||
}
|
||||
return `${Math.floor(seconds / 86_400)}d`
|
||||
}
|
||||
|
||||
async function listDevices(args: ParsedArgs): Promise<number> {
|
||||
const { url, token } = await resolveRemoteAndToken(args)
|
||||
const httpBase = wsToHttp(url)
|
||||
@@ -150,52 +192,63 @@ async function listDevices(args: ParsedArgs): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
|
||||
if (sessions.length === 0) {
|
||||
process.stdout.write(`(no paired devices on ${url})\n`)
|
||||
process.stdout.write(t.muted(`(no paired devices on ${url})`) + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
process.stdout.write(`Devices paired with ${url} (${sessions.length}):\n\n`)
|
||||
for (const s of sessions) {
|
||||
const tag = s.is_current ? ' ● (this device)' : ''
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
const rows = sessions.map((s) => {
|
||||
const name = s.device_name ?? '(unnamed)'
|
||||
process.stdout.write(` ${s.token_prefix} ${name}${tag}\n`)
|
||||
if (s.last_seen) {
|
||||
const ageSec = Math.floor(Date.now() / 1000) - s.last_seen
|
||||
const ageHuman =
|
||||
ageSec < 60
|
||||
? `${ageSec}s`
|
||||
: ageSec < 3600
|
||||
? `${Math.floor(ageSec / 60)}m`
|
||||
: ageSec < 86_400
|
||||
? `${Math.floor(ageSec / 3600)}h`
|
||||
: `${Math.floor(ageSec / 86_400)}d`
|
||||
process.stdout.write(` last seen: ${ageHuman} ago\n`)
|
||||
}
|
||||
process.stdout.write(` expires: ${humanExpiry(s.expires_at ?? null)}\n`)
|
||||
if (s.transport_hint) {
|
||||
process.stdout.write(` transport: ${s.transport_hint}\n`)
|
||||
}
|
||||
if (s.grants && Object.keys(s.grants).length > 0) {
|
||||
const formatted = Object.entries(s.grants)
|
||||
.map(([k, v]) => `${k}=${v === null ? 'never' : humanExpiry(v)}`)
|
||||
.sort()
|
||||
.join(', ')
|
||||
process.stdout.write(` grants: ${formatted}\n`)
|
||||
}
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
const nameCell = s.is_current ? `${name} ${t.muted('(this device)')}` : name
|
||||
const lastSeen = s.last_seen ? `${humanAge(nowSec - s.last_seen)} ago` : '—'
|
||||
const grants =
|
||||
s.grants && Object.keys(s.grants).length > 0
|
||||
? Object.entries(s.grants)
|
||||
.map(([k, v]) => `${k}=${v === null ? 'never' : humanExpiry(v)}`)
|
||||
.sort()
|
||||
.join(', ')
|
||||
: '—'
|
||||
return [
|
||||
s.token_prefix,
|
||||
nameCell,
|
||||
lastSeen,
|
||||
humanExpiry(s.expires_at ?? null),
|
||||
s.transport_hint ?? '—',
|
||||
grants
|
||||
]
|
||||
})
|
||||
|
||||
process.stdout.write(t.bold(`Devices paired with ${url} (${sessions.length})`) + '\n\n')
|
||||
process.stdout.write(
|
||||
` Use \`hermes-relay devices revoke <prefix>\` to delete a session, or\n` +
|
||||
` \`hermes-relay devices extend <prefix> --ttl <seconds>\` to push the expiry.\n`
|
||||
renderTable(
|
||||
[
|
||||
{ header: 'PREFIX' },
|
||||
{ header: 'DEVICE' },
|
||||
{ header: 'LAST SEEN' },
|
||||
{ header: 'EXPIRES' },
|
||||
{ header: 'TRANSPORT' },
|
||||
{ header: 'GRANTS' }
|
||||
],
|
||||
rows,
|
||||
{ theme: t }
|
||||
) + '\n'
|
||||
)
|
||||
process.stdout.write(
|
||||
'\n' +
|
||||
t.muted('revoke: hermes-relay devices revoke <prefix> extend: … extend <prefix> --ttl <s>') +
|
||||
'\n'
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
async function revokeDevice(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
const prefix = args.positional[0]
|
||||
if (!prefix) {
|
||||
process.stderr.write('error: `devices revoke` needs a token prefix. Run `devices` to see them.\n')
|
||||
process.stderr.write('error: `devices revoke` needs a token prefix. Run `hermes-relay devices` to list them.\n')
|
||||
return 2
|
||||
}
|
||||
const { url, token } = await resolveRemoteAndToken(args)
|
||||
@@ -205,7 +258,9 @@ async function revokeDevice(args: ParsedArgs): Promise<number> {
|
||||
})
|
||||
if (status === 200 || status === 204) {
|
||||
const revokedSelf = typeof body === 'object' && body !== null && (body as Record<string, unknown>).revoked_self === true
|
||||
process.stdout.write(`✓ revoked ${prefix}${revokedSelf ? ' (this device — subsequent commands will re-pair)' : ''}\n`)
|
||||
process.stdout.write(
|
||||
`${t.okLine(`revoked ${prefix}`)}${revokedSelf ? t.muted(' (this device — subsequent commands will re-pair)') : ''}\n`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
if (status === 404) {
|
||||
@@ -221,9 +276,10 @@ async function revokeDevice(args: ParsedArgs): Promise<number> {
|
||||
}
|
||||
|
||||
async function extendDevice(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
const prefix = args.positional[0]
|
||||
if (!prefix) {
|
||||
process.stderr.write('error: `devices extend` needs a token prefix. Run `devices` to see them.\n')
|
||||
process.stderr.write('error: `devices extend` needs a token prefix. Run `hermes-relay devices` to list them.\n')
|
||||
return 2
|
||||
}
|
||||
const rawTtl = typeof args.flags.ttl === 'string' ? args.flags.ttl : null
|
||||
@@ -241,9 +297,9 @@ async function extendDevice(args: ParsedArgs): Promise<number> {
|
||||
if (status === 200) {
|
||||
const expiresAt = (body as { expires_at?: number | null })?.expires_at ?? null
|
||||
process.stdout.write(
|
||||
`✓ extended ${prefix} — now expires ${humanExpiry(expiresAt)}` +
|
||||
(expiresAt === null ? ' (never)' : '') +
|
||||
'\n'
|
||||
t.okLine(
|
||||
`extended ${prefix} — now expires ${humanExpiry(expiresAt)}${expiresAt === null ? ' (never)' : ''}`
|
||||
) + '\n'
|
||||
)
|
||||
return 0
|
||||
}
|
||||
@@ -252,43 +308,40 @@ async function extendDevice(args: ParsedArgs): Promise<number> {
|
||||
}
|
||||
|
||||
export async function devicesCommand(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (args.flags.help) {
|
||||
printUsage(DEVICES_USAGE, t)
|
||||
return 0
|
||||
}
|
||||
|
||||
// The first positional after `devices` is the sub-verb: list (default) /
|
||||
// revoke / extend. Shift it out so the remaining positionals are available
|
||||
// to the sub-handler (which uses positional[0] for the token prefix).
|
||||
const sub = args.positional[0] ?? 'list'
|
||||
const url = typeof args.flags.remote === 'string' ? args.flags.remote : undefined
|
||||
const run = async (fn: (a: ParsedArgs) => Promise<number>): Promise<number> => {
|
||||
try {
|
||||
return await fn(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(formatError(e, { command: 'devices', url }, t) + '\n')
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
if (args.positional.length > 0 && args.positional[0] === 'list') {
|
||||
if (args.positional[0] === 'list') {
|
||||
args.positional.shift()
|
||||
}
|
||||
try {
|
||||
return await listDevices(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`)
|
||||
return 1
|
||||
}
|
||||
return run(listDevices)
|
||||
}
|
||||
|
||||
if (sub === 'revoke') {
|
||||
args.positional.shift()
|
||||
try {
|
||||
return await revokeDevice(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`)
|
||||
return 1
|
||||
}
|
||||
return run(revokeDevice)
|
||||
}
|
||||
|
||||
if (sub === 'extend') {
|
||||
args.positional.shift()
|
||||
try {
|
||||
return await extendDevice(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`)
|
||||
return 1
|
||||
}
|
||||
return run(extendDevice)
|
||||
}
|
||||
|
||||
process.stderr.write(`unknown devices sub-verb "${sub}". Try: list | revoke <prefix> | extend <prefix>\n`)
|
||||
return 2
|
||||
return unknownSubcommand(DEVICES_USAGE, sub, t)
|
||||
}
|
||||
|
||||
@@ -19,12 +19,22 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { humanExpiry } from '../banner.js'
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { theme as makeTheme, type Theme } from '../lib/theme.js'
|
||||
import { printUsage, type UsageSpec } from '../lib/usage.js'
|
||||
import { listSessions } from '../remoteSessions.js'
|
||||
import { VERSION } from '../version.js'
|
||||
import { detectWorkspaceContext, type WorkspaceContext } from '../workspaceContext.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const DOCTOR_USAGE: UsageSpec = {
|
||||
name: 'doctor',
|
||||
summary: 'local diagnostic report: version, binary path, PATH, stored sessions, daemon, workspace',
|
||||
usage: ['doctor [--json]'],
|
||||
flags: [{ flag: '--json', desc: 'Machine-readable report (safe to paste — tokens omitted)' }],
|
||||
examples: ['hermes-relay doctor', 'hermes-relay doctor --json']
|
||||
}
|
||||
|
||||
function readVersion(): string {
|
||||
if (VERSION) {
|
||||
return VERSION
|
||||
@@ -194,19 +204,19 @@ async function gather(): Promise<DoctorReport> {
|
||||
}
|
||||
}
|
||||
|
||||
function renderHuman(report: DoctorReport): string {
|
||||
function renderHuman(report: DoctorReport, t: Theme): string {
|
||||
const lines: string[] = []
|
||||
const hints: string[] = []
|
||||
|
||||
lines.push('hermes-relay doctor')
|
||||
lines.push(` version: ${report.version}`)
|
||||
lines.push(` binary: ${report.binary_path}`)
|
||||
lines.push(` node: ${report.node_version} (${report.platform}/${report.arch})`)
|
||||
lines.push(t.bold('hermes-relay doctor'))
|
||||
lines.push(` ${t.muted('version: ')} ${report.version}`)
|
||||
lines.push(` ${t.muted('binary: ')} ${report.binary_path}`)
|
||||
lines.push(` ${t.muted('node: ')} ${report.node_version} (${report.platform}/${report.arch})`)
|
||||
|
||||
if (report.on_path) {
|
||||
lines.push(` on PATH: yes`)
|
||||
lines.push(` ${t.muted('on PATH: ')} ${t.ok('yes')}`)
|
||||
} else {
|
||||
lines.push(`!! on PATH: no (install_dir: ${report.install_dir})`)
|
||||
lines.push(` ${t.warnLine(`on PATH: ${t.warn('no')} (install_dir: ${report.install_dir})`)}`)
|
||||
hints.push(
|
||||
`add ${report.install_dir} to your PATH, or re-run the installer from desktop/scripts/`
|
||||
)
|
||||
@@ -214,9 +224,9 @@ function renderHuman(report: DoctorReport): string {
|
||||
|
||||
if (report.sessions_file_exists) {
|
||||
const sz = report.sessions_file_size !== null ? ` (${humanSize(report.sessions_file_size)})` : ''
|
||||
lines.push(` sessions file: ${report.sessions_file}${sz}`)
|
||||
lines.push(` ${t.muted('sessions file:')} ${report.sessions_file}${sz}`)
|
||||
} else {
|
||||
lines.push(`!! sessions file: ${report.sessions_file} (missing)`)
|
||||
lines.push(` ${t.warnLine(`sessions file: ${report.sessions_file} (missing)`)}`)
|
||||
hints.push('run `hermes-relay pair --remote <url>` to create it')
|
||||
}
|
||||
|
||||
@@ -277,10 +287,15 @@ function renderHuman(report: DoctorReport): string {
|
||||
lines.push(` shell: ${ws.active_shell}`)
|
||||
}
|
||||
|
||||
// doctor is intentionally zero-network — point at the live-relay checks for
|
||||
// anything that needs to actually talk to the server.
|
||||
lines.push('')
|
||||
lines.push(t.muted(' live relay checks: hermes-relay relay info | relay context | voice'))
|
||||
|
||||
if (hints.length > 0) {
|
||||
lines.push('')
|
||||
for (const hint of hints) {
|
||||
lines.push(`hint: ${hint}`)
|
||||
lines.push(t.warn(`hint: ${hint}`))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +303,11 @@ function renderHuman(report: DoctorReport): string {
|
||||
}
|
||||
|
||||
export async function doctorCommand(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (args.flags.help) {
|
||||
printUsage(DOCTOR_USAGE, t)
|
||||
return 0
|
||||
}
|
||||
const report = await gather()
|
||||
|
||||
if (args.flags.json) {
|
||||
@@ -295,7 +315,7 @@ export async function doctorCommand(args: ParsedArgs): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
|
||||
process.stdout.write(renderHuman(report))
|
||||
process.stdout.write(renderHuman(report, t))
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
// render "Paired via LAN / Tailscale / Public" correctly).
|
||||
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { formatError } from '../lib/hints.js'
|
||||
import { SYMBOLS, theme as makeTheme } from '../lib/theme.js'
|
||||
import { printUsage, type UsageSpec } from '../lib/usage.js'
|
||||
import {
|
||||
cleanCode,
|
||||
isValidCode,
|
||||
@@ -22,15 +25,46 @@ import {
|
||||
probeCandidatesByPriority,
|
||||
relayPairingCodeFromPayload
|
||||
} from '../pairingQr.js'
|
||||
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
|
||||
import { DEFAULT_RELAY_PORT, normalizeRelayUrl, resolveFirstRunUrl } from '../relayUrlPrompt.js'
|
||||
import { saveSession } from '../remoteSessions.js'
|
||||
import { ensureToolsConsent } from '../tools/consent.js'
|
||||
import { RelayTransport } from '../transport/RelayTransport.js'
|
||||
|
||||
const PAIR_USAGE: UsageSpec = {
|
||||
name: 'pair',
|
||||
summary: 'pair with a relay and store a session token',
|
||||
usage: ['pair [CODE] --remote <url>', 'pair --pair-qr "<invite>"'],
|
||||
flags: [
|
||||
{
|
||||
flag: '--pair-qr <invite>',
|
||||
desc: 'Paste a full QR payload or hermes-relay://pair invite (recommended — probes endpoints)'
|
||||
},
|
||||
{ flag: '--remote <url>', desc: 'Relay URL (with [CODE] or an interactive prompt)' },
|
||||
{ flag: '--code <code>', desc: '6-char pairing code (or pass it as the positional arg)' },
|
||||
{
|
||||
flag: '--grant-tools',
|
||||
desc: 'Also grant desktop-tool consent now (TTY prompt) — lets `daemon` work with no `shell` round-trip'
|
||||
},
|
||||
{ flag: '--auto-grant-tools', desc: 'Grant desktop-tool consent without prompting (scripts/CI)' }
|
||||
],
|
||||
examples: [
|
||||
'hermes-relay pair --pair-qr "hermes-relay://pair?payload=…"',
|
||||
'hermes-relay pair --remote ws://192.168.1.50:8767',
|
||||
'hermes-relay pair ABC123 --remote ws://host:8767 --grant-tools'
|
||||
]
|
||||
}
|
||||
|
||||
function resolveRemote(args: ParsedArgs): string | null {
|
||||
const v = args.flags.remote
|
||||
const url = (typeof v === 'string' ? v : null) ?? process.env.HERMES_RELAY_URL ?? null
|
||||
return url ? url.trim() : null
|
||||
const raw = (typeof v === 'string' ? v : null) ?? process.env.HERMES_RELAY_URL ?? null
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const norm = normalizeRelayUrl(raw)
|
||||
if (norm.added) {
|
||||
process.stderr.write(` (no port given — using :${DEFAULT_RELAY_PORT})\n`)
|
||||
}
|
||||
return norm.url
|
||||
}
|
||||
|
||||
interface PairTarget {
|
||||
@@ -61,15 +95,27 @@ async function resolvePairTarget(args: ParsedArgs): Promise<PairTarget | { error
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
process.stderr.write(`Probing ${candidates.length} endpoint(s)...\n`)
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
process.stderr.write(t.bold(`Probing ${candidates.length} endpoint(s)…`) + '\n')
|
||||
let winner
|
||||
try {
|
||||
winner = await probeCandidatesByPriority(candidates)
|
||||
winner = await probeCandidatesByPriority(candidates, {
|
||||
onProbe: (ev) => {
|
||||
const label = `[${ev.index}/${ev.total}] ${ev.candidate.role} ${ev.candidate.relay.url}`
|
||||
if (ev.phase === 'result' && ev.reachable) {
|
||||
process.stderr.write(` ${t.okLine(label)} ${t.muted(`${ev.elapsedMs}ms`)}\n`)
|
||||
} else if (ev.phase === 'result') {
|
||||
process.stderr.write(` ${t.muted(`${SYMBOLS.dot} ${label} — ${ev.error ?? 'unreachable'}`)}\n`)
|
||||
} else if (ev.phase === 'cached') {
|
||||
process.stderr.write(` ${t.okLine(label)} ${t.muted('(cached)')}\n`)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
return { error: `no endpoints reachable: ${e instanceof Error ? e.message : String(e)}` }
|
||||
}
|
||||
process.stderr.write(
|
||||
` → picked ${winner.role} endpoint ${winner.relay.url}\n`
|
||||
` ${t.cyan(SYMBOLS.arrow)} picked ${t.bold(winner.role)} endpoint ${winner.relay.url}\n`
|
||||
)
|
||||
return {
|
||||
url: winner.relay.url,
|
||||
@@ -120,9 +166,14 @@ async function resolvePairTarget(args: ParsedArgs): Promise<PairTarget | { error
|
||||
}
|
||||
|
||||
export async function pairCommand(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (args.flags.help) {
|
||||
printUsage(PAIR_USAGE, t)
|
||||
return 0
|
||||
}
|
||||
const target = await resolvePairTarget(args)
|
||||
if ('error' in target) {
|
||||
process.stderr.write(`error: ${target.error}\n`)
|
||||
process.stderr.write(formatError(target.error, { command: 'pair' }, t) + '\n')
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -135,7 +186,7 @@ export async function pairCommand(args: ParsedArgs): Promise<number> {
|
||||
const autoGrant = !!args.flags['auto-grant-tools']
|
||||
const promptGrant = !!args.flags['grant-tools'] && !autoGrant
|
||||
|
||||
process.stderr.write(`Pairing with ${target.url}...\n`)
|
||||
process.stderr.write(t.muted(`Pairing with ${target.url}…`) + '\n')
|
||||
|
||||
const relay = new RelayTransport({
|
||||
url: target.url,
|
||||
@@ -156,25 +207,32 @@ export async function pairCommand(args: ParsedArgs): Promise<number> {
|
||||
endpointRole: target.endpointRole,
|
||||
...(autoGrant ? { toolsConsented: true } : {})
|
||||
})
|
||||
process.stdout.write(`✓ Paired. Token stored in ~/.hermes/remote-sessions.json\n`)
|
||||
process.stdout.write(` Server: ${outcome.serverVersion ?? '?'}\n`)
|
||||
process.stdout.write(` Relay: ${target.url}\n`)
|
||||
process.stdout.write(t.okLine('Paired. Token stored in ~/.hermes/remote-sessions.json') + '\n')
|
||||
process.stdout.write(t.muted(` server: ${outcome.serverVersion ?? '?'}`) + '\n')
|
||||
process.stdout.write(t.muted(` relay: ${target.url}`) + '\n')
|
||||
if (target.endpointRole) {
|
||||
process.stdout.write(` Route: ${target.endpointRole}\n`)
|
||||
process.stdout.write(t.muted(` route: ${target.endpointRole}`) + '\n')
|
||||
}
|
||||
|
||||
if (autoGrant) {
|
||||
process.stdout.write(`✓ Desktop tool consent granted (--auto-grant-tools).\n`)
|
||||
process.stdout.write(t.okLine('Desktop tool consent granted (--auto-grant-tools).') + '\n')
|
||||
} else if (promptGrant) {
|
||||
const result = await ensureToolsConsent(target.url)
|
||||
if (result.consented) {
|
||||
process.stdout.write(`✓ Desktop tool consent granted.\n`)
|
||||
process.stdout.write(t.okLine('Desktop tool consent granted.') + '\n')
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`! Tool consent not granted: ${result.reason ?? 'declined'}\n` +
|
||||
` Pair succeeded; rerun \`hermes-relay pair --remote ${target.url} --grant-tools\` on a TTY to grant.\n`
|
||||
t.warnLine(`Tool consent not granted: ${result.reason ?? 'declined'}`) + '\n' +
|
||||
t.muted(` Pair succeeded; rerun \`hermes-relay pair --remote ${target.url} --grant-tools\` on a TTY to grant.`) + '\n'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Nudge the daemon-first workflow: most users who pair from a terminal
|
||||
// want desktop tools, and discovering --grant-tools after the fact means
|
||||
// an extra `shell` round-trip. Surface it once, here.
|
||||
process.stdout.write(
|
||||
t.muted(' tip: add --grant-tools to also enable desktop tools (needed for `daemon`).') + '\n'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -185,8 +243,14 @@ export async function pairCommand(args: ParsedArgs): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
|
||||
process.stderr.write(`✗ Pairing failed: ${outcome.reason}\n`)
|
||||
process.stderr.write(` ${relay.getLogTail(5)}\n`)
|
||||
process.stderr.write(t.errLine(`Pairing failed: ${outcome.reason}`) + '\n')
|
||||
const hint = formatError(outcome.reason, { command: 'pair', url: target.url }, t)
|
||||
// formatError repeats the message; only emit the hint line (2nd line) if present.
|
||||
const hintLine = hint.split('\n')[1]
|
||||
if (hintLine) {
|
||||
process.stderr.write(hintLine + '\n')
|
||||
}
|
||||
process.stderr.write(t.muted(` ${relay.getLogTail(5)}`) + '\n')
|
||||
try {
|
||||
relay.kill()
|
||||
} catch {
|
||||
|
||||
@@ -18,8 +18,22 @@
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { captureClipboardImage } from '../chatAttach.js'
|
||||
import { getActiveDesktopRelayUrl } from '../desktopConfig.js'
|
||||
import { theme as makeTheme } from '../lib/theme.js'
|
||||
import { printUsage, type UsageSpec } from '../lib/usage.js'
|
||||
import { getSession, listSessions } from '../remoteSessions.js'
|
||||
|
||||
const PASTE_USAGE: UsageSpec = {
|
||||
name: 'paste',
|
||||
summary: 'stage the local clipboard image for /paste (or Alt+V) in the Hermes TUI',
|
||||
usage: ['paste [--remote <url>]'],
|
||||
flags: [
|
||||
{ flag: '--remote <url>', desc: 'Relay to stage into (default: stored/active)' },
|
||||
{ flag: '--json', desc: 'Machine-readable result' },
|
||||
{ flag: '--quiet', desc: 'Suppress the success line' }
|
||||
],
|
||||
examples: ['hermes-relay paste']
|
||||
}
|
||||
|
||||
function wsToHttp(url: string): string {
|
||||
const trimmed = url.trim()
|
||||
if (trimmed.startsWith('wss://')) return 'https://' + trimmed.slice(6)
|
||||
@@ -140,6 +154,11 @@ export async function stageClipboardImageToInbox(
|
||||
}
|
||||
|
||||
export async function pasteCommand(args: ParsedArgs): Promise<number> {
|
||||
if (args.flags.help) {
|
||||
printUsage(PASTE_USAGE)
|
||||
return 0
|
||||
}
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
const json = !!args.flags.json
|
||||
const quiet = !!args.flags.quiet
|
||||
|
||||
@@ -222,8 +241,8 @@ export async function pasteCommand(args: ParsedArgs): Promise<number> {
|
||||
}) + '\n'
|
||||
)
|
||||
} else if (!quiet) {
|
||||
process.stdout.write(`✓ Image queued for /paste in TUI (${dims}, ${sizeKb} KB)\n`)
|
||||
process.stdout.write(` Type /paste (or Alt+V) in the TUI to attach to next message.\n`)
|
||||
process.stdout.write(t.okLine(`Image queued for /paste in TUI (${dims}, ${sizeKb} KB)`) + '\n')
|
||||
process.stdout.write(t.muted(' Type /paste (or Alt+V) in the TUI to attach to next message.') + '\n')
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { theme as makeTheme } from '../lib/theme.js'
|
||||
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
|
||||
import {
|
||||
getSurfacePlugin,
|
||||
listSurfacePluginStatuses,
|
||||
@@ -8,6 +10,33 @@ import {
|
||||
type SurfacePluginStatus
|
||||
} from '../surfacePlugins.js'
|
||||
|
||||
const PLUGINS_USAGE: UsageSpec = {
|
||||
name: 'plugins',
|
||||
summary: 'list / install / update / launch desktop surface plugins (e.g. herm)',
|
||||
usage: [
|
||||
'plugins [list]',
|
||||
'plugins status [id]',
|
||||
'plugins install <id>',
|
||||
'plugins update <id>',
|
||||
'plugins launch <id>',
|
||||
'plugins resume <id>'
|
||||
],
|
||||
subcommands: [
|
||||
{ verb: 'list', desc: 'List known surface plugins + install state (default)' },
|
||||
{ verb: 'status [id]', desc: 'Detailed status for one (or all) plugins' },
|
||||
{ verb: 'install <id>', desc: 'Install via Bun/npm' },
|
||||
{ verb: 'update <id>', desc: 'Update an installed plugin' },
|
||||
{ verb: 'launch <id>', desc: 'Launch the plugin surface' },
|
||||
{ verb: 'resume <id>', desc: 'Resume a running plugin surface' }
|
||||
],
|
||||
flags: [{ flag: '--json', desc: 'Machine-readable output' }],
|
||||
examples: [
|
||||
'hermes-relay plugins',
|
||||
'hermes-relay plugins install herm',
|
||||
'hermes-relay plugins launch herm'
|
||||
]
|
||||
}
|
||||
|
||||
function renderStatus(status: SurfacePluginStatus): string {
|
||||
const plugin = status.descriptor
|
||||
const lines = [
|
||||
@@ -41,6 +70,10 @@ function resolvePluginOrPrint(id: string): ReturnType<typeof getSurfacePlugin> {
|
||||
}
|
||||
|
||||
export async function pluginsCommand(args: ParsedArgs): Promise<number> {
|
||||
if (args.flags.help) {
|
||||
printUsage(PLUGINS_USAGE)
|
||||
return 0
|
||||
}
|
||||
const sub = args.positional[0] ?? 'list'
|
||||
const id = args.positional[1] ?? 'herm'
|
||||
const wantJson = !!args.flags.json
|
||||
@@ -60,7 +93,8 @@ export async function pluginsCommand(args: ParsedArgs): Promise<number> {
|
||||
process.stdout.write(JSON.stringify(statuses, null, 2) + '\n')
|
||||
return 0
|
||||
}
|
||||
process.stdout.write('Desktop surface plugins:\n\n')
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
process.stdout.write(t.bold('Desktop surface plugins:') + '\n\n')
|
||||
process.stdout.write(statuses.map(renderStatus).join('\n\n') + '\n')
|
||||
return 0
|
||||
}
|
||||
@@ -106,6 +140,5 @@ export async function pluginsCommand(args: ParsedArgs): Promise<number> {
|
||||
return code
|
||||
}
|
||||
|
||||
process.stderr.write('unknown plugins sub-verb. Try: list | status [id] | install <id> | update <id> | launch <id> | resume <id>\n')
|
||||
return 2
|
||||
return unknownSubcommand(PLUGINS_USAGE, sub)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// relay — inspect the relay SERVER itself (plugin v1.2.0 management surface).
|
||||
//
|
||||
// Three read surfaces the relay gained but the CLI never exposed:
|
||||
// GET /relay/info version, uptime, sessions, pending (LOOPBACK-ONLY)
|
||||
// GET /relay/security runtime auth toggles (LOOPBACK-ONLY)
|
||||
// GET /context/injected what the relay injects into the agent's system
|
||||
// prompt — loopback OR a relay session bearer, so this
|
||||
// one works from a remote laptop too.
|
||||
//
|
||||
// info/security are gated to the relay host (operators); when a remote caller
|
||||
// hits them we get a 403 and explain that honestly rather than failing raw.
|
||||
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { getActiveDesktopRelayUrl } from '../desktopConfig.js'
|
||||
import { formatError } from '../lib/hints.js'
|
||||
import { theme as makeTheme, type Theme } from '../lib/theme.js'
|
||||
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
|
||||
import { getSession, listSessions } from '../remoteSessions.js'
|
||||
|
||||
const RELAY_USAGE: UsageSpec = {
|
||||
name: 'relay',
|
||||
summary: 'inspect the relay server — info, security toggles, injected agent context',
|
||||
usage: ['relay info', 'relay security', 'relay context'],
|
||||
subcommands: [
|
||||
{ verb: 'info', desc: 'Version, uptime, sessions, pending (loopback-only — run on the relay host)' },
|
||||
{ verb: 'security', desc: 'Runtime security toggles (loopback-only)' },
|
||||
{ verb: 'context', desc: 'Audit the system-prompt context the relay injects into the agent' }
|
||||
],
|
||||
flags: [
|
||||
{ flag: '--remote <url>', desc: 'Relay to query (default: stored/active)' },
|
||||
{ flag: '--json', desc: 'Machine-readable output' }
|
||||
],
|
||||
examples: ['hermes-relay relay context', 'hermes-relay relay info']
|
||||
}
|
||||
|
||||
function wsToHttp(url: string): string {
|
||||
const t = url.trim()
|
||||
if (t.startsWith('wss://')) return 'https://' + t.slice(6)
|
||||
if (t.startsWith('ws://')) return 'http://' + t.slice(5)
|
||||
return t
|
||||
}
|
||||
|
||||
async function resolveRemoteAndToken(args: ParsedArgs): Promise<{ url: string; token: string }> {
|
||||
const argUrl = typeof args.flags.remote === 'string' ? args.flags.remote.trim() : null
|
||||
const envUrl = process.env.HERMES_RELAY_URL?.trim()
|
||||
const argToken = typeof args.flags.token === 'string' ? args.flags.token.trim() : null
|
||||
const envToken = process.env.HERMES_RELAY_TOKEN?.trim()
|
||||
|
||||
if (argToken || envToken) {
|
||||
const url = argUrl ?? envUrl
|
||||
if (!url) {
|
||||
throw new Error('--token supplied without --remote. Pass both, or set HERMES_RELAY_URL.')
|
||||
}
|
||||
return { url, token: (argToken ?? envToken)! }
|
||||
}
|
||||
|
||||
const stored = await listSessions()
|
||||
const urls = Object.keys(stored)
|
||||
const activeDesktopUrl = await getActiveDesktopRelayUrl()
|
||||
let url: string
|
||||
if (argUrl || envUrl) {
|
||||
url = argUrl ?? envUrl!
|
||||
} else if (activeDesktopUrl) {
|
||||
url = activeDesktopUrl
|
||||
} else if (urls.length === 1) {
|
||||
url = urls[0]!
|
||||
} else if (urls.length === 0) {
|
||||
throw new Error('No paired relays. Run `hermes-relay pair --remote ws://host:port` first.')
|
||||
} else {
|
||||
throw new Error(`Multiple paired relays; pass --remote to pick one (${urls.join(', ')}).`)
|
||||
}
|
||||
const rec = await getSession(url)
|
||||
if (!rec) {
|
||||
throw new Error(`No stored session for ${url}. Run \`hermes-relay pair --remote ${url}\` first.`)
|
||||
}
|
||||
return { url, token: rec.token }
|
||||
}
|
||||
|
||||
async function getJson(
|
||||
httpUrl: string,
|
||||
token: string
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const res = await fetch(httpUrl, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }
|
||||
})
|
||||
const text = await res.text()
|
||||
let body: unknown
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
body = text
|
||||
}
|
||||
}
|
||||
return { status: res.status, body }
|
||||
}
|
||||
|
||||
function fmtUptime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '?'
|
||||
const d = Math.floor(seconds / 86_400)
|
||||
const h = Math.floor((seconds % 86_400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (d) parts.push(`${d}d`)
|
||||
if (h) parts.push(`${h}h`)
|
||||
if (m || parts.length === 0) parts.push(`${m}m`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const kv = (t: Theme) => (label: string, value: string): string =>
|
||||
` ${t.muted((label + ':').padEnd(12))} ${value}`
|
||||
|
||||
/** Loopback-only routes return 403 to remote callers. Explain that instead of
|
||||
* dumping a raw error — it's expected behaviour, not a misconfiguration. */
|
||||
function loopbackNote(t: Theme, route: string): string {
|
||||
return (
|
||||
t.warnLine(`${route} is loopback-only — run this on the relay host.`) + '\n' +
|
||||
t.muted(' (it serves server operators / the dashboard; remote callers get 403)')
|
||||
)
|
||||
}
|
||||
|
||||
async function relayInfo(args: ParsedArgs, t: Theme): Promise<number> {
|
||||
const { url, token } = await resolveRemoteAndToken(args)
|
||||
const { status, body } = await getJson(`${wsToHttp(url)}/relay/info`, token)
|
||||
if (status === 403) {
|
||||
process.stderr.write(loopbackNote(t, '/relay/info') + '\n')
|
||||
return 1
|
||||
}
|
||||
if (status !== 200) {
|
||||
process.stderr.write(t.err(`error: GET /relay/info returned ${status}`) + '\n')
|
||||
return 1
|
||||
}
|
||||
if (args.flags.json) {
|
||||
process.stdout.write(JSON.stringify(body, null, 2) + '\n')
|
||||
return 0
|
||||
}
|
||||
const r = (body ?? {}) as Record<string, unknown>
|
||||
const row = kv(t)
|
||||
process.stdout.write(t.bold(`Relay ${url}`) + '\n')
|
||||
process.stdout.write(row('version', String(r.version ?? '?')) + '\n')
|
||||
process.stdout.write(row('health', String(r.health ?? '?')) + '\n')
|
||||
process.stdout.write(row('uptime', fmtUptime(Number(r.uptime_seconds))) + '\n')
|
||||
process.stdout.write(row('sessions', String(r.session_count ?? '?')) + '\n')
|
||||
process.stdout.write(row('devices', String(r.paired_device_count ?? '?')) + '\n')
|
||||
process.stdout.write(row('pending', String(r.pending_commands ?? 0)) + '\n')
|
||||
process.stdout.write(row('media', `${r.media_entry_count ?? 0} entries`) + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
async function relaySecurity(args: ParsedArgs, t: Theme): Promise<number> {
|
||||
const { url, token } = await resolveRemoteAndToken(args)
|
||||
const { status, body } = await getJson(`${wsToHttp(url)}/relay/security`, token)
|
||||
if (status === 403) {
|
||||
process.stderr.write(loopbackNote(t, '/relay/security') + '\n')
|
||||
return 1
|
||||
}
|
||||
if (status !== 200) {
|
||||
process.stderr.write(t.err(`error: GET /relay/security returned ${status}`) + '\n')
|
||||
return 1
|
||||
}
|
||||
if (args.flags.json) {
|
||||
process.stdout.write(JSON.stringify(body, null, 2) + '\n')
|
||||
return 0
|
||||
}
|
||||
const r = (body ?? {}) as Record<string, unknown>
|
||||
const row = kv(t)
|
||||
const insecure = r.allow_insecure_api_bearer === true
|
||||
process.stdout.write(t.bold(`Relay security — ${url}`) + '\n')
|
||||
process.stdout.write(
|
||||
row('insecure bearer', `${t.statusDot(!insecure)} ${insecure ? t.warn('allowed (plaintext API bearer)') : 'requires HTTPS off-loopback'}`) + '\n'
|
||||
)
|
||||
process.stdout.write(row('trust proxy', String(r.trust_proxy_headers ?? false)) + '\n')
|
||||
process.stdout.write(row('scope', String(r.scope ?? 'runtime')) + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
async function relayContext(args: ParsedArgs, t: Theme): Promise<number> {
|
||||
const { url, token } = await resolveRemoteAndToken(args)
|
||||
const { status, body } = await getJson(`${wsToHttp(url)}/context/injected`, token)
|
||||
if (status === 404) {
|
||||
process.stderr.write(
|
||||
t.muted(`relay at ${url} has no /context/injected — server predates the relay context layer.`) + '\n'
|
||||
)
|
||||
return 1
|
||||
}
|
||||
if (status !== 200) {
|
||||
process.stderr.write(t.err(`error: GET /context/injected returned ${status}`) + '\n')
|
||||
return 1
|
||||
}
|
||||
if (args.flags.json) {
|
||||
process.stdout.write(JSON.stringify(body, null, 2) + '\n')
|
||||
return 0
|
||||
}
|
||||
const r = (body ?? {}) as { enabled?: boolean; blocks?: { name?: string; text?: string }[] }
|
||||
const blocks = r.blocks ?? []
|
||||
process.stdout.write(t.bold(`Relay-injected agent context — ${url}`) + '\n')
|
||||
process.stdout.write(` ${t.statusDot(!!r.enabled)} injection ${r.enabled ? t.ok('enabled') : t.muted('disabled')}\n`)
|
||||
if (blocks.length === 0) {
|
||||
process.stdout.write(t.muted(' (no context blocks are being injected into the agent prompt)') + '\n')
|
||||
return 0
|
||||
}
|
||||
process.stdout.write('\n')
|
||||
for (const b of blocks) {
|
||||
process.stdout.write(` ${t.bold(b.name ?? '(unnamed)')}\n`)
|
||||
const text = (b.text ?? '').trim()
|
||||
const preview = text.length > 280 ? text.slice(0, 279) + '…' : text
|
||||
for (const line of preview.split('\n')) {
|
||||
process.stdout.write(` ${t.muted(line)}\n`)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export async function relayCommand(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (args.flags.help) {
|
||||
printUsage(RELAY_USAGE, t)
|
||||
return 0
|
||||
}
|
||||
const sub = args.positional[0] ?? 'info'
|
||||
const url = typeof args.flags.remote === 'string' ? args.flags.remote : undefined
|
||||
const run = async (fn: (a: ParsedArgs, th: Theme) => Promise<number>): Promise<number> => {
|
||||
try {
|
||||
return await fn(args, t)
|
||||
} catch (e) {
|
||||
process.stderr.write(formatError(e, { command: 'relay', url }, t) + '\n')
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === 'info') {
|
||||
if (args.positional[0] === 'info') args.positional.shift()
|
||||
return run(relayInfo)
|
||||
}
|
||||
if (sub === 'security') {
|
||||
args.positional.shift()
|
||||
return run(relaySecurity)
|
||||
}
|
||||
if (sub === 'context') {
|
||||
args.positional.shift()
|
||||
return run(relayContext)
|
||||
}
|
||||
return unknownSubcommand(RELAY_USAGE, sub, t)
|
||||
}
|
||||
|
||||
export default relayCommand
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { renderTable } from '../lib/table.js'
|
||||
import { theme as makeTheme } from '../lib/theme.js'
|
||||
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
|
||||
import {
|
||||
clearActiveTerminalSession,
|
||||
getActiveTerminalSession
|
||||
@@ -7,6 +10,32 @@ import { connectAndAuth, shellCommand } from './shell.js'
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 15_000
|
||||
|
||||
const SESSIONS_USAGE: UsageSpec = {
|
||||
name: 'sessions',
|
||||
summary: 'list / resume / create / kill the relay-side Hermes TUI tmux sessions',
|
||||
usage: [
|
||||
'sessions [list]',
|
||||
'sessions resume [name]',
|
||||
'sessions new [name]',
|
||||
'sessions kill <name>'
|
||||
],
|
||||
subcommands: [
|
||||
{ verb: 'list', desc: 'List live TUI sessions (default)' },
|
||||
{ verb: 'resume [name]', desc: 'Attach a shell (active/default if name omitted)' },
|
||||
{ verb: 'new [name]', desc: 'Start a fresh session and attach' },
|
||||
{ verb: 'kill <name>', desc: 'Terminate a tmux session' }
|
||||
],
|
||||
flags: [
|
||||
{ flag: '--remote <url>', desc: 'Relay to target' },
|
||||
{ flag: '--json', desc: 'Machine-readable list output' }
|
||||
],
|
||||
examples: [
|
||||
'hermes-relay sessions',
|
||||
'hermes-relay sessions resume default',
|
||||
'hermes-relay sessions kill scratch'
|
||||
]
|
||||
}
|
||||
|
||||
interface TerminalSessionInfo {
|
||||
name: string
|
||||
tmux_name?: string
|
||||
@@ -120,26 +149,42 @@ async function listTerminalSessions(args: ParsedArgs): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (sessions.length === 0) {
|
||||
process.stdout.write(`No Hermes TUI sessions on ${url}.\n`)
|
||||
process.stdout.write('Run `hermes-relay` to start the default session.\n')
|
||||
process.stdout.write(t.muted(`No Hermes TUI sessions on ${url}.`) + '\n')
|
||||
process.stdout.write(t.muted('Run `hermes-relay` to start the default session.') + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
process.stdout.write(`Hermes TUI sessions on ${url}:\n\n`)
|
||||
for (const session of sessions) {
|
||||
const activeMark = active?.name === session.name ? ' * active' : ''
|
||||
const rows = sessions.map((session) => {
|
||||
const activeMark = active?.name === session.name ? ` ${t.muted('(active)')}` : ''
|
||||
const attached = session.attached ?? (session.live ? 1 : 0)
|
||||
process.stdout.write(` ${session.name}${activeMark}\n`)
|
||||
process.stdout.write(` tmux: ${session.tmux_name ?? `hermes-${session.name}`}\n`)
|
||||
process.stdout.write(` attached: ${attached}\n`)
|
||||
if (session.windows !== undefined) {
|
||||
process.stdout.write(` windows: ${session.windows}\n`)
|
||||
}
|
||||
process.stdout.write(` created: ${formatCreated(session.created_at)}\n\n`)
|
||||
}
|
||||
return [
|
||||
`${session.name}${activeMark}`,
|
||||
session.tmux_name ?? `hermes-${session.name}`,
|
||||
String(attached),
|
||||
session.windows !== undefined ? String(session.windows) : '—',
|
||||
formatCreated(session.created_at)
|
||||
]
|
||||
})
|
||||
process.stdout.write(t.bold(`Hermes TUI sessions on ${url} (${sessions.length})`) + '\n\n')
|
||||
process.stdout.write(
|
||||
' Resume with `hermes-relay sessions resume <name>`, or run bare `hermes-relay` for the active/default session.\n'
|
||||
renderTable(
|
||||
[
|
||||
{ header: 'NAME' },
|
||||
{ header: 'TMUX' },
|
||||
{ header: 'ATTACHED', align: 'right' },
|
||||
{ header: 'WINDOWS', align: 'right' },
|
||||
{ header: 'CREATED' }
|
||||
],
|
||||
rows,
|
||||
{ theme: t }
|
||||
) + '\n'
|
||||
)
|
||||
process.stdout.write(
|
||||
'\n' +
|
||||
t.muted('resume: hermes-relay sessions resume <name> or run bare `hermes-relay` for the active session.') +
|
||||
'\n'
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
@@ -174,6 +219,10 @@ async function killTerminalSession(args: ParsedArgs): Promise<number> {
|
||||
}
|
||||
|
||||
export async function sessionsCommand(args: ParsedArgs): Promise<number> {
|
||||
if (args.flags.help) {
|
||||
printUsage(SESSIONS_USAGE)
|
||||
return 0
|
||||
}
|
||||
const sub = args.positional[0] ?? 'list'
|
||||
|
||||
if (sub === 'list') {
|
||||
@@ -213,6 +262,5 @@ export async function sessionsCommand(args: ParsedArgs): Promise<number> {
|
||||
return killTerminalSession(args)
|
||||
}
|
||||
|
||||
process.stderr.write('unknown sessions sub-verb. Try: list | resume [name] | new [name] | kill <name>\n')
|
||||
return 2
|
||||
return unknownSubcommand(SESSIONS_USAGE, sub)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,21 @@
|
||||
|
||||
import { humanExpiry, parseRole, roleLabel } from '../banner.js'
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { theme as makeTheme } from '../lib/theme.js'
|
||||
import { printUsage, type UsageSpec } from '../lib/usage.js'
|
||||
import { listSessions, type RemoteSessionRecord } from '../remoteSessions.js'
|
||||
|
||||
const STATUS_USAGE: UsageSpec = {
|
||||
name: 'status',
|
||||
summary: 'show the relays this machine is paired with (grants, TTL, desktop-tool consent)',
|
||||
usage: ['status [--json] [--reveal-tokens]'],
|
||||
flags: [
|
||||
{ flag: '--json', desc: 'Machine-readable output (tokens redacted by default)' },
|
||||
{ flag: '--reveal-tokens', desc: 'Include full session tokens (use with care)' }
|
||||
],
|
||||
examples: ['hermes-relay status', 'hermes-relay status --json']
|
||||
}
|
||||
|
||||
function humanAge(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`
|
||||
@@ -30,6 +43,11 @@ function humanAge(seconds: number): string {
|
||||
const REDACTED = '(redacted — pass --reveal-tokens to show)'
|
||||
|
||||
export async function statusCommand(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (args.flags.help) {
|
||||
printUsage(STATUS_USAGE, t)
|
||||
return 0
|
||||
}
|
||||
const sessions = await listSessions()
|
||||
const entries = Object.entries(sessions)
|
||||
const revealTokens = !!args.flags['reveal-tokens']
|
||||
@@ -51,28 +69,32 @@ export async function statusCommand(args: ParsedArgs): Promise<number> {
|
||||
|
||||
if (entries.length === 0) {
|
||||
process.stdout.write(
|
||||
'No paired relays. Run `hermes-relay pair --remote ws://host:port` to pair.\n'
|
||||
t.muted('No paired relays. Run `hermes-relay pair --remote ws://host:port` to pair.') + '\n'
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
process.stdout.write(`Paired relays (${entries.length}):\n\n`)
|
||||
process.stdout.write(t.bold(`Paired relays (${entries.length})`) + '\n\n')
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const kv = (label: string, value: string): string =>
|
||||
` ${t.muted((label + ':').padEnd(9))} ${value}`
|
||||
for (const [url, rec] of entries) {
|
||||
const age = humanAge(Math.max(0, now - rec.pairedAt))
|
||||
const tokenDisplay = revealTokens ? rec.token : REDACTED
|
||||
process.stdout.write(` ${url}\n`)
|
||||
process.stdout.write(` server: ${rec.serverVersion ?? '(unknown)'}\n`)
|
||||
process.stdout.write(` paired: ${age} ago\n`)
|
||||
process.stdout.write(` token: ${tokenDisplay}\n`)
|
||||
process.stdout.write(` expires: ${humanExpiry(rec.ttlExpiresAt)}\n`)
|
||||
const expiresRaw = humanExpiry(rec.ttlExpiresAt)
|
||||
const expires = expiresRaw === 'expired' ? t.err('expired') : expiresRaw
|
||||
process.stdout.write(` ${t.cyan(url)}\n`)
|
||||
process.stdout.write(kv('server', rec.serverVersion ?? '(unknown)') + '\n')
|
||||
process.stdout.write(kv('paired', `${age} ago`) + '\n')
|
||||
process.stdout.write(kv('token', tokenDisplay) + '\n')
|
||||
process.stdout.write(kv('expires', expires) + '\n')
|
||||
const computerUse = rec.toolsConsented ? 'feature-flagged opt-in' : 'no'
|
||||
process.stdout.write(
|
||||
` desktop: tools=${rec.toolsConsented ? 'yes' : 'no'}, computer-use=${computerUse}\n`
|
||||
kv('desktop', `${t.statusDot(!!rec.toolsConsented)} tools=${rec.toolsConsented ? 'yes' : 'no'}, computer-use=${computerUse}`) + '\n'
|
||||
)
|
||||
const role = parseRole(rec.endpointRole)
|
||||
if (role) {
|
||||
process.stdout.write(` route: ${roleLabel(role)}\n`)
|
||||
process.stdout.write(kv('route', roleLabel(role)) + '\n')
|
||||
}
|
||||
if (rec.grants && Object.keys(rec.grants).length > 0) {
|
||||
const formatted = Object.entries(rec.grants)
|
||||
@@ -81,10 +103,10 @@ export async function statusCommand(args: ParsedArgs): Promise<number> {
|
||||
return `${channel} (${when})`
|
||||
})
|
||||
.sort()
|
||||
process.stdout.write(` grants: ${formatted.join(', ')}\n`)
|
||||
process.stdout.write(kv('grants', formatted.join(', ')) + '\n')
|
||||
}
|
||||
if (rec.certPinSha256) {
|
||||
process.stdout.write(` cert: sha256:${rec.certPinSha256.slice(0, 12)}…\n`)
|
||||
process.stdout.write(kv('cert', `sha256:${rec.certPinSha256.slice(0, 12)}…`) + '\n')
|
||||
}
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
|
||||
@@ -7,13 +7,29 @@ import type { ParsedArgs } from '../cli.js'
|
||||
import { resolveCredentials } from '../credentials.js'
|
||||
import { GatewayClient } from '../gatewayClient.js'
|
||||
import type { GatewayEvent, ToolsListResponse } from '../gatewayTypes.js'
|
||||
import { formatError } from '../lib/hints.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { createSpinner } from '../lib/spinner.js'
|
||||
import { SYMBOLS, theme as makeTheme } from '../lib/theme.js'
|
||||
import { printUsage, type UsageSpec } from '../lib/usage.js'
|
||||
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
|
||||
import { deleteSession, saveSession } from '../remoteSessions.js'
|
||||
import { RelayTransport } from '../transport/RelayTransport.js'
|
||||
|
||||
const READY_TIMEOUT_MS = 60_000
|
||||
|
||||
const TOOLS_USAGE: UsageSpec = {
|
||||
name: 'tools',
|
||||
summary: 'show the tool access the agent will have on this connection',
|
||||
usage: ['tools [--verbose] [--json]'],
|
||||
flags: [
|
||||
{ flag: '--verbose', desc: 'List individual tools under each toolset' },
|
||||
{ flag: '--json', desc: 'Machine-readable toolset list' },
|
||||
{ flag: '--remote <url>', desc: 'Relay to query (default: stored/active)' }
|
||||
],
|
||||
examples: ['hermes-relay tools', 'hermes-relay tools --verbose']
|
||||
}
|
||||
|
||||
function resolveRemote(args: ParsedArgs): string | null {
|
||||
const v = args.flags.remote
|
||||
return (typeof v === 'string' ? v : null) ?? process.env.HERMES_RELAY_URL ?? null
|
||||
@@ -39,6 +55,11 @@ function waitForReady(gw: GatewayClient): Promise<void> {
|
||||
}
|
||||
|
||||
export async function toolsCommand(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (args.flags.help) {
|
||||
printUsage(TOOLS_USAGE, t)
|
||||
return 0
|
||||
}
|
||||
let urlFlag = resolveRemote(args)
|
||||
const argCode = typeof args.flags.code === 'string' ? args.flags.code : undefined
|
||||
const argToken = typeof args.flags.token === 'string' ? args.flags.token : undefined
|
||||
@@ -98,6 +119,12 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
|
||||
})
|
||||
})
|
||||
|
||||
const spinner = createSpinner(`Connecting to ${url}…`, {
|
||||
enabled: !args.flags.json && !args.flags.quiet,
|
||||
theme: t
|
||||
})
|
||||
spinner.start()
|
||||
|
||||
relay.start()
|
||||
const outcome = await relay.whenAuthResolved()
|
||||
|
||||
@@ -105,7 +132,8 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
|
||||
if (creds.sessionToken) {
|
||||
await deleteSession(url)
|
||||
}
|
||||
process.stderr.write(`error: ${outcome.reason}\n`)
|
||||
spinner.fail('connection failed')
|
||||
process.stderr.write(formatError(outcome.reason, { command: 'tools', url }, t) + '\n')
|
||||
try {
|
||||
relay.kill()
|
||||
} catch {
|
||||
@@ -114,6 +142,7 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
|
||||
return 1
|
||||
}
|
||||
|
||||
spinner.update('Loading toolsets…')
|
||||
const gw = new GatewayClient(relay)
|
||||
const ready = waitForReady(gw)
|
||||
gw.start()
|
||||
@@ -122,13 +151,15 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
|
||||
try {
|
||||
await ready
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
spinner.fail('gateway not ready')
|
||||
process.stderr.write(formatError(e, { command: 'tools', url }, t) + '\n')
|
||||
gw.kill()
|
||||
return 1
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await gw.request<ToolsListResponse>('tools.list', {})
|
||||
spinner.stop()
|
||||
const result = asRpcResult<ToolsListResponse>(raw)
|
||||
const toolsets = result?.toolsets ?? []
|
||||
|
||||
@@ -139,43 +170,46 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
|
||||
}
|
||||
|
||||
if (toolsets.length === 0) {
|
||||
process.stdout.write('(server returned no toolsets)\n')
|
||||
process.stdout.write(t.muted('(server returned no toolsets)') + '\n')
|
||||
gw.kill()
|
||||
return 0
|
||||
}
|
||||
|
||||
const enabled = toolsets.filter((t) => t.enabled).length
|
||||
const enabled = toolsets.filter((ts) => ts.enabled).length
|
||||
process.stdout.write(
|
||||
`Server: ${url}\n` +
|
||||
`Version: ${relay.serverVersion ?? '?'}\n` +
|
||||
`Toolsets: ${toolsets.length} (${enabled} enabled)\n\n`
|
||||
`${t.muted('Server: ')} ${url}\n` +
|
||||
`${t.muted('Version: ')} ${relay.serverVersion ?? '?'}\n` +
|
||||
`${t.muted('Toolsets:')} ${toolsets.length} (${enabled} enabled)\n\n`
|
||||
)
|
||||
|
||||
for (const ts of toolsets) {
|
||||
const mark = ts.enabled ? '●' : '○'
|
||||
const count = typeof ts.tool_count === 'number' ? `${ts.tool_count} tools` : '?'
|
||||
process.stdout.write(` ${mark} ${ts.name} (${count})`)
|
||||
process.stdout.write(` ${t.statusDot(!!ts.enabled)} ${t.bold(ts.name)} ${t.muted(`(${count})`)}`)
|
||||
if (ts.description) {
|
||||
process.stdout.write(` — ${ts.description}`)
|
||||
process.stdout.write(` ${t.muted('— ' + ts.description)}`)
|
||||
}
|
||||
process.stdout.write('\n')
|
||||
|
||||
if (args.flags.verbose && ts.tools && ts.tools.length > 0) {
|
||||
for (const t of ts.tools) {
|
||||
process.stdout.write(` • ${t.name}`)
|
||||
if (t.description) {
|
||||
process.stdout.write(` ${t.description}`)
|
||||
for (const tool of ts.tools) {
|
||||
process.stdout.write(` ${t.muted(SYMBOLS.bullet)} ${tool.name}`)
|
||||
if (tool.description) {
|
||||
process.stdout.write(` ${t.muted(tool.description)}`)
|
||||
}
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
process.stdout.write('\n ● = enabled for this session ○ = available but off\n')
|
||||
process.stdout.write(
|
||||
`\n ${t.statusDot(true)} ${t.muted('enabled for this session')} ` +
|
||||
`${t.statusDot(false)} ${t.muted('available but off')}\n`
|
||||
)
|
||||
|
||||
gw.kill()
|
||||
return 0
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
spinner.stop()
|
||||
process.stderr.write(formatError(e, { command: 'tools', url }, t) + '\n')
|
||||
gw.kill()
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -32,8 +32,11 @@ import type {
|
||||
SessionResumeResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { getActiveDesktopRelayUrl } from '../desktopConfig.js'
|
||||
import { formatError } from '../lib/hints.js'
|
||||
import { setupGracefulExit } from '../lib/gracefulExit.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { theme as makeTheme, type Theme } from '../lib/theme.js'
|
||||
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
|
||||
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
|
||||
import { deleteSession, getSession, listSessions, saveSession } from '../remoteSessions.js'
|
||||
import { RelayTransport } from '../transport/RelayTransport.js'
|
||||
@@ -42,6 +45,34 @@ import { discoverTray, notifyTrayShowVoice } from '../trayBridge.js'
|
||||
|
||||
const READY_TIMEOUT_MS = 60_000
|
||||
|
||||
const VOICE_USAGE: UsageSpec = {
|
||||
name: 'voice',
|
||||
summary: 'inspect native Hermes voice config (STT/TTS/realtime) and run push-to-talk',
|
||||
usage: ['voice [status]', 'voice mode [--port <n>] [--no-open]'],
|
||||
subcommands: [
|
||||
{ verb: 'status', desc: 'Show STT/TTS/realtime providers + enhanced-voice capabilities (default)' },
|
||||
{ verb: 'mode', desc: 'Push-to-talk in a browser tab, proxied through this CLI' }
|
||||
],
|
||||
flags: [
|
||||
{ flag: '--remote <url>', desc: 'Relay to query (default: stored/active)' },
|
||||
{ flag: '--json', desc: 'status: raw JSON for scripting' },
|
||||
{ flag: '--port <n>', desc: 'mode: local voice-server port (default: ephemeral)' },
|
||||
{ flag: '--no-open', desc: 'mode: do not auto-open the browser' }
|
||||
],
|
||||
examples: ['hermes-relay voice', 'hermes-relay voice mode']
|
||||
}
|
||||
|
||||
/** Per-provider enhanced-voice capability hint, surfaced by `/voice/config`
|
||||
* (plugin v1.2.0). Gemini carries tone-tags + persona; xAI carries speech-tags
|
||||
* + language. The CLI previously dropped this block entirely. */
|
||||
interface VoiceEnhanced {
|
||||
audio_tags_enabled?: boolean
|
||||
audio_tags_label?: string | null
|
||||
supports_persona?: boolean
|
||||
persona_prompt_file?: string | null
|
||||
overrides?: string[]
|
||||
}
|
||||
|
||||
interface VoiceProvider {
|
||||
provider?: string | null
|
||||
model?: string | null
|
||||
@@ -49,6 +80,7 @@ interface VoiceProvider {
|
||||
voice_id?: string | null
|
||||
enabled?: boolean
|
||||
available?: boolean
|
||||
enhanced?: VoiceEnhanced | null
|
||||
}
|
||||
|
||||
interface VoiceConfigResponse {
|
||||
@@ -159,36 +191,57 @@ async function getJson<T>(
|
||||
return { status: res.status, body: body as T | { error?: string } | string | undefined }
|
||||
}
|
||||
|
||||
function formatProvider(p: VoiceProvider | null | undefined, label: string): string {
|
||||
function formatProvider(p: VoiceProvider | null | undefined, label: string, t: Theme): string {
|
||||
if (!p || !p.provider) {
|
||||
return ` ${label}: (not configured)`
|
||||
return ` ${t.muted(`${label}: (not configured)`)}`
|
||||
}
|
||||
const enabled = p.enabled === false ? '○' : '●'
|
||||
const provider = p.provider
|
||||
const model = p.model ? ` · ${p.model}` : ''
|
||||
const voice = p.voice ?? p.voice_id
|
||||
const voiceTag = voice ? ` · voice=${voice}` : ''
|
||||
return ` ${enabled} ${label}: ${provider}${model}${voiceTag}`
|
||||
return ` ${t.statusDot(p.enabled !== false)} ${t.bold(label)}: ${p.provider}${model}${voiceTag}`
|
||||
}
|
||||
|
||||
function formatRealtime(rt: RealtimeVoiceConfigResponse | null): string[] {
|
||||
/** Render the enhanced-voice capability sub-line under a provider, if present.
|
||||
* Surfaces what the relay's per-request `/voice/synthesize` overrides can do
|
||||
* (Gemini tone-tags + persona, xAI speech-tags + language). */
|
||||
function formatEnhanced(p: VoiceProvider | null | undefined, t: Theme): string | null {
|
||||
const e = p?.enhanced
|
||||
if (!e) {
|
||||
return null
|
||||
}
|
||||
const bits: string[] = []
|
||||
if (e.audio_tags_label) {
|
||||
bits.push(`${e.audio_tags_label} ${e.audio_tags_enabled ? t.ok('(on)') : t.muted('(off)')}`)
|
||||
}
|
||||
if (e.supports_persona) {
|
||||
bits.push('persona supported')
|
||||
}
|
||||
if (e.overrides?.length) {
|
||||
bits.push(`overrides: ${e.overrides.join(', ')}`)
|
||||
}
|
||||
if (bits.length === 0) {
|
||||
return null
|
||||
}
|
||||
return ` ${t.muted('enhanced: ' + bits.join(' · '))}`
|
||||
}
|
||||
|
||||
function formatRealtime(rt: RealtimeVoiceConfigResponse | null, t: Theme): string[] {
|
||||
if (!rt) {
|
||||
return [' Realtime: (unavailable)']
|
||||
return [` ${t.muted('Realtime: (unavailable)')}`]
|
||||
}
|
||||
if (rt.success === false) {
|
||||
return [` Realtime: error — ${rt.error ?? 'unknown'}`]
|
||||
return [` ${t.warn(`Realtime: error — ${rt.error ?? 'unknown'}`)}`]
|
||||
}
|
||||
const lines: string[] = []
|
||||
const enabled = rt.enabled ? '●' : '○'
|
||||
const provider = rt.default_provider ?? '(none)'
|
||||
const model = rt.default_model ? ` · ${rt.default_model}` : ''
|
||||
const voice = rt.default_voice ? ` · voice=${rt.default_voice}` : ''
|
||||
const rate = rt.sample_rate ? ` @ ${rt.sample_rate}Hz` : ''
|
||||
lines.push(` ${enabled} Realtime: ${provider}${model}${voice}${rate}`)
|
||||
lines.push(` ${t.statusDot(!!rt.enabled)} ${t.bold('Realtime')}: ${provider}${model}${voice}${rate}`)
|
||||
const providers = (rt.providers ?? []).filter((p) => p.status && p.status !== 'unavailable')
|
||||
if (providers.length > 0) {
|
||||
const labels = providers.map((p) => p.name ?? p.id)
|
||||
lines.push(` available: ${labels.join(', ')}`)
|
||||
lines.push(` ${t.muted('available: ' + labels.join(', '))}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -244,13 +297,22 @@ async function voiceStatus(args: ParsedArgs): Promise<number> {
|
||||
return 1
|
||||
}
|
||||
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
const cfg = basic.body as VoiceConfigResponse
|
||||
const rt = (realtime.status === 200 ? (realtime.body as RealtimeVoiceConfigResponse) : null)
|
||||
|
||||
process.stdout.write(`Voice on ${url}:\n\n`)
|
||||
process.stdout.write(formatProvider(cfg.stt, 'STT') + '\n')
|
||||
process.stdout.write(formatProvider(cfg.tts, 'TTS') + '\n')
|
||||
for (const line of formatRealtime(rt)) {
|
||||
process.stdout.write(t.bold(`Voice on ${url}`) + '\n\n')
|
||||
process.stdout.write(formatProvider(cfg.stt, 'STT', t) + '\n')
|
||||
const sttEnhanced = formatEnhanced(cfg.stt, t)
|
||||
if (sttEnhanced) {
|
||||
process.stdout.write(sttEnhanced + '\n')
|
||||
}
|
||||
process.stdout.write(formatProvider(cfg.tts, 'TTS', t) + '\n')
|
||||
const ttsEnhanced = formatEnhanced(cfg.tts, t)
|
||||
if (ttsEnhanced) {
|
||||
process.stdout.write(ttsEnhanced + '\n')
|
||||
}
|
||||
for (const line of formatRealtime(rt, t)) {
|
||||
process.stdout.write(line + '\n')
|
||||
}
|
||||
|
||||
@@ -258,19 +320,19 @@ async function voiceStatus(args: ParsedArgs): Promise<number> {
|
||||
const ttsOk = cfg.tts?.enabled !== false && !!cfg.tts?.provider
|
||||
process.stdout.write('\n')
|
||||
if (sttOk && ttsOk) {
|
||||
process.stdout.write(' Native Hermes voice is configured on the server. ✓\n')
|
||||
process.stdout.write(t.okLine('Native Hermes voice is configured on the server.') + '\n')
|
||||
} else if (!sttOk && !ttsOk) {
|
||||
process.stdout.write(
|
||||
' Neither STT nor TTS is configured.\n' +
|
||||
' Edit ~/.hermes/config.yaml on the server (stt.provider / tts.provider) and restart.\n'
|
||||
t.warnLine('Neither STT nor TTS is configured.') + '\n' +
|
||||
t.muted(' Edit ~/.hermes/config.yaml on the server (stt.provider / tts.provider) and restart.') + '\n'
|
||||
)
|
||||
} else {
|
||||
process.stdout.write(
|
||||
` Partial: ${sttOk ? 'STT' : 'TTS'} is configured, ${sttOk ? 'TTS' : 'STT'} is not.\n` +
|
||||
' See ~/.hermes/config.yaml on the server.\n'
|
||||
t.warnLine(`Partial: ${sttOk ? 'STT' : 'TTS'} is configured, ${sttOk ? 'TTS' : 'STT'} is not.`) + '\n' +
|
||||
t.muted(' See ~/.hermes/config.yaml on the server.') + '\n'
|
||||
)
|
||||
}
|
||||
process.stdout.write('\n ● = enabled ○ = available but off\n')
|
||||
process.stdout.write(`\n ${t.statusDot(true)} ${t.muted('enabled')} ${t.statusDot(false)} ${t.muted('available but off')}\n`)
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -500,16 +562,22 @@ async function voiceMode(args: ParsedArgs): Promise<number> {
|
||||
}
|
||||
|
||||
export async function voiceCommand(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (args.flags.help) {
|
||||
printUsage(VOICE_USAGE, t)
|
||||
return 0
|
||||
}
|
||||
const sub = args.positional[0] ?? 'status'
|
||||
const url = typeof args.flags.remote === 'string' ? args.flags.remote : undefined
|
||||
|
||||
if (sub === 'status') {
|
||||
if (args.positional.length > 0 && args.positional[0] === 'status') {
|
||||
if (args.positional[0] === 'status') {
|
||||
args.positional.shift()
|
||||
}
|
||||
try {
|
||||
return await voiceStatus(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`)
|
||||
process.stderr.write(formatError(e, { command: 'voice', url }, t) + '\n')
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -519,11 +587,10 @@ export async function voiceCommand(args: ParsedArgs): Promise<number> {
|
||||
try {
|
||||
return await voiceMode(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`)
|
||||
process.stderr.write(formatError(e, { command: 'voice', url }, t) + '\n')
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write(`unknown voice sub-verb "${sub}". Try: status | mode\n`)
|
||||
return 2
|
||||
return unknownSubcommand(VOICE_USAGE, sub, t)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,21 @@
|
||||
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { detectActiveEditor, type ActiveEditorHint } from '../activeEditor.js'
|
||||
import { theme as makeTheme, type Theme } from '../lib/theme.js'
|
||||
import { printUsage, type UsageSpec } from '../lib/usage.js'
|
||||
import { detectWorkspaceContext, type WorkspaceContext } from '../workspaceContext.js'
|
||||
|
||||
const WORKSPACE_USAGE: UsageSpec = {
|
||||
name: 'workspace',
|
||||
summary: 'print the local workspace context the CLI advertises to the relay (cwd, git, editor, shell)',
|
||||
usage: ['workspace [--json]'],
|
||||
flags: [{ flag: '--json', desc: 'Emit the exact desktop.workspace payload as JSON' }],
|
||||
examples: [
|
||||
'hermes-relay workspace',
|
||||
'hermes-relay workspace --json | jq .workspace.git_branch'
|
||||
]
|
||||
}
|
||||
|
||||
function renderStatusSummary(
|
||||
summary: WorkspaceContext['git_status_summary']
|
||||
): string {
|
||||
@@ -43,20 +56,28 @@ function renderEditorLine(hint: ActiveEditorHint): string {
|
||||
return '(none detected)'
|
||||
}
|
||||
|
||||
function renderHuman(ctx: WorkspaceContext, editor: ActiveEditorHint): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`cwd: ${ctx.cwd}`)
|
||||
lines.push(`repo: ${ctx.repo_name ?? '(not a git repo)'}`)
|
||||
lines.push(`branch: ${ctx.git_branch ?? '(n/a)'}`)
|
||||
lines.push(`status: ${renderStatusSummary(ctx.git_status_summary)}`)
|
||||
lines.push(`host: ${ctx.hostname}`)
|
||||
lines.push(`platform: ${ctx.platform}/${ctx.arch}`)
|
||||
lines.push(`shell: ${ctx.active_shell ?? '(unknown)'}`)
|
||||
lines.push(`editor: ${renderEditorLine(editor)}`)
|
||||
return lines.join('\n') + '\n'
|
||||
function renderHuman(ctx: WorkspaceContext, editor: ActiveEditorHint, t: Theme): string {
|
||||
const kv = (label: string, value: string): string => `${t.muted((label + ':').padEnd(10))} ${value}`
|
||||
return (
|
||||
[
|
||||
kv('cwd', ctx.cwd),
|
||||
kv('repo', ctx.repo_name ?? '(not a git repo)'),
|
||||
kv('branch', ctx.git_branch ?? '(n/a)'),
|
||||
kv('status', renderStatusSummary(ctx.git_status_summary)),
|
||||
kv('host', ctx.hostname),
|
||||
kv('platform', `${ctx.platform}/${ctx.arch}`),
|
||||
kv('shell', ctx.active_shell ?? '(unknown)'),
|
||||
kv('editor', renderEditorLine(editor))
|
||||
].join('\n') + '\n'
|
||||
)
|
||||
}
|
||||
|
||||
export async function workspaceCommand(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (args.flags.help) {
|
||||
printUsage(WORKSPACE_USAGE, t)
|
||||
return 0
|
||||
}
|
||||
const ctx = await detectWorkspaceContext()
|
||||
const editor = await detectActiveEditor()
|
||||
|
||||
@@ -69,7 +90,7 @@ export async function workspaceCommand(args: ParsedArgs): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
|
||||
process.stdout.write(renderHuman(ctx, editor))
|
||||
process.stdout.write(renderHuman(ctx, editor, t))
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
// caller is expected to inspect `resolvedEndpoint.relay.url` and override its
|
||||
// `--remote` argument — we don't reach into the caller's URL state from here.
|
||||
|
||||
import { humanExpiry } from './banner.js'
|
||||
import type { EndpointCandidate } from './endpoint.js'
|
||||
import { theme as makeTheme } from './lib/theme.js'
|
||||
import { promptForPairingCode } from './pairing.js'
|
||||
import {
|
||||
decodePairingPayload,
|
||||
@@ -26,6 +28,31 @@ import {
|
||||
} from './pairingQr.js'
|
||||
import { getSession } from './remoteSessions.js'
|
||||
|
||||
/** Warn (on a TTY only) when a stored token is at/near expiry, so a returning
|
||||
* user gets a heads-up + the exact re-pair command instead of a bare auth
|
||||
* failure on the next request. Piped/scripted output stays clean. */
|
||||
function maybeWarnExpiry(url: string, ttlExpiresAt: number | null | undefined): void {
|
||||
if (ttlExpiresAt === null || ttlExpiresAt === undefined) {
|
||||
return
|
||||
}
|
||||
if (!process.stderr.isTTY) {
|
||||
return
|
||||
}
|
||||
const delta = ttlExpiresAt - Date.now() / 1000
|
||||
const t = makeTheme()
|
||||
if (delta <= 0) {
|
||||
process.stderr.write(
|
||||
t.warnLine(`stored session for ${url} has expired — re-pair: hermes-relay pair --remote ${url}`) + '\n'
|
||||
)
|
||||
} else if (delta < 3600) {
|
||||
process.stderr.write(
|
||||
t.warnLine(
|
||||
`stored session for ${url} expires ${humanExpiry(ttlExpiresAt)} — re-pair soon: hermes-relay pair --remote ${url}`
|
||||
) + '\n'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
sessionToken?: string
|
||||
pairingCode?: string
|
||||
@@ -83,6 +110,7 @@ export async function resolveCredentials(
|
||||
|
||||
const stored = await getSession(url)
|
||||
if (stored) {
|
||||
maybeWarnExpiry(url, stored.ttlExpiresAt)
|
||||
return { sessionToken: stored.token }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Local desktop-tool audit log — "what did the agent run on THIS machine?"
|
||||
//
|
||||
// The relay keeps a server-side ring buffer of desktop commands, but that
|
||||
// route (`GET /desktop/health`) is loopback-only — a laptop CLI talking to a
|
||||
// remote relay can't read it. Since the CLI client is the actual EXECUTOR of
|
||||
// every desktop tool, it is the right place to record activity: a JSONL log at
|
||||
// ~/.hermes/desktop-audit.jsonl that `hermes-relay audit` tails. No network,
|
||||
// no auth, works regardless of where the relay lives.
|
||||
//
|
||||
// Best-effort by design: a logging failure must never break a tool dispatch.
|
||||
|
||||
import { appendFile, mkdir, readFile, rename, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
export interface AuditEntry {
|
||||
/** Epoch milliseconds when the command completed. */
|
||||
ts: number
|
||||
tool: string
|
||||
ok: boolean
|
||||
aborted?: boolean
|
||||
request_id?: string
|
||||
/** Truncated preview of the call args, for context. */
|
||||
args_preview?: string
|
||||
/** Short success summary (path / exit code / first stdout line). */
|
||||
summary?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Rotate the log once it crosses ~1 MB, keeping a single `.1` backup. */
|
||||
const MAX_BYTES = 1_000_000
|
||||
|
||||
export function auditLogPath(): string {
|
||||
return join(homedir(), '.hermes', 'desktop-audit.jsonl')
|
||||
}
|
||||
|
||||
export async function appendAudit(entry: AuditEntry): Promise<void> {
|
||||
const path = auditLogPath()
|
||||
try {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
try {
|
||||
const st = await stat(path)
|
||||
if (st.size > MAX_BYTES) {
|
||||
await rename(path, path + '.1').catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
/* missing file — nothing to rotate */
|
||||
}
|
||||
await appendFile(path, JSON.stringify(entry) + '\n', { mode: 0o600 })
|
||||
} catch {
|
||||
// Audit is best-effort; never throw into the dispatch path.
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRecentAudit(limit = 50): Promise<AuditEntry[]> {
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(auditLogPath(), 'utf8')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const lines = text.split('\n').filter((l) => l.trim().length > 0)
|
||||
const out: AuditEntry[] = []
|
||||
for (const l of lines.slice(-limit)) {
|
||||
try {
|
||||
out.push(JSON.parse(l) as AuditEntry)
|
||||
} catch {
|
||||
/* skip a torn/partial line */
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Best-effort one-line preview of tool args (paths, commands) for the log. */
|
||||
export function previewArgs(args: Record<string, unknown>): string | undefined {
|
||||
try {
|
||||
const parts: string[] = []
|
||||
for (const key of ['path', 'command', 'cmd', 'pattern', 'cwd', 'pid', 'port', 'name']) {
|
||||
const v = (args as Record<string, unknown>)[key]
|
||||
if (v !== undefined && v !== null && typeof v !== 'object') {
|
||||
parts.push(`${key}=${String(v)}`)
|
||||
}
|
||||
if (parts.length >= 2) {
|
||||
break
|
||||
}
|
||||
}
|
||||
const s = parts.length ? parts.join(' ') : JSON.stringify(args)
|
||||
return s.length > 120 ? s.slice(0, 119) + '…' : s
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort short success summary from a handler result. */
|
||||
export function summarizeResult(result: unknown): string | undefined {
|
||||
if (result === null || typeof result !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const r = result as Record<string, unknown>
|
||||
if (typeof r.exit_code === 'number') {
|
||||
return `exit ${r.exit_code}`
|
||||
}
|
||||
if (typeof r.path === 'string') {
|
||||
return r.path
|
||||
}
|
||||
if (typeof r.pid === 'number') {
|
||||
return `pid ${r.pid}`
|
||||
}
|
||||
if (typeof r.stdout === 'string' && r.stdout.trim()) {
|
||||
const first = r.stdout.trim().split('\n')[0]!
|
||||
return first.length > 80 ? first.slice(0, 79) + '…' : first
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Daemon status file — make the headless daemon observable.
|
||||
//
|
||||
// The daemon parks forever and logs JSON lines to stderr; once it's a service,
|
||||
// there's no easy "is it alive and connected right now?" check without tailing
|
||||
// journald. This writes a small heartbeat file at ~/.hermes/daemon-status.json
|
||||
// that `hermes-relay daemon --status` reads — uptime, connection state, server
|
||||
// version, advertised-tool count. Mirrors the existing desktop-voice.json
|
||||
// discovery-file pattern in daemon.ts.
|
||||
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
export type DaemonState = 'starting' | 'connected' | 'reconnecting' | 'stopped'
|
||||
|
||||
export interface DaemonStatus {
|
||||
pid: number
|
||||
url: string
|
||||
state: DaemonState
|
||||
/** Epoch seconds. */
|
||||
started_at: number
|
||||
/** Epoch seconds — bumped on every state change + a periodic heartbeat so
|
||||
* a reader can tell a live daemon from a crashed one whose file lingers. */
|
||||
updated_at: number
|
||||
server_version?: string | null
|
||||
advertised_tools?: number
|
||||
voice_url?: string | null
|
||||
last_event?: string
|
||||
}
|
||||
|
||||
export function daemonStatusPath(): string {
|
||||
return join(homedir(), '.hermes', 'daemon-status.json')
|
||||
}
|
||||
|
||||
export async function writeDaemonStatus(status: DaemonStatus): Promise<void> {
|
||||
const filePath = daemonStatusPath()
|
||||
try {
|
||||
await fs.mkdir(dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, JSON.stringify(status, null, 2) + '\n', { mode: 0o600 })
|
||||
} catch {
|
||||
// Best-effort — never let status bookkeeping take down the daemon.
|
||||
}
|
||||
}
|
||||
|
||||
export async function readDaemonStatus(): Promise<DaemonStatus | null> {
|
||||
try {
|
||||
const text = await fs.readFile(daemonStatusPath(), 'utf8')
|
||||
return JSON.parse(text) as DaemonStatus
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearDaemonStatus(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(daemonStatusPath())
|
||||
} catch {
|
||||
/* missing — fine */
|
||||
}
|
||||
}
|
||||
|
||||
/** Is a process with this pid currently alive? `kill(pid, 0)` sends no signal
|
||||
* but throws ESRCH when the pid is gone — the standard cross-platform liveness
|
||||
* probe (works on Windows too via libuv). EPERM means alive-but-not-ours. */
|
||||
export function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (e) {
|
||||
return (e as NodeJS.ErrnoException)?.code === 'EPERM'
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user