Compare commits

..
Author SHA1 Message Date
Bailey Dixon 8156a821eb chore: refresh tablet layout branch from origin/dev
# Conflicts:
#	CHANGELOG.md
2026-09-01 07:38:43 -04:00
Bailey Dixon e6368dada8 release: Android 1.15.0, Plugin 1.11.1, CLI+UI 0.4.0-beta.6 (#524) 2026-08-31 23:23:05 -04:00
Bailey Dixon e55662e0e1 test(android): align release presentation coverage 2026-08-31 23:11:10 -04:00
Bailey Dixon 051844bc14 release(desktop): desktop-v0.4.0-beta.6 2026-08-31 22:30:34 -04:00
Bailey Dixon 1e5ce986be release(server): server-v1.11.1 2026-08-31 22:30:34 -04:00
Bailey Dixon 06b24631e3 release(android): android-v1.15.0 2026-08-31 22:30:34 -04:00
Bailey Dixon 2d2e54ba79 Merge pull request #523 from Codename-11/feature/upstream-first-media
feat(android): prefer upstream standard surfaces
2026-08-31 22:16:38 -04:00
Bailey Dixon ee59149b41 Merge origin/dev into feature/upstream-first-media
# Conflicts:
#	README.md
2026-08-31 21:41:37 -04:00
Bailey Dixon 23a6231b20 chore: restore localized docs metadata 2026-08-31 21:41:02 -04:00
Bailey Dixon a4fac4550a chore: complete localization refresh 2026-08-31 21:40:48 -04:00
Bailey Dixon 88ab48cfc0 chore: refresh localization metadata 2026-08-31 21:39:51 -04:00
Bailey Dixon 7c03f8554f Merge docs/readme-marketing-hero into integration/readme-marketing-visuals-20260831 2026-08-31 21:39:07 -04:00
Bailey Dixon 367e5d271c Merge origin/dev into feature/upstream-first-media
# Conflicts:
#	CHANGELOG.md
#	README.md
#	docs/localization-status.json
#	docs/upstream-surface-matrix.md
#	user-docs/features/connections.md
#	user-docs/features/index.md
#	user-docs/guide/index.md
#	user-docs/guide/remote-access.md
#	user-docs/reference/configuration.md
2026-08-31 21:39:05 -04:00
Bailey Dixon 47a395ab76 feat(marketing): refresh README and store visuals 2026-08-31 21:38:45 -04:00
Bailey Dixon e7cbed3c8f feat(android): prefer upstream standard surfaces 2026-08-31 21:36:18 -04:00
Bailey Dixon 2f7bd843bc docs: note Android tablet layout improvements 2026-08-31 21:32:50 -04:00
Bailey Dixon d1527d47e4 chore: refresh tablet layout branch from origin/dev 2026-08-31 21:00:44 -04:00
Bailey Dixon bc0853360a fix(android): adapt chat and voice for tablets 2026-08-31 21:00:07 -04:00
Bailey Dixon b72ab5aef2 Merge pull request #521 from Codename-11/fix/android-stale-stream-liveness
fix(android): settle owned streams from live idle state
2026-08-31 20:35:58 -04:00
Bailey Dixon 40fcb80a7d Merge origin/dev into fix/android-stale-stream-liveness
# Conflicts:
#	CHANGELOG.md
#	docs/spec.md
#	docs/upstream-surface-matrix.md
2026-08-31 20:12:08 -04:00
Bailey Dixon ff64548085 fix(android): settle owned streams from live idle state 2026-08-31 20:09:06 -04:00
Bailey Dixon 35362b8895 chore: refresh tablet layout branch from origin/dev 2026-08-31 20:06:05 -04:00
Bailey Dixon ff7ca89b90 fix(android): improve tablet chat layout 2026-08-31 20:06:05 -04:00
Bailey Dixon 042f02b852 Merge pull request #518 from Codename-11/fix/android-passive-session-activity
fix(android): show passive external session activity
2026-08-31 20:04:12 -04:00
129 changed files with 4653 additions and 1255 deletions
+19 -2
View File
@@ -8,20 +8,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Changed
- **Android Chat and Voice use tablet space intentionally.** Expanded layouts keep introductions, transcripts, composer controls, and status chrome on readable centered rails, while landscape Voice Focus separates identity controls from conversation activity without changing phone or portrait interaction behavior.
## [Android 1.15.0] - 2026-08-31
### Changed
- **Android prefers current upstream Hermes for standard media, Git, usage, and notices.** Authenticated Dashboard file delivery, current-session `/api/git/*`, Gateway `usage.bars`, and keyed agent notices work without the optional Hermes-Relay Plugin; Relay remains additive for older-host media compatibility, sensitivity metadata, repository discovery and guarded mutations, multi-provider usage, and true Relay tools.
- **Android Settings separates standard Hermes from Relay tools.** Media now sits with Chat and Voice under Hermes, while proactive Threads, Terminal, Notification Companion, Relay sessions, and Device Control remain clearly grouped behind the optional plugin.
- **Android Supervised Mode uses app-specific parent access.** Parents choose a six-digit PIN or password, receive a shareable six-word recovery phrase, and can remove the credential without losing their supervised profile, capability, appearance, visibility, session, or relock settings. Android device credentials and biometrics no longer grant parent access.
- **Android What's New now provides a readable, complete release record.** One overall title and summary lead into selected highlights, every remaining user-visible addition, improvement, and fix, and relevant compatibility boundaries. Toast counts and previews are derived from that same inventory, so View all no longer promises details the expanded dialog and history cannot show.
### Fixed
- **Standard Hermes attachments no longer demand Relay pairing.** Host-local images, audio, video, and files download through the authenticated Dashboard, stay loaded across history reconciliation, and fall back to one neutral compatibility card on older hosts instead of flashing `Relay URL not configured` or retrying indefinitely.
- **Removing optional Relay does not strand Standard voice or leak preferences across connections.** Runtime fallback keeps Dashboard voice usable, preserves configured choices through temporary outages, and normalizes only connection-scoped named-profile settings after explicit Relay removal.
- **Passively observed Desktop/TUI turns now show live activity in the Android session drawer.** A uniquely matched selected session projects Working or Waiting without Android resuming, activating, or interrupting the external runtime; ambiguous cross-profile matches remain neutral. (Related: #365)
- **Hermes-Relay Plugin installs through the native Hermes command again.** The manifest remains fully described for current hosts while avoiding the installer/runtime schema mismatch in affected Hermes releases.
- **Android Chat keeps one transport owner through sign-out and outages.** Dashboard/Gateway conversations now preserve their transcript, draft, profile, and session for sign-in or retry instead of silently sending the next turn to a reachable Direct API database. Legacy API-only connections and explicitly selected Direct API chats remain supported.
- **Android keeps completed chat text visible when Dashboard sign-in expires.** Generic and reason-coded history `401` responses settle the local turn, preserve its transcript, and surface the existing sign-in recovery without reading another profile's API history.
- **Android keeps long-running context compaction alive.** A client-visible compaction status extends and refreshes the Gateway turn watchdog instead of interrupting healthy compression after the ordinary idle window. (Supersedes #484.)
- **Android Bot Chats render loaded history immediately.** Route-owned chat screens observe their own handler state from first composition, including fast history loads that settle before another frame. (Supersedes #453.)
- **Android Chat settles an owned Gateway turn when its terminal frame is lost.** An exact idle `session.active_list` snapshot now completes the matching local stream, reconciles durable history, and drains its queued follow-up without interrupting or claiming Desktop/TUI work.
- **Supervised Gateway setup stays parent-owned.** Add Gateway is single-flight and checks live parent authority before allocating a draft, relock/back cancels the exact pending setup, and the locked Chat footer no longer attempts protected navigation.
- **Generated images stay visible and use their intended Chat animation.** Completed image media survives a marker-lagging history refresh, and both the built-in `image_generate` tool and profile tools ending in `_create_image` use the image-generation presentation.
## [Plugin 1.11.1] - 2026-08-31
### Fixed
- **Hermes-Relay Plugin installs through the native Hermes command again.** The manifest remains fully described for current hosts while avoiding the installer/runtime schema mismatch in affected Hermes releases.
- **Relay prompt context advertises only real callable phone tools.** Phone-control and cross-platform delivery guidance now follows the exact selected session/profile tool catalog instead of implying unavailable `android_*` or `send_message` capabilities.
## [Android 1.14.0] - 2026-08-30
### Added
@@ -70,7 +87,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Dense pairing QRs scan reliably.** Dashboard, CLI, and TUI render integer-sized modules with a full quiet zone.
- **Remote-access migration keeps existing listeners safe.** Recommended setup avoids taking over `:443`, explicit legacy cleanup remains available, and default disable actions remove only the listeners they own.
## [0.4.0-beta.6] - 2026-08-30
## [0.4.0-beta.6] - 2026-08-31
### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
# Hermes-Relay CLI+UI v__VERSION__
**Release Date:** 2026-08-30
**Release Date:** 2026-08-31
This beta preserves complete multi-route pairing while preventing Desktop from dialing Dashboard-ingress Relay routes before Dashboard WebSocket ticket support is available. (Related: #399)
+5 -19
View File
@@ -1,29 +1,15 @@
# Hermes-Relay Plugin v__VERSION__
**Release Date:** August 30, 2026
**Release Date:** August 31, 2026
## Summary
This release lets one authenticated Hermes Dashboard origin carry Gateway plus optional Relay extensions, adds a bounded Git workspace, and reorganizes the Dashboard plugin around operator tasks. Standard chat, session history, profiles, Manage, and standard voice remain upstream-owned and do not require this plugin.
## Added
- **Dashboard same-origin Relay ingress.** Fixed allowlisted HTTP and WebSocket routes proxy to the local Relay while Dashboard admission and Relay session authentication remain independent. (Related: #399)
- **Bounded Git workspace.** Configured roots, path containment, line totals, diffs, branches, staging, commits, remotes, grants, and explicit confirmations protect repository operations.
## Changed
- **Task-oriented Dashboard UI.** Overview, Devices, Activity, Remote Access, Git, and Settings now have dedicated surfaces with QR-first pairing, responsive device cards, and honest media diagnostics. (#486)
- **One explicit route topology.** Dashboard, CLI, and TUI pairing advertise Dashboard, Relay, and optional API surfaces with stable priorities across Tailscale, public HTTPS, and LAN.
- **Dedicated Tailscale listener.** Recommended setup uses tailnet HTTPS `:10443` to local Dashboard `:9119`, avoiding ownership of a reverse proxy's `:443`. Existing `:443`, `:9119`, and direct `:8767` routes remain migration compatibility.
This patch restores native installation compatibility on affected Hermes versions and makes Relay prompt context advertise only capabilities the selected session can actually call. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
## Fixed
- Public and roaming invites no longer synthesize closed direct Relay `:8767` or wrong Dashboard `:9119` routes.
- Ambiguous, credential-bearing, or plaintext public candidates fail closed before an invite is exposed.
- Dense pairing QRs use integer-sized modules and a full quiet zone.
- Inactive optional API routes are omitted; protected Dashboard-ingress `401/403` responses display as authentication-required while direct Relay and API failures remain failures.
- Default Tailscale disable actions remove only owned listeners, and explicit migration cleanup accepts only the bounded supported ports.
- **Native installer compatibility.** The plugin keeps its complete current manifest while avoiding the installer/runtime schema mismatch that caused `manifest_version 2` installs to fail after an apparent Hermes update.
- **Capability-gated phone context.** Phone-control and cross-platform delivery guidance now follows the selected session/profile tool catalog instead of implying unavailable `android_*` or `send_message` callables.
## Install / update
@@ -35,7 +21,7 @@ This release lets one authenticated Hermes Dashboard origin carry Gateway plus o
# or, if already installed:
hermes-relay-update
Restart or reload the Hermes Dashboard and Relay after updating so the new manifest, routes, and committed Dashboard bundle are active.
Restart or reload the Hermes Dashboard and Relay after updating so the new manifest and prompt context are active.
## Verify
+19 -20
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — your Hermes agent, in your pocket" width="800">
<img src="assets/readme-hero-v2.jpg" alt="Hermes-Relay — Your Hermes agent. Wherever you are. Android, Voice, Desktop." width="1000">
</p>
<p align="center">
@@ -38,10 +38,10 @@ Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-age
- **📱 Android app** — streaming chat, hands-free voice, native plugin pages, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. Add a floating Petdex companion or optionally make Hermes your Android assistant; sideload builds can also let the agent read and act on your screen.
- **⌨️ Hermes-Relay CLI** *(beta)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough for the upstream standard path: chat, management, voice, Petdex, and ordinary installed-plugin pages. The Hermes-Relay plugin is optional for that base but encouraged for the complete current experience: Terminal/TUI, notifications, media, desktop tools, enhanced voice, Relay sessions, page drafts, and optional Device Control. Hermes-Relay prefers compatible upstream surfaces as they become available instead of keeping duplicate extension paths. **Connect Hermes first, then grant Hermes-Relay separately; the same one-time invite contract pairs Android or the Desktop CLI.**
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough for the upstream standard path: chat, management, voice, inbound files, Petdex, and ordinary installed-plugin pages. The Hermes-Relay plugin is optional for that base but encouraged for the complete current experience: Terminal/TUI, notifications, desktop tools, enhanced voice, Relay sessions, page drafts, optional Device Control, and media compatibility or metadata. Hermes-Relay prefers compatible upstream surfaces as they become available instead of keeping duplicate extension paths. **Connect Hermes first, then grant Hermes-Relay separately; the same one-time invite contract pairs Android or the Desktop CLI.**
<p align="center">
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — upstream Hermes owns Chat, Manage, and standard Voice; the encouraged Relay extension fills current gaps for Terminal, notifications, media, enhanced voice, sessions, desktop tools, and optional Device Control." width="900">
<img src="assets/readme-connection-map-v2.png" alt="How Hermes-Relay connects — Dashboard and Gateway own the standard Android path for Chat, Manage, Voice, and inbound files; the optional Relay plugin separately adds Android enhancements plus CLI and UI tools; sideload adds Device Control." width="1000">
</p>
## Quick Start (Android)
@@ -50,7 +50,7 @@ Install → connect → talk, in about two minutes.
### 1 · Install the app
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, sessions, and Manage work with standard Hermes; pairing the Hermes-Relay plugin adds Terminal/TUI, media, notifications, and Relay sessions.
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, sessions, Manage, and inbound files work with standard Hermes; pairing the Hermes-Relay plugin adds Terminal/TUI, notifications, Relay sessions, and media enhancements.
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://hermes-relay.dev/docs/guide/getting-started.html#sideload-apk).
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
@@ -58,7 +58,7 @@ Sideload builds check GitHub for updates and show a one-tap banner when you're b
### 2 · Have the Hermes Dashboard running
The normal Android connection uses the upstream Hermes Dashboard/Gateway for
chat, sign-in, sessions, Manage, and voice. Installing Hermes and choosing a
chat, sign-in, sessions, Manage, voice, and inbound files. Installing Hermes and choosing a
provider is vanilla Hermes setup:
```bash
@@ -77,16 +77,15 @@ the [remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/).
### 3 · Connect and talk
For a plugin-enabled host, open the Web Dashboard's **Relay** page, click
**Connect mobile app**, and scan that tokenless QR from Android **Connect → Scan
Hermes setup QR**. It contains only the Dashboard address and configures the
upstream Chat, sessions, Manage, sign-in, and standard voice connection.
Without the Dashboard plugin, use **Find Hermes on LAN** or enter the Dashboard
address manually (conventionally `http://<host>:9119`). Sign in through the
Use **Find Hermes on LAN** or enter the Dashboard address manually
(conventionally `http://<host>:9119`). Sign in through the
Dashboard's configured provider when prompted. The app probes the available
upstream capabilities and finishes with a connection summary.
If the Relay Dashboard page is already installed, **Connect mobile app** offers
the same standard connection as a tokenless QR. It contains only the Dashboard
address and does not install, enable, or pair Relay.
The separate API server can be discovered automatically or added later under
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
key is requested only when that optional endpoint is configured. Existing
@@ -109,9 +108,9 @@ the whole Vanilla Hermes setup.
### 4 · Recommended: pair Relay for the complete experience
Install Relay for Terminal/TUI, notifications, media handoff, desktop tools,
enhanced voice, Relay sessions, approval-gated page drafts, and optional Device
Control:
Install Relay for Terminal/TUI, notifications, desktop tools, enhanced voice,
Relay sessions, approval-gated page drafts, optional Device Control, and media
compatibility or sensitivity metadata:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
@@ -161,7 +160,7 @@ manual fallbacks when QR or clipboard transfer is unavailable.
<p align="center">
<img src="assets/screenshots/supplemental/15_git_workspace.png" alt="Native Git workspace showing repository changes, an inline diff, and staging controls" width="260"><br>
<sub><b>Native Git workspace</b> — optional Hermes-Relay plugin</sub>
<sub><b>Native Git workspace</b> — upstream session context with optional Relay discovery and operations</sub>
</p>
### Simplified Chinese
@@ -239,17 +238,17 @@ remote tool surface. See the [desktop tools guide](https://hermes-relay.dev/docs
## How It Works
```
Phone (HTTP/WSS) --> Hermes Dashboard (:9119) [chat gateway, manage, vanilla voice]
Phone (HTTP/WSS) --> Hermes Dashboard (:9119) [chat gateway, manage, vanilla voice, inbound files]
Phone (HTTP/SSE) --> Hermes API Server (:8642) [Direct API chat, sessions, runs]
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media enhancements, relay voice, sessions]
CLI (WSS) --> Relay (:8767) [machine tools, tui, terminal]
```
Standard connections keep Chat on the Hermes Dashboard/Gateway. Explicit API-only
connections use the upstream Direct API SSE path with an API key. Manage and Vanilla Hermes
voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla
install needs no plugin for either. The optional relay on `:8767` adds the power
surfaces: terminal, bridge phone control, media handoff, machine tools, and
install needs no plugin for those surfaces or ordinary inbound files. The optional relay on `:8767` adds
terminal, bridge phone control, media compatibility/metadata, machine tools, and
relay-side voice, which is preferred automatically when paired. One QR can
configure API, dashboard, and relay routes without merging their auth models.
+22 -23
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.14.0
# Hermes-Relay Android v1.15.0
**Release Date:** August 30, 2026
**Release Date:** August 31, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.14.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.15.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,33 +12,32 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This release makes saved Hermes connections reliable across LAN, Tailscale, and public HTTPS while keeping Dashboard authentication bound to its exact trusted origin. It also adds delegated-agent previews and an optional native Git workspace, and improves Voice, Assistant, Threads, profile drafts, and Clarify interactions.
## Added
- **Delegated-agent previews.** Follow bounded lifecycle, progress, tool previews, and available read-only child history without leaving the parent chat. Partial history and reconnect gaps remain explicit. (#447)
- **Native Git workspace.** Review repository state, diffs, branches, staging, commits, and remotes from Chat or Settings. Git operations require Hermes-Relay Plugin v1.11.0 and retain confirmation and grant boundaries.
This release makes the everyday Android experience work through current upstream Hermes without treating the optional Hermes-Relay Plugin as a prerequisite. It also strengthens parent access, cross-client activity, transport ownership, recovery, and release visibility.
## Changed
- **Route-aware connections.** Dashboard, Relay, and optional API health are evaluated independently across LAN, Tailscale, and public HTTPS. Same-origin Relay ingress stays on the Dashboard origin that owns authentication, while direct compatibility routes keep separate credentials. (Related: #399)
- **Voice Focus controls.** Stop and immediate spoken steering remain accessible while Hermes is Thinking, Transcribing, or Speaking, including TalkBack, Switch Access, keyboard, and sideload overlay surfaces.
- **Upstream-first standard surfaces.** Chat attachments, inbound host files, current-session Git reads, Nous usage bars, and keyed Hermes notices use authenticated Dashboard/Gateway contracts first. Relay remains an additive compatibility and enhancement layer.
- **Clear Settings ownership.** Media sits with Chat and Voice under Hermes. Proactive Threads, Terminal, Notification Companion, Relay sessions, enhanced voice, and Device Control stay grouped under Relay tools.
- **App-specific supervised parent access.** Parents choose a PIN or password and receive a six-word recovery phrase; Android device credentials and biometrics no longer grant parent authority.
- **Complete What's New history.** Release highlights, the remaining improvements and fixes, compatibility notes, toast counts, and history now come from one structured inventory.
## Fixed
- Wake-word detection packages one compatible ONNX Runtime for sherpa and Java JNI on every supported ABI. (#444)
- Continuous voice waits for barge-in microphone teardown before listening again. (#464)
- Fresh chats retain their selected profile without reopening a previous session or carrying a proactive Thread route across profiles. (#436)
- Provisional Threads can be removed locally and reconcile with promoted sessions without deleting server history. (#461)
- Clarify cards expose a reachable Other answer, keyboard Send, and authoritative expiry behavior. (#446)
- Passive Android browsing no longer claims or interrupts a turn owned by another client. (Related: #365)
- Assistant sessions show retryable no-speech feedback, recover their active state after recreation, and redact conversation details behind the keyguard. (Related: #424)
- Protected Relay ingress `401/403` responses are recognized as authentication boundaries rather than outages; malformed, different-origin, and direct unauthorized routes still fail closed.
- Host-local images, audio, video, and files download through the authenticated Dashboard and remain loaded across history reconciliation instead of flashing `Relay URL not configured` or returning to Loading.
- Standard voice remains usable after explicit Relay removal, while temporary Relay outages preserve configured choices and default-profile preferences stay isolated across connections.
- Android can display uniquely matched Desktop/TUI Working and Waiting activity without resuming, activating, or interrupting the external turn.
- Dashboard/Gateway chats preserve their transcript, draft, profile, and session through sign-out or outages instead of silently changing to Direct API.
- Completed text survives Dashboard-history authentication expiry and returns the existing sign-in recovery path.
- Long-running context compaction refreshes the turn watchdog instead of being interrupted as idle.
- Bot Chat history renders immediately after its route-owned handler binds.
- An exact idle live-session snapshot settles an Android-owned turn whose terminal frame was lost, reconciles history, and drains its queued follow-up.
- Supervised Add Gateway remains parent-owned across relock, back navigation, and pending setup cancellation.
- Completed generated images stay visible during marker persistence lag and retain the intended image-generation presentation.
## Install / Verify
- App version: **1.14.0** (versionCode **52**).
- Standard Chat, sessions, profiles, Manage, and standard voice continue to work against unmodified upstream Hermes without the optional Relay plugin.
- Install Hermes-Relay Plugin v1.11.0 for same-origin Relay extensions, Git workspace actions, Bridge, media, proactive features, and enhanced voice.
- App version: **1.15.0** (versionCode **53**).
- Standard Chat, sessions, profiles, Manage, standard voice, outbound attachments, and ordinary inbound files work against current unmodified upstream Hermes without the optional Hermes-Relay Plugin.
- Install Hermes-Relay Plugin v1.11.1 for Terminal, proactive Threads/offline delivery, Notification Companion, Relay sessions, provider-native/realtime voice, compatibility media metadata, Secure Link, and phone/device control.
- Explicit Direct API/API-only connections remain supported and are never used as a silent failover for a Dashboard-owned chat.
- Granular Device Control and the system Voice Focus overlay remain sideload-only; the Google Play build does not declare their restricted permissions.
- Existing connections, drafts, sessions, profile ownership, and legacy direct Relay routes remain data-preserving compatibility paths.
+31 -2
View File
@@ -45,6 +45,32 @@ compatibility route.
---
## Upstream an Android Gateway platform hint
Android currently identifies its standard Gateway sessions with the legacy
`webui` source because upstream has no stable Android/mobile session platform.
Current upstream deliberately removed the unused `webui` prompt hint and only
ships renderer-verified `desktop` and `tui` guidance. Do not relabel Android as
Desktop: that would also advertise Desktop-only inline widgets and directives.
Propose an upstream Android/mobile platform hint, or a bounded authenticated
client-surface context contract, that accurately describes mobile Markdown,
standard upstream media/file delivery, and concise-response expectations. Once
that contract is available in the supported Hermes baseline, adopt it and add
Gateway conformance coverage proving the exact prompt bytes and session source.
---
## Scope sensitive-media prompt guidance to capable clients
The Relay plugin's sensitive-media prompt section currently describes the
Android `||![...](...)||` and alt-text conventions profile-wide. Before
expanding that behavior, make the section depend on an authoritative client
capability or replace it with a portable convention verified against every
renderer that receives the profile prompt. Do not make Desktop/TUI sessions
emit Android-only spoiler syntax merely because the Relay plugin is installed.
---
## Certify Android session activity across lifecycle and profile boundaries
The contract fixture now covers every upstream live status, complete-snapshot
@@ -53,8 +79,11 @@ and older Gateways without `session.active_list`. Before calling the status
model device-certified:
- Exercise working, quiet tool-heavy work, each pending-input surface, normal
completion, Stop, reconnect, app restart, and process recreation against
current vanilla upstream.
completion, a lost terminal followed by an exact active-list Idle row, Stop,
reconnect, app restart, and process recreation against current vanilla
upstream. Confirm the lost-terminal path preserves the partial transcript,
settles composer/steering state, and drains or cancels queued corrections
exactly once according to the owning turn outcome.
- Verify All Profiles with duplicate session ids across two profiles and two
saved connections; no late snapshot or old socket generation may mark the
wrong row live.
@@ -258,6 +258,46 @@ class GatewayForegroundRecoveryInstrumentedTest {
assertEquals(0, fixture.requestsTo("/v1/chat/completions"))
}
@Test
fun terminalGapActiveList_settlesExactOwnedTurnAndRendersAuthoritativeHistory() {
viewModel.sendMessage("Run an Android-owned task")
fixture.awaitRpc("prompt.submit")
serverSocket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
serverSocket.send(
fixture.event(
"message.delta",
buildJsonObject { put("text", PARTIAL_ANSWER) },
LIVE_SESSION_ID,
),
)
compose.waitUntil(5_000) { handler.isStreaming.value }
compose.onNodeWithTag("stream-state").assertTextEquals("STREAMING")
persistedHistory = listOf(
MessageItem(
id = PERSISTED_ANSWER_ID,
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive(AUTHORITATIVE_ANSWER),
),
)
fixture.activeSessionStatus = "idle"
runBlocking { gatewayClient.listActiveSessions() }
compose.waitUntil(5_000) {
!handler.isStreaming.value &&
!gatewayClient.hasActiveTurn() &&
handler.messages.value.singleOrNull()?.id == PERSISTED_ANSWER_ID
}
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
compose.onNodeWithTag("message-$PERSISTED_ANSWER_ID")
.assertTextEquals("${MessageRole.ASSISTANT.name}:$AUTHORITATIVE_ANSWER")
assertEquals(1, fixture.rpcCount("prompt.submit"))
assertEquals(0, fixture.rpcCount("session.interrupt"))
assertEquals(0, fixture.rpcCount("session.activate"))
assertEquals(0, fixture.requestsTo("/v1/chat/completions"))
}
@Test
fun desktopOwnedTurn_remainsReadOnlyAcrossAndroidForegroundLifecycle() {
viewModel.setChatVisible(false)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 509 KiB

@@ -1,3 +1,3 @@
v1.14.0 - Connections, delegated work, Git, and voice
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
Standard Chat, Voice, attachments, returned files, current-session Git, usage, and Hermes notices now prefer upstream Dashboard and Gateway support without requiring Relay. Returned media stays loaded, voice survives Relay removal, and Settings clearly separates standard Hermes from Relay tools. Supervised Mode also gains app-specific parent access and recovery.
+102
View File
@@ -1,6 +1,108 @@
{
"schema": 3,
"versions": [
{
"version": "1.15.0",
"title": "Standard Hermes first, with clearer Relay boundaries",
"date": "2026-08-31",
"summary": "Chat, voice, attachments, inbound files, current-session Git, usage, and Hermes notices now prefer current upstream Dashboard and Gateway support. Relay stays optional for compatibility and the tools it uniquely provides.",
"changes": [
{
"id": "upstream-standard-surfaces",
"kind": "improved",
"title": "Use standard Hermes without Relay prompts",
"summary": "Chat attachments, inbound files, current-session Git, Nous usage, and Hermes notices use upstream routes first.",
"highlight": true
},
{
"id": "stable-inbound-media",
"kind": "fixed",
"title": "Keep returned files loaded",
"summary": "Images, audio, video, and documents download through the Dashboard and no longer flash Relay errors or return to Loading.",
"highlight": true
},
{
"id": "supervised-parent-access",
"kind": "improved",
"title": "Use app-specific parent access",
"summary": "A parent PIN or password plus recovery phrase protects Supervised Mode without trusting the phone unlock credential.",
"highlight": true
},
{
"id": "settings-relay-boundaries",
"kind": "improved",
"title": "See which features need Relay",
"summary": "Media sits with standard Hermes settings while Threads, Terminal, notifications, enhanced voice, and device tools stay under Relay tools."
},
{
"id": "complete-release-history",
"kind": "improved",
"title": "Read the complete release record",
"summary": "What's New shows one release summary, selected highlights, every remaining change, and relevant compatibility notes."
},
{
"id": "relay-removal-voice",
"kind": "fixed",
"title": "Keep Standard voice after removing Relay",
"summary": "Dashboard voice remains ready, temporary outages preserve choices, and shared default-profile settings stay isolated."
},
{
"id": "passive-external-activity",
"kind": "fixed",
"title": "Observe another client's activity safely",
"summary": "A uniquely matched Desktop or TUI turn can show Working or Waiting without Android taking control."
},
{
"id": "chat-transport-ownership",
"kind": "fixed",
"title": "Keep each chat on its chosen transport",
"summary": "Dashboard chats preserve their transcript, draft, profile, and session through sign-out or outages instead of silently changing databases."
},
{
"id": "history-auth-recovery",
"kind": "fixed",
"title": "Preserve completed replies at sign-in expiry",
"summary": "A Dashboard history authentication failure keeps completed text visible and opens the existing sign-in recovery path."
},
{
"id": "compaction-watchdog",
"kind": "fixed",
"title": "Let long context compaction finish",
"summary": "Visible compaction activity refreshes the turn watchdog instead of being interrupted as idle."
},
{
"id": "bot-chat-binding",
"kind": "fixed",
"title": "Render Bot Chat history immediately",
"summary": "Route-owned Bot Chats observe their bound history from first composition."
},
{
"id": "missing-terminal-recovery",
"kind": "fixed",
"title": "Settle turns after a lost terminal frame",
"summary": "An exact idle live-session snapshot reconciles the Android-owned turn and drains its queued follow-up."
},
{
"id": "supervised-gateway-setup",
"kind": "fixed",
"title": "Keep Gateway setup parent-owned",
"summary": "Relock and back navigation cancel the exact pending setup without bypassing parent authority."
},
{
"id": "generated-image-retention",
"kind": "fixed",
"title": "Keep completed generated images visible",
"summary": "Generated media survives marker persistence lag and retains its intended Chat animation."
}
],
"compatibility": [
"Current upstream Hermes provides standard Chat, sessions, Manage, voice, attachments, inbound files, current-session Git reads, usage, and notices without the optional Hermes-Relay Plugin.",
"Hermes-Relay Plugin 1.11.1 remains required for Terminal, proactive Threads and offline delivery, Notification Companion, Relay sessions, enhanced voice, Secure Link, and phone or device control.",
"Granular Device Control and the system Voice Focus overlay remain sideload-only."
],
"playNotes": "Standard Chat, Voice, attachments, returned files, current-session Git, usage, and Hermes notices now prefer upstream Dashboard and Gateway support without requiring Relay. Returned media stays loaded, voice survives Relay removal, and Settings clearly separates standard Hermes from Relay tools. Supervised Mode also gains app-specific parent access and recovery.",
"sections": []
},
{
"version": "1.14.0",
"title": "Connections, delegated work, Git, and voice",
+19 -21
View File
@@ -1,31 +1,29 @@
v1.14.0 - Connections, delegated work, Git, and voice
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
Summary
* Connections now recover cleanly across networks. You can also follow delegated agents, work with Git repositories, and rely on steadier voice, sessions, Threads, profiles, Assistant, and Clarify controls.
* Chat, voice, attachments, inbound files, current-session Git, usage, and Hermes notices now prefer current upstream Dashboard and Gateway support. Relay stays optional for compatibility and the tools it uniquely provides.
Highlights
* Connections recover independently — Move between LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication.
* Follow delegated-agent activity — See lifecycle, progress, tool previews, and available read-only child history from the parent chat.
* Work with repositories from Android — Review status, diffs, branches, staging, commits, and remotes from Chat or Settings.
* Steer voice at any time — Stop or redirect Hermes while it is Thinking, Transcribing, or Speaking, including with accessibility controls.
* Use standard Hermes without Relay prompts — Chat attachments, inbound files, current-session Git, Nous usage, and Hermes notices use upstream routes first.
* Keep returned files loaded — Images, audio, video, and documents download through the Dashboard and no longer flash Relay errors or return to Loading.
* Use app-specific parent access — A parent PIN or password plus recovery phrase protects Supervised Mode without trusting the phone unlock credential.
Improved
* Release notes stay out of your way — A dismissible post-update notice keeps startup usable and leaves the complete history available from Settings.
* Chat uses one consistent presentation — The overlapping clean-focus mode was removed while the separate Voice Focus experience remains available.
* See which features need Relay — Media sits with standard Hermes settings while Threads, Terminal, notifications, enhanced voice, and device tools stay under Relay tools.
* Read the complete release record — What's New shows one release summary, selected highlights, every remaining change, and relevant compatibility notes.
Fixed
* Wake-word detection starts reliably — Compatible native voice components are now packaged for every supported phone architecture.
* The visible Sphere keeps moving smoothly — Foreground animation no longer falls back to a stepped ambient pulse.
* Continuous voice keeps the microphone — The next listening turn waits for barge-in recording to release cleanly.
* New chats keep the selected profile — Fresh drafts no longer reopen an older session or carry a Thread route into another profile.
* Provisional Threads can be removed safely — Local removal and later session promotion no longer risk duplicate rows or server history.
* Clarify keeps custom answers reachable — Other answers, keyboard Send, and expired prompts now behave consistently.
* Browsing no longer interrupts another client — Passive Android observation does not claim a turn owned by Desktop, TUI, or another client.
* Assistant sessions recover more clearly — No-speech feedback, recreated session state, and keyguard privacy now remain intact.
* Protected Relay routes report the right problem — Authentication challenges are no longer presented as outages, while unsafe routes still fail closed.
* Connections and sessions become ready sooner — Unavailable optional API and Relay routes no longer delay a healthy Dashboard or authenticated session history.
* Keep Standard voice after removing Relay — Dashboard voice remains ready, temporary outages preserve choices, and shared default-profile settings stay isolated.
* Observe another client's activity safely — A uniquely matched Desktop or TUI turn can show Working or Waiting without Android taking control.
* Keep each chat on its chosen transport — Dashboard chats preserve their transcript, draft, profile, and session through sign-out or outages instead of silently changing databases.
* Preserve completed replies at sign-in expiry — A Dashboard history authentication failure keeps completed text visible and opens the existing sign-in recovery path.
* Let long context compaction finish — Visible compaction activity refreshes the turn watchdog instead of being interrupted as idle.
* Render Bot Chat history immediately — Route-owned Bot Chats observe their bound history from first composition.
* Settle turns after a lost terminal frame — An exact idle live-session snapshot reconciles the Android-owned turn and drains its queued follow-up.
* Keep Gateway setup parent-owned — Relock and back navigation cancel the exact pending setup without bypassing parent authority.
* Keep completed generated images visible — Generated media survives marker persistence lag and retains its intended Chat animation.
Compatibility
* Standard Chat, sessions, profiles, Manage, and standard voice continue to work without the optional Hermes-Relay Plugin.
* The Git workspace and same-origin Relay extensions require Hermes-Relay Plugin 1.11.0.
* Granular Device Control and the system Voice Focus overlay remain available only in the sideload build.
* Current upstream Hermes provides standard Chat, sessions, Manage, voice, attachments, inbound files, current-session Git reads, usage, and notices without the optional Hermes-Relay Plugin.
* Hermes-Relay Plugin 1.11.1 remains required for Terminal, proactive Threads and offline delivery, Notification Companion, Relay sessions, enhanced voice, Secure Link, and phone or device control.
* Granular Device Control and the system Voice Focus overlay remain sideload-only.
@@ -1,7 +1,13 @@
package com.hermesandroid.relay.data
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardHttpException
import com.hermesandroid.relay.plugins.runtime.ScopedPluginApiClient
import java.io.IOException
import java.net.URLEncoder
import java.util.Locale
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
@@ -9,113 +15,178 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.put
// Read + write client for the Hermes-Relay Git State endpoints.
// All requests are confined to the ``hermes-relay`` plugin namespace and the
// ``git/*`` sub-path via ScopedPluginApiClient, which rejects traversal and
// encodes query values.
private fun pathsArray(paths: List<String>) = buildJsonArray { paths.forEach { add(JsonPrimitive(it)) } }
/**
* Uses official Dashboard `/api/git/…` reads for the active session repository.
* Relay remains the discovery source and owns stronger write/preview extensions.
* Operational upstream failures are never hidden by a Relay retry; only a 404
* can fall back to a matching Relay-discovered repository.
*/
class GitStateApiClient(
dashboard: DashboardApiClient,
private val dashboard: DashboardApiClient,
) {
private val scoped = ScopedPluginApiClient("hermes-relay", dashboard)
private val json = Json { ignoreUnknownKeys = true }
private val reposById = linkedMapOf<String, GitRepo>()
private val relayRepoIdsByRoot = linkedMapOf<String, String>()
suspend fun repos(): Result<List<GitRepo>> = scoped
.get("git/repos")
.mapCatching { element ->
json.decodeFromJsonElement<ReposResponse>(element).repos
suspend fun repos(
sessionRepoPath: String? = null,
includeRelayDiscovery: Boolean = true,
): Result<List<GitRepo>> {
reposById.clear()
relayRepoIdsByRoot.clear()
val path = sessionRepoPath?.trim().orEmpty()
if (path.isBlank()) {
return if (includeRelayDiscovery) relayRepos().onSuccess(::rememberRepos)
else Result.success(emptyList())
}
suspend fun status(repo: String): Result<GitStatus> = scoped
.get("git/status", mapOf("repo" to repo))
.mapCatching { element -> json.decodeFromJsonElement<GitStatus>(element) }
suspend fun branches(repo: String): Result<List<GitBranch>> = scoped
.get("git/branches", mapOf("repo" to repo))
.mapCatching { element ->
json.decodeFromJsonElement<BranchesResponse>(element).branches
val upstream = upstreamStatus(path)
if (upstream.isFailure) {
val error = upstream.exceptionOrNull()!!
if (!error.isUnsupportedGitRoute()) return Result.failure(error)
return if (includeRelayDiscovery) relayRepos().onSuccess(::rememberRepos)
else Result.failure(error)
}
val status = upstream.getOrNull()
if (status == null) {
return if (includeRelayDiscovery) relayRepos().onSuccess(::rememberRepos)
else Result.success(emptyList())
}
suspend fun diff(repo: String, path: String, kind: String): Result<GitDiff> = scoped
.get("git/diff", mapOf("repo" to repo, "path" to path, "kind" to kind))
.mapCatching { element -> json.decodeFromJsonElement<GitDiff>(element) }
val standardRepo = GitRepo(
id = UPSTREAM_SESSION_REPO_ID,
name = path.replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { path },
root = path,
currentBranch = status.branch,
dirty = status.changed > 0,
route = GitRepositoryRoute.UPSTREAM,
)
suspend fun file(repo: String, path: String): Result<GitFile> = scoped
.get("git/file", mapOf("repo" to repo, "path" to path))
.mapCatching { element -> json.decodeFromJsonElement<GitFile>(element) }
// Plugin discovery is an enhancement. Once upstream answered, plugin
// absence or breakage cannot take the standard session repository down.
val relay = if (includeRelayDiscovery) relayRepos().getOrDefault(emptyList()) else emptyList()
val merged = buildList {
add(standardRepo)
addAll(relay.filterNot { sameRoot(it.root, standardRepo.root) })
}
rememberRepos(merged)
rememberRelayRepos(relay)
return Result.success(merged)
}
// ── Write operations ───────────────────────────────────────────────────
// Every write requires the plugin.api.write grant, which the app enforces
// (see GitStateViewModel: a POST is never sent without the grant). The
// server additionally enforces per-use confirmation strings for destructive
// ops (discard/push/dirty-checkout) — the caller passes the echoed token.
suspend fun status(repo: String): Result<GitStatus> {
val target = reposById[repo]
if (target?.route != GitRepositoryRoute.UPSTREAM) return relayStatus(repo)
return fallbackOnUnsupported(target, upstreamStatusWithFiles(target.root), ::relayStatus)
}
suspend fun branches(repo: String): Result<List<GitBranch>> {
val target = reposById[repo]
if (target?.route != GitRepositoryRoute.UPSTREAM) return relayBranches(repo)
return fallbackOnUnsupported(target, upstreamBranches(target.root), ::relayBranches)
}
suspend fun diff(repo: String, path: String, kind: String): Result<GitDiff> {
val target = reposById[repo]
if (target?.route != GitRepositoryRoute.UPSTREAM) return relayDiff(repo, path, kind)
val upstream = dashboard.getJsonElement(
upstreamPath(
"/api/git/review/diff",
mapOf(
"path" to target.root,
"file" to path,
"scope" to "uncommitted",
"staged" to (kind == "staged").toString(),
),
),
).mapCatching { element ->
GitDiff(
path = path,
kind = kind,
diff = json.decodeFromJsonElement<UpstreamDiffResponse>(element).diff,
)
}
return fallbackOnUnsupported(target, upstream) { relay -> relayDiff(relay, path, kind) }
}
/** Clean tracked-file preview is a Relay enhancement; file-diff is not equivalent. */
suspend fun file(repo: String, path: String): Result<GitFile> {
val relay = relayRepoId(repo)
?: return Result.failure(IOException("Tracked-file preview requires the Relay plugin"))
return relayFile(relay, path)
}
// Writes intentionally stay on Relay. The upstream Desktop mutation shape
// does not carry plugin.api.write or the server-enforced confirmation echoes
// used by this mobile surface, so it is not an equivalent safety contract.
suspend fun stage(repo: String, paths: List<String>): Result<GitMutationResult> =
scoped.post("git/stage", buildJsonObject {
put("repo", repo)
relayWrite(repo) { relay -> scoped.post("git/stage", buildJsonObject {
put("repo", relay)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
}).decodeMutation() }
suspend fun unstage(repo: String, paths: List<String>): Result<GitMutationResult> =
scoped.post("git/unstage", buildJsonObject {
put("repo", repo)
relayWrite(repo) { relay -> scoped.post("git/unstage", buildJsonObject {
put("repo", relay)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
}).decodeMutation() }
suspend fun discard(
repo: String,
paths: List<String>,
confirmation: String,
deleteUntracked: Boolean = false,
): Result<GitMutationResult> = scoped.post("git/discard", buildJsonObject {
put("repo", repo)
): Result<GitMutationResult> = relayWrite(repo) { relay -> scoped.post("git/discard", buildJsonObject {
put("repo", relay)
put("paths", pathsArray(paths))
put("confirmation", confirmation)
put("delete_untracked", deleteUntracked)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
}).decodeMutation() }
suspend fun commit(repo: String, message: String): Result<GitMutationResult> =
scoped.post("git/commit", buildJsonObject {
put("repo", repo)
relayWrite(repo) { relay -> scoped.post("git/commit", buildJsonObject {
put("repo", relay)
put("message", message)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
}).decodeMutation() }
suspend fun commitSelected(
repo: String,
message: String,
paths: List<String>,
): Result<GitMutationResult> = scoped.post("git/commit_selected", buildJsonObject {
put("repo", repo)
): Result<GitMutationResult> = relayWrite(repo) { relay -> scoped.post("git/commit_selected", buildJsonObject {
put("repo", relay)
put("message", message)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
}).decodeMutation() }
suspend fun fetch(repo: String, remote: String = "origin"): Result<GitMutationResult> =
scoped.post("git/fetch", buildJsonObject {
put("repo", repo)
relayWrite(repo) { relay -> scoped.post("git/fetch", buildJsonObject {
put("repo", relay)
put("remote", remote)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
}).decodeMutation() }
suspend fun pull(repo: String, remote: String = "origin", branch: String = ""): Result<GitMutationResult> =
scoped.post("git/pull", buildJsonObject {
put("repo", repo)
relayWrite(repo) { relay -> scoped.post("git/pull", buildJsonObject {
put("repo", relay)
put("remote", remote)
put("branch", branch)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
}).decodeMutation() }
suspend fun push(
repo: String,
confirmation: String,
remote: String = "origin",
branch: String = "",
): Result<GitMutationResult> = scoped.post("git/push", buildJsonObject {
put("repo", repo)
): Result<GitMutationResult> = relayWrite(repo) { relay -> scoped.post("git/push", buildJsonObject {
put("repo", relay)
put("remote", remote)
put("branch", branch)
put("confirmation", confirmation)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
}).decodeMutation() }
suspend fun checkout(
repo: String,
@@ -123,41 +194,217 @@ class GitStateApiClient(
confirmation: String? = null,
newBranch: String = "",
track: Boolean = false,
): Result<GitMutationResult> = scoped.post("git/checkout", buildJsonObject {
put("repo", repo)
): Result<GitMutationResult> = relayWrite(repo) { relay -> scoped.post("git/checkout", buildJsonObject {
put("repo", relay)
put("ref", ref)
if (confirmation != null) put("confirmation", confirmation)
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
put("track", track)
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
}).decodeMutation() }
// ── Phase 3 extras ─────────────────────────────────────────────────────
suspend fun commitMessage(repo: String): Result<GitCommitMessage> = relayWrite(repo) { relay ->
scoped.post("git/commit_message", buildJsonObject { put("repo", relay) })
.mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
}
/** Generate a commit-message suggestion from the staged diff. */
suspend fun commitMessage(repo: String): Result<GitCommitMessage> =
scoped.post("git/commit_message", buildJsonObject {
put("repo", repo)
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
suspend fun commitMessageSelected(repo: String, paths: List<String>): Result<GitCommitMessage> =
relayWrite(repo) { relay -> scoped.post("git/commit_message_selected", buildJsonObject {
put("repo", relay)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) } }
/** Generate a commit-message suggestion from the given paths' staged diff. */
suspend fun commitMessageSelected(
repo: String,
paths: List<String>,
): Result<GitCommitMessage> = scoped.post("git/commit_message_selected", buildJsonObject {
put("repo", repo)
put("paths", pathsArray(paths))
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
/** Checkout that auto-stashes a dirty tree first. */
suspend fun stashCheckout(
repo: String,
ref: String,
newBranch: String = "",
track: Boolean = false,
): Result<GitStashCheckoutResult> = scoped.post("git/stash_checkout", buildJsonObject {
put("repo", repo)
): Result<GitStashCheckoutResult> = relayWrite(repo) { relay -> scoped.post("git/stash_checkout", buildJsonObject {
put("repo", relay)
put("ref", ref)
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
put("track", track)
}).mapCatching { json.decodeFromJsonElement<GitStashCheckoutResult>(it) }
}).mapCatching { json.decodeFromJsonElement<GitStashCheckoutResult>(it) } }
private suspend fun relayRepos(): Result<List<GitRepo>> = scoped.get("git/repos").mapCatching {
json.decodeFromJsonElement<ReposResponse>(it).repos
}
private suspend fun relayStatus(repo: String): Result<GitStatus> = scoped
.get("git/status", mapOf("repo" to repo))
.mapCatching { json.decodeFromJsonElement<GitStatus>(it) }
private suspend fun relayBranches(repo: String): Result<List<GitBranch>> = scoped
.get("git/branches", mapOf("repo" to repo))
.mapCatching { json.decodeFromJsonElement<BranchesResponse>(it).branches }
private suspend fun relayDiff(repo: String, path: String, kind: String): Result<GitDiff> = scoped
.get("git/diff", mapOf("repo" to repo, "path" to path, "kind" to kind))
.mapCatching { json.decodeFromJsonElement<GitDiff>(it) }
private suspend fun relayFile(repo: String, path: String): Result<GitFile> = scoped
.get("git/file", mapOf("repo" to repo, "path" to path))
.mapCatching { json.decodeFromJsonElement<GitFile>(it) }
private suspend fun upstreamStatus(path: String): Result<UpstreamStatus?> = dashboard
.getJsonElement(upstreamPath("/api/git/status", mapOf("path" to path)))
.mapCatching { json.decodeFromJsonElement<UpstreamStatus?>(it) }
private suspend fun upstreamStatusWithFiles(path: String): Result<GitStatus> {
val status = upstreamStatus(path).mapCatching {
it ?: throw IOException("The active session path is not a Git repository")
}.getOrElse { return Result.failure(it) }
val review = dashboard.getJsonElement(
upstreamPath("/api/git/review/list", mapOf("path" to path, "scope" to "uncommitted")),
).mapCatching { json.decodeFromJsonElement<UpstreamReviewList>(it) }
.getOrElse { return Result.failure(it) }
val statusByPath = status.files.associateBy { it.path }
val staged = mutableListOf<GitStatusEntry>()
val modified = mutableListOf<GitStatusEntry>()
val untracked = mutableListOf<GitStatusEntry>()
review.files.forEach { file ->
val entry = GitStatusEntry(file.path, file.added, file.removed)
val fileStatus = statusByPath[file.path]
if (fileStatus?.untracked == true) {
untracked += entry
} else {
if (file.staged || fileStatus?.staged == true) staged += entry
if (fileStatus?.unstaged == true || (!file.staged && fileStatus == null)) {
modified += entry
}
}
}
return Result.success(
GitStatus(
counts = GitStatusCounts(
staged = status.staged,
modified = status.unstaged,
untracked = status.untracked,
changes = status.changed,
additions = status.added,
deletions = status.removed,
),
staged = staged,
modified = modified,
untracked = untracked,
),
)
}
private suspend fun upstreamBranches(path: String): Result<List<GitBranch>> = dashboard
.getJsonElement(upstreamPath("/api/git/branches", mapOf("path" to path)))
.mapCatching { element ->
json.decodeFromJsonElement<UpstreamBranches>(element).branches.map {
GitBranch(name = it.name, isCurrent = it.checkedOut)
}
}
private suspend fun <T> fallbackOnUnsupported(
target: GitRepo,
upstream: Result<T>,
relayCall: suspend (String) -> Result<T>,
): Result<T> {
if (upstream.isSuccess) return upstream
val error = upstream.exceptionOrNull()!!
if (!error.isUnsupportedGitRoute()) return Result.failure(error)
val relay = relayRepoIdsByRoot[normalizedRoot(target.root)] ?: return Result.failure(error)
return relayCall(relay)
}
private suspend fun <T> relayWrite(repo: String, block: suspend (String) -> Result<T>): Result<T> {
val relay = relayRepoId(repo)
?: return Result.failure(IOException("This Git action requires the Relay plugin enhancement"))
return block(relay)
}
private fun relayRepoId(repo: String): String? {
val target = reposById[repo] ?: return repo.takeUnless { it == UPSTREAM_SESSION_REPO_ID }
return if (target.route == GitRepositoryRoute.RELAY) target.id
else relayRepoIdsByRoot[normalizedRoot(target.root)]
}
private fun rememberRepos(repos: List<GitRepo>) {
repos.forEach { reposById[it.id] = it }
rememberRelayRepos(repos.filter { it.route == GitRepositoryRoute.RELAY })
}
private fun rememberRelayRepos(repos: List<GitRepo>) {
repos.forEach { relayRepoIdsByRoot[normalizedRoot(it.root)] = it.id }
}
private fun upstreamPath(path: String, query: Map<String, String>): String = buildString {
append(path)
if (query.isNotEmpty()) {
append('?')
append(query.entries.joinToString("&") { (key, value) -> "${encode(key)}=${encode(value)}" })
}
}
private fun encode(value: String): String =
URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20")
private fun normalizedRoot(path: String): String {
val normalized = path.trim().replace('\\', '/').trimEnd('/')
return if (WINDOWS_ROOT.containsMatchIn(normalized) || normalized.startsWith("//")) {
normalized.lowercase(Locale.ROOT)
} else {
normalized
}
}
private fun sameRoot(first: String, second: String): Boolean =
normalizedRoot(first) == normalizedRoot(second)
private fun Result<kotlinx.serialization.json.JsonObject>.decodeMutation(): Result<GitMutationResult> =
mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
private fun Throwable.isUnsupportedGitRoute(): Boolean =
this is DashboardHttpException && statusCode == 404
private companion object {
const val UPSTREAM_SESSION_REPO_ID = "__upstream_session__"
val WINDOWS_ROOT = Regex("^[A-Za-z]:/")
}
}
@Serializable
private data class UpstreamStatus(
val branch: String? = null,
val staged: Int = 0,
val unstaged: Int = 0,
val untracked: Int = 0,
val changed: Int = 0,
val added: Int = 0,
val removed: Int = 0,
val files: List<UpstreamStatusFile> = emptyList(),
)
@Serializable
private data class UpstreamStatusFile(
val path: String,
val staged: Boolean = false,
val unstaged: Boolean = false,
val untracked: Boolean = false,
)
@Serializable
private data class UpstreamReviewList(val files: List<UpstreamReviewFile> = emptyList())
@Serializable
private data class UpstreamReviewFile(
val path: String,
val added: Int = 0,
val removed: Int = 0,
val staged: Boolean = false,
)
@Serializable
private data class UpstreamBranches(val branches: List<UpstreamBranch> = emptyList())
@Serializable
private data class UpstreamBranch(
val name: String,
@SerialName("checkedOut") val checkedOut: Boolean = false,
)
@Serializable
private data class UpstreamDiffResponse(val diff: String = "")
@@ -3,7 +3,7 @@ package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** A repository discovered by the plugin's /git/repos endpoint. */
/** A current-session upstream repository or a Relay-discovered repository. */
@Serializable
data class GitRepo(
val id: String,
@@ -11,9 +11,19 @@ data class GitRepo(
val root: String,
@SerialName("current_branch") val currentBranch: String? = null,
val dirty: Boolean = false,
val route: GitRepositoryRoute = GitRepositoryRoute.RELAY,
)
/** Working-tree status from /git/status. */
@Serializable
enum class GitRepositoryRoute {
@SerialName("relay")
RELAY,
@SerialName("upstream")
UPSTREAM,
}
/** Normalized working-tree status from upstream or Relay Git routes. */
@Serializable
data class GitStatus(
val counts: GitStatusCounts = GitStatusCounts(),
@@ -344,6 +344,46 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
dataStore.edit { it[key] = route.storageValue }
}
/**
* Clear Relay-only selections after Relay has been explicitly removed from
* the active connection. A temporarily unreachable configured Relay must
* not call this: preserving the selection lets the richer route resume
* when connectivity returns.
*
* [expectedScope] fences profile/connection changes that can race the
* DataStore edit. Values are re-read inside the transaction instead of
* trusting an earlier settings snapshot, so a newer user choice wins.
* The legacy default-profile keys are global (their storage names predate
* connection scoping), so they are never rewritten here: runtime fallback
* handles an unpaired default profile without changing another
* connection's selection.
*/
suspend fun reconcileRelayRemoval(expectedScope: VoiceProfileScope): Boolean {
if (_scope.value != expectedScope || expectedScope.profileName == null) return false
var changed = false
dataStore.edit { prefs ->
if (_scope.value != expectedScope) return@edit
val engine = VoiceEngineMode.fromStorage(
resolveString(prefs, KEY_ENGINE_MODE, expectedScope, DEFAULT_ENGINE_MODE),
)
val route = VoiceAudioRoute.fromStorage(
resolveString(prefs, KEY_AUDIO_ROUTE, expectedScope, DEFAULT_AUDIO_ROUTE),
)
if (engine == VoiceEngineMode.RealtimeAgent) {
prefs[stringPreferencesKey(scopedName(KEY_ENGINE_MODE, expectedScope))] =
VoiceEngineMode.HermesVoiceOutput.storageValue
changed = true
}
if (route == VoiceAudioRoute.Relay) {
prefs[stringPreferencesKey(scopedName(KEY_AUDIO_ROUTE, expectedScope))] =
VoiceAudioRoute.Auto.storageValue
changed = true
}
}
return changed
}
/** "" clears the override (relay falls back to the server's saved voice). */
suspend fun setEnhancedVoice(voice: String) {
val key = stringPreferencesKey(scopedName(KEY_ENH_VOICE, _scope.value))
@@ -28,6 +28,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.io.ByteArrayOutputStream
internal const val RELAY_SESSION_HEADER: String = "X-Hermes-Relay-Session"
@@ -88,6 +89,7 @@ class RelayHttpClient(
companion object {
private const val TAG = "RelayHttpClient"
private const val DEFAULT_MEDIA_DOWNLOAD_LIMIT_BYTES = 100L * 1024L * 1024L
const val MAX_MODEL_CAPABILITY_ROWS = 64
private const val MAX_MODEL_CAPABILITY_PROVIDER_CHARS = 128
private const val MAX_MODEL_CAPABILITY_MODEL_CHARS = 512
@@ -257,7 +259,10 @@ class RelayHttpClient(
* underlying exception with a human-readable message suitable for
* surfacing in the attachment's `errorMessage` field.
*/
suspend fun fetchMedia(token: String): Result<FetchedMedia> = withContext(Dispatchers.IO) {
suspend fun fetchMedia(
token: String,
maxBytes: Long = DEFAULT_MEDIA_DOWNLOAD_LIMIT_BYTES,
): Result<FetchedMedia> = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
return@withContext Result.failure(
@@ -318,7 +323,7 @@ class RelayHttpClient(
if (body == null) {
return@withContext Result.failure(IOException("Empty response body"))
}
val bytes = body.bytes()
val bytes = body.readBytesBounded(maxBytes)
Result.success(FetchedMedia(contentType, bytes, fileName, sensitive))
}
} catch (e: IOException) {
@@ -352,6 +357,7 @@ class RelayHttpClient(
suspend fun fetchMediaByPath(
path: String,
contentTypeHint: String? = null,
maxBytes: Long = DEFAULT_MEDIA_DOWNLOAD_LIMIT_BYTES,
): Result<FetchedMedia> = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
@@ -427,12 +433,16 @@ class RelayHttpClient(
if (body == null) {
return@withContext Result.failure(IOException("Empty response body"))
}
val bytes = body.bytes()
val bytes = body.readBytesBounded(maxBytes)
Result.success(FetchedMedia(contentType, bytes, fileName, sensitive))
}
} catch (e: IOException) {
Log.w(TAG, "fetchMediaByPath failed for $path: ${e.message}")
Result.failure(IOException("Relay unreachable: ${e.message ?: "IO error"}"))
if (e is RelayMediaLimitException) {
Result.failure(e)
} else {
Result.failure(IOException("Relay unreachable: ${e.message ?: "IO error"}"))
}
} catch (e: Exception) {
Log.w(TAG, "fetchMediaByPath unexpected error for $path: ${e.message}")
Result.failure(e)
@@ -1456,6 +1466,33 @@ class RelayHttpClient(
return match?.groupValues?.get(1)?.trim()?.ifBlank { null }
}
private fun okhttp3.ResponseBody.readBytesBounded(maxBytes: Long): ByteArray {
if (maxBytes <= 0L) throw RelayMediaLimitException()
val declared = contentLength().takeIf { it >= 0L }
if (declared != null && declared > maxBytes) throw RelayMediaLimitException()
val output = ByteArrayOutputStream(
declared?.coerceAtMost(Int.MAX_VALUE.toLong())?.toInt() ?: DEFAULT_BUFFER_SIZE,
)
byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0L
while (true) {
val count = input.read(buffer)
if (count < 0) break
total += count
if (total > maxBytes) throw RelayMediaLimitException()
output.write(buffer, 0, count)
}
if (declared != null && total != declared) {
throw IOException("Media file changed while it was being downloaded")
}
}
return output.toByteArray()
}
private class RelayMediaLimitException :
IOException("File exceeds the configured download limit")
/**
* Parse the relay's `X-Media-Sensitive` response header into a bool.
*
@@ -1468,7 +1505,7 @@ class RelayHttpClient(
return value == "1" || value == "true"
}
/** Provider-neutral compatibility fetch for gateways without `account.usage`. */
/** Provider-neutral enhancement for pools and providers upstream does not expose. */
suspend fun fetchProviderUsage(
profile: String? = null,
sessionId: String? = null,
@@ -1540,19 +1540,28 @@ class ChatHandler {
val prior = priorById[messageId]
// Outbound attachments: prefer an id-match (covers any future
// user-message id reconciliation), else fall back to the
// content-keyed queue. Inbound attachments normally come back via
// marker re-dispatch. One narrow exception retains a completed
// image_generate result when the immediate post-turn history read
// still lacks its MEDIA marker; otherwise the rendered image
// disappears during the persistence-lag window.
// content-keyed queue. Exact inbound marker attachments are also
// carried by id: a history refresh must not replace a successfully
// loaded image/file with a fresh LOADING placeholder. A process
// restart has no prior attachment, so the marker still dispatches
// normally and rehydrates it. One additional narrow exception
// retains a completed image_generate result when the immediate
// post-turn history read still lacks its MEDIA marker.
val carriedAttachments = run {
val persistedImagePaths = persistedImages.paths.toHashSet()
val persistedMediaKeys = messageMediaHits.mapTo(HashSet()) { (_, hit) ->
when (hit) {
is MediaMarkerHit.RelayToken -> hit.token
is MediaMarkerHit.BarePath -> hit.path
}
}
val priorGeneratedImage = prior?.toolCalls.orEmpty().any { tool ->
isImageGenerationToolName(tool.name) &&
tool.isComplete && tool.success != false
}
val byId = prior?.attachments.orEmpty().filter { attachment ->
attachment.relayToken == null ||
attachment.relayToken in persistedMediaKeys ||
(role == MessageRole.USER && attachment.relayToken in persistedImagePaths) ||
(
role == MessageRole.ASSISTANT &&
@@ -1708,15 +1717,27 @@ class ChatHandler {
is MediaMarkerHit.RelayToken -> {
val dedupeKey = "$messageId:relay:${hit.token}"
if (dispatchedMediaMarkers.add(dedupeKey)) {
Log.d(TAG, "Media marker accepted from reloaded Relay history")
onMediaAttachmentRequested(messageId, hit.token)
val alreadyHydrated = _messages.value
.firstOrNull { it.matchesIdentity(messageId) }
?.attachments
?.any { it.relayToken == hit.token } == true
if (!alreadyHydrated) {
Log.d(TAG, "Media marker accepted from reloaded Relay history")
onMediaAttachmentRequested(messageId, hit.token)
}
}
}
is MediaMarkerHit.BarePath -> {
val dedupeKey = "$messageId:bare:${hit.path}"
if (dispatchedMediaMarkers.add(dedupeKey)) {
Log.d(TAG, "Media marker (bare-path, reload): ${hit.path}")
onMediaBarePathRequested(messageId, hit.path)
val alreadyHydrated = _messages.value
.firstOrNull { it.matchesIdentity(messageId) }
?.attachments
?.any { it.relayToken == hit.path } == true
if (!alreadyHydrated) {
Log.d(TAG, "Media marker (bare-path, reload): ${hit.path}")
onMediaBarePathRequested(messageId, hit.path)
}
}
}
}
@@ -52,6 +52,7 @@ import okhttp3.Response
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.ByteArrayOutputStream
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
@@ -310,6 +311,19 @@ data class ElevenLabsVoices(
val voices: List<ElevenLabsVoice>,
)
/**
* Bytes fetched from upstream's authenticated managed-files surface.
*
* The Dashboard applies its own managed-root, sensitive-file, and maximum-size
* policy before these bytes leave the Hermes host. Android applies the user's
* stricter inbound-media cap while reading the response as a second boundary.
*/
data class DashboardFetchedFile(
val bytes: ByteArray,
val contentType: String,
val fileName: String?,
)
/**
* Native client for the Hermes dashboard/admin server (:9119).
*
@@ -381,6 +395,75 @@ class DashboardApiClient(
executeJsonElement(request, normalized)
}
/**
* Download a server-local artifact through current upstream Hermes.
*
* This is the same authenticated `/api/files/download` route official
* Desktop uses for remote gateway files. The caller supplies its display
* cap so a malicious or stale Content-Length cannot cause an unbounded
* allocation. Audio/video are downloaded into Android's local media cache;
* local playback supplies seeking, so `/api/files/stream` is unnecessary
* on this path.
*/
suspend fun downloadManagedFile(
serverPath: String,
maxBytes: Long,
): Result<DashboardFetchedFile> = withContext(Dispatchers.IO) {
if (serverPath.isBlank()) {
return@withContext Result.failure(IOException("Media path is empty"))
}
if (maxBytes <= 0L) {
return@withContext Result.failure(IOException("Media download limit is invalid"))
}
val httpUrl = resolveUrl("/api/files/download")
?.newBuilder()
?.addQueryParameter("path", serverPath)
?.build()
?: return@withContext Result.failure(invalidUrlException())
val request = Request.Builder().url(httpUrl).get().build()
executeCancellable(request, "Dashboard media download") { response ->
val body = response.body
val declaredLength = body.contentLength().takeIf { it >= 0L }
if (declaredLength != null && declaredLength > maxBytes) {
throw IOException("File exceeds the configured download limit")
}
val initialSize = declaredLength
?.coerceAtMost(Int.MAX_VALUE.toLong())
?.toInt()
?: DEFAULT_BUFFER_SIZE
val output = ByteArrayOutputStream(initialSize)
body.byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var readTotal = 0L
while (true) {
val count = input.read(buffer)
if (count < 0) break
readTotal += count
if (readTotal > maxBytes) {
throw IOException("File exceeds the configured download limit")
}
output.write(buffer, 0, count)
}
if (declaredLength != null && readTotal != declaredLength) {
throw IOException("Media file changed while it was being downloaded")
}
}
DashboardFetchedFile(
bytes = output.toByteArray(),
contentType = response.header("Content-Type")
?.substringBefore(';')
?.trim()
?.takeIf(String::isNotEmpty)
?: "application/octet-stream",
fileName = response.header("Content-Disposition")
?.let(::contentDispositionFileName)
?: serverPath.substringAfterLast('/').substringAfterLast('\\')
.takeIf(String::isNotBlank),
)
}
}
suspend fun postJsonObject(
path: String,
payload: JsonObject = JsonObject(emptyMap()),
@@ -2405,6 +2488,23 @@ private fun Response.readJsonElement(json: Json): JsonElement {
return json.parseToJsonElement(raw)
}
private fun contentDispositionFileName(header: String): String? {
val encoded = Regex("""filename\*=UTF-8''([^;]+)""", RegexOption.IGNORE_CASE)
.find(header)
?.groupValues
?.getOrNull(1)
?.let { runCatching { java.net.URLDecoder.decode(it, "UTF-8") }.getOrNull() }
val plain = Regex("""filename=\"([^\"]+)\"|filename=([^;]+)""", RegexOption.IGNORE_CASE)
.find(header)
?.let { it.groupValues[1].ifBlank { it.groupValues[2] } }
?.trim()
?.trim('"')
return (encoded ?: plain)
?.substringAfterLast('/')
?.substringAfterLast('\\')
?.takeIf(String::isNotBlank)
}
internal class DashboardHttpException(
val statusCode: Int,
message: String,
@@ -2448,6 +2548,33 @@ internal fun Throwable.isDashboardSignInRequiredFailure(): Boolean {
return false
}
/** True only when the managed-file route itself is absent on this Hermes build. */
internal fun Throwable.isDashboardManagedFilesUnsupported(): Boolean {
var current: Throwable? = this
val seen = java.util.Collections.newSetFromMap(
java.util.IdentityHashMap<Throwable, Boolean>(),
)
while (current != null && seen.add(current)) {
if (current is DashboardHttpException) {
if (current.statusCode in setOf(405, 501)) return true
if (current.statusCode == 404) {
val detail = current.message.orEmpty()
// FastAPI's missing-route response is the generic "Not Found".
// A real managed-file miss says "File not found" and must not
// silently escape to Relay's broader path policy.
val isManagedFileMiss = detail.contains("File not found", ignoreCase = true) ||
detail.contains("Path not found", ignoreCase = true)
val isGenericRouteMiss = detail.trim().endsWith(": not found", ignoreCase = true) ||
detail.contains("\"detail\":\"Not Found\"", ignoreCase = true) ||
detail.contains("\"detail\": \"Not Found\"", ignoreCase = true)
if (!isManagedFileMiss && isGenericRouteMiss) return true
}
}
current = current.cause
}
return false
}
private fun apiFailure(response: Response, operation: String): IOException {
val bodyDetail = runCatching { response.body.string() }.getOrDefault("")
val detail = bodyDetail.take(240).ifBlank { response.message }
@@ -399,6 +399,14 @@ class GatewayChatClient(
private val _serverProject = MutableStateFlow<GatewaySessionProject?>(null)
val serverProject: StateFlow<GatewaySessionProject?> = _serverProject.asStateFlow()
/**
* Exact model-callable tool names from upstream `session.info.tools` for
* the selected live session/profile. Null means the gateway has not
* supplied a catalog; an empty set means it authoritatively supplied none.
*/
private val _serverTools = MutableStateFlow<Set<String>?>(null)
val serverTools: StateFlow<Set<String>?> = _serverTools.asStateFlow()
/** Serializes connect / session-establish so concurrent sends share one socket. */
private val connectMutex = Mutex()
@@ -563,6 +571,28 @@ class GatewayChatClient(
val terminalRequired: Boolean,
)
/**
* One exact turn settled from authoritative session state may still receive
* the terminal frame that was already in flight. Consume only that terminal
* so it cannot be reported as a second unmatched completion. A subsequent
* message.start clears the drain because it establishes the next turn on
* the same live runtime.
*/
@Volatile
private var settledTurnDrain: SettledTurnDrain? = null
private data class SettledTurnDrain(
val storedSessionId: String,
val liveSessionId: String,
)
private data class ActiveTurnLivenessProbe(
val turn: GatewayTurn,
val storedSessionId: String,
val liveSessionId: String,
val progressGeneration: Long,
)
/**
* Creates UI callbacks when the server starts a turn that has no matching
* [sendTurn] call (for example a background-process completion). The
@@ -692,6 +722,7 @@ class GatewayChatClient(
): ActiveTurnHandle {
val turn = GatewayTurn(
callbacks = dispatchOn(callbacks),
androidOwned = true,
onTransportAccepted = onTransportAccepted,
)
// Warm = the connection-establish phases are skipped this turn (socket
@@ -739,6 +770,10 @@ class GatewayChatClient(
cleanupStagedAttachments(stagedImagePaths)
return@launch
}
// A newly accepted Android send is a distinct generation on
// this runtime. Its terminal must never be consumed by the
// prior turn's optional late-terminal drain.
settledTurnDrain = null
activeTurn = turn
turn.armWatchdog()
// Generic `file.attach` uploads are staged artifacts, not
@@ -865,6 +900,7 @@ class GatewayChatClient(
storedSessionId = null
liveSessionProfile = null
cancelledTurnDrain = null
_serverTools.value = null
}
/**
@@ -1378,6 +1414,7 @@ class GatewayChatClient(
callbacks = dispatchOn(callbacks),
dedupeAdjacentMessageStarts = true,
deferEvents = true,
androidOwned = true,
).also { turn ->
turn.markRecoveredStarted()
activeTurn = turn
@@ -1501,6 +1538,7 @@ class GatewayChatClient(
boundTurn = GatewayTurn(
callbacks = dispatchOn(callbacks),
dedupeAdjacentMessageStarts = true,
androidOwned = true,
).also { turn ->
turn.markRecoveredStarted()
activeTurn = turn
@@ -1534,6 +1572,7 @@ class GatewayChatClient(
val queuedTurn = GatewayTurn(
callbacks = dispatchOn(registration.callbacks),
dedupeAdjacentMessageStarts = true,
androidOwned = true,
)
// recoverTurn is resumed on its caller's coroutine context;
// ChatViewModel calls it from Main, so this admission runs
@@ -1780,18 +1819,17 @@ class GatewayChatClient(
}
/**
* Provider-neutral account limits owned by upstream Hermes. Current hosts
* may not expose this additive method yet; callers should treat JSON-RPC
* method-not-found as capability absence and use the optional Relay
* compatibility surface when paired.
* Official upstream Nous usage bars. Current hosts may not expose this
* additive method yet; callers should treat JSON-RPC method-not-found as
* capability absence and use the optional Relay enhancement when paired.
*/
suspend fun providerUsage(): Result<JsonObject> {
suspend fun usageBars(): Result<JsonObject> {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
return rpc("account.usage", JsonObject(emptyMap()))
return rpc("usage.bars", JsonObject(emptyMap()))
}
/**
@@ -2429,6 +2467,7 @@ class GatewayChatClient(
} catch (error: Exception) {
return GatewayActiveSessionsResult.TransientFailure(error)
}
val livenessProbe = captureActiveTurnLivenessProbe()
val result = rpc(
"session.active_list",
buildJsonObject {
@@ -2448,12 +2487,58 @@ class GatewayChatClient(
val payload = result.getOrThrow()
val rows = payload["sessions"] as? JsonArray
?: throw GatewayRpcException("session.active_list returned no sessions array")
GatewayActiveSessionsResult.Success(rows.map(::parseGatewayActiveSession))
val sessions = rows.map(::parseGatewayActiveSession)
reconcileActiveTurnFromSnapshot(livenessProbe, sessions)
GatewayActiveSessionsResult.Success(sessions)
} catch (parseError: Exception) {
GatewayActiveSessionsResult.TransientFailure(parseError)
}
}
/**
* Capture only a locally submitted/recovered turn. Merely observing an
* exact session through the shared Gateway socket never grants Android
* authority to settle Desktop/TUI work.
*/
private fun captureActiveTurnLivenessProbe(): ActiveTurnLivenessProbe? {
val turn = activeTurn ?: return null
val generation = turn.captureLivenessGeneration() ?: return null
val storedId = storedSessionId ?: return null
val liveId = liveSessionId ?: return null
return ActiveTurnLivenessProbe(turn, storedId, liveId, generation)
}
/**
* `session.active_list` is process-wide, but a row naming both identifiers
* already owned by this client is authoritative for that exact runtime.
* Fence the delayed snapshot by turn identity and progress generation so an
* old idle result cannot settle a newer turn or race newer live events.
*/
private fun reconcileActiveTurnFromSnapshot(
probe: ActiveTurnLivenessProbe?,
sessions: List<GatewayActiveSession>,
) {
probe ?: return
if (activeTurn !== probe.turn ||
storedSessionId != probe.storedSessionId ||
liveSessionId != probe.liveSessionId
) return
val exact = sessions.singleOrNull { row ->
row.runtimeSessionId == probe.liveSessionId &&
row.storedSessionId == probe.storedSessionId
} ?: return
if (exact.status != GatewayActiveSessionStatus.Idle) return
if (probe.turn.settleFromAuthoritativeSessionState(
running = false,
source = "session.active_list",
expectedProgressGeneration = probe.progressGeneration,
)
) {
if (activeTurn === probe.turn) activeTurn = null
if (!AppForegroundTracker.isForeground.value) scheduleBackgroundClose()
}
}
/** Stop one process owned by the current live gateway session. */
suspend fun killProcess(processId: String): Result<Unit> {
if (processId.isBlank()) {
@@ -2846,6 +2931,7 @@ class GatewayChatClient(
activeTurn = null
backgroundTurns.clear()
cancelledTurnDrain = null
settledTurnDrain = null
unsolicitedTurnProvider = null
coldPrewarmSessionReadyListener = null
unmatchedTurnCompleteListener = null
@@ -3149,6 +3235,18 @@ class GatewayChatClient(
)
}
}
if (info.containsKey("tools")) {
val groups = info["tools"] as? JsonObject
_serverTools.value = groups
?.values
?.asSequence()
?.mapNotNull { it as? JsonArray }
?.flatMap { it.asSequence() }
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
?.filter { it.isNotBlank() }
?.toSet()
?: emptySet()
}
// Context usage: require used > 0 — a COLD resume resets counters and
// reports 0 until the first turn rebuilds the prompt; painting 0 would
// mislead on a session that actually has history.
@@ -3164,6 +3262,7 @@ class GatewayChatClient(
/** Apply a session create/resume result without leaking metadata from the prior session. */
private fun applySessionResultInfo(result: JsonObject) {
_serverProject.value = null
_serverTools.value = null
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
}
@@ -3282,6 +3381,7 @@ class GatewayChatClient(
val requestedProfile = currentSessionProfile()
if (requestedStoredId != null && requestedStoredId != storedSessionId) {
cancelledTurnDrain = null
settledTurnDrain = null
}
if (
liveSessionId != null &&
@@ -3351,6 +3451,7 @@ class GatewayChatClient(
storedSessionId = stored
liveSessionProfile = requestedProfile
if (cancelledTurnDrain?.storedSessionId != stored) cancelledTurnDrain = null
if (settledTurnDrain?.storedSessionId != stored) settledTurnDrain = null
turn.callbacks.onSessionId(stored)
}
@@ -3728,6 +3829,7 @@ class GatewayChatClient(
}
dispatchProcessEvent(type, payload, eventSessionId)
if (consumeCancelledTurnEvent(type, eventSessionId)) return
if (consumeSettledTurnTerminal(type, eventSessionId)) return
var turn = activeTurn
if (turn == null && type == "message.start") {
// Unsolicited turns are accepted only with an explicit exact live-
@@ -4254,6 +4356,7 @@ class GatewayChatClient(
val callbacks: GatewayTurnCallbacks,
dedupeAdjacentMessageStarts: Boolean = false,
deferEvents: Boolean = false,
private val androidOwned: Boolean = false,
private val onTransportAccepted: () -> Unit = { },
) : ActiveTurnHandle {
private val mapper = GatewayEventMapper(callbacks, dedupeAdjacentMessageStarts)
@@ -4280,6 +4383,7 @@ class GatewayChatClient(
private val rejoinAttempts = java.util.concurrent.atomic.AtomicInteger(0)
private val transportAccepted = AtomicBoolean(false)
private val progressGeneration = java.util.concurrent.atomic.AtomicLong(0L)
fun markTransportAccepted() {
if (transportAccepted.compareAndSet(false, true)) {
@@ -4356,6 +4460,7 @@ class GatewayChatClient(
if (settledWithoutTerminalFrame) return
if (type != "session.info") {
started = true
progressGeneration.incrementAndGet()
markTransportAccepted()
}
tracer.mark("ttfe")
@@ -4385,10 +4490,20 @@ class GatewayChatClient(
* exact turn has proved it went live. A pre-start `running=false`
* heartbeat can race `prompt.submit` and is not a completion boundary.
*/
fun settleFromAuthoritativeSessionState(running: Boolean?, source: String): Boolean {
fun captureLivenessGeneration(): Long? =
if (androidOwned && started && !ended) progressGeneration.get() else null
fun settleFromAuthoritativeSessionState(
running: Boolean?,
source: String,
expectedProgressGeneration: Long? = null,
): Boolean {
if (running != false || !started) return false
val settled = synchronized(deferredEventLock) {
if (ended) {
if (ended ||
(expectedProgressGeneration != null &&
progressGeneration.get() != expectedProgressGeneration)
) {
false
} else {
settledWithoutTerminalFrame = true
@@ -4399,6 +4514,7 @@ class GatewayChatClient(
if (!settled) return false
disarmWatchdog()
armSettledTurnDrain()
Log.i(TAG, "Gateway turn settled from $source after missing terminal frame")
callbacks.onReconcileRequired()
callbacks.onComplete()
@@ -4419,6 +4535,7 @@ class GatewayChatClient(
callbacks = dispatchOn(registration.callbacks),
dedupeAdjacentMessageStarts = true,
deferEvents = true,
androidOwned = true,
)
}
}
@@ -4546,6 +4663,26 @@ class GatewayChatClient(
)
}
private fun armSettledTurnDrain() {
val storedId = storedSessionId ?: return
val liveId = liveSessionId ?: return
settledTurnDrain = SettledTurnDrain(storedId, liveId)
}
/** Consume one late terminal from a turn already settled by session state. */
private fun consumeSettledTurnTerminal(type: String, eventSessionId: String?): Boolean {
val drain = settledTurnDrain ?: return false
if (eventSessionId != drain.liveSessionId) return false
if (type == "message.start") {
if (settledTurnDrain === drain) settledTurnDrain = null
return false
}
if (type != "message.complete" && type != "error") return false
if (settledTurnDrain === drain) settledTurnDrain = null
Log.d(TAG, "Ignored late terminal for gateway turn settled from session state")
return true
}
private fun updateCancelledDrainLiveSession(storedId: String, liveId: String) {
val drain = cancelledTurnDrain ?: return
if (drain.storedSessionId == storedId) {
@@ -4672,6 +4809,8 @@ class GatewayChatClient(
dispatchIfCurrent(stillCurrent) { callbacks.onStatusUpdate(kind, text) }
},
onStatusClear = { kind -> dispatchIfCurrent(stillCurrent) { callbacks.onStatusClear(kind) } },
onNoticeShow = { notice -> dispatchIfCurrent(stillCurrent) { callbacks.onNoticeShow(notice) } },
onNoticeClear = { key -> dispatchIfCurrent(stillCurrent) { callbacks.onNoticeClear(key) } },
)
private fun dispatchIfCurrent(stillCurrent: () -> Boolean, callback: () -> Unit) {
@@ -7,6 +7,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.booleanOrNull
/**
@@ -397,8 +398,26 @@ class GatewayEventMapper(
}
}
// Known-but-unrendered (notification.show, …) and unknown types
// alike: ignore.
"notification.show" -> {
val text = payload.string("text")?.trim().orEmpty()
if (text.isNotEmpty()) {
callbacks.onNoticeShow(
GatewayAgentNotice(
text = text,
level = payload.string("level"),
kind = payload.string("kind"),
ttlMs = payload.long("ttl_ms"),
key = payload.string("key"),
id = payload.string("id"),
),
)
}
}
"notification.clear" ->
payload.string("key")?.trim()?.takeIf(String::isNotEmpty)?.let(callbacks.onNoticeClear)
// Unknown event types remain forward-compatible no-ops.
else -> Unit
}
previousEventType = type
@@ -596,6 +615,9 @@ private fun JsonObject?.string(key: String): String? =
private fun JsonObject?.int(key: String): Int? =
(this?.get(key) as? JsonPrimitive)?.intOrNull
private fun JsonObject?.long(key: String): Long? =
(this?.get(key) as? JsonPrimitive)?.longOrNull
private fun JsonObject?.double(key: String): Double? =
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
@@ -256,6 +256,16 @@ data class GatewayToolOutputRisk(
val redacted: Boolean,
)
/** Official upstream `notification.show` AgentNotice payload. */
data class GatewayAgentNotice(
val text: String,
val level: String? = null,
val kind: String? = null,
val ttlMs: Long? = null,
val key: String? = null,
val id: String? = null,
)
/**
* One `subagent.*` lifecycle event, emitted on the PARENT session. Lifecycle
* per task: SPAWN_REQUESTED → START → (THINKING | TOOL | PROGRESS)* →
@@ -727,6 +737,10 @@ class GatewayTurnCallbacks(
val onStatusUpdate: (kind: String?, text: String) -> Unit = { _, _ -> },
/** Clear a transient status only when [kind] still owns the visible status slot. */
val onStatusClear: (kind: String) -> Unit = { _ -> },
/** Official upstream account/agent notice; distinct from Relay proactive messages. */
val onNoticeShow: (GatewayAgentNotice) -> Unit = { _ -> },
/** Exact-key dismissal for an upstream notice. */
val onNoticeClear: (key: String) -> Unit = { _ -> },
)
/**
@@ -3,10 +3,12 @@ package com.hermesandroid.relay.network.usage
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
/** Relay-enhanced usage with an upstream fallback for hosts without Relay support. */
/** Upstream usage enriched by the optional Relay provider/pool surface. */
class ProviderUsageRepository(
private val gatewayClientProvider: () -> GatewayChatClient?,
private val dashboardClientProvider: () -> DashboardApiClient? = { null },
@@ -14,33 +16,142 @@ class ProviderUsageRepository(
private val profileProvider: () -> String? = { null },
private val sessionProvider: () -> String? = { null },
) {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
explicitNulls = false
}
suspend fun fetch(): Result<ProviderUsageResponse?> {
val profile = profileProvider()
val session = sessionProvider()
val upstream: Result<ProviderUsageResponse?> = gatewayClientProvider()
?.usageBars()
?.mapCatching(::providerUsageFromUpstreamBars)
?: Result.success(null)
val dashboard = dashboardClientProvider()
var enhancement: Result<ProviderUsageResponse?>? = null
if (dashboard != null) {
val enhanced = dashboard.getProviderUsage(profile, session)
if (enhanced.isSuccess && enhanced.getOrNull() != null) return enhanced
enhancement = dashboard.getProviderUsage(profile, session)
}
val relay = relayHttpClient.fetchProviderUsage(
profile = profile,
sessionId = session,
if (enhancement?.getOrNull() == null) {
enhancement = relayHttpClient.fetchProviderUsage(
profile = profile,
sessionId = session,
)
}
val merged = mergeProviderUsage(
upstream = upstream.getOrNull(),
enhancement = enhancement?.getOrNull(),
)
if (relay.isSuccess && relay.getOrNull() != null) return relay
if (merged != null) return Result.success(merged)
val gateway = gatewayClientProvider()
if (gateway != null) {
val upstream = gateway.providerUsage()
.mapCatching { json.decodeFromJsonElement<ProviderUsageResponse>(it) }
if (upstream.isSuccess) return upstream
return when {
upstream.isFailure && enhancement?.isFailure == true ->
Result.failure(enhancement?.exceptionOrNull()!!)
enhancement?.isFailure == true -> Result.failure(enhancement?.exceptionOrNull()!!)
else -> Result.success(null)
}
return relay
}
}
internal fun providerUsageFromUpstreamBars(root: JsonObject): ProviderUsageResponse? {
if (root.boolean("available") != true) return null
val renewsAt = root.string("renews_at")
val windows = listOfNotNull(
root.usageWindow("plan", "Plan", renewsAt),
root.usageWindow("topup", "Top-up", null),
)
val details = listOfNotNull(
root.string("subscription_remaining_display")?.let { "Subscription remaining: $it" },
root.string("topup_remaining_display")?.let { "Top-up remaining: $it" },
root.string("total_spendable_display")?.let { "Total spendable: $it" },
)
return ProviderUsageResponse(
providers = listOf(
ProviderUsageProvider(
id = "nous",
displayName = "Nous",
status = ProviderUsageProvider.STATUS_AVAILABLE,
source = "upstream:usage.bars",
plan = root.string("plan_name"),
windows = windows,
details = details,
renewsAt = renewsAt,
),
),
)
}
internal fun mergeProviderUsage(
upstream: ProviderUsageResponse?,
enhancement: ProviderUsageResponse?,
): ProviderUsageResponse? {
if (upstream == null) return enhancement
if (enhancement == null) return upstream
val providers = linkedMapOf<String, ProviderUsageProvider>()
upstream.providers.forEach { providers[it.id] = it }
enhancement.providers.forEach { enhanced ->
val standard = providers[enhanced.id]
providers[enhanced.id] = when {
standard == null -> enhanced
standard.available -> standard.copy(
// Official usage.bars stays authoritative for every field it
// supplies. Relay enriches the row with pool/balance metadata
// and fills only gaps that upstream left absent.
fetchedAt = standard.fetchedAt ?: enhanced.fetchedAt,
plan = standard.plan ?: enhanced.plan,
windows = standard.windows.ifEmpty { enhanced.windows },
details = (standard.details + enhanced.details).distinct(),
balances = enhanced.balances,
renewsAt = standard.renewsAt ?: enhanced.renewsAt,
actionUrl = standard.actionUrl ?: enhanced.actionUrl,
credentials = enhanced.credentials,
activeCredentialId = enhanced.activeCredentialId,
activeCredentialState = enhanced.activeCredentialState,
activeObservedAt = enhanced.activeObservedAt,
message = standard.message ?: enhanced.message,
)
enhanced.available -> enhanced
else -> enhanced
}
}
return ProviderUsageResponse(
schemaVersion = maxOf(upstream.schemaVersion, enhancement.schemaVersion),
fetchedAt = enhancement.fetchedAt ?: upstream.fetchedAt,
capabilities = upstream.capabilities + enhancement.capabilities,
providers = providers.values.toList(),
)
}
private fun JsonObject.usageWindow(
id: String,
label: String,
resetAt: String?,
): ProviderUsageWindow? {
val bar = this["${id}_bar"] as? JsonObject ?: return null
val remaining = bar.string("remaining_display")
val total = bar.string("total_display")
val detail = when {
remaining != null && total != null -> "$remaining remaining of $total"
remaining != null -> "$remaining remaining"
total != null -> "$total total"
else -> null
}
return ProviderUsageWindow(
id = id,
label = label,
usedPercent = bar.double("pct_used"),
resetAt = resetAt,
detail = detail,
)
}
private fun JsonObject.string(key: String): String? =
(this[key] as? JsonPrimitive)?.content?.trim()?.takeIf(String::isNotEmpty)
private fun JsonObject.boolean(key: String): Boolean? =
(this[key] as? JsonPrimitive)?.booleanOrNull
private fun JsonObject.double(key: String): Double? =
(this[key] as? JsonPrimitive)?.doubleOrNull
@@ -21,6 +21,7 @@ import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.data.VoiceEngineMode
import com.hermesandroid.relay.data.VoicePreferencesRepository
import com.hermesandroid.relay.data.VoiceProfileScope
import com.hermesandroid.relay.data.VoiceSettings
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
import com.hermesandroid.relay.network.relay.RelayVoiceClient
@@ -170,6 +171,9 @@ internal class HermesRuntimeBinder(
relayHttpClient = connection.relayHttpClient,
mediaSettingsRepo = connection.mediaSettingsRepo,
mediaCacheWriter = connection.mediaCacheWriter,
dashboardMediaClientProvider = {
connection.activeDashboardUrl()?.let(connection::dashboardClientForActive)
},
)
chat.setSelectedProfileProvider { connection.selectedProfile.value }
chat.setIsolatedProfileApiProvider { connection.selectedProfileUsesIsolatedApiRoute() }
@@ -287,6 +291,36 @@ internal class HermesRuntimeBinder(
}
}
}
jobs += runtime.coroutineScope.launch {
combine(
connection.connectionsHydrated,
connection.activeConnectionId,
connection.relayConfigured,
voiceSettingsHydrated,
voicePreferencesRepository.activeScope,
) { connectionsReady, connectionId, relayConfigured, settingsReady, scope ->
VoiceRelayReconciliationInputs(
connectionsReady = connectionsReady,
connectionId = connectionId,
relayConfigured = relayConfigured,
settingsReady = settingsReady,
scope = scope,
)
}.collectLatest { inputs ->
if (
inputs.connectionsReady &&
inputs.settingsReady &&
inputs.connectionId != null &&
!inputs.relayConfigured &&
// Default-profile storage is a legacy global layer shared
// across connections. Keep its fallback runtime-only.
inputs.scope.profileName != null &&
inputs.scope.connectionId == inputs.connectionId
) {
voicePreferencesRepository.reconcileRelayRemoval(inputs.scope)
}
}
}
jobs += runtime.coroutineScope.launch {
combine(
connection.streamingEndpoint,
@@ -403,13 +437,22 @@ internal class HermesRuntimeBinder(
connection.chatReady,
connection.standardVoiceAvailability,
connection.relayVoiceReady,
connection.profileSelectionSettled,
) { settings, chatReady, standard, relayReady, profileSettled ->
connection.relayConfigured,
) { settings, chatReady, standard, relayReady, relayConfigured ->
VoiceReadinessInputs(
settings = settings,
chatReady = chatReady,
standardAvailability = standard,
relayReady = relayReady,
relayConfigured = relayConfigured,
)
}.combine(connection.profileSelectionSettled) { inputs, profileSettled ->
resolveVoiceActivationReadiness(
settings,
chatReady,
standard,
relayReady,
inputs.settings,
inputs.chatReady,
inputs.standardAvailability,
inputs.relayReady,
inputs.relayConfigured,
profileSettled,
)
}
@@ -563,6 +606,22 @@ internal class HermesRuntimeBinder(
val hiddenSources: Set<String> = emptySet(),
)
private data class VoiceRelayReconciliationInputs(
val connectionsReady: Boolean,
val connectionId: String?,
val relayConfigured: Boolean,
val settingsReady: Boolean,
val scope: VoiceProfileScope,
)
private data class VoiceReadinessInputs(
val settings: VoiceSettings,
val chatReady: Boolean,
val standardAvailability: StandardVoiceAvailability,
val relayReady: Boolean,
val relayConfigured: Boolean,
)
private companion object {
const val PROFILE_SETTLE_BACKSTOP_MS = 2_500L
const val PROFILE_CONTEXT_COALESCE_MS = 160L
@@ -601,12 +660,14 @@ internal fun resolveVoiceActivationReadiness(
chatReady: Boolean,
standardAvailability: StandardVoiceAvailability,
relayReady: Boolean,
relayConfigured: Boolean,
profileSettled: Boolean,
): HermesVoiceActivationReadiness {
if (!profileSettled) {
return HermesVoiceActivationReadiness.Waiting("Loading the selected Hermes profile")
}
return when (VoiceEngineMode.fromStorage(settings.engineMode)) {
val effectiveSettings = voiceSettingsForRelayConfiguration(settings, relayConfigured)
return when (VoiceEngineMode.fromStorage(effectiveSettings.engineMode)) {
VoiceEngineMode.RealtimeAgent -> {
if (relayReady) {
HermesVoiceActivationReadiness.Ready(HermesVoiceActivationRoute.Realtime)
@@ -618,7 +679,7 @@ internal fun resolveVoiceActivationReadiness(
if (!chatReady) {
return HermesVoiceActivationReadiness.Waiting("Waiting for Hermes chat")
}
when (VoiceAudioRoute.fromStorage(settings.audioRoute)) {
when (VoiceAudioRoute.fromStorage(effectiveSettings.audioRoute)) {
VoiceAudioRoute.Relay -> if (relayReady) {
HermesVoiceActivationReadiness.Ready(
HermesVoiceActivationRoute.RelayAudio
@@ -642,6 +703,24 @@ internal fun resolveVoiceActivationReadiness(
}
}
/**
* Relay absence is a topology decision, unlike a transient route outage. Only
* the former may fall back from Relay-only persisted selections.
*/
internal fun voiceSettingsForRelayConfiguration(
settings: VoiceSettings,
relayConfigured: Boolean,
): VoiceSettings {
if (relayConfigured) return settings
return settings.copy(
engineMode = VoiceEngineMode.HermesVoiceOutput.storageValue,
audioRoute = when (VoiceAudioRoute.fromStorage(settings.audioRoute)) {
VoiceAudioRoute.Relay -> VoiceAudioRoute.Auto.storageValue
else -> settings.audioRoute
},
)
}
private fun standardVoiceReadiness(
availability: StandardVoiceAvailability,
): HermesVoiceActivationReadiness = when (availability) {
@@ -0,0 +1,40 @@
package com.hermesandroid.relay.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** Width policy shared by Chat's content and persistent status surfaces. */
internal data class ChatResponsiveLayout(
val introMaxWidth: Dp?,
val avatarSize: Dp?,
val chromeMaxWidth: Dp?,
val transcriptMaxWidth: Dp?,
val focusVoiceMaxWidth: Dp?,
)
internal fun chatResponsiveLayout(screenWidthDp: Int): ChatResponsiveLayout = when {
screenWidthDp >= 840 -> ChatResponsiveLayout(
introMaxWidth = 720.dp,
avatarSize = 360.dp,
chromeMaxWidth = 960.dp,
transcriptMaxWidth = 960.dp,
focusVoiceMaxWidth = 1120.dp,
)
screenWidthDp >= 600 -> ChatResponsiveLayout(
introMaxWidth = 600.dp,
avatarSize = 300.dp,
chromeMaxWidth = 760.dp,
transcriptMaxWidth = 760.dp,
focusVoiceMaxWidth = 760.dp,
)
else -> ChatResponsiveLayout(
introMaxWidth = null,
avatarSize = null,
chromeMaxWidth = null,
transcriptMaxWidth = null,
focusVoiceMaxWidth = null,
)
}
internal fun useSplitVoiceLayout(screenWidthDp: Int, screenHeightDp: Int): Boolean =
screenWidthDp >= 840 && screenWidthDp > screenHeightDp
@@ -1015,6 +1015,17 @@ fun RelayApp() {
chatSessions.firstOrNull { it.sessionId == currentChatSessionId }
}
LaunchedEffect(
gitOwnerKey,
activeChatSession?.gitRepoRoot,
activeChatSession?.workingDirectory,
) {
gitStateViewModel.setSessionWorkspace(
repoRoot = activeChatSession?.gitRepoRoot,
workingDirectory = activeChatSession?.workingDirectory,
)
}
// Bind Git to the active coding session when upstream supplies its exact
// workspace metadata. CWD fallback only matches a path-segment descendant;
// an ambiguous multi-repo catalog stays unselected until the user chooses.
@@ -1031,7 +1042,7 @@ fun RelayApp() {
}
}
val gitWorkspaceAvailable = gitRepoScanningEnabled &&
val gitWorkspaceAvailable =
(gitReposState as? GitStateUiState.Ready)?.repos?.isNotEmpty() == true
val gitWorkspaceSummary = remember(
gitReposState,
@@ -2189,6 +2200,9 @@ fun RelayApp() {
// Routine in-progress reconnect surfaces here (amber cue)
// instead of a take-space banner or a floating toast.
reconnecting = connectionReconnecting,
maxContentWidth = chatResponsiveLayout(
LocalConfiguration.current.screenWidthDp,
).chromeMaxWidth,
modifier = Modifier.petPerchSurface(
key = APP_STATUS_PET_WALK_REGION,
routes = APP_STATUS_PET_ROUTES,
@@ -8,17 +8,38 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicLong
/** Visual tone of a transient banner message. Errors are NOT modelled here —
* they stay on the snackbar (see [LocalSnackbarHost]); this bus is info-only. */
enum class UiMessageSeverity { Info, Success, Status }
/** Visual tone of a transient banner message. */
enum class UiMessageSeverity { Info, Success, Status, Warning }
data class UiMessage(
val id: Long,
val text: String,
val severity: UiMessageSeverity,
val ttlMillis: Long,
/** Stable upstream key for replace-in-place and exact dismissal. */
val key: String? = null,
)
sealed interface UiMessageEvent {
data class Show(val message: UiMessage) : UiMessageEvent
data class Clear(val key: String) : UiMessageEvent
}
internal fun reduceUiMessages(
current: List<UiMessage>,
event: UiMessageEvent,
maxRetained: Int,
): List<UiMessage> = when (event) {
is UiMessageEvent.Clear -> current.filterNot { it.key == event.key }
is UiMessageEvent.Show -> {
val incoming = event.message
val withoutDuplicate = current.filterNot { existing ->
if (incoming.key != null) existing.key == incoming.key else existing.text == incoming.text
}
(withoutDuplicate + incoming).takeLast(maxRetained)
}
}
/**
* App-wide bus for transient, non-error status/confirmation messages that
* surface in the top [com.hermesandroid.relay.ui.components.MessageBannerHost]
@@ -26,8 +47,10 @@ data class UiMessage(
* shows the newest line collapsed, expands to a few recent lines, auto-dismisses
* and coalesces duplicates.
*
* Deliberately info-only: errors and persistent/actionable messages keep going
* to the snackbar so they demand acknowledgement. Migrate frequent
* App-owned errors and persistent/actionable messages keep going to the
* snackbar so they demand acknowledgement. Upstream keyed AgentNotices may use
* the warning tone here because their own sticky/clear lifecycle owns them.
* Migrate frequent
* `snackbarHostState.showSnackbar("…")` confirmations/status to [info] /
* [success] / [status] here.
*
@@ -39,8 +62,8 @@ object UiMessageBus {
const val STATUS_TTL_MS = 6_000L
private val counter = AtomicLong(0L)
private val _events = MutableSharedFlow<UiMessage>(extraBufferCapacity = 24)
val events: SharedFlow<UiMessage> = _events.asSharedFlow()
private val _events = MutableSharedFlow<UiMessageEvent>(extraBufferCapacity = 24)
val events: SharedFlow<UiMessageEvent> = _events.asSharedFlow()
// Number of messages currently shown by the host. Lifted here so the app
// scaffold can fold banner visibility into its status-bar inset accounting
@@ -52,10 +75,26 @@ object UiMessageBus {
text: String,
severity: UiMessageSeverity = UiMessageSeverity.Info,
ttlMillis: Long = DEFAULT_TTL_MS,
key: String? = null,
) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
_events.tryEmit(UiMessage(counter.incrementAndGet(), trimmed, severity, ttlMillis))
_events.tryEmit(
UiMessageEvent.Show(
UiMessage(
id = counter.incrementAndGet(),
text = trimmed,
severity = severity,
ttlMillis = ttlMillis.coerceAtLeast(0L),
key = key?.trim()?.takeIf(String::isNotEmpty),
),
),
)
}
/** Dismiss only the keyed message owned by the matching upstream notice. */
fun clear(key: String) {
key.trim().takeIf(String::isNotEmpty)?.let { _events.tryEmit(UiMessageEvent.Clear(it)) }
}
/** Neutral confirmation/info (e.g. "Pairing code copied"). */
@@ -741,6 +741,7 @@ fun AgentInfoSheet(
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
val streamingEndpoint by connectionViewModel.streamingEndpoint.collectAsState()
val voiceReady by connectionViewModel.voiceReady.collectAsState()
val standardVoiceReady by connectionViewModel.standardVoiceReady.collectAsState()
val proactiveEnabled by connectionViewModel.proactiveEnabled.collectAsState()
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
val allConnections by connectionViewModel.connectionStore.connections.collectAsState()
@@ -842,6 +843,7 @@ fun AgentInfoSheet(
val sessionCaps = sessionCapabilities(
transport = sessionTransport,
gatewayAvailability = gatewayAvailability,
upstreamMediaAvailable = gatewayAvailability == GatewayAvailability.Ready || standardVoiceReady,
relayConnected = relayConnectionState == ConnectionState.Connected,
relayConfigured = relayUrl.isNotBlank(),
voiceReady = voiceReady,
@@ -3423,6 +3425,8 @@ private fun LegacyAgentInfoSheet(
val sessionCaps = sessionCapabilities(
transport = sessionTransport,
gatewayAvailability = gatewayAvailability,
upstreamMediaAvailable = gatewayAvailability == GatewayAvailability.Ready ||
connectionViewModel.standardVoiceReady.collectAsState().value,
relayConnected = relayConnected,
relayConfigured = relayUrl.isNotBlank(),
voiceReady = voiceReady,
@@ -206,29 +206,49 @@ private fun FailedCard(
modifier: Modifier,
maxWidth: Dp
) {
val hostOnly = attachment.errorMessage == ChatViewModel.MEDIA_HOST_ONLY
Surface(
shape = appearanceRoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.errorContainer,
color = if (hostOnly) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.errorContainer,
modifier = modifier
.widthIn(max = maxWidth)
.clickable { onRetry() }
.then(if (hostOnly) Modifier else Modifier.clickable { onRetry() })
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(text = "\u26A0\uFE0F", style = MaterialTheme.typography.titleMedium)
Text(
text = if (hostOnly) "\uD83D\uDCCE" else "\u26A0\uFE0F",
style = MaterialTheme.typography.titleMedium,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = attachment.errorMessage ?: stringResource(R.string.inbound_attach_failed),
text = if (hostOnly) {
stringResource(R.string.inbound_attach_host_only)
} else {
attachment.errorMessage ?: stringResource(R.string.inbound_attach_failed)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
color = if (hostOnly) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onErrorContainer
},
)
Text(
text = stringResource(R.string.inbound_attach_tap_retry),
text = if (hostOnly) {
stringResource(R.string.inbound_attach_host_only_help)
} else {
stringResource(R.string.inbound_attach_tap_retry)
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
color = if (hostOnly) {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
},
)
}
}
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -53,7 +54,9 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.UiMessage
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageEvent
import com.hermesandroid.relay.ui.UiMessageSeverity
import com.hermesandroid.relay.ui.reduceUiMessages
import kotlinx.coroutines.delay
private const val MAX_RETAINED = 6
@@ -68,7 +71,8 @@ private const val ROW_MIN_HEIGHT_DP = 34
* overlay. Auto-dismisses (paused while expanded) and coalesces duplicates so a
* burst of the same status collapses to one refreshed row.
*
* Errors stay on the snackbar — only post info/success/status here.
* App-owned errors stay on the snackbar. Keyed upstream AgentNotices may also
* use the warning tone because their sticky/clear lifecycle is server-owned.
*/
@Composable
fun MessageBannerHost(
@@ -82,18 +86,19 @@ fun MessageBannerHost(
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
UiMessageBus.events.collect { msg ->
// Coalesce identical text so e.g. repeated "Reconnecting…" collapses
// to a single, freshly-timed row rather than stacking.
shown.filter { it.text == msg.text }.forEach { dup ->
shown.remove(dup)
expiresAt.remove(dup.id)
}
shown.add(msg)
expiresAt[msg.id] = nowMs() + msg.ttlMillis
while (shown.size > MAX_RETAINED) {
val dropped = shown.removeAt(0)
expiresAt.remove(dropped.id)
UiMessageBus.events.collect { event ->
val next = reduceUiMessages(shown, event, MAX_RETAINED)
val retainedIds = next.mapTo(mutableSetOf()) { it.id }
expiresAt.keys.filterNot(retainedIds::contains).forEach { expiresAt.remove(it) }
shown.clear()
shown.addAll(next)
if (event is UiMessageEvent.Show) {
val msg = event.message
expiresAt[msg.id] = if (msg.ttlMillis == 0L) {
Long.MAX_VALUE
} else {
nowMs() + msg.ttlMillis
}
}
}
}
@@ -104,6 +109,7 @@ fun MessageBannerHost(
while (shown.isNotEmpty()) {
val now = nowMs()
val soonest = shown.minOfOrNull { expiresAt[it.id] ?: Long.MAX_VALUE } ?: break
if (soonest == Long.MAX_VALUE) break
if (soonest <= now) {
shown.filter { (expiresAt[it.id] ?: Long.MAX_VALUE) <= now }.forEach { expired ->
shown.remove(expired)
@@ -281,6 +287,7 @@ private fun MessageRow(
private fun severityContainer(severity: UiMessageSeverity): Color = when (severity) {
UiMessageSeverity.Success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
UiMessageSeverity.Status -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.72f)
UiMessageSeverity.Info -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.90f)
}
@@ -288,12 +295,14 @@ private fun severityContainer(severity: UiMessageSeverity): Color = when (severi
private fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
UiMessageSeverity.Success -> MaterialTheme.colorScheme.onTertiaryContainer
UiMessageSeverity.Status -> MaterialTheme.colorScheme.onSecondaryContainer
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.onErrorContainer
UiMessageSeverity.Info -> MaterialTheme.colorScheme.onSurfaceVariant
}
private fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
UiMessageSeverity.Success -> Icons.Filled.CheckCircle
UiMessageSeverity.Status -> Icons.Filled.Sync
UiMessageSeverity.Warning -> Icons.Filled.Warning
UiMessageSeverity.Info -> Icons.Filled.Info
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
@@ -24,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
@@ -48,63 +51,70 @@ fun RelayStatusStrip(
* surfaces — the top chrome stays empty so chat content never shifts.
*/
reconnecting: Boolean = false,
maxContentWidth: Dp? = null,
) {
Column(
modifier = modifier
.fillMaxWidth()
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
),
)
.padding(start = 14.dp, end = 14.dp, top = 3.dp, bottom = 4.dp)
.clip(appearanceRoundedCornerShape(16.dp))
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.relayPanel(
shape = appearanceRoundedCornerShape(16.dp),
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
borderColor = RelayRefresh.Line,
),
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier
Column(
modifier = modifier
.then(maxContentWidth?.let { Modifier.widthIn(max = it) } ?: Modifier)
.fillMaxWidth()
.heightIn(min = 22.dp)
.padding(horizontal = 14.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
),
)
.padding(start = 14.dp, end = 14.dp, top = 3.dp, bottom = 4.dp)
.clip(appearanceRoundedCornerShape(16.dp))
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.relayPanel(
shape = appearanceRoundedCornerShape(16.dp),
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
borderColor = RelayRefresh.Line,
),
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 22.dp)
.padding(horizontal = 14.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
leadingBadge()
if (securityGlyph != null) {
securityGlyph()
}
// Route is in flux mid-reconnect, so the amber cue replaces the
// route label rather than stacking beside it in the 22dp strip.
when {
reconnecting -> ReconnectingCue(modifier = Modifier.weight(1f))
routeLabel.isNotBlank() -> Text(
text = "· $routeLabel",
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
leadingBadge()
if (securityGlyph != null) {
securityGlyph()
}
// Route is in flux mid-reconnect, so the amber cue replaces the
// route label rather than stacking beside it in the 22dp strip.
when {
reconnecting -> ReconnectingCue(modifier = Modifier.weight(1f))
routeLabel.isNotBlank() -> Text(
text = "· $routeLabel",
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
Spacer(modifier = Modifier.width(10.dp))
Text(
text = trailing,
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(10.dp))
Text(
text = trailing,
style = relayMetadataStyle(),
color = RelayRefresh.Muted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -141,8 +141,8 @@ internal data class SessionCapability(
* live. SSE paths only get post-hoc reasoning, so this is gateway-only.
* While the gateway is still being probed (Unknown) we surface it as a
* "checking" reason rather than a confirmed chip.
* - Media / Terminal → require the relay to be CONNECTED (the relay brokers
* those channels). A configured-but-disconnected relay is not enough.
* - Media → current upstream Dashboard managed-file delivery OR connected
* Relay compatibility transport. Terminal remains Relay-owned.
* - Voice → `voiceReady` (standard dashboard voice OR relay voice — whichever
* the connection actually has).
*/
@@ -150,6 +150,7 @@ internal data class SessionCapability(
internal fun sessionCapabilities(
transport: SessionPathTransport,
gatewayAvailability: GatewayAvailability,
upstreamMediaAvailable: Boolean,
relayConnected: Boolean,
relayConfigured: Boolean,
voiceReady: Boolean,
@@ -170,11 +171,11 @@ internal fun sessionCapabilities(
),
SessionCapability(
type = "media",
available = relayConnected,
available = upstreamMediaAvailable || relayConnected,
reason = when {
relayConnected -> null
upstreamMediaAvailable || relayConnected -> null
relayConfigured -> stringResource(R.string.session_path_relay_not_connected)
else -> stringResource(R.string.session_path_pair_relay_media)
else -> stringResource(R.string.session_path_media_not_ready)
},
),
SessionCapability(
@@ -23,14 +23,18 @@ 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
@@ -71,6 +75,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
@@ -81,6 +86,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.hermesandroid.relay.data.ChatMessage
@@ -92,7 +98,9 @@ import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.components.avatar.LocalBackgroundVisualizationEnabled
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.chatResponsiveLayout
import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.ui.useSplitVoiceLayout
import com.hermesandroid.relay.util.HumanError
import kotlinx.coroutines.delay
import com.hermesandroid.relay.viewmodel.BackgroundRunPhase
@@ -112,6 +120,8 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
internal const val VOICE_MODE_MIC_TEST_TAG = "voiceModeMic"
internal const val VOICE_FOCUS_SPLIT_LAYOUT_TEST_TAG = "voiceFocusSplitLayout"
internal const val VOICE_FOCUS_STACKED_LAYOUT_TEST_TAG = "voiceFocusStackedLayout"
/**
* Full-screen voice-mode overlay. Renders the MorphingSphere in its voiceMode
@@ -186,6 +196,56 @@ fun VoiceModeOverlay(
val backgroundVisualizationEnabled = LocalBackgroundVisualizationEnabled.current
val surface = MaterialTheme.colorScheme.surface
val haptic = LocalHapticFeedback.current
val configuration = LocalConfiguration.current
val responsiveLayout = chatResponsiveLayout(configuration.screenWidthDp)
val splitFocusLayout = useSplitVoiceLayout(
screenWidthDp = configuration.screenWidthDp,
screenHeightDp = configuration.screenHeightDp,
)
val focusMicTap: () -> Unit = {
dispatchVoiceMicTap(
uiState = uiState,
onStartListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onMicTap()
},
onStopListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
} catch (_: Exception) { /* ignore */ }
onMicRelease()
},
onInterrupt = onInterrupt,
onPauseAutoMode = onPauseAutoMode,
)
}
val focusMicHoldPress: () -> Unit = {
dispatchVoiceMicHoldPress(
uiState = uiState,
onStartListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onMicTap()
},
onInterruptAndStart = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onInterrupt()
onMicTap()
},
)
}
val focusMicHoldRelease: () -> Unit = {
try {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
} catch (_: Exception) { /* ignore */ }
onMicRelease()
}
var controlsExpanded by remember { mutableStateOf(false) }
val focusMode = presentationMode == VoicePresentationMode.Focus
@@ -253,6 +313,11 @@ fun VoiceModeOverlay(
onOpenSettings = onOpenSettings,
onExit = onDismiss,
modifier = Modifier
.then(
responsiveLayout.focusVoiceMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 8.dp)
@@ -328,162 +393,93 @@ fun VoiceModeOverlay(
exit = fadeOut(tween(120)),
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 92.dp, bottom = 160.dp, start = 20.dp, end = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1.0f),
contentAlignment = Alignment.Center,
) {
if (backgroundVisualizationEnabled) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
state = voiceStateToSphereState(uiState.state),
voiceAmplitude = uiState.amplitude,
voiceMode = true,
),
modifier = Modifier.fillMaxSize(),
if (splitFocusLayout) {
Row(
modifier = Modifier
.widthIn(max = responsiveLayout.focusVoiceMaxWidth ?: 1120.dp)
.fillMaxSize()
.navigationBarsPadding()
.padding(top = 92.dp, bottom = 28.dp, start = 32.dp, end = 32.dp)
.testTag(VOICE_FOCUS_SPLIT_LAYOUT_TEST_TAG),
verticalAlignment = Alignment.CenterVertically,
) {
VoiceFocusIdentityPane(
uiState = uiState,
backgroundVisualizationEnabled = backgroundVisualizationEnabled,
onMicTap = focusMicTap,
onMicHoldPress = focusMicHoldPress,
onMicHoldRelease = focusMicHoldRelease,
showMic = true,
modifier = Modifier
.weight(0.86f)
.fillMaxHeight(),
)
Spacer(Modifier.width(32.dp))
VoiceFocusStatusAndTranscriptPane(
uiState = uiState,
transcriptMessages = visibleTranscriptMessages,
pendingTranscriptText = pendingTranscriptText,
transcriptListState = transcriptListState,
showThinking = showThinking,
onBackgroundRunCancel = onBackgroundRunCancel,
onBackgroundRunTap = onBackgroundRunTap,
onPermissionDeniedChipTap = onPermissionDeniedChipTap,
onHermesConfirmationAnswer = onHermesConfirmationAnswer,
onPresentationModeChange = onPresentationModeChange,
onCardAction = onCardAction,
onCardInput = onCardInput,
horizontalContentPadding = 0.dp,
modifier = Modifier
.weight(1.14f)
.fillMaxHeight(),
)
}
}
VoiceWaveform(
amplitude = uiState.amplitude,
state = uiState.state,
outputAudioActive = uiState.outputAudioActive,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 8.dp),
)
AnimatedVisibility(
visible = uiState.handoffStatus != null,
enter = fadeIn(tween(140)),
exit = fadeOut(tween(180)),
) {
VoiceHandoffStrip(
status = uiState.handoffStatus,
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
)
}
BackgroundRunChip(
run = uiState.backgroundRun,
onCancel = onBackgroundRunCancel,
onTap = onBackgroundRunTap,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
)
Spacer(Modifier.height(8.dp))
DestructiveCountdownRow(
countdown = uiState.destructiveCountdown,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
)
PermissionDeniedChip(
callout = uiState.permissionDeniedCallout,
onTap = onPermissionDeniedChipTap,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
)
HermesConfirmationCard(
confirmation = uiState.hermesConfirmation,
onAnswer = onHermesConfirmationAnswer,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
)
Spacer(Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1.25f, fill = true),
) {
val hasTranscript = visibleTranscriptMessages.isNotEmpty() ||
pendingTranscriptText != null
if (hasTranscript) {
val latestId = visibleTranscriptMessages.lastOrNull()?.id
LazyColumn(
state = transcriptListState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// Match ChatScreen's identity contract. A history
// reconcile can adopt the same authoritative server
// id into a live row while Compose still holds the
// pre-reconcile row for a frame. uiKey remains stable
// and unique across that transition.
items(visibleTranscriptMessages, key = ::voiceTranscriptItemKey) { msg ->
CompactTranscriptRow(
message = msg,
showThinking = showThinking,
expanded = msg.id == latestId || msg.isStreaming,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
)
}
if (pendingTranscriptText != null) {
item(key = "aux:pending-voice-transcript") {
CompactTranscriptRow(
message = ChatMessage(
id = "pending-voice-transcript",
role = MessageRole.USER,
content = pendingTranscriptText,
timestamp = System.currentTimeMillis(),
isStreaming = true,
),
showThinking = showThinking,
expanded = true,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
)
}
}
item { Spacer(Modifier.height(12.dp)) }
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = stateHint(uiState.state),
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "stateHint",
) { hint ->
Text(
text = hint,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
.then(
responsiveLayout.focusVoiceMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxSize()
.padding(top = 92.dp, bottom = 160.dp, start = 20.dp, end = 20.dp)
.testTag(VOICE_FOCUS_STACKED_LAYOUT_TEST_TAG),
horizontalAlignment = Alignment.CenterHorizontally,
) {
VoiceFocusIdentityPane(
uiState = uiState,
backgroundVisualizationEnabled = backgroundVisualizationEnabled,
onMicTap = focusMicTap,
onMicHoldPress = focusMicHoldPress,
onMicHoldRelease = focusMicHoldRelease,
showMic = false,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
VoiceFocusStatusAndTranscriptPane(
uiState = uiState,
transcriptMessages = visibleTranscriptMessages,
pendingTranscriptText = pendingTranscriptText,
transcriptListState = transcriptListState,
showThinking = showThinking,
onBackgroundRunCancel = onBackgroundRunCancel,
onBackgroundRunTap = onBackgroundRunTap,
onPermissionDeniedChipTap = onPermissionDeniedChipTap,
onHermesConfirmationAnswer = onHermesConfirmationAnswer,
onPresentationModeChange = onPresentationModeChange,
onCardAction = onCardAction,
onCardInput = onCardInput,
horizontalContentPadding = 24.dp,
modifier = Modifier
.fillMaxWidth()
.weight(1.25f),
)
}
}
}
@@ -498,6 +494,12 @@ fun VoiceModeOverlay(
exit = fadeOut(tween(180)),
modifier = Modifier
.align(Alignment.BottomCenter)
.then(
responsiveLayout.chromeMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxWidth()
.padding(bottom = 176.dp, start = 24.dp, end = 24.dp),
) {
BackgroundRunChip(
@@ -515,6 +517,12 @@ fun VoiceModeOverlay(
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopCenter)
.then(
responsiveLayout.chromeMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxWidth()
.padding(top = 64.dp, start = 16.dp, end = 16.dp),
) {
Surface(
@@ -570,57 +578,205 @@ fun VoiceModeOverlay(
// that visually said "Stop" but was actually wired to "Start."
VoiceMicButton(
uiState = uiState,
onTap = {
dispatchVoiceMicTap(
uiState = uiState,
onStartListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onMicTap()
},
onStopListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
} catch (_: Exception) { /* ignore */ }
onMicRelease()
},
onInterrupt = onInterrupt,
onPauseAutoMode = onPauseAutoMode,
)
},
onHoldPress = {
dispatchVoiceMicHoldPress(
uiState = uiState,
onStartListening = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onMicTap()
},
onInterruptAndStart = {
try {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
} catch (_: Exception) { /* ignore */ }
onInterrupt()
onMicTap()
},
)
},
onHoldRelease = {
try {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
} catch (_: Exception) { /* ignore */ }
onMicRelease()
},
onTap = focusMicTap,
onHoldPress = focusMicHoldPress,
onHoldRelease = focusMicHoldRelease,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
visible = focusMode,
visible = focusMode && !splitFocusLayout,
)
}
}
@Composable
private fun VoiceFocusIdentityPane(
uiState: VoiceUiState,
backgroundVisualizationEnabled: Boolean,
onMicTap: () -> Unit,
onMicHoldPress: () -> Unit,
onMicHoldRelease: () -> Unit,
showMic: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.testTag("voiceFocusIdentityPane"),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
if (backgroundVisualizationEnabled) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
state = voiceStateToSphereState(uiState.state),
voiceAmplitude = uiState.amplitude,
voiceMode = true,
),
modifier = Modifier.fillMaxSize(),
)
}
}
VoiceWaveform(
amplitude = uiState.amplitude,
state = uiState.state,
outputAudioActive = uiState.outputAudioActive,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
)
if (showMic) {
Spacer(Modifier.height(16.dp))
VoiceMicButton(
uiState = uiState,
onTap = onMicTap,
onHoldPress = onMicHoldPress,
onHoldRelease = onMicHoldRelease,
visible = true,
)
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
private fun VoiceFocusStatusAndTranscriptPane(
uiState: VoiceUiState,
transcriptMessages: List<ChatMessage>,
pendingTranscriptText: String?,
transcriptListState: LazyListState,
showThinking: Boolean,
onBackgroundRunCancel: () -> Unit,
onBackgroundRunTap: () -> Unit,
onPermissionDeniedChipTap: (PermissionDeniedCallout) -> Unit,
onHermesConfirmationAnswer: (String) -> Unit,
onPresentationModeChange: (VoicePresentationMode) -> Unit,
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit,
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit,
horizontalContentPadding: Dp,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.testTag("voiceFocusTranscriptPane")) {
AnimatedVisibility(
visible = uiState.handoffStatus != null,
enter = fadeIn(tween(140)),
exit = fadeOut(tween(180)),
) {
VoiceHandoffStrip(
status = uiState.handoffStatus,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
)
}
BackgroundRunChip(
run = uiState.backgroundRun,
onCancel = onBackgroundRunCancel,
onTap = onBackgroundRunTap,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
)
Spacer(Modifier.height(8.dp))
DestructiveCountdownRow(
countdown = uiState.destructiveCountdown,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalContentPadding),
)
PermissionDeniedChip(
callout = uiState.permissionDeniedCallout,
onTap = onPermissionDeniedChipTap,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
)
HermesConfirmationCard(
confirmation = uiState.hermesConfirmation,
onAnswer = onHermesConfirmationAnswer,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
)
Spacer(Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = true),
) {
val hasTranscript = transcriptMessages.isNotEmpty() || pendingTranscriptText != null
if (hasTranscript) {
val latestId = transcriptMessages.lastOrNull()?.id
LazyColumn(
state = transcriptListState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// uiKey remains stable while Gateway reconciliation adopts
// an authoritative server id into a live transcript row.
items(transcriptMessages, key = ::voiceTranscriptItemKey) { msg ->
CompactTranscriptRow(
message = msg,
showThinking = showThinking,
expanded = msg.id == latestId || msg.isStreaming,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
)
}
if (pendingTranscriptText != null) {
item(key = "aux:pending-voice-transcript") {
CompactTranscriptRow(
message = ChatMessage(
id = "pending-voice-transcript",
role = MessageRole.USER,
content = pendingTranscriptText,
timestamp = System.currentTimeMillis(),
isStreaming = true,
),
showThinking = showThinking,
expanded = true,
onViewConversation = {
onPresentationModeChange(VoicePresentationMode.Conversation)
},
onCardAction = onCardAction,
onCardInput = onCardInput,
)
}
}
item { Spacer(Modifier.height(12.dp)) }
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = stateHint(uiState.state),
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "stateHint",
) { hint ->
Text(
text = hint,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
@Composable
private fun VoiceMicButton(
uiState: VoiceUiState,
@@ -98,6 +98,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@@ -119,6 +120,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.chatResponsiveLayout
import com.hermesandroid.relay.ui.theme.radialNavyBackground
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.upstream.ChatMode
@@ -749,6 +751,7 @@ fun ChatScreen(
gitWorkspaceAvailable: Boolean = gitWorkspaceSummary != null,
onNavigateToGitWorkspace: () -> Unit = {},
) {
val responsiveLayout = chatResponsiveLayout(LocalConfiguration.current.screenWidthDp)
val supervised = supervisedPolicy.enabled
val supervisedVisibility = supervisedPolicy.visibility.resolved()
LaunchedEffect(supervisedPolicy) {
@@ -3311,7 +3314,13 @@ fun ChatScreen(
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 32.dp)
modifier = Modifier
.padding(horizontal = 32.dp)
.then(
responsiveLayout.introMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
),
) {
Spacer(modifier = Modifier.weight(0.15f))
@@ -3320,12 +3329,15 @@ fun ChatScreen(
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.weight(0.7f, fill = false)
) {
val avatarModifier = responsiveLayout.avatarSize?.let { size ->
Modifier
.size(size)
.clipToBounds()
} ?: Modifier
.fillMaxWidth()
.aspectRatio(1f)
.weight(0.7f, fill = false)
Box(modifier = avatarModifier) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
state = if (error != null) SphereState.Error else SphereState.Idle,
@@ -3568,7 +3580,14 @@ fun ChatScreen(
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
.fillMaxHeight()
.then(
responsiveLayout.transcriptMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = if (showTranscriptSearch) 112.dp else 0.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
@@ -4650,6 +4669,13 @@ fun ChatScreen(
supervisedPolicy.capabilities.attachmentCategories
)) pasteImageFromClipboard else ({ }),
onLongPressAttach = { if (!supervised) showCommandPalette = true },
modifier = Modifier
.align(Alignment.CenterHorizontally)
.then(
responsiveLayout.chromeMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
),
charLimit = charLimit,
caption = turnStatus ?: inputCaption,
voiceReady = voiceReady,
@@ -706,8 +706,9 @@ fun ChatSettingsScreen(
)
}
// Live preview of the exact text that will be sent.
// Rebuilds on every toggle change via remember(key1..key5).
// Representative preview of each enabled block.
// The chat context audit remains the exact selected-
// session preview. Rebuilds on every toggle change.
val previewText = remember(
appContextEnabled,
appContextBridgeState,
@@ -735,6 +736,14 @@ fun ChatSettingsScreen(
destructiveVerbCount = 5,
autoDisableMinutes = 15,
),
// This card is an explanatory fixture, not
// the active session audit. Supply one
// representative Relay tool so the bridge
// and safety toggles remain visible; the
// real send path uses the selected
// session/profile's authoritative catalog.
availableTools =
com.hermesandroid.relay.util.PHONE_CONTEXT_PREVIEW_TOOLS,
)
}
Card(
@@ -544,19 +544,24 @@ internal fun buildStatusChecks(
)
}
// A healthy standard-only connection should not read like three missing
// dependencies. Collapse the absent optional extension to one neutral row;
// configured Relay keeps the detailed auth/server/plugin troubleshooting.
if (!relayConfigured) {
checks += StatusCheck(
context.getString(R.string.diag_relay_tools_optional),
CheckStatus.Unknown,
reason = context.getString(R.string.diag_relay_tools_not_paired),
category = DiagnosticCategory.Relay,
)
}
// 6) Optional Relay / pairing auth.
val authLabel = context.getString(R.string.diag_check_pairing_auth)
val authRelayActive = context.getString(R.string.diag_check_relay_active)
val authPairingProg = context.getString(R.string.diag_check_pairing_progress)
val authNotPaired = context.getString(R.string.diag_check_not_paired)
val authErr = recentError(DiagnosticCategory.Auth)
checks += when {
!relayConfigured ->
StatusCheck(
authLabel, CheckStatus.Unknown,
reason = context.getString(R.string.active_section_optional),
category = DiagnosticCategory.Auth,
)
if (relayConfigured) checks += when {
authState is AuthState.Paired ->
StatusCheck(
authLabel, CheckStatus.Pass,
@@ -586,18 +591,11 @@ internal fun buildStatusChecks(
// 7) Relay server (optional — Unknown when not paired/configured).
val relayLabel = context.getString(R.string.active_section_optional_relay)
val relayNotConfigured = context.getString(R.string.diag_check_relay_not_configured)
val relayConnected = context.getString(R.string.diag_check_connected)
val relayReachableNotReady = context.getString(R.string.diag_check_reachable_not_ready)
val relayConfiguredNotReachable = context.getString(R.string.diag_check_configured_not_reachable)
val relayErr = recentError(DiagnosticCategory.Relay)
checks += when {
!relayConfigured ->
StatusCheck(
relayLabel, CheckStatus.Unknown,
reason = relayNotConfigured,
category = DiagnosticCategory.Relay,
)
if (relayConfigured) checks += when {
relayReady ->
StatusCheck(
relayLabel, CheckStatus.Pass,
@@ -629,7 +627,7 @@ internal fun buildStatusChecks(
relayReady = relayReady,
relayUpdateInfo = relayUpdateInfo,
)
checks += when (pluginState) {
if (relayConfigured) checks += when (pluginState) {
RelayPluginDiagnosticState.NotConfigured ->
StatusCheck(
pluginLabel, CheckStatus.Unknown,
@@ -98,7 +98,7 @@ private data class DisplayFile(
val deletions: Int?,
)
/** First-class native Git workspace backed by the optional Relay contribution. */
/** Native Git workspace: upstream current-session reads plus optional Relay enhancements. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GitStateScreen(
@@ -131,7 +131,7 @@ fun GitStateScreen(
var pendingConfirm by remember { mutableStateOf<ConfirmationRequest?>(null) }
LaunchedEffect(scanningEnabled) {
if (scanningEnabled) viewModel.loadRepos()
viewModel.loadRepos()
}
val repos = (reposState as? GitStateUiState.Ready)?.repos.orEmpty()
@@ -202,29 +202,27 @@ fun GitStateScreen(
}
},
actions = {
if (scanningEnabled) {
IconButton(onClick = { selectedRepo?.let { viewModel.selectRepo(it.id) } ?: viewModel.loadRepos() }) {
Icon(Icons.Filled.Refresh, "Refresh Git workspace")
}
Box {
IconButton(onClick = { showOverflow = true }) { Icon(Icons.Filled.MoreVert, "More Git actions") }
DropdownMenu(expanded = showOverflow, onDismissRequest = { showOverflow = false }) {
DropdownMenuItem(text = { Text("Choose repository") }, onClick = { showOverflow = false; showRepos = true })
DropdownMenuItem(text = { Text(stringResource(R.string.git_state_branches)) }, enabled = detail != null, onClick = { showOverflow = false; showBranches = true })
}
IconButton(onClick = { selectedRepo?.let { viewModel.selectRepo(it.id) } ?: viewModel.loadRepos() }) {
Icon(Icons.Filled.Refresh, "Refresh Git workspace")
}
Box {
IconButton(onClick = { showOverflow = true }) { Icon(Icons.Filled.MoreVert, "More Git actions") }
DropdownMenu(expanded = showOverflow, onDismissRequest = { showOverflow = false }) {
DropdownMenuItem(text = { Text("Choose repository") }, onClick = { showOverflow = false; showRepos = true })
DropdownMenuItem(text = { Text(stringResource(R.string.git_state_branches)) }, enabled = detail != null, onClick = { showOverflow = false; showBranches = true })
}
}
},
)
},
bottomBar = {
if (scanningEnabled && detail != null) {
if (detail != null) {
Surface(shadowElevation = 8.dp, tonalElevation = 2.dp) {
Column {
if (selection.isNotEmpty()) {
SelectionRail(
count = selection.size,
canWrite = hasGrant,
canWrite = hasGrant && scanningEnabled,
allStaged = selection.all { it.filter == FileFilter.Staged },
onStage = stageSelection,
onDiscard = discardSelection,
@@ -234,7 +232,7 @@ fun GitStateScreen(
OutlinedButton(onClick = { showBranches = true }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.AccountTree, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.git_state_branches))
}
Button(onClick = { showCommit = true }, enabled = hasGrant && detail.status.counts.staged > 0, modifier = Modifier.weight(1f)) {
Button(onClick = { showCommit = true }, enabled = scanningEnabled && hasGrant && detail.status.counts.staged > 0, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.AutoAwesome, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.git_state_commit))
}
}
@@ -248,21 +246,21 @@ fun GitStateScreen(
enabled = scanningEnabled,
onEnabledChange = onScanningEnabledChange,
)
if (scanningEnabled) {
when (val state = reposState) {
GitStateUiState.Loading -> FullState(Modifier.weight(1f), true, "Finding repositories")
is GitStateUiState.Unavailable -> UnavailableState(Modifier.weight(1f), state.message, viewModel::loadRepos)
is GitStateUiState.Error -> UnavailableState(Modifier.weight(1f), state.message, viewModel::loadRepos)
is GitStateUiState.Ready -> when {
state.repos.isEmpty() -> FullState(Modifier.weight(1f), false, "No Git repositories found", "Add a repository to the host's configured Git roots, then refresh.")
selectedRepo == null -> RepositoryPrompt(Modifier.weight(1f), state.repos, viewModel::selectRepo)
else -> WorkspaceBody(
when (val state = reposState) {
GitStateUiState.Loading -> FullState(Modifier.weight(1f), true, "Finding repositories")
is GitStateUiState.Unavailable -> UnavailableState(Modifier.weight(1f), state.message, viewModel::loadRepos)
is GitStateUiState.Error -> UnavailableState(Modifier.weight(1f), state.message, viewModel::loadRepos)
is GitStateUiState.Ready -> when {
state.repos.isEmpty() -> FullState(Modifier.weight(1f), false, "No current-session Git repository", "Open a Hermes coding session with repository context, or enable host repository discovery above.")
selectedRepo == null -> RepositoryPrompt(Modifier.weight(1f), state.repos, viewModel::selectRepo)
else -> WorkspaceBody(
modifier = Modifier.weight(1f),
repo = selectedRepo,
reposNotice = state.notice,
detailState = detailState,
mutation = mutation,
stashNotice = stashNotice,
relayEnhancementsEnabled = scanningEnabled,
hasGrant = hasGrant,
filter = filter,
onFilter = { filter = it },
@@ -289,8 +287,7 @@ fun GitStateScreen(
onPush = { viewModel.currentTarget()?.let { pendingConfirm = ConfirmationRequest.Push(it) } },
onClearMutation = viewModel::clearMutationError,
onRetry = { viewModel.selectRepo(selectedRepo.id) },
)
}
)
}
}
}
@@ -311,7 +308,7 @@ fun GitStateScreen(
onCreate = { name, track -> showBranches = false; viewModel.checkout("", newBranch = name, track = track) },
)
}
if (showCommit && detail != null) {
if (showCommit && scanningEnabled && detail != null) {
val stagedPaths = detail.status.staged.map { it.path }
CommitDialog(
hasStaged = stagedPaths.isNotEmpty(),
@@ -392,6 +389,7 @@ private fun WorkspaceBody(
detailState: GitRepoDetailState,
mutation: GitMutationState,
stashNotice: String?,
relayEnhancementsEnabled: Boolean,
hasGrant: Boolean,
filter: FileFilter,
onFilter: (FileFilter) -> Unit,
@@ -421,9 +419,11 @@ private fun WorkspaceBody(
LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 18.dp)) {
item {
SummaryRail(repo, detailState)
RemoteActions(hasGrant, status, onFetch, onPull, onPush)
if (relayEnhancementsEnabled) {
RemoteActions(hasGrant, status, onFetch, onPull, onPush)
}
reposNotice?.let { NoticeCard(it) }
if (!hasGrant) WriteGrantNotice()
if (relayEnhancementsEnabled && !hasGrant) WriteGrantNotice()
MutationBanner(mutation, onClearMutation)
stashNotice?.let { NoticeCard(it) }
if (status.truncated) NoticeCard(stringResource(R.string.git_state_truncated), error = true)
@@ -438,6 +438,7 @@ private fun WorkspaceBody(
FileRow(
file = file,
selected = file in selection,
selectionEnabled = relayEnhancementsEnabled,
expanded = expandedPath == file.path,
mode = contentMode,
contentState = contentState,
@@ -554,6 +555,7 @@ private fun GitStatus.uniqueChangeCount(): Int = counts.changes.takeIf { it >= 0
private fun FileRow(
file: DisplayFile,
selected: Boolean,
selectionEnabled: Boolean,
expanded: Boolean,
mode: ContentMode,
contentState: GitContentViewState,
@@ -572,7 +574,7 @@ private fun FileRow(
) {
Checkbox(
checked = selected,
onCheckedChange = { onToggleSelected() },
onCheckedChange = if (selectionEnabled) ({ _ -> onToggleSelected() }) else null,
modifier = Modifier.semantics {
contentDescription = "Select ${file.path}"
},
@@ -602,7 +604,9 @@ private fun FileRow(
if (expanded && file.filter != FileFilter.Untracked) {
Row(Modifier.padding(start = 56.dp, end = 16.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (file.filter != FileFilter.Untracked) FilterChip(mode == ContentMode.Diff, { onOpen(ContentMode.Diff) }, label = { Text("Diff") })
FilterChip(mode == ContentMode.File, { onOpen(ContentMode.File) }, label = { Text("File") })
if (selectionEnabled) {
FilterChip(mode == ContentMode.File, { onOpen(ContentMode.File) }, label = { Text("File") })
}
}
InlineContent(contentState, Modifier.padding(start = 16.dp, end = 16.dp, bottom = 10.dp))
}
@@ -355,7 +355,7 @@ fun SettingsScreen(
)
else -> null
}
// The Power tools below all ride the relay plugin. Rather than stamp an
// The Relay tools below all ride the optional plugin. Rather than stamp an
// identical badge on every card (noise, not signal), the dependency is
// surfaced ONCE on the section header as a single plugin-state badge.
val pluginBadge = when (relayUiState) {
@@ -629,6 +629,14 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Image,
title = stringResource(R.string.settings_media),
subtitle = stringResource(R.string.settings_media_desc),
onClick = onNavigateToMediaSettings,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.GraphicEq,
title = stringResource(R.string.settings_voice_mode),
@@ -637,6 +645,8 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
SettingsSectionHeader(stringResource(R.string.settings_power_tools), trailing = pluginBadge)
SettingsCategoryRow(
icon = Icons.AutoMirrored.Filled.Message,
title = stringResource(R.string.settings_threads),
@@ -645,8 +655,6 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
SettingsSectionHeader(stringResource(R.string.settings_power_tools), trailing = pluginBadge)
SettingsCategoryRow(
icon = Icons.Filled.Code,
title = stringResource(R.string.settings_terminal),
@@ -681,13 +689,6 @@ fun SettingsScreen(
)
// === END PHASE3-notif-listener-followup ===
SettingsCategoryRow(
icon = Icons.Filled.Image,
title = stringResource(R.string.settings_media),
subtitle = stringResource(R.string.settings_media_desc), onClick = onNavigateToMediaSettings,
isDarkTheme = isDarkTheme,
)
if (BuildFlavor.isSideload) {
// === PHASE3-safety-rails: bridge safety entry-point ===
SettingsCategoryRow(
@@ -957,25 +957,6 @@ private fun VoiceForThisProfileCard(
) {
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 = stringResource(R.string.voice_settings_for_profile_title)) {
Text(
text = stringResource(R.string.voice_settings_engine_label),
@@ -99,8 +99,14 @@ private const val PREAMBLE =
"The user is chatting via the Hermes-Relay Android app. " +
"Keep responses mobile-friendly and concise when possible."
/** Representative capability used only by the Settings preview fixture. */
internal val PHONE_CONTEXT_PREVIEW_TOOLS = setOf("android_phone_status")
/**
* Build the system-prompt block from [settings] + [snapshot].
* Build the system-prompt block from [settings] + [snapshot]. Relay-only
* bridge guidance is emitted only when [availableTools] proves that this exact
* selected session/profile can call at least one `android_*` tool. A missing
* catalog fails closed for tool claims while preserving neutral mobile context.
*
* Returns `null` iff:
* 1. [AppContextSettings.master] is false, OR
@@ -112,14 +118,27 @@ private const val PREAMBLE =
* itself is useful (it's the original v0.1.0 behavior). The "everything
* off → null" case in the brief is the `master=false` branch.
*/
fun buildPromptBlock(settings: AppContextSettings, snapshot: PhoneSnapshot): String? {
fun buildPromptBlock(
settings: AppContextSettings,
snapshot: PhoneSnapshot,
availableTools: Set<String>? = null,
): String? {
if (!settings.master) return null
val lines = mutableListOf<String>()
lines += PREAMBLE
if (settings.bridgeState) {
lines += buildBridgeLine(snapshot)
val phoneControlTools = availableTools
?.filterTo(mutableSetOf()) {
it.startsWith("android_") && it != "android_setup"
}
.orEmpty()
if (settings.bridgeState && phoneControlTools.isNotEmpty()) {
lines += buildBridgeLine(
snapshot = snapshot,
phoneStatusToolAvailable = "android_phone_status" in phoneControlTools,
)
}
if (settings.currentApp && snapshot.currentApp != null) {
@@ -130,7 +149,7 @@ fun buildPromptBlock(settings: AppContextSettings, snapshot: PhoneSnapshot): Str
lines += "Battery: ${snapshot.batteryPercent}%."
}
if (settings.safetyStatus) {
if (settings.safetyStatus && phoneControlTools.isNotEmpty()) {
buildSafetyLine(snapshot)?.let { lines += it }
}
@@ -151,7 +170,10 @@ fun buildPromptBlock(settings: AppContextSettings, snapshot: PhoneSnapshot): Str
* the bridge isn't bound — "Phone bridge: not installed" is itself useful
* context (tells the agent not to try tool calls into the phone).
*/
private fun buildBridgeLine(snapshot: PhoneSnapshot): String {
private fun buildBridgeLine(
snapshot: PhoneSnapshot,
phoneStatusToolAvailable: Boolean,
): String {
if (!snapshot.bridgeBound) {
return "Phone bridge: not connected. Tool calls into the phone are unavailable."
}
@@ -189,8 +211,12 @@ private fun buildBridgeLine(snapshot: PhoneSnapshot): String {
val screenText = if (snapshot.screenOn) "Screen: on." else "Screen: off."
return "Phone bridge: enabled. $permsText. $screenText $unattendedText " +
"For full phone status (current app, battery, blocklist), call the android_phone_status tool."
val statusToolHint = if (phoneStatusToolAvailable) {
" For full phone status (current app, battery, blocklist), call the android_phone_status tool."
} else {
""
}
return "Phone bridge: enabled. $permsText. $screenText $unattendedText$statusToolHint"
}
/**
@@ -70,6 +70,7 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
import com.hermesandroid.relay.network.upstream.GatewayAsk
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
import com.hermesandroid.relay.network.upstream.GatewayAgentNotice
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionsResult
@@ -82,6 +83,7 @@ import com.hermesandroid.relay.network.upstream.GatewayCompressResult
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.network.upstream.GatewayReconnectDisposition
import com.hermesandroid.relay.network.upstream.isDashboardSignInRequiredFailure
import com.hermesandroid.relay.network.upstream.isDashboardManagedFilesUnsupported
import com.hermesandroid.relay.network.upstream.GatewayEventMapper
import com.hermesandroid.relay.network.upstream.GatewayInboundTurnRegistration
import com.hermesandroid.relay.network.upstream.GatewayModelProvider
@@ -98,11 +100,13 @@ import com.hermesandroid.relay.network.upstream.resolveReasoningEffortAvailabili
import com.hermesandroid.relay.network.upstream.GatewayAttachment
import com.hermesandroid.relay.network.upstream.GatewayRpcException
import com.hermesandroid.relay.network.upstream.GatewayTurnCallbacks
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.upstream.ApiModelRoutingErrorCode
import com.hermesandroid.relay.network.upstream.ApiModelRoutingException
import com.hermesandroid.relay.network.upstream.ApiModelSelectionAck
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.ToolsetInfo
import com.hermesandroid.relay.network.upstream.isCurrentModelOptionsResponse
import com.hermesandroid.relay.network.upstream.modelOptionsIdentityToPublish
import com.hermesandroid.relay.network.upstream.parsePersonalityPrompts
@@ -126,6 +130,8 @@ import com.hermesandroid.relay.notifications.InteractionRequestNotifier
import com.hermesandroid.relay.reliability.ReliabilityCenter
import com.hermesandroid.relay.reliability.SessionResetEvidence
import com.hermesandroid.relay.ui.components.ServerImageResult
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
import com.hermesandroid.relay.data.isImageGenerationToolName
import com.hermesandroid.relay.ui.components.SlashCommand
import com.hermesandroid.relay.voice.RealtimeTurnSyncBuilder
@@ -176,6 +182,39 @@ import java.util.concurrent.atomic.AtomicLong
internal const val SESSION_DIRECTORY_PAGE_SIZE = 50
internal data class GatewayNoticePresentation(
val text: String,
val severity: UiMessageSeverity,
val ttlMillis: Long,
val key: String?,
)
private val LEADING_NOTICE_GLYPH = Regex("^[•⚠✕✗✓]\uFE0F?\\s*")
internal fun gatewayNoticePresentation(notice: GatewayAgentNotice): GatewayNoticePresentation {
val severity = when (notice.level?.trim()?.lowercase()) {
"success" -> UiMessageSeverity.Success
"warn", "warning", "error" -> UiMessageSeverity.Warning
else -> UiMessageSeverity.Info
}
val ttl = when (notice.kind?.trim()?.lowercase()) {
"ttl" -> notice.ttlMs?.takeIf { it > 0L }
?.coerceAtMost(60_000L)
?: UiMessageBus.DEFAULT_TTL_MS
// Official Desktop keeps every non-TTL notice until an exact keyed
// notification.clear arrives. This includes kind="agent" startup
// notices whose lifetime is owned by the gateway, not a client timer.
else -> 0L
}
return GatewayNoticePresentation(
text = notice.text.trim().replaceFirst(LEADING_NOTICE_GLYPH, "").trim(),
severity = severity,
ttlMillis = ttl,
key = notice.key?.trim()?.takeIf(String::isNotEmpty)
?: notice.id?.trim()?.takeIf(String::isNotEmpty),
)
}
data class SessionDirectoryReadyEvent(
val contextKey: String?,
val profileName: String?,
@@ -354,6 +393,22 @@ internal fun voiceTurnTransportRejection(
null
}
internal fun buildMediaCapabilityHint(
upstreamAvailable: Boolean,
relayAvailable: Boolean,
): String? = listOfNotNull(
ChatViewModel.UPSTREAM_MEDIA_HINT.takeIf { upstreamAvailable },
ChatViewModel.RELAY_MEDIA_HINT.takeIf { relayAvailable },
).joinToString("\n\n").ifBlank { null }
internal fun eligibleSseToolNames(toolsets: List<ToolsetInfo>?): Set<String>? =
toolsets
?.asSequence()
?.filter { it.enabled && it.configured }
?.flatMap { it.tools.asSequence() }
?.filter { it.isNotBlank() }
?.toSet()
class ChatViewModel : ViewModel() {
/**
* Active Android-only supervision policy. RelayApp replaces this snapshot
@@ -376,6 +431,8 @@ class ChatViewModel : ViewModel() {
private var apiClient: HermesApiClient? = null
private var chatHandler: ChatHandler? = null
private var sseToolCatalogJob: Job? = null
private val _sseToolNames = MutableStateFlow<Set<String>?>(null)
/**
* The in-flight chat turn, transport-agnostic: SSE turns wrap their
@@ -652,6 +709,7 @@ class ChatViewModel : ViewModel() {
// --- Media dependencies (wired via initializeMedia from RelayApp) ---
private var relayHttpClient: RelayHttpClient? = null
private var dashboardMediaClientProvider: (() -> DashboardApiClient?)? = null
private var mediaSettingsRepo: MediaSettingsRepository? = null
private var mediaCacheWriter: MediaCacheWriter? = null
private var appContext: Context? = null
@@ -674,6 +732,7 @@ class ChatViewModel : ViewModel() {
// `send()` below for the construction site.
// === END PHASE3-status ===
const val MEDIA_TAP_TO_DOWNLOAD = "Tap to download"
const val MEDIA_HOST_ONLY = "File is on your Hermes host"
private const val MEDIA_FETCH_TIMEOUT_MS = 120_000L
private val WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX = Regex("""^[A-Za-z]:[\\/].+""")
@@ -693,21 +752,18 @@ class ChatViewModel : ViewModel() {
private const val MAX_CHECKPOINT_MOA_LABEL_CHARS = 120
private const val MAX_CHECKPOINT_MOA_TEXT_CHARS = 16_000
/**
* One-line capability nudge appended to the SSE `system_message` when a
* relay route is configured, so the agent knows it can surface images/
* files by absolute server path (the client fetches them over the relay
* `/media/by-path` channel and renders them inline). SSE-only: the
* gateway transport has no per-turn system slot, so this can't ride a
* gateway turn — there the upstream prompt_builder's own `MEDIA:`
* instruction plus the client-side render fallback cover it.
*/
/** Upstream-first file/media capability for SSE fallback turns. */
const val UPSTREAM_MEDIA_HINT =
"Media display: this client supports standard upstream Hermes file delivery. " +
"To show a host-local image, audio, video, or file, put its absolute path " +
"on its own line as `MEDIA:/absolute/path`. The client fetches it through " +
"the authenticated upstream Dashboard file routes and renders it inline."
/** Additive Relay compatibility/metadata capability for SSE fallback turns. */
const val RELAY_MEDIA_HINT =
"Media display: this client can render images and files you reference by " +
"absolute server path. To show one to the user, put its absolute path on " +
"its own line as `MEDIA:/absolute/path`, or use a markdown image " +
"`![description](/absolute/path)`. The client fetches it over the secure " +
"relay channel and shows it inline."
"Relay enhancement: a paired Relay may add legacy-path compatibility and " +
"sensitivity metadata when standard upstream delivery cannot serve an " +
"artifact. Relay is not required for ordinary upstream media delivery."
}
/** Callback to persist session ID — set by RelayApp */
@@ -3095,6 +3151,8 @@ class ChatViewModel : ViewModel() {
onStatusClear = { kind ->
if (acceptsEvent()) handler.clearTurnStatus(kind)
},
onNoticeShow = ::showGatewayNotice,
onNoticeClear = ::clearGatewayNotice,
)
return GatewayInboundTurnRegistration(
callbacks = callbacks,
@@ -3568,6 +3626,21 @@ class ChatViewModel : ViewModel() {
private val _transientNotice = MutableSharedFlow<String>(extraBufferCapacity = 8)
val transientNotice: SharedFlow<String> = _transientNotice.asSharedFlow()
private fun showGatewayNotice(notice: GatewayAgentNotice) {
val presentation = gatewayNoticePresentation(notice)
if (presentation.text.isEmpty()) return
UiMessageBus.post(
text = presentation.text,
severity = presentation.severity,
ttlMillis = presentation.ttlMillis,
key = presentation.key,
)
}
private fun clearGatewayNotice(key: String) {
UiMessageBus.clear(key)
}
fun reactToMessage(message: ChatMessage, emoji: String?) {
val gateway = gatewayClient ?: return
val role = message.role
@@ -4467,6 +4540,7 @@ class ChatViewModel : ViewModel() {
fetchSkills()
fetchPersonalities()
fetchModels()
refreshSseToolCatalog(apiClient)
// Keep the tool-call history in sync with the active chat handler's
// messages. Subscribed on every initialize() call so a replaced
// handler (connection switch) picks up fresh events without leaking
@@ -4533,6 +4607,8 @@ class ChatViewModel : ViewModel() {
* repeatedly; re-subscribes the tool-call history collector.
*/
fun bindDemoHandler(handler: ChatHandler) {
sseToolCatalogJob?.cancel()
_sseToolNames.value = null
this.chatHandler = handler
backgroundProcessSessionJob?.cancel()
selectBackgroundProcessSession(null)
@@ -4575,7 +4651,8 @@ class ChatViewModel : ViewModel() {
context: Context,
relayHttpClient: RelayHttpClient,
mediaSettingsRepo: MediaSettingsRepository,
mediaCacheWriter: MediaCacheWriter
mediaCacheWriter: MediaCacheWriter,
dashboardMediaClientProvider: () -> DashboardApiClient? = { null },
) {
this.appContext = context.applicationContext
if (chatTurnCheckpointStore == null) {
@@ -4583,6 +4660,7 @@ class ChatViewModel : ViewModel() {
}
ensureCheckpointObservers()
this.relayHttpClient = relayHttpClient
this.dashboardMediaClientProvider = dashboardMediaClientProvider
if (_modelProviders.value.isNotEmpty()) refreshRelayReasoningCapabilities()
this.mediaSettingsRepo = mediaSettingsRepo
this.mediaCacheWriter = mediaCacheWriter
@@ -4624,6 +4702,16 @@ class ChatViewModel : ViewModel() {
}
}
private fun refreshSseToolCatalog(client: HermesApiClient?) {
sseToolCatalogJob?.cancel()
_sseToolNames.value = null
if (client == null) return
sseToolCatalogJob = viewModelScope.launch {
val names = eligibleSseToolNames(client.getToolsets().getOrNull())
if (apiClient === client) _sseToolNames.value = names
}
}
fun updateApiClient(client: HermesApiClient?) {
// A route handoff / reconnect rebuilds the HTTP API client. An SSE turn
// is bound to the OLD client, so it must be cancelled. A GATEWAY turn
@@ -4653,6 +4741,7 @@ class ChatViewModel : ViewModel() {
fetchSkills()
fetchPersonalities()
fetchModels()
refreshSseToolCatalog(client)
}
/**
@@ -8022,6 +8111,8 @@ class ChatViewModel : ViewModel() {
onStatusClear = { kind ->
if (owns()) handler.clearTurnStatus(kind)
},
onNoticeShow = ::showGatewayNotice,
onNoticeClear = ::clearGatewayNotice,
)
}
@@ -9368,11 +9459,7 @@ class ChatViewModel : ViewModel() {
val personaPrompt: String?,
val appContext: String?,
val interfaceContext: String?,
/**
* Relay media-capability hint — tells the agent it can surface images/
* files by absolute server path. Non-null only on SSE turns when a relay
* route is configured (the gateway has no system slot to carry it).
*/
/** Upstream-first media capability plus any additive Relay enhancement. */
val mediaCapability: String?,
/**
* Relay-plugin-owned server-side context blocks that are injected into
@@ -9427,13 +9514,23 @@ class ChatViewModel : ViewModel() {
selected != _defaultPersonality.value -> personalityPrompts[selected]
else -> null
}
val appContextRaw = buildPromptBlock(appContextSettings, capturePhoneSnapshot())
// Tell the agent it can surface images/files by absolute server path —
// but only on SSE (the gateway carries no system_message) and only when
// a relay route is configured (otherwise the client can't fetch them).
val availableTools = if (gateway) {
gatewayClient?.serverTools?.value
} else {
_sseToolNames.value
}
val appContextRaw = buildPromptBlock(
settings = appContextSettings,
snapshot = capturePhoneSnapshot(),
availableTools = availableTools,
)
// Gateway has no per-turn system slot. SSE carries the standard
// Dashboard route first, then the optional Relay enhancement.
val upstreamMediaAvailable = dashboardMediaClientProvider?.invoke() != null
val relayMediaAvailable = relayHttpClient?.mediaUrlConfigured() == true
val mediaCapability: String? =
RELAY_MEDIA_HINT.takeIf { !gateway && relayMediaAvailable }
buildMediaCapabilityHint(upstreamMediaAvailable, relayMediaAvailable)
.takeIf { !gateway }
// combinedSystemMessage appends the media hint after the phone-status
// block (a stable environment fact, like phone status) and before the
// per-turn interface context. The per-block fields below null out blanks
@@ -9673,6 +9770,7 @@ class ChatViewModel : ViewModel() {
scheduleCheckpointWrite(immediate = true)
}
val observedImageToolStates = mutableMapOf<String, String>()
val nativeImageToolProgressObserved = AtomicBoolean(false)
val handleToolCallStart = { toolCallId: String, toolName: String, argsPreview: String? ->
markTransportAccepted()
ensurePostInterimMessage()
@@ -9686,10 +9784,16 @@ class ChatViewModel : ViewModel() {
scheduleCheckpointWrite(immediate = true)
}
val onToolCallStartCb = { toolCallId: String, toolName: String ->
if (isImageGenerationToolName(toolName)) {
nativeImageToolProgressObserved.set(true)
}
handleToolCallStart(toolCallId, toolName, null)
}
val onGatewayToolCallStartCb =
{ toolCallId: String, toolName: String, argsPreview: String? ->
if (isImageGenerationToolName(toolName)) {
nativeImageToolProgressObserved.set(true)
}
handleToolCallStart(toolCallId, toolName, argsPreview)
}
val onToolCallDoneCb = { toolCallId: String, resultPreview: String? ->
@@ -9722,10 +9826,18 @@ class ChatViewModel : ViewModel() {
}
fun startImageActivityBridge() {
val relay = relayHttpClient ?: return
if (!relay.mediaUrlConfigured()) return
stopImageActivityBridge()
imageActivityJob = viewModelScope.launch {
// Current upstream emits native tool progress. Give that
// authoritative path the first opportunity to identify image
// work; Relay polling is compatibility-only for older hosts
// whose stream omitted those events.
delay(1_000)
if (nativeImageToolProgressObserved.get()) return@launch
var consecutiveErrors = 0
while (true) {
if (nativeImageToolProgressObserved.get()) break
val activeSessionId = handler.currentSessionId.value
if (activeSessionId.isNullOrBlank()) {
delay(250)
@@ -9747,7 +9859,7 @@ class ChatViewModel : ViewModel() {
snapshot.activities.forEach { activity ->
val prior = observedImageToolStates[activity.callId]
if (prior == null) {
onToolCallStartCb(activity.callId, activity.toolName)
handleToolCallStart(activity.callId, activity.toolName, null)
}
if (
activity.state == "completed" &&
@@ -10454,6 +10566,8 @@ class ChatViewModel : ViewModel() {
onStatusClear = { kind ->
handler.clearTurnStatus(kind)
},
onNoticeShow = ::showGatewayNotice,
onNoticeClear = ::clearGatewayNotice,
),
attachments = (attachments.orEmpty() + gatewayOnlyAttachments)
.map { it.toGatewayAttachment() },
@@ -10625,6 +10739,8 @@ class ChatViewModel : ViewModel() {
val relay = relayHttpClient
val repo = mediaSettingsRepo
val cache = mediaCacheWriter
val routeOwner = activeProfileContextKey
val historyGeneration = historyLoadGeneration.get()
// 1. Insert LOADING placeholder.
val placeholder = Attachment(
@@ -10662,8 +10778,19 @@ class ChatViewModel : ViewModel() {
}
// 3. Fetch + cache.
performFetchWith(handler, messageId, token, settings) {
relay.fetchMedia(token)
performFetchWith(
handler,
messageId,
token,
settings,
expectedContextKey = routeOwner,
expectedHistoryGeneration = historyGeneration,
) { _ ->
if (relay.mediaUrlConfigured()) {
relay.fetchMedia(token, maxBytes = settings.maxInboundSizeMb.toLong().coerceAtLeast(1L) * 1024L * 1024L)
} else {
Result.failure(MediaRouteUnavailableException())
}
}
}
}
@@ -10690,6 +10817,9 @@ class ChatViewModel : ViewModel() {
val att = msg.attachments.getOrNull(attachmentIndex) ?: return
val fetchKey = att.relayToken ?: return
val expectedRole = msg.role
val routeOwner = activeProfileContextKey
val historyGeneration = historyLoadGeneration.get()
val upstreamMediaClient = dashboardMediaClientProvider?.invoke()
// Flip back to a pure LOADING spinner (drop the CTA marker) so the
// user gets immediate feedback that the download kicked off.
@@ -10710,14 +10840,20 @@ class ChatViewModel : ViewModel() {
fetchKey,
settings,
expectedRole = expectedRole,
) {
expectedContextKey = routeOwner,
expectedHistoryGeneration = historyGeneration,
) { maxBytes ->
if (
fetchKey.startsWith("/") ||
WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX.matches(fetchKey)
) {
relay.fetchMediaByPath(fetchKey)
fetchServerPath(fetchKey, maxBytes, upstreamMediaClient)
} else {
relay.fetchMedia(fetchKey)
if (relay.mediaUrlConfigured()) {
relay.fetchMedia(fetchKey, maxBytes = maxBytes)
} else {
Result.failure(MediaRouteUnavailableException())
}
}
}
}
@@ -10757,14 +10893,24 @@ class ChatViewModel : ViewModel() {
if (supervisedModePolicy.enabled &&
!supervisedModePolicy.capabilities.generatedImages
) return ServerImageResult.Failure("Generated images are disabled in supervised mode")
val relay = relayHttpClient
?: return ServerImageResult.Failure("Relay not configured on this connection")
// fetchMediaByPath returns Result<MediaBytes>; fold it ONCE, right here,
// into a non-Result type. The resolver boundary must not return
// kotlin.Result from a suspend fun (see [ServerImageResult]).
return relay.fetchMediaByPath(serverPath).fold(
val routeOwner = activeProfileContextKey
val historyGeneration = historyLoadGeneration.get()
val upstreamMediaClient = dashboardMediaClientProvider?.invoke()
val maxBytes = mediaSettingsRepo?.settings?.first()?.maxInboundSizeMb
?.toLong()
?.coerceAtLeast(1L)
?.times(1024L * 1024L)
?: 25L * 1024L * 1024L
val result = fetchServerPath(serverPath, maxBytes, upstreamMediaClient)
if (
routeOwner != activeProfileContextKey ||
historyGeneration != historyLoadGeneration.get()
) {
return ServerImageResult.Failure("Connection changed while loading image")
}
return result.fold(
onSuccess = { ServerImageResult.Success(it.bytes, it.sensitive) },
onFailure = { ServerImageResult.Failure(it.message ?: "relay fetch failed") },
onFailure = { ServerImageResult.Failure(it.message ?: "Image unavailable") },
)
}
@@ -10808,6 +10954,9 @@ class ChatViewModel : ViewModel() {
val relay = relayHttpClient
val repo = mediaSettingsRepo
val cache = mediaCacheWriter
val routeOwner = activeProfileContextKey
val historyGeneration = historyLoadGeneration.get()
val upstreamMediaClient = dashboardMediaClientProvider?.invoke()
val placeholder = Attachment(
contentType = if (expectedRole == MessageRole.USER) {
@@ -10864,12 +11013,58 @@ class ChatViewModel : ViewModel() {
originalPath,
settings,
expectedRole = expectedRole,
) {
relay.fetchMediaByPath(originalPath)
expectedContextKey = routeOwner,
expectedHistoryGeneration = historyGeneration,
) { maxBytes ->
fetchServerPath(originalPath, maxBytes, upstreamMediaClient)
}
}
}
/**
* Resolve a gateway-local path upstream-first. Relay is an additive
* compatibility route for older hosts or paths outside upstream's managed
* file policy; it is never required when current upstream can serve the
* file directly.
*/
private suspend fun fetchServerPath(
serverPath: String,
maxBytes: Long,
upstream: DashboardApiClient?,
): Result<RelayHttpClient.FetchedMedia> {
if (upstream != null) {
val upstreamResult = upstream.downloadManagedFile(serverPath, maxBytes)
.map { fetched ->
RelayHttpClient.FetchedMedia(
contentType = fetched.contentType,
bytes = fetched.bytes,
fileName = fetched.fileName,
sensitive = false,
)
}
if (upstreamResult.isSuccess) return upstreamResult
val upstreamFailure = upstreamResult.exceptionOrNull()
if (upstreamFailure?.isDashboardManagedFilesUnsupported() != true) {
return upstreamResult
}
val relay = relayHttpClient
if (relay?.mediaUrlConfigured() == true) {
return relay.fetchMediaByPath(serverPath, maxBytes = maxBytes)
}
return Result.failure(MediaRouteUnavailableException(upstreamFailure))
}
val relay = relayHttpClient
return if (relay?.mediaUrlConfigured() == true) {
relay.fetchMediaByPath(serverPath, maxBytes = maxBytes)
} else {
Result.failure(MediaRouteUnavailableException())
}
}
private class MediaRouteUnavailableException(cause: Throwable? = null) :
java.io.IOException(MEDIA_HOST_ONLY, cause)
private fun persistedImageContentType(path: String): String =
when (path.substringAfterLast('.').lowercase()) {
"avif" -> "image/avif"
@@ -10899,13 +11094,15 @@ class ChatViewModel : ViewModel() {
fetchKey: String,
settings: MediaSettings,
expectedRole: MessageRole = MessageRole.ASSISTANT,
fetch: suspend () -> Result<RelayHttpClient.FetchedMedia>,
expectedContextKey: String? = activeProfileContextKey,
expectedHistoryGeneration: Int = historyLoadGeneration.get(),
fetch: suspend (maxBytes: Long) -> Result<RelayHttpClient.FetchedMedia>,
) {
val cache = mediaCacheWriter ?: return
val maxBytes = settings.maxInboundSizeMb.toLong().coerceAtLeast(1) * 1024L * 1024L
val result = try {
withTimeout(MEDIA_FETCH_TIMEOUT_MS) { fetch() }
withTimeout(MEDIA_FETCH_TIMEOUT_MS) { fetch(maxBytes) }
} catch (e: TimeoutCancellationException) {
Result.failure(java.io.IOException("Media download timed out"))
} catch (e: CancellationException) {
@@ -10913,6 +11110,11 @@ class ChatViewModel : ViewModel() {
} catch (e: Exception) {
Result.failure(e)
}
if (
chatHandler !== handler ||
activeProfileContextKey != expectedContextKey ||
historyLoadGeneration.get() != expectedHistoryGeneration
) return
result.fold(
onSuccess = { fetched ->
if (fetched.bytes.size > maxBytes) {
@@ -10965,6 +11167,15 @@ class ChatViewModel : ViewModel() {
}
},
onFailure = { err ->
if (err is MediaRouteUnavailableException) {
updateAttachmentByToken(handler, messageId, fetchKey, expectedRole = expectedRole) { att ->
att.copy(
state = AttachmentState.FAILED,
errorMessage = MEDIA_HOST_ONLY,
)
}
return@fold
}
val human = classifyError(err, context = "media_fetch", ctx = appContext)
updateAttachmentByToken(handler, messageId, fetchKey, expectedRole = expectedRole) { att ->
att.copy(
@@ -72,8 +72,9 @@ data class GitTarget(
/**
* View model for the Git State Android surface (read + write).
*
* Loads the scanned repo list from the Hermes-Relay plugin and, on selection,
* fetches working-tree status + branches. Mutations (stage/unstage/discard/
* Loads the active session repository from upstream first and adds repositories
* discovered by the Hermes-Relay plugin. On selection it fetches working-tree
* status + branches. Mutations (stage/unstage/discard/
* commit/fetch/pull/push/checkout) all require the ``plugin.api.write`` grant:
* ``configure`` binds one connection/profile/Dashboard owner and every mutation
* refuses (surfacing a readable message, never a POST) when that owner's grant
@@ -119,12 +120,12 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
private var mutationJob: Job? = null
private var messageJob: Job? = null
private var scopeKey: String? = null
private var sessionRepoPath: String? = null
private var targetGeneration: Long = 0
fun selectedRepoIdForDisplay(): String? = _selectedRepoId.value
fun currentTarget(): GitTarget? {
if (!_scanningEnabled.value) return null
val owner = scopeKey ?: return null
val repo = _selectedRepoId.value ?: return null
return GitTarget(owner, repo, targetGeneration)
@@ -143,10 +144,34 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
api = dashboard?.let(::GitStateApiClient)
}
/**
* Bind standard Git to the active upstream session workspace.
*
* An exact `git_repo_root` wins; `cwd` is the official Desktop-compatible
* fallback. Changing sessions invalidates every selected repository target
* before starting a fresh, owner-bound discovery pass.
*/
fun setSessionWorkspace(repoRoot: String?, workingDirectory: String?) {
val next = repoRoot?.trim()?.takeIf { it.isNotBlank() }
?: workingDirectory?.trim()?.takeIf { it.isNotBlank() }
if (sessionRepoPath == next) return
val workspaceWasLoaded = _repos.value !is GitStateUiState.Loading
sessionRepoPath = next
targetGeneration += 1
clearWorkspaceState()
// Preserve lazy discovery: the first host scan still starts only when
// the Git workspace asks for it. Once visible/loaded, a session switch
// refreshes immediately against the new exact workspace.
if (workspaceWasLoaded) loadRepos()
}
fun setScanningEnabled(enabled: Boolean) {
if (_scanningEnabled.value == enabled) return
val workspaceWasLoaded = _repos.value !is GitStateUiState.Loading
_scanningEnabled.value = enabled
if (!enabled) clearWorkspaceState()
targetGeneration += 1
clearWorkspaceState()
if (workspaceWasLoaded) loadRepos()
}
/** Grants the plugin.api.write capability for this connection/profile. */
@@ -158,7 +183,6 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
fun hasWriteGrant(): Boolean = _writeGrant.value
fun loadRepos() {
if (!_scanningEnabled.value) return
val client = api ?: run {
_repos.value = GitStateUiState.Error("Dashboard connection unavailable")
return
@@ -167,7 +191,10 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
reposJob?.cancel()
reposJob = viewModelScope.launch {
_repos.value = GitStateUiState.Loading
client.repos().fold(
client.repos(
sessionRepoPath = sessionRepoPath,
includeRelayDiscovery = _scanningEnabled.value,
).fold(
onSuccess = { list ->
if (scopeKey == expectedScope) {
_repos.value = GitStateUiState.Ready(list, null)
@@ -208,7 +235,6 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
}
fun selectRepo(repoId: String) {
if (!_scanningEnabled.value) return
val client = api ?: return
targetGeneration += 1
_selectedRepoId.value = repoId
+15 -10
View File
@@ -669,8 +669,8 @@
<string name="settings_voice_mode">Modo de voz</string>
<string name="settings_voice_mode_desc">Voz do painel, opções de relay em tempo real e provedores</string>
<string name="settings_threads">Threads</string>
<string name="settings_threads_desc">Permita que o agente inicie conversas com você (desativado por padrão)</string>
<string name="settings_power_tools">Ferramentas avançadas</string>
<string name="settings_threads_desc">Conversas iniciadas pelo Relay e entrega proativa (desativado por padrão)</string>
<string name="settings_power_tools">Ferramentas do Relay</string>
<string name="settings_terminal">Terminal</string>
<string name="settings_terminal_desc">Acesso ao shell do servidor por uma sessão pareada do relay</string>
<string name="settings_bridge">Bridge</string>
@@ -682,7 +682,7 @@
<string name="settings_notification_companion">Assistente de notificações</string>
<string name="settings_notification_companion_desc">Notificações compartilhadas do celular para ferramentas pareadas do relay</string>
<string name="settings_media">Mídia</string>
<string name="settings_media_desc">Anexos recebidos pelo Relay, busca automática e limite do cache</string>
<string name="settings_media_desc">Anexos do chat, downloads, privacidade e cache</string>
<string name="settings_bridge_safety">Segurança do Bridge</string>
<string name="settings_bridge_safety_desc">Lista de bloqueio, confirmação de verbos destrutivos e desativação automática</string>
<string name="settings_sideload">Sideload</string>
@@ -1162,8 +1162,8 @@
<!-- P1: MediaSettingsScreen -->
<string name="media_title">Mídia</string>
<string name="media_back">Voltar</string>
<string name="media_intro_1">Controla como o app lida com arquivos enviados pelos resultados de ferramentas (capturas de tela, PDFs etc.) pelo relay.</string>
<string name="media_intro_2">Somente Relay — estas configurações não afetam as imagens que você anexa no chat nem nada em uma conexão padrão (sem Relay).</string>
<string name="media_intro_1">Controla como o app baixa, protege e armazena em cache os arquivos enviados pelo Hermes no chat.</string>
<string name="media_intro_2">A mídia padrão do Dashboard é priorizada. Quando pareado, o Relay pode acrescentar entrega de compatibilidade e metadados de sensibilidade.</string>
<string name="media_max_inbound">Tamanho máximo dos anexos recebidos</string>
<string name="media_max_inbound_value">%1$d MB</string>
<string name="media_max_inbound_desc">Arquivos maiores que isso são rejeitados após o download.</string>
@@ -2080,7 +2080,8 @@
<string name="session_path_signin_for_gateway">Entre em Gerenciar para usar o Gateway com pensamento ao vivo.</string>
<string name="session_path_gateway_api_server">Disponível no transporte do Gateway — esta sessão transmite pelo servidor API.</string>
<string name="session_path_relay_not_connected">Relay pareado, mas desconectado.</string>
<string name="session_path_pair_relay_media">Pareie o relay para enviar e receber mídia.</string>
<string name="session_path_pair_relay_media">Pareie o Relay para compatibilidade de mídia em hosts Hermes mais antigos.</string>
<string name="session_path_media_not_ready">Atualize o Hermes para entrega de mídia padrão ou pareie o Relay para compatibilidade.</string>
<string name="session_path_pair_relay_terminal">Pareie o relay para acessar o terminal.</string>
<string name="session_path_voice_not_ready">A voz não está pronta nesta conexão.</string>
<string name="session_path_threads_pair_relay">Pareie o relay e ative \"Permitir que o Hermes me envie mensagens\" para o agente poder abrir Threads.</string>
@@ -2678,6 +2679,8 @@
<string name="inbound_attach_downloading">Baixando…%1$s</string>
<string name="inbound_attach_failed">Falha no anexo</string>
<string name="inbound_attach_tap_retry">Toque para tentar novamente</string>
<string name="inbound_attach_host_only">O arquivo está no seu host Hermes</string>
<string name="inbound_attach_host_only_help">Atualize o Hermes para downloads padrão ou pareie o Relay para compatibilidade.</string>
<string name="inbound_attach_open">Abrir externamente</string>
<string name="inbound_attach_share">Compartilhar</string>
<string name="inbound_attach_save">Salvar no dispositivo</string>
@@ -3151,6 +3154,8 @@
<string name="diag_check_ready_with">Pronto com %s</string>
<string name="diag_check_relay_active">Relay ativo</string>
<string name="diag_check_relay_not_configured">Relay não configurado</string>
<string name="diag_relay_tools_optional">Ferramentas do Relay (opcionais)</string>
<string name="diag_relay_tools_not_paired">Não pareado</string>
<string name="diag_check_relay_server">Verificar servidor relay</string>
<string name="diag_check_relay_plugin">Plugin do Relay</string>
<string name="diag_plugin_not_configured">O plugin opcional não está configurado</string>
@@ -3180,8 +3185,8 @@
<string name="inbound_attach_cd_cancel">Cancelar</string>
<string name="inbound_attach_open_failed">Não foi possível abrir o anexo</string>
<string name="inbound_attach_share_failed">Não foi possível compartilhar o anexo</string>
<string name="injected_context_media_no_relay">A mídia exige uma conexão ativa com o Relay</string>
<string name="injected_context_media_relay_active">Relay ativo para mídia</string>
<string name="injected_context_media_no_relay">Nenhuma instrução extra de mídia — a entrega padrão continua sob responsabilidade do servidor</string>
<string name="injected_context_media_relay_active">Aprimoramento de mídia do Relay disponível</string>
<string name="injected_context_media_title">Compartilhamento de mídia</string>
<string name="injected_context_persona_not_set">Nenhuma persona definida</string>
<string name="injected_context_persona_server_side">Persona no servidor</string>
@@ -4332,7 +4337,7 @@
<string name="chat_git_deletions">%1$d exclusões</string>
<plurals name="chat_git_change_count"><item quantity="one">%1$d alteração</item><item quantity="other">%1$d alterações</item></plurals>
<string name="settings_git_workspace">Espaço de trabalho Git</string>
<string name="settings_git_workspace_desc">Revise alterações, branches, commits e remotos</string>
<string name="settings_git_workspace_desc">Git da sessão atual primeiro, com descoberta opcional do host</string>
<string name="current_chat_activity_title">Atividade atual do chat</string>
<string name="current_chat_activity_subtitle">Detalhes ao vivo e somente leitura deste chat</string>
<string name="current_chat_activity_open">Visualizar atividade atual do chat</string>
@@ -4367,7 +4372,7 @@
<string name="agent_activity_child_role_task">Tarefa</string>
<string name="agent_activity_child_role_agent">Agente</string>
<string name="agent_activity_child_role_system">Sistema</string>
<string name="settings_git_workspace_off_desc">Desativado · A verificação de repositórios no host é opcional</string>
<string name="settings_git_workspace_off_desc">Somente repositório da sessão · A descoberta do host é opcional</string>
<string name="active_section_available_fallback">Available fallback</string>
<string name="active_section_available_fallback_mechanism">Available fallback · %1$s</string>
<string name="active_section_in_use">In use</string>
+15 -10
View File
@@ -707,8 +707,8 @@
<string name="settings_voice_mode">语音模式</string>
<string name="settings_voice_mode_desc">仪表盘语音、实时 Relay 选项、提供商</string>
<string name="settings_threads">话题</string>
<string name="settings_threads_desc">允许代理主动与你对话(默认关闭)</string>
<string name="settings_power_tools">高级工具</string>
<string name="settings_threads_desc">由 Relay 发起的对话和主动推送(默认关闭)</string>
<string name="settings_power_tools">Relay 工具</string>
<string name="settings_terminal">终端</string>
<string name="settings_terminal_desc">通过已配对的 Relay 会话访问服务器 shell</string>
<string name="settings_bridge">Bridge</string>
@@ -720,7 +720,7 @@
<string name="settings_notification_companion">通知助手</string>
<string name="settings_notification_companion_desc">为已配对的 Relay 工具共享手机通知</string>
<string name="settings_media">媒体</string>
<string name="settings_media_desc">Relay 入站附件、自动获取、缓存上限</string>
<string name="settings_media_desc">聊天附件、下载行为、隐私和缓存</string>
<string name="settings_bridge_safety">Bridge 安全</string>
<string name="settings_bridge_safety_desc">黑名单、破坏性动词确认、自动禁用</string>
<string name="settings_sideload">Sideload</string>
@@ -1212,8 +1212,8 @@
<!-- P1: MediaSettingsScreen -->
<string name="media_title">媒体</string>
<string name="media_back">返回</string>
<string name="media_intro_1">控制应用如何处理通过 Relay 传来的工具结果文件(截图、PDF 等)。</string>
<string name="media_intro_2">仅限 Relay——这些设置不影响你在聊天中附加的图片或标准(无 Relay)连接上的任何内容。</string>
<string name="media_intro_1">控制应用如何下载、保护和缓存 Hermes 在聊天中发送的文件。</string>
<string name="media_intro_2">优先使用标准 Dashboard 媒体。配对后,Relay 可补充兼容性传输和敏感度元数据。</string>
<string name="media_max_inbound">最大入站附件大小</string>
<string name="media_max_inbound_value">%1$d MB</string>
<string name="media_max_inbound_desc">超过此大小的文件在下载后被拒绝。</string>
@@ -2166,7 +2166,8 @@
<string name="session_path_signin_for_gateway">请在管理页面登录以使用实时思考 Gateway。</string>
<string name="session_path_gateway_api_server">Gateway 传输可用——此会话通过 API 服务器传输。</string>
<string name="session_path_relay_not_connected">Relay 已配对但未连接。</string>
<string name="session_path_pair_relay_media">配对 Relay 以发送和接收媒体。</string>
<string name="session_path_pair_relay_media">配对 Relay,以兼容较旧 Hermes 主机上的媒体。</string>
<string name="session_path_media_not_ready">更新 Hermes 以使用标准媒体传输,或配对 Relay 以获得兼容性。</string>
<string name="session_path_pair_relay_terminal">配对 Relay 以使用终端。</string>
<string name="session_path_voice_not_ready">此连接的语音未就绪。</string>
<string name="session_path_threads_pair_relay">配对 Relay 并开启"让 Hermes 联系我",代理才能打开话题。</string>
@@ -2791,6 +2792,8 @@
<string name="inbound_attach_downloading">正在下载…%1$s</string>
<string name="inbound_attach_failed">附件失败</string>
<string name="inbound_attach_tap_retry">点击重试</string>
<string name="inbound_attach_host_only">文件位于你的 Hermes 主机上</string>
<string name="inbound_attach_host_only_help">更新 Hermes 以使用标准下载,或配对 Relay 以获得兼容性。</string>
<string name="inbound_attach_open">外部打开</string>
<string name="inbound_attach_share">分享</string>
<string name="inbound_attach_save">保存到设备</string>
@@ -3250,6 +3253,8 @@
<string name="diag_check_ready_with">已就绪(%s)</string>
<string name="diag_check_relay_active">Relay 活跃</string>
<string name="diag_check_relay_not_configured">Relay 未配置</string>
<string name="diag_relay_tools_optional">Relay 工具(可选)</string>
<string name="diag_relay_tools_not_paired">未配对</string>
<string name="diag_check_relay_server">检查 Relay 服务器</string>
<string name="diag_check_relay_plugin">Relay 插件</string>
<string name="diag_plugin_not_configured">未配置可选插件</string>
@@ -3279,8 +3284,8 @@
<string name="inbound_attach_cd_cancel">取消</string>
<string name="inbound_attach_open_failed">无法打开附件</string>
<string name="inbound_attach_share_failed">无法分享附件</string>
<string name="injected_context_media_no_relay">媒体需要活跃的 Relay 连接</string>
<string name="injected_context_media_relay_active">Relay 已为媒体激活</string>
<string name="injected_context_media_no_relay">没有额外媒体指令——标准传输仍由服务器负责</string>
<string name="injected_context_media_relay_active">Relay 媒体增强可用</string>
<string name="injected_context_media_title">媒体共享</string>
<string name="injected_context_persona_not_set">未设置角色</string>
<string name="injected_context_persona_server_side">服务端角色</string>
@@ -4413,7 +4418,7 @@
<string name="chat_git_deletions">删除 %1$d 行</string>
<plurals name="chat_git_change_count"><item quantity="other">%1$d 个更改</item></plurals>
<string name="settings_git_workspace">Git 工作区</string>
<string name="settings_git_workspace_desc">查看更改、分支、提交和远程仓库</string>
<string name="settings_git_workspace_desc">优先使用当前会话的 Git,可选择发现主机仓库</string>
<string name="current_chat_activity_title">当前聊天活动</string>
<string name="current_chat_activity_subtitle">此聊天中的只读实时详情</string>
<string name="current_chat_activity_open">预览当前聊天活动</string>
@@ -4448,7 +4453,7 @@
<string name="agent_activity_child_role_task">任务</string>
<string name="agent_activity_child_role_agent">代理</string>
<string name="agent_activity_child_role_system">系统</string>
<string name="settings_git_workspace_off_desc">关闭 · 主机仓库扫描需手动启用</string>
<string name="settings_git_workspace_off_desc">仅会话仓库 · 主机发现需手动启用</string>
<string name="active_section_available_fallback">Available fallback</string>
<string name="active_section_available_fallback_mechanism">Available fallback · %1$s</string>
<string name="active_section_in_use">In use</string>
+15 -10
View File
@@ -710,8 +710,8 @@
<string name="settings_voice_mode">Sprachmodus</string>
<string name="settings_voice_mode_desc">Dashboard-Sprache, Echtzeit-Relay-Optionen, Anbieter</string>
<string name="settings_threads">Threads</string>
<string name="settings_threads_desc">Dem Agenten erlauben, Unterhaltungen mit dir zu beginnen (standardmäßig aus)</string>
<string name="settings_power_tools">Profiwerkzeuge</string>
<string name="settings_threads_desc">Vom Relay gestartete Unterhaltungen und proaktive Zustellung (standardmäßig aus)</string>
<string name="settings_power_tools">Relay-Werkzeuge</string>
<string name="settings_terminal">Terminal</string>
<string name="settings_terminal_desc">Zugriff auf die Server-Shell über eine gekoppelte Relay-Sitzung</string>
<string name="settings_bridge">Bridge</string>
@@ -723,7 +723,7 @@
<string name="settings_notification_companion">Benachrichtigungsassistent</string>
<string name="settings_notification_companion_desc">Geteilte Smartphone-Benachrichtigungen für gekoppelte Relay-Werkzeuge</string>
<string name="settings_media">Medien</string>
<string name="settings_media_desc">Eingehende Relay-Anhänge, automatischer Abruf, Cache-Limit</string>
<string name="settings_media_desc">Chat-Anhänge, Downloadverhalten, Datenschutz und Cache</string>
<string name="settings_bridge_safety">Bridge-Sicherheit</string>
<string name="settings_bridge_safety_desc">Sperrliste, Bestätigung destruktiver Verben, automatische Deaktivierung</string>
<string name="settings_sideload">Sideload</string>
@@ -1218,8 +1218,8 @@
<!-- P1: MediaSettingsScreen -->
<string name="media_title">Medien</string>
<string name="media_back">Zurück</string>
<string name="media_intro_1">Legt fest, wie die App über das Relay gesendete Dateien aus Werkzeugergebnissen (Screenshots, PDFs usw.) behandelt.</string>
<string name="media_intro_2">Nur Relay — diese Einstellungen betreffen weder Bilder, die du im Chat anhängst, noch Inhalte einer Standardverbindung ohne Relay.</string>
<string name="media_intro_1">Legt fest, wie die App von Hermes im Chat gesendete Dateien herunterlädt, schützt und zwischenspeichert.</string>
<string name="media_intro_2">Standardmäßig werden Dashboard-Medien bevorzugt. Wenn Relay gekoppelt ist, kann es Kompatibilitätszustellung und Vertraulichkeitsmetadaten ergänzen.</string>
<string name="media_max_inbound">Max. Größe eingehender Anhänge</string>
<string name="media_max_inbound_value">%1$d MB</string>
<string name="media_max_inbound_desc">Größere Dateien werden nach dem Download abgelehnt.</string>
@@ -2172,7 +2172,8 @@
<string name="session_path_signin_for_gateway">Melde dich unter Verwalten für das Live-Denken-Gateway an.</string>
<string name="session_path_gateway_api_server">Auf dem Gateway-Transport verfügbar — diese Sitzung streamt über den API-Server.</string>
<string name="session_path_relay_not_connected">Relay gekoppelt, aber nicht verbunden.</string>
<string name="session_path_pair_relay_media">Kopple das Relay, um Medien zu senden und zu empfangen.</string>
<string name="session_path_pair_relay_media">Kopple Relay für Medienkompatibilität mit älteren Hermes-Hosts.</string>
<string name="session_path_media_not_ready">Aktualisiere Hermes für die standardmäßige Medienzustellung oder kopple Relay für Kompatibilität.</string>
<string name="session_path_pair_relay_terminal">Kopple das Relay für Terminalzugriff.</string>
<string name="session_path_voice_not_ready">Sprache ist bei dieser Verbindung nicht bereit.</string>
<string name="session_path_threads_pair_relay">Kopple das Relay und aktiviere „Hermes darf mir schreiben“, damit der Agent Threads öffnen kann.</string>
@@ -2797,6 +2798,8 @@
<string name="inbound_attach_downloading">Download läuft&#8230;%1$s</string>
<string name="inbound_attach_failed">Anhang fehlgeschlagen</string>
<string name="inbound_attach_tap_retry">Zum erneuten Versuch tippen</string>
<string name="inbound_attach_host_only">Datei befindet sich auf deinem Hermes-Host</string>
<string name="inbound_attach_host_only_help">Aktualisiere Hermes für Standarddownloads oder kopple Relay für Kompatibilität.</string>
<string name="inbound_attach_open">Extern öffnen</string>
<string name="inbound_attach_share">Teilen</string>
<string name="inbound_attach_save">Auf Gerät speichern</string>
@@ -3319,6 +3322,8 @@
<string name="diag_check_ready_with">Bereit mit %s</string>
<string name="diag_check_relay_active">Relay aktiv</string>
<string name="diag_check_relay_not_configured">Relay nicht konfiguriert</string>
<string name="diag_relay_tools_optional">Relay-Werkzeuge (optional)</string>
<string name="diag_relay_tools_not_paired">Nicht gekoppelt</string>
<string name="diag_check_relay_server">Relay-Server prüfen</string>
<string name="diag_check_relay_plugin">Relay-Plugin</string>
<string name="diag_plugin_not_configured">Optionales Plugin ist nicht konfiguriert</string>
@@ -3348,8 +3353,8 @@
<string name="inbound_attach_cd_cancel">Abbrechen</string>
<string name="inbound_attach_open_failed">Anhang konnte nicht geöffnet werden</string>
<string name="inbound_attach_share_failed">Anhang konnte nicht geteilt werden</string>
<string name="injected_context_media_no_relay">Medien erfordern eine aktive Relay-Verbindung</string>
<string name="injected_context_media_relay_active">Relay für Medien aktiv</string>
<string name="injected_context_media_no_relay">Keine zusätzliche Medienanweisung — die Standardzustellung bleibt Aufgabe des Servers</string>
<string name="injected_context_media_relay_active">Relay-Medienerweiterung verfügbar</string>
<string name="injected_context_media_title">Medienfreigabe</string>
<string name="injected_context_persona_not_set">Keine Persona festgelegt</string>
<string name="injected_context_persona_server_side">Serverseitige Persona</string>
@@ -4489,7 +4494,7 @@
<string name="chat_git_deletions">%1$d Löschungen</string>
<plurals name="chat_git_change_count"><item quantity="one">%1$d Änderung</item><item quantity="other">%1$d Änderungen</item></plurals>
<string name="settings_git_workspace">Git-Arbeitsbereich</string>
<string name="settings_git_workspace_desc">Änderungen, Branches, Commits und Remotes prüfen</string>
<string name="settings_git_workspace_desc">Zuerst Git der aktuellen Sitzung, mit optionaler Host-Erkennung</string>
<string name="current_chat_activity_title">Aktuelle Chat-Aktivität</string>
<string name="current_chat_activity_subtitle">Schreibgeschützte Live-Details aus diesem Chat</string>
<string name="current_chat_activity_open">Aktuelle Chat-Aktivität ansehen</string>
@@ -4524,7 +4529,7 @@
<string name="agent_activity_child_role_task">Aufgabe</string>
<string name="agent_activity_child_role_agent">Agent</string>
<string name="agent_activity_child_role_system">System</string>
<string name="settings_git_workspace_off_desc">Aus · Repository-Scan auf dem Host ist optional</string>
<string name="settings_git_workspace_off_desc">Nur Sitzungs-Repository · Host-Erkennung ist optional</string>
<string name="active_section_available_fallback">Available fallback</string>
<string name="active_section_available_fallback_mechanism">Available fallback · %1$s</string>
<string name="active_section_in_use">In use</string>
+15 -10
View File
@@ -637,8 +637,8 @@
<string name="settings_voice_mode">Modo de voz</string>
<string name="settings_voice_mode_desc">Panel de voz, opciones relay en tiempo real, proveedores</string>
<string name="settings_threads">Threads</string>
<string name="settings_threads_desc">Permita que el agente inicie conversaciones con usted (desactivado de forma predeterminada)</string>
<string name="settings_power_tools">herramientas eléctricas</string>
<string name="settings_threads_desc">Conversaciones iniciadas por Relay y entrega proactiva (desactivado de forma predeterminada)</string>
<string name="settings_power_tools">Herramientas de Relay</string>
<string name="settings_terminal">Terminal</string>
<string name="settings_terminal_desc">Acceso al shell del servidor a través de una sesión relay emparejada</string>
<string name="settings_bridge">Bridge</string>
@@ -650,7 +650,7 @@
<string name="settings_notification_companion">Compañero de notificación</string>
<string name="settings_notification_companion_desc">Notificaciones telefónicas compartidas para herramientas relay emparejadas</string>
<string name="settings_media">Medios de comunicación</string>
<string name="settings_media_desc">Relay archivos adjuntos entrantes, búsqueda automática, límite de caché</string>
<string name="settings_media_desc">Adjuntos del chat, descargas, privacidad y caché</string>
<string name="settings_bridge_safety">seguridad Bridge</string>
<string name="settings_bridge_safety_desc">Lista de bloqueo, confirmación de verbo destructivo, desactivación automática</string>
<string name="settings_sideload">Carga lateral</string>
@@ -1119,8 +1119,8 @@
<string name="about_credits">Axiom Labs ❤️ Agente Hermes · Nous Research</string>
<string name="media_title">Medios de comunicación</string>
<string name="media_back">Atrás</string>
<string name="media_intro_1">Controla cómo la aplicación maneja los archivos enviados por los resultados de la herramienta (capturas de pantalla, PDF, etc.) a través del relay.</string>
<string name="media_intro_2">Solo Relay: esta configuración no afecta a las imágenes que adjunte en el chat ni a nada en una conexión estándar (no-Relay).</string>
<string name="media_intro_1">Controla cómo la aplicación descarga, protege y almacena en caché los archivos que Hermes envía en el chat.</string>
<string name="media_intro_2">Se priorizan los archivos multimedia del Dashboard estándar. Cuando está emparejado, Relay puede añadir entrega de compatibilidad y metadatos de confidencialidad.</string>
<string name="media_max_inbound">Tamaño máximo del archivo adjunto entrante</string>
<string name="media_max_inbound_value">%1$d MB</string>
<string name="media_max_inbound_desc">Los archivos de mayor tamaño se rechazan después de la descarga.</string>
@@ -1989,7 +1989,8 @@
<string name="session_path_signin_for_gateway">Inicie sesión en Administrar para el gateway que piensa en vivo.</string>
<string name="session_path_gateway_api_server">Disponible en el transporte gateway: esta sesión se transmite a través del servidor API.</string>
<string name="session_path_relay_not_connected">Relay emparejado pero no conectado.</string>
<string name="session_path_pair_relay_media">Empareje el relay para enviar y recibir medios.</string>
<string name="session_path_pair_relay_media">Empareja Relay para obtener compatibilidad multimedia en hosts Hermes antiguos.</string>
<string name="session_path_media_not_ready">Actualiza Hermes para la entrega multimedia estándar o empareja Relay por compatibilidad.</string>
<string name="session_path_pair_relay_terminal">Empareje el relay para acceder al terminal.</string>
<string name="session_path_voice_not_ready">La voz no está lista en esta conexión.</string>
<string name="session_path_threads_pair_relay">Empareje el relay y active "Dejar que Hermes me envíe un mensaje" para que el agente pueda abrir Threads.</string>
@@ -2556,6 +2557,8 @@
<string name="inbound_attach_downloading">Descargando…%1$s</string>
<string name="inbound_attach_failed">Error al adjuntar</string>
<string name="inbound_attach_tap_retry">Toca para volver a intentarlo</string>
<string name="inbound_attach_host_only">El archivo está en tu host de Hermes</string>
<string name="inbound_attach_host_only_help">Actualiza Hermes para las descargas estándar o empareja Relay por compatibilidad.</string>
<string name="inbound_attach_open">Abierto externamente</string>
<string name="inbound_attach_share">Compartir</string>
<string name="inbound_attach_save">Guardar en dispositivo</string>
@@ -2983,6 +2986,8 @@
<string name="diag_check_ready_with">Listo con %s</string>
<string name="diag_check_relay_active">Relay activo</string>
<string name="diag_check_relay_not_configured">Relay no configurado</string>
<string name="diag_relay_tools_optional">Herramientas de Relay (opcionales)</string>
<string name="diag_relay_tools_not_paired">Sin emparejar</string>
<string name="diag_check_relay_server">Verifique el servidor relay</string>
<string name="diag_check_relay_plugin">Plugin de Relay</string>
<string name="diag_plugin_not_configured">El plugin opcional no está configurado</string>
@@ -3012,8 +3017,8 @@
<string name="inbound_attach_cd_cancel">Cancelar</string>
<string name="inbound_attach_open_failed">No se pudo abrir el archivo adjunto</string>
<string name="inbound_attach_share_failed">No se pudo compartir el archivo adjunto</string>
<string name="injected_context_media_no_relay">Los medios requieren una conexión Relay activa</string>
<string name="injected_context_media_relay_active">Relay activo para medios</string>
<string name="injected_context_media_no_relay">Sin instrucciones multimedia adicionales: la entrega estándar sigue a cargo del servidor</string>
<string name="injected_context_media_relay_active">Mejora multimedia de Relay disponible</string>
<string name="injected_context_media_title">Compartir medios</string>
<string name="injected_context_persona_not_set">No se ha definido ninguna persona</string>
<string name="injected_context_persona_server_side">Persona del lado del servidor</string>
@@ -4180,7 +4185,7 @@
<string name="chat_git_deletions">%1$d eliminaciones</string>
<plurals name="chat_git_change_count"><item quantity="one">%1$d cambio</item><item quantity="other">%1$d cambios</item></plurals>
<string name="settings_git_workspace">Espacio de Git</string>
<string name="settings_git_workspace_desc">Revisa cambios, ramas, commits y remotos</string>
<string name="settings_git_workspace_desc">Primero el Git de la sesión actual, con detección opcional del host</string>
<string name="current_chat_activity_title">Actividad actual del chat</string>
<string name="current_chat_activity_subtitle">Detalles en vivo de solo lectura de este chat</string>
<string name="current_chat_activity_open">Ver la actividad actual del chat</string>
@@ -4215,7 +4220,7 @@
<string name="agent_activity_child_role_task">Tarea</string>
<string name="agent_activity_child_role_agent">Agente</string>
<string name="agent_activity_child_role_system">Sistema</string>
<string name="settings_git_workspace_off_desc">Desactivado · El análisis de repositorios del host es opcional</string>
<string name="settings_git_workspace_off_desc">Solo el repositorio de la sesión · La detección del host es opcional</string>
<string name="active_section_available_fallback">Available fallback</string>
<string name="active_section_available_fallback_mechanism">Available fallback · %1$s</string>
<string name="active_section_in_use">In use</string>
+15 -10
View File
@@ -707,8 +707,8 @@
<string name="settings_voice_mode">ボイスモード</string>
<string name="settings_voice_mode_desc">ダッシュボード音声、リアルタイムRelayオプション、プロバイダー</string>
<string name="settings_threads">Threads</string>
<string name="settings_threads_desc">エージェントがあなたと会話を開始できるようにします (デフォルトではオフ)</string>
<string name="settings_power_tools">パワーツール</string>
<string name="settings_threads_desc">Relay が開始する会話とプロアクティブ配信(デフォルトではオフ)</string>
<string name="settings_power_tools">Relay ツール</string>
<string name="settings_terminal">ターミナル</string>
<string name="settings_terminal_desc">ペアリングされたRelayセッションを介したサーバーシェルアクセス</string>
<string name="settings_bridge">Bridge</string>
@@ -720,7 +720,7 @@
<string name="settings_notification_companion">通知コンパニオン</string>
<string name="settings_notification_companion_desc">ペアリングされたRelayツールの共有電話通知</string>
<string name="settings_media">メディア</string>
<string name="settings_media_desc">Relay 受信添付ファイル、自動取得、キャッシュキャップ</string>
<string name="settings_media_desc">チャットの添付ファイル、ダウンロード動作、プライバシー、キャッシュ</string>
<string name="settings_bridge_safety">Bridge 安全性</string>
<string name="settings_bridge_safety_desc">ブロックリスト、破壊的な動詞の確認、自動無効化</string>
<string name="settings_sideload">サイドロード</string>
@@ -1228,8 +1228,8 @@
<!-- P1: MediaSettingsScreen -->
<string name="media_title">メディア</string>
<string name="media_back">戻る</string>
<string name="media_intro_1">ツールの結果 (スクリーンショット、PDF など) によってRelay経由で送信されたファイルをアプリが処理する方法を制御します。</string>
<string name="media_intro_2">Relay のみ — これらの設定は、チャットに添付した画像や標準 (Relay なし) 接続上のものには影響しません。</string>
<string name="media_intro_1">Hermes がチャットで送信したファイルのダウンロード、保護、キャッシュ方法を設定します。</string>
<string name="media_intro_2">標準の Dashboard メディアが優先されます。Relay をペアリングすると、互換配信と機密性メタデータを追加できます。</string>
<string name="media_max_inbound">受信添付ファイルの最大サイズ</string>
<string name="media_max_inbound_value">%1$d MB</string>
<string name="media_max_inbound_desc">これより大きいファイルはダウンロード後に拒否されます。</string>
@@ -2182,7 +2182,8 @@
<string name="session_path_signin_for_gateway">Live Thinking Gateway の [管理] でサインインします。</string>
<string name="session_path_gateway_api_server">Gateway トランスポートで利用可能 — このセッションは API サーバー経由でストリーミングされます。</string>
<string name="session_path_relay_not_connected">Relay はペアリングされていますが、接続されていません。</string>
<string name="session_path_pair_relay_media">Relayをペアリングしてメディアを送受信します。</string>
<string name="session_path_pair_relay_media">古い Hermes ホストとのメディア互換性のために Relay をペアリングしてください。</string>
<string name="session_path_media_not_ready">標準メディア配信には Hermes を更新するか、互換性のために Relay をペアリングしてください。</string>
<string name="session_path_pair_relay_terminal">ターミナルアクセス用にRelayをペアリングします。</string>
<string name="session_path_voice_not_ready">この接続では音声の準備ができていません。</string>
<string name="session_path_threads_pair_relay">Relayをペアリングし、「Hermes にメッセージを送信する」をオンにすると、エージェントが Threads を開けるようになります。</string>
@@ -2805,6 +2806,8 @@
<string name="inbound_attach_downloading">ダウンロード中&#8230;%1$s</string>
<string name="inbound_attach_failed">添付に失敗しました</string>
<string name="inbound_attach_tap_retry">タップして再試行してください</string>
<string name="inbound_attach_host_only">ファイルは Hermes ホスト上にあります</string>
<string name="inbound_attach_host_only_help">標準ダウンロードには Hermes を更新するか、互換性のために Relay をペアリングしてください。</string>
<string name="inbound_attach_open">外部に開く</string>
<string name="inbound_attach_share">共有</string>
<string name="inbound_attach_save">デバイスに保存</string>
@@ -3326,6 +3329,8 @@
<string name="diag_check_ready_with">%s で準備完了</string>
<string name="diag_check_relay_active">Relay アクティブ</string>
<string name="diag_check_relay_not_configured">Relay が構成されていません</string>
<string name="diag_relay_tools_optional">Relay ツール(オプション)</string>
<string name="diag_relay_tools_not_paired">未ペアリング</string>
<string name="diag_check_relay_server">中継サーバーを確認する</string>
<string name="diag_check_relay_plugin">Relay プラグイン</string>
<string name="diag_plugin_not_configured">オプションのプラグインが設定されていません</string>
@@ -3355,8 +3360,8 @@
<string name="inbound_attach_cd_cancel">キャンセル</string>
<string name="inbound_attach_open_failed">添付ファイルを開けませんでした</string>
<string name="inbound_attach_share_failed">添付ファイルを共有できませんでした</string>
<string name="injected_context_media_no_relay">メディアにはアクティブな Relay 接続が必要です</string>
<string name="injected_context_media_relay_active">Relay はメディア向けにアクティブです</string>
<string name="injected_context_media_no_relay">追加のメディア指示なし — 標準配信は引き続きサーバーが担当します</string>
<string name="injected_context_media_relay_active">Relay メディア拡張を利用できます</string>
<string name="injected_context_media_title">メディア共有</string>
<string name="injected_context_persona_not_set">ペルソナ設定なし</string>
<string name="injected_context_persona_server_side">サーバー側のペルソナ</string>
@@ -4484,7 +4489,7 @@
<string name="chat_git_deletions">%1$d 件の削除</string>
<plurals name="chat_git_change_count"><item quantity="other">%1$d 件の変更</item></plurals>
<string name="settings_git_workspace">Git ワークスペース</string>
<string name="settings_git_workspace_desc">変更、ブランチ、コミット、リモートを確認</string>
<string name="settings_git_workspace_desc">現在のセッションの Git を優先し、必要に応じてホストを検出</string>
<string name="current_chat_activity_title">現在のチャットのアクティビティ</string>
<string name="current_chat_activity_subtitle">このチャットからの読み取り専用ライブ詳細</string>
<string name="current_chat_activity_open">現在のチャットのアクティビティを表示</string>
@@ -4519,7 +4524,7 @@
<string name="agent_activity_child_role_task">タスク</string>
<string name="agent_activity_child_role_agent">エージェント</string>
<string name="agent_activity_child_role_system">システム</string>
<string name="settings_git_workspace_off_desc">オフ · ホストのリポジトリスキャンはオプトインです</string>
<string name="settings_git_workspace_off_desc">セッションのリポジトリのみ · ホスト検出はオプトインです</string>
<string name="active_section_available_fallback">Available fallback</string>
<string name="active_section_available_fallback_mechanism">Available fallback · %1$s</string>
<string name="active_section_in_use">In use</string>
+15 -10
View File
@@ -686,8 +686,8 @@
<string name="settings_voice_mode">Режим голоса</string>
<string name="settings_voice_mode_desc">Голос панели управления, опции Relay в реальном времени, поставщики</string>
<string name="settings_threads">Потоки</string>
<string name="settings_threads_desc">Позволяет агенту начинать разговоры с вами (по умолчанию выключено)</string>
<string name="settings_power_tools">Мощные инструменты</string>
<string name="settings_threads_desc">Разговоры, начатые Relay, и проактивная доставка (по умолчанию выключено)</string>
<string name="settings_power_tools">Инструменты Relay</string>
<string name="settings_terminal">Терминал</string>
<string name="settings_terminal_desc">Доступ к оболочке сервера через сопряженный сеанс Relay</string>
<string name="settings_bridge">Мост</string>
@@ -699,7 +699,7 @@
<string name="settings_notification_companion">Сопровождение уведомлений</string>
<string name="settings_notification_companion_desc">Общие уведомления телефона для сопряженных инструментов Relay</string>
<string name="settings_media">Медиа</string>
<string name="settings_media_desc">Передача входящих вложений, автозагрузка, кэш</string>
<string name="settings_media_desc">Вложения чата, загрузка, конфиденциальность и кэш</string>
<string name="settings_bridge_safety">Безопасность моста</string>
<string name="settings_bridge_safety_desc">Черный список, подтверждение деструктивных действий, автоотключение</string>
<string name="settings_sideload">Установка из APK</string>
@@ -1233,8 +1233,8 @@
<string name="about_credits">Axiom Labs ❤️ Агент Гермес · Nous Research</string>
<string name="media_title">Медиа</string>
<string name="media_back">Назад</string>
<string name="media_intro_1">Управляет тем, как приложение обрабатывает файлы, отправленные результатами инструментов (скриншоты, PDF и т. д.) через Relay.</string>
<string name="media_intro_2">Только Relay — эти настройки не влияют на изображения, которые вы прикрепляете в чате, или что-либо на стандартном (без Relay) соединении.</string>
<string name="media_intro_1">Определяет, как приложение скачивает, защищает и кэширует файлы, отправленные Hermes в чате.</string>
<string name="media_intro_2">Предпочтение отдаётся стандартным медиа Dashboard. После сопряжения Relay может добавить совместимую доставку и метаданные конфиденциальности.</string>
<string name="media_max_inbound">Максимальный размер входящего вложения</string>
<string name="media_max_inbound_value">%1$d МБ</string>
<string name="media_max_inbound_desc">Файлы, превышающие этот размер, отклоняются после загрузки.</string>
@@ -2185,7 +2185,8 @@
<string name="session_path_signin_for_gateway">Войдите в систему под управлением для шлюза живого мышления.</string>
<string name="session_path_gateway_api_server">Доступно на транспортном шлюзе — этот сеанс передается через сервер API.</string>
<string name="session_path_relay_not_connected">Relay сопряжён, но не подключен.</string>
<string name="session_path_pair_relay_media">Сопрягите Relay для отправки и получения медиа.</string>
<string name="session_path_pair_relay_media">Сопрягите Relay для совместимости медиа со старыми хостами Hermes.</string>
<string name="session_path_media_not_ready">Обновите Hermes для стандартной доставки медиа или сопрягите Relay для совместимости.</string>
<string name="session_path_pair_relay_terminal">Сопрягите Relay для доступа к терминалу.</string>
<string name="session_path_voice_not_ready">Голос не готов на этом подключении.</string>
<string name="session_path_threads_pair_relay">Сопрягите Relay и включите &quot;Позволить Гермесу отправлять мне сообщения&quot;, чтобы агент мог открывать потоки.</string>
@@ -2782,6 +2783,8 @@
<string name="inbound_attach_downloading">Скачивание\&amp;#8230;%1$s</string>
<string name="inbound_attach_failed">Ошибка вложения</string>
<string name="inbound_attach_tap_retry">Нажмите для повторной попытки</string>
<string name="inbound_attach_host_only">Файл находится на вашем хосте Hermes</string>
<string name="inbound_attach_host_only_help">Обновите Hermes для стандартных загрузок или сопрягите Relay для совместимости.</string>
<string name="inbound_attach_open">Открыть внешне</string>
<string name="inbound_attach_share">Поделиться</string>
<string name="inbound_attach_save">Сохранить на устройство</string>
@@ -3187,6 +3190,8 @@
<string name="diag_check_ready_with">Готово с %s</string>
<string name="diag_check_relay_active">Relay активен</string>
<string name="diag_check_relay_not_configured">Relay не настроен</string>
<string name="diag_relay_tools_optional">Инструменты Relay (необязательно)</string>
<string name="diag_relay_tools_not_paired">Не сопряжено</string>
<string name="diag_check_relay_server">Проверить сервер Relay</string>
<string name="diag_check_relay_plugin">Плагин Relay</string>
<string name="diag_plugin_not_configured">Дополнительный плагин не настроен</string>
@@ -3218,8 +3223,8 @@
<string name="inbound_attach_cd_cancel">Отмена</string>
<string name="inbound_attach_open_failed">Не удалось открыть вложение</string>
<string name="inbound_attach_share_failed">Не удалось поделиться вложением</string>
<string name="injected_context_media_no_relay">Для мультимедиа требуется активное подключение Relay</string>
<string name="injected_context_media_relay_active">плагин Relay активен для мультимедиа</string>
<string name="injected_context_media_no_relay">Нет дополнительных инструкций для медиа — стандартная доставка остаётся на стороне сервера</string>
<string name="injected_context_media_relay_active">Доступно расширение Relay для медиа</string>
<string name="injected_context_media_title">Обмен мультимедиа</string>
<string name="injected_context_persona_not_set">Персона не установлена</string>
<string name="injected_context_persona_server_side">Персона на стороне сервера</string>
@@ -4228,7 +4233,7 @@
<string name="chat_git_deletions">Удалено строк: %1$d</string>
<plurals name="chat_git_change_count"><item quantity="one">%1$d изменение</item><item quantity="few">%1$d изменения</item><item quantity="many">%1$d изменений</item><item quantity="other">%1$d изменения</item></plurals>
<string name="settings_git_workspace">Рабочая область Git</string>
<string name="settings_git_workspace_desc">Изменения, ветки, коммиты и удалённые репозитории</string>
<string name="settings_git_workspace_desc">Сначала Git текущего сеанса, с дополнительным обнаружением на хосте</string>
<string name="current_chat_activity_title">Текущая активность чата</string>
<string name="current_chat_activity_subtitle">Доступные только для чтения сведения в реальном времени из этого чата</string>
<string name="current_chat_activity_open">Просмотреть текущую активность чата</string>
@@ -4263,7 +4268,7 @@
<string name="agent_activity_child_role_task">Задача</string>
<string name="agent_activity_child_role_agent">Агент</string>
<string name="agent_activity_child_role_system">Система</string>
<string name="settings_git_workspace_off_desc">Выкл. · Сканирование репозиториев на хосте включается вручную</string>
<string name="settings_git_workspace_off_desc">Только репозиторий сеанса · Обнаружение на хосте включается вручную</string>
<string name="active_section_available_fallback">Available fallback</string>
<string name="active_section_available_fallback_mechanism">Available fallback · %1$s</string>
<string name="active_section_in_use">In use</string>
+15 -10
View File
@@ -755,8 +755,8 @@
<string name="settings_voice_mode">Voice mode</string>
<string name="settings_voice_mode_desc">Dashboard voice, realtime relay options, providers</string>
<string name="settings_threads">Threads</string>
<string name="settings_threads_desc">Let the agent start conversations with you (off by default)</string>
<string name="settings_power_tools">Power tools</string>
<string name="settings_threads_desc">Relay-started conversations and proactive delivery (off by default)</string>
<string name="settings_power_tools">Relay tools</string>
<string name="settings_terminal">Terminal</string>
<string name="settings_terminal_desc">Server shell access through a paired relay session</string>
<string name="settings_bridge">Bridge</string>
@@ -768,7 +768,7 @@
<string name="settings_notification_companion">Notification companion</string>
<string name="settings_notification_companion_desc">Shared phone notifications for paired relay tools</string>
<string name="settings_media">Media</string>
<string name="settings_media_desc">Relay inbound attachments, auto-fetch, cache cap</string>
<string name="settings_media_desc">Chat attachments, download behavior, privacy, and cache</string>
<string name="settings_bridge_safety">Bridge safety</string>
<string name="settings_bridge_safety_desc">Blocklist, destructive-verb confirmation, auto-disable</string>
<string name="settings_sideload">Sideload</string>
@@ -1383,8 +1383,8 @@
<!-- P1: MediaSettingsScreen -->
<string name="media_title">Media</string>
<string name="media_back">Back</string>
<string name="media_intro_1">Controls how the app handles files sent by tool results (screenshots, PDFs, etc.) over the relay.</string>
<string name="media_intro_2">Relay only — these settings don\'t affect images you attach in chat or anything on a standard (no-Relay) connection.</string>
<string name="media_intro_1">Controls how the app downloads, protects, and caches files Hermes sends in chat.</string>
<string name="media_intro_2">Standard Dashboard media is preferred. When paired, Relay can add compatibility delivery and sensitivity metadata.</string>
<string name="media_max_inbound">Max inbound attachment size</string>
<string name="media_max_inbound_value">%1$d MB</string>
<string name="media_max_inbound_desc">Files larger than this are rejected after download.</string>
@@ -2513,7 +2513,8 @@
<string name="session_path_signin_for_gateway">Sign in under Manage for the live-thinking gateway.</string>
<string name="session_path_gateway_api_server">Available on the gateway transport — this session streams over the API server.</string>
<string name="session_path_relay_not_connected">Relay paired but not connected.</string>
<string name="session_path_pair_relay_media">Pair the relay to send and receive media.</string>
<string name="session_path_pair_relay_media">Pair Relay for media compatibility on older Hermes hosts.</string>
<string name="session_path_media_not_ready">Update Hermes for standard media delivery, or pair Relay for compatibility.</string>
<string name="session_path_pair_relay_terminal">Pair the relay for terminal access.</string>
<string name="session_path_voice_not_ready">Voice not ready on this connection.</string>
<string name="session_path_threads_pair_relay">Pair the relay and turn on "Let Hermes message me" so the agent can open Threads.</string>
@@ -3161,6 +3162,8 @@
<string name="inbound_attach_downloading">Downloading&#8230;%1$s</string>
<string name="inbound_attach_failed">Attachment failed</string>
<string name="inbound_attach_tap_retry">Tap to retry</string>
<string name="inbound_attach_host_only">File is on your Hermes host</string>
<string name="inbound_attach_host_only_help">Update Hermes for standard downloads, or pair Relay for compatibility.</string>
<string name="inbound_attach_open">Open externally</string>
<string name="inbound_attach_share">Share</string>
<string name="inbound_attach_save">Save to device</string>
@@ -3689,6 +3692,8 @@
<string name="diag_check_ready_with">Ready with %s</string>
<string name="diag_check_relay_active">Relay active</string>
<string name="diag_check_relay_not_configured">Relay not configured</string>
<string name="diag_relay_tools_optional">Relay tools (optional)</string>
<string name="diag_relay_tools_not_paired">Not paired</string>
<string name="diag_check_relay_server">Check relay server</string>
<string name="diag_check_relay_plugin">Relay plugin</string>
<string name="diag_plugin_not_configured">Optional plugin is not configured</string>
@@ -3720,8 +3725,8 @@
<string name="inbound_attach_cd_cancel">Cancel</string>
<string name="inbound_attach_open_failed">Couldn\u0027t open attachment</string>
<string name="inbound_attach_share_failed">Couldn\u0027t share attachment</string>
<string name="injected_context_media_no_relay">Media requires an active Relay connection</string>
<string name="injected_context_media_relay_active">Relay active for media</string>
<string name="injected_context_media_no_relay">No extra media instruction — standard delivery stays server-owned</string>
<string name="injected_context_media_relay_active">Relay media enhancement available</string>
<string name="injected_context_media_title">Media sharing</string>
<string name="injected_context_persona_not_set">No persona set</string>
<string name="injected_context_persona_server_side">Server-side persona</string>
@@ -4586,6 +4591,6 @@
<string name="provider_usage_capability_basic_title">Basic usage from Hermes</string>
<string name="provider_usage_capability_basic_body">Install or update the Relay plugin for credential pools, structured Nous balances, and OpenCode Go.</string>
<string name="settings_git_workspace">Git workspace</string>
<string name="settings_git_workspace_desc">Review changes, branches, commits, and remotes</string>
<string name="settings_git_workspace_off_desc">Off · Host repository scanning is opt-in</string>
<string name="settings_git_workspace_desc">Current-session Git first, with optional host discovery</string>
<string name="settings_git_workspace_off_desc">Session repository only · Host discovery is opt-in</string>
</resources>
@@ -97,6 +97,70 @@ class VoicePreferencesRepositoryTest {
repository.setStopPhrases(emptyList())
assertTrue(repository.settings.first().stopPhrases.isEmpty())
}
@Test
fun relayRemoval_normalizesOnlyRelayOwnedSelectionsInExpectedScope() = runTest {
repository.setActiveScope("connection-a", "coder")
repository.setEngineMode(VoiceEngineMode.RealtimeAgent)
repository.setAudioRoute(VoiceAudioRoute.Relay)
val scope = repository.activeScope.value
assertTrue(repository.reconcileRelayRemoval(scope))
val settings = repository.settings.first()
assertEquals(VoiceEngineMode.HermesVoiceOutput.storageValue, settings.engineMode)
assertEquals(VoiceAudioRoute.Auto.storageValue, settings.audioRoute)
assertFalse(repository.reconcileRelayRemoval(scope))
}
@Test
fun relayRemoval_doesNotMutateAProfileThatNoLongerOwnsTheScope() = runTest {
repository.setActiveScope("connection-a", "coder")
repository.setEngineMode(VoiceEngineMode.RealtimeAgent)
repository.setAudioRoute(VoiceAudioRoute.Relay)
val staleScope = repository.activeScope.value
repository.setActiveScope("connection-a", "writer")
assertFalse(repository.reconcileRelayRemoval(staleScope))
repository.setActiveScope("connection-a", "coder")
val settings = repository.settings.first()
assertEquals(VoiceEngineMode.RealtimeAgent.storageValue, settings.engineMode)
assertEquals(VoiceAudioRoute.Relay.storageValue, settings.audioRoute)
}
@Test
fun relayRemoval_doesNotRewriteGlobalDefaultSelectionSharedByAnotherConnection() = runTest {
repository.setActiveScope("connection-a", null)
repository.setEngineMode(VoiceEngineMode.RealtimeAgent)
repository.setAudioRoute(VoiceAudioRoute.Relay)
assertFalse(repository.reconcileRelayRemoval(repository.activeScope.value))
repository.setActiveScope("connection-b", null)
val settings = repository.settings.first()
assertEquals(VoiceEngineMode.RealtimeAgent.storageValue, settings.engineMode)
assertEquals(VoiceAudioRoute.Relay.storageValue, settings.audioRoute)
}
@Test
fun relayRemoval_normalizesNamedProfileWithoutChangingSameProfileOnAnotherConnection() = runTest {
repository.setActiveScope("connection-a", "coder")
repository.setEngineMode(VoiceEngineMode.RealtimeAgent)
repository.setAudioRoute(VoiceAudioRoute.Relay)
repository.setActiveScope("connection-b", "coder")
repository.setEngineMode(VoiceEngineMode.RealtimeAgent)
repository.setAudioRoute(VoiceAudioRoute.Relay)
repository.setActiveScope("connection-a", "coder")
assertTrue(repository.reconcileRelayRemoval(repository.activeScope.value))
repository.setActiveScope("connection-b", "coder")
val settings = repository.settings.first()
assertEquals(VoiceEngineMode.RealtimeAgent.storageValue, settings.engineMode)
assertEquals(VoiceAudioRoute.Relay.storageValue, settings.audioRoute)
}
}
private class InMemoryVoicePreferencesDataStore : DataStore<Preferences> {
@@ -0,0 +1,47 @@
package com.hermesandroid.relay.network.relay
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class RelayHttpClientMediaDownloadTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun fetchMediaByPathStopsChunkedResponseAtCallerLimit() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/octet-stream")
.setChunkedBody("x".repeat(64), 8),
)
val client = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = {
server.url("/").toString().replaceFirst("http://", "ws://").trimEnd('/')
},
sessionTokenProvider = { "paired-session" },
)
val result = client.fetchMediaByPath("/tmp/large.bin", maxBytes = 16)
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull()?.message.orEmpty().contains("download limit"))
assertEquals("/media/by-path", server.takeRequest().requestUrl?.encodedPath)
}
}
@@ -1530,6 +1530,42 @@ class ChatHandlerTest {
assertTrue(handler.messages.value.single().attachments.isEmpty())
}
@Test
fun loadMessageHistory_carriesHydratedMarkerAttachmentWithoutReloading() {
var requestCount = 0
val path = "/tmp/voice-note.mp3"
handler.onMediaBarePathRequested = { messageId, requestedPath ->
requestCount++
handler.mutateMessage(messageId) { message ->
message.copy(
attachments = message.attachments + Attachment(
contentType = "audio/mpeg",
content = "",
fileName = "voice-note.mp3",
relayToken = requestedPath,
cachedUri = "content://media/voice-note.mp3",
state = AttachmentState.LOADED,
),
)
}
}
val history = listOf(
MessageItem(
id = "assistant-media",
role = "assistant",
content = JsonPrimitive("Voice note\nMEDIA:$path"),
),
)
handler.loadMessageHistory(history)
handler.loadMessageHistory(history)
val attachment = handler.messages.value.single().attachments.single()
assertEquals(1, requestCount)
assertEquals(AttachmentState.LOADED, attachment.state)
assertEquals("content://media/voice-note.mp3", attachment.cachedUri)
}
@Test
fun loadMessageHistory_preservesCompletedGeneratedImageUntilMarkerPersists() {
handler.addPlaceholderMessage(
@@ -2032,6 +2032,61 @@ class DashboardApiClientTest {
assertEquals("/api/messaging/whatsapp/onboarding/pair-1/apply", apply.requestUrl!!.encodedPath)
assertTrue(apply.body.readUtf8().contains(""""profile":"worker""""))
}
@Test
fun downloadManagedFile_usesUpstreamPathRouteAndPreservesMetadata() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "audio/mpeg")
.setHeader("Content-Disposition", "attachment; filename*=UTF-8''voice%20reply.mp3")
.setBody("audio-bytes"),
)
val fetched = DashboardApiClient(baseUrl = server.url("/").toString())
.downloadManagedFile("/tmp/Hermes audio/voice reply.mp3", 1024)
.getOrThrow()
assertEquals("audio/mpeg", fetched.contentType)
assertEquals("voice reply.mp3", fetched.fileName)
assertEquals("audio-bytes", fetched.bytes.decodeToString())
val request = server.takeRequest()
assertEquals("/api/files/download", request.requestUrl!!.encodedPath)
assertEquals("/tmp/Hermes audio/voice reply.mp3", request.requestUrl!!.queryParameter("path"))
}
@Test
fun downloadManagedFile_rejectsDeclaredLengthAboveCallerCap() = runTest {
server.enqueue(MockResponse().setHeader("Content-Length", 2048))
val result = DashboardApiClient(baseUrl = server.url("/").toString())
.downloadManagedFile("/tmp/large.bin", 1024)
assertTrue(result.isFailure)
}
@Test
fun managedFileFallbackClassificationDistinguishesMissingRouteFromMissingFile() {
assertTrue(
DashboardHttpException(
404,
"Dashboard media download failed - HTTP 404: {\"detail\":\"Not Found\"}",
).isDashboardManagedFilesUnsupported(),
)
assertFalse(
DashboardHttpException(
404,
"Dashboard media download failed - HTTP 404: {\"detail\":\"File not found\"}",
).isDashboardManagedFilesUnsupported(),
)
assertFalse(
DashboardHttpException(403, "Access to sensitive files is not allowed")
.isDashboardManagedFilesUnsupported(),
)
assertFalse(
DashboardHttpException(500, "Managed file read failed")
.isDashboardManagedFilesUnsupported(),
)
}
}
private fun messagePageResponse(
@@ -227,6 +227,13 @@ class GatewayClientHarness(
@Volatile
var profileGetAssetPayload: JsonObject = buildJsonObject { put("found", false) }
@Volatile
var usageBarsPayload: JsonObject = buildJsonObject {
put("ok", true)
put("available", true)
put("plan_name", "Pro")
}
/** Methods answered with JSON-RPC -32601 — exercises the legacy-name fallback. */
val methodNotFound: MutableSet<String> = ConcurrentHashMap.newKeySet()
@@ -436,6 +443,7 @@ class GatewayClientHarness(
"profiles.list" -> profilesListPayload
"profiles.create" -> profileCreatePayload
"profiles.get_asset" -> profileGetAssetPayload
"usage.bars" -> usageBarsPayload
"profiles.set_asset" -> buildJsonObject {
put("ok", true)
put("asset", "avatar")
@@ -772,6 +780,7 @@ class GatewayChatClientTest {
val moaReferences = ConcurrentLinkedQueue<GatewayMoaReference>()
val usages = ConcurrentLinkedQueue<UsageInfo>()
val reconcileRequests = AtomicInteger(0)
val completions = AtomicInteger(0)
val completeLatch = CountDownLatch(1)
val preflightFailures = ConcurrentLinkedQueue<String>()
@@ -785,7 +794,7 @@ class GatewayChatClientTest {
onToolCallFailed = { _, _ -> },
onTurnComplete = { },
onReconcileRequired = { reconcileRequests.incrementAndGet() },
onComplete = { completeLatch.countDown() },
onComplete = { completions.incrementAndGet(); completeLatch.countDown() },
onUsage = { it?.let(usages::add) },
onError = { errors += it; completeLatch.countDown() },
onToolGenerating = { toolGenerating += it ?: "" },
@@ -839,6 +848,21 @@ class GatewayChatClientTest {
assertTrue("condition did not settle within ${timeoutMs}ms", condition())
}
private fun exactActiveSessionPayload(
status: String,
liveSessionId: String = "live-1",
storedSessionId: String = "20260612_120000_abc123",
): JsonObject = buildJsonObject {
put("sessions", buildJsonArray {
add(buildJsonObject {
put("id", liveSessionId)
put("session_key", storedSessionId)
put("status", status)
put("last_active", 1_777_000_000.0)
})
})
}
/**
* Swap in a client with shortened timeout seams. Mints a FRESH scope:
* shutdown() cancels the scope's Job, and the replacement client must
@@ -888,6 +912,21 @@ class GatewayChatClientTest {
harness.shutdown()
}
@Test
fun `provider usage calls official upstream usage bars method`() = runBlocking {
harness.usageBarsPayload = buildJsonObject {
put("ok", true)
put("available", true)
put("plan_name", "Pro")
}
val response = client.usageBars().getOrThrow()
assertEquals("Pro", (response["plan_name"] as? JsonPrimitive)?.contentOrNull)
assertEquals("usage.bars", harness.rpcLog.last().first)
assertTrue(harness.rpcLog.none { it.first == "account.usage" })
}
@Test
fun `profile editor describes exact profile and maps upstream shape`() = runBlocking {
val description = client.describeProfile("operator").getOrThrow()
@@ -3449,6 +3488,42 @@ class GatewayChatClientTest {
)
}
@Test
fun `session info exposes exact model callable tool catalog`() {
val recorder = Recorder()
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
recorder.preflightFailures += it
}
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
serverWs.send(
harness.eventFrame(
"session.info",
buildJsonObject {
put("tools", buildJsonObject {
put("android", buildJsonArray {
add(JsonPrimitive("android_phone_status"))
add(JsonPrimitive("android_tap"))
})
put("terminal", buildJsonArray { add(JsonPrimitive("terminal")) })
})
},
"live-resumed",
),
)
waitUntil { client.serverTools.value?.size == 3 }
assertEquals(
setOf("android_phone_status", "android_tap", "terminal"),
client.serverTools.value,
)
client.clearSession()
assertNull(client.serverTools.value)
}
@Test
fun `session info without provider clears prior session identity`() {
val recorder = Recorder()
@@ -4714,6 +4789,180 @@ class GatewayChatClientTest {
assertTrue(harness.ticketMints.get() >= 2)
}
@Test
fun `active session idle settles exact Android turn without interrupt`() = runBlocking {
val recorder = Recorder()
client.sendTurn(null, "finish without terminal", null, recorder.callbacks) {
recorder.preflightFailures += it
}
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
serverWs.send(harness.eventFrame("message.start", null, "live-1"))
serverWs.send(
harness.eventFrame(
"message.delta",
buildJsonObject { put("text", "durable partial") },
"live-1",
),
)
awaitCondition { recorder.textDeltas.isNotEmpty() }
harness.activeSessionListPayload = exactActiveSessionPayload("idle")
assertTrue(client.listActiveSessions() is GatewayActiveSessionsResult.Success)
assertTrue("idle snapshot did not settle turn", recorder.completeLatch.await(5, TimeUnit.SECONDS))
assertEquals(1, recorder.completions.get())
assertEquals(1, recorder.reconcileRequests.get())
assertTrue(recorder.errors.isEmpty())
assertTrue(harness.rpcLog.none { it.first == "session.interrupt" })
}
@Test
fun `active session idle never settles passively observed turn`() = runBlocking {
val recorder = Recorder()
client.setUnsolicitedTurnProvider {
GatewayInboundTurnRegistration(recorder.callbacks) { true }
}
assertTrue(client.prewarmAwait("stored-session"))
val serverWs = harness.awaitServerSocket()
serverWs.send(harness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(
harness.eventFrame(
"message.delta",
buildJsonObject { put("text", "desktop-owned") },
"live-resumed",
),
)
awaitCondition { recorder.textDeltas.isNotEmpty() }
harness.activeSessionListPayload = exactActiveSessionPayload(
status = "idle",
liveSessionId = "live-resumed",
storedSessionId = "stored-session",
)
client.listActiveSessions()
assertFalse(recorder.completeLatch.await(250, TimeUnit.MILLISECONDS))
assertTrue(harness.rpcLog.none { it.first == "session.interrupt" })
serverWs.send(
harness.eventFrame(
"message.complete",
buildJsonObject { put("text", "desktop-owned") },
"live-resumed",
),
)
assertTrue(recorder.completeLatch.await(5, TimeUnit.SECONDS))
}
@Test
fun `stale active session snapshot cannot settle newer turn generation`() = runBlocking {
val first = Recorder()
client.sendTurn(null, "first", null, first.callbacks) { first.preflightFailures += it }
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
serverWs.send(harness.eventFrame("message.start", null, "live-1"))
serverWs.send(
harness.eventFrame("message.delta", buildJsonObject { put("text", "first") }, "live-1"),
)
awaitCondition { first.textDeltas.isNotEmpty() }
harness.suppressAckMethods += "session.active_list"
val staleSnapshot = scope.async { client.listActiveSessions() }
val staleAck = harness.awaitPendingAck()
serverWs.send(
harness.eventFrame(
"message.complete",
buildJsonObject { put("text", "first") },
"live-1",
),
)
assertTrue(first.completeLatch.await(5, TimeUnit.SECONDS))
val second = Recorder()
client.sendTurn(
"20260612_120000_abc123",
"second",
null,
second.callbacks,
) { second.preflightFailures += it }
harness.awaitRpcCount("prompt.submit", 2)
serverWs.send(harness.eventFrame("message.start", null, "live-1"))
serverWs.send(
harness.eventFrame("message.delta", buildJsonObject { put("text", "second") }, "live-1"),
)
awaitCondition { second.textDeltas.isNotEmpty() }
harness.releaseAck(staleAck, exactActiveSessionPayload("idle"))
assertTrue(staleSnapshot.await() is GatewayActiveSessionsResult.Success)
assertFalse(second.completeLatch.await(250, TimeUnit.MILLISECONDS))
assertEquals(0, second.reconcileRequests.get())
serverWs.send(
harness.eventFrame(
"message.complete",
buildJsonObject { put("text", "second") },
"live-1",
),
)
assertTrue(second.completeLatch.await(5, TimeUnit.SECONDS))
}
@Test
fun `cancellation wins over delayed active session idle snapshot`() = runBlocking {
val recorder = Recorder()
val handle = client.sendTurn(null, "cancel me", null, recorder.callbacks) {
recorder.preflightFailures += it
}
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
serverWs.send(harness.eventFrame("message.start", null, "live-1"))
serverWs.send(
harness.eventFrame("message.delta", buildJsonObject { put("text", "partial") }, "live-1"),
)
awaitCondition { recorder.textDeltas.isNotEmpty() }
harness.suppressAckMethods += "session.active_list"
val delayedSnapshot = scope.async { client.listActiveSessions() }
val delayedAck = harness.awaitPendingAck()
handle.cancel()
harness.awaitRpc("session.interrupt")
harness.releaseAck(delayedAck, exactActiveSessionPayload("idle"))
assertTrue(delayedSnapshot.await() is GatewayActiveSessionsResult.Success)
assertEquals(0, recorder.completions.get())
assertEquals(0, recorder.reconcileRequests.get())
}
@Test
fun `late terminal after active session settle is consumed once`() = runBlocking {
val recorder = Recorder()
val unmatched = ConcurrentLinkedQueue<GatewayBackgroundTurnCompletion>()
client.setUnmatchedTurnCompleteListener(unmatched::add)
client.sendTurn(null, "late terminal", null, recorder.callbacks) {
recorder.preflightFailures += it
}
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
serverWs.send(harness.eventFrame("message.start", null, "live-1"))
serverWs.send(
harness.eventFrame("message.delta", buildJsonObject { put("text", "done") }, "live-1"),
)
awaitCondition { recorder.textDeltas.isNotEmpty() }
harness.activeSessionListPayload = exactActiveSessionPayload("idle")
client.listActiveSessions()
assertTrue(recorder.completeLatch.await(5, TimeUnit.SECONDS))
serverWs.send(
harness.eventFrame(
"message.complete",
buildJsonObject { put("text", "done") },
"live-1",
),
)
Thread.sleep(150)
assertEquals(1, recorder.completions.get())
assertTrue(unmatched.isEmpty())
}
@Test
fun `idle watchdog does not fire while events keep arriving slowly`() {
rebuildClient(turnIdleTimeoutMs = 1_000L)
@@ -34,6 +34,8 @@ class GatewayEventMapperTest {
val failures = mutableListOf<GatewayTurnFailure>()
val statusUpdates = mutableListOf<Pair<String?, String>>()
val statusClears = mutableListOf<String>()
val notices = mutableListOf<GatewayAgentNotice>()
val noticeClears = mutableListOf<String>()
val sessionIds = mutableListOf<String>()
var starts = 0
var turnCompletes = 0
@@ -70,6 +72,8 @@ class GatewayEventMapperTest {
onFailure = { failures += it },
onStatusUpdate = { kind, text -> statusUpdates += kind to text },
onStatusClear = { statusClears += it },
onNoticeShow = { notices += it },
onNoticeClear = { noticeClears += it },
)
}
@@ -123,6 +127,45 @@ class GatewayEventMapperTest {
assertTrue(r.statusUpdates.isEmpty())
}
@Test
fun `official notice show and clear preserve exact keyed contract`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent(
"notification.show",
obj(
"""{"text":"⚠ Credits depleted","level":"warn","kind":"sticky","ttl_ms":null,"key":"credits.depleted","id":"notice-1"}""",
),
)
mapper.onEvent("notification.clear", obj("""{"key":"credits.depleted"}"""))
assertEquals(
GatewayAgentNotice(
text = "⚠ Credits depleted",
level = "warn",
kind = "sticky",
ttlMs = null,
key = "credits.depleted",
id = "notice-1",
),
r.notices.single(),
)
assertEquals(listOf("credits.depleted"), r.noticeClears)
}
@Test
fun `malformed official notices remain forward compatible no ops`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("notification.show", obj("""{"level":"info"}"""))
mapper.onEvent("notification.clear", obj("""{"key":" "}"""))
assertTrue(r.notices.isEmpty())
assertTrue(r.noticeClears.isEmpty())
}
@Test
fun `compaction status clears on resumed model tool and MoA activity only`() {
listOf(
@@ -0,0 +1,116 @@
package com.hermesandroid.relay.network.usage
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ProviderUsageRepositoryTest {
@Test
fun mapsOfficialUpstreamUsageBars() {
val response = providerUsageFromUpstreamBars(buildJsonObject {
put("ok", true)
put("available", true)
put("plan_name", "Pro")
put("renews_at", "2026-09-15T00:00:00Z")
put("subscription_remaining_display", "\$12.50")
put("topup_remaining_display", "\$3.00")
put("total_spendable_display", "\$15.50")
put("plan_bar", buildJsonObject {
put("remaining_display", "\$12.50")
put("total_display", "\$20.00")
put("pct_used", 37.5)
})
put("topup_bar", buildJsonObject {
put("remaining_display", "\$3.00")
put("total_display", "\$5.00")
put("pct_used", 40.0)
})
})!!
val nous = response.providers.single()
assertEquals("nous", nous.id)
assertEquals("upstream:usage.bars", nous.source)
assertEquals("Pro", nous.plan)
assertEquals("2026-09-15T00:00:00Z", nous.renewsAt)
assertEquals(37.5, nous.windows.first().usedPercent!!, 0.001)
assertEquals("\$12.50 remaining of \$20.00", nous.windows.first().detail)
assertEquals(3, nous.details.size)
}
@Test
fun unavailableUpstreamUsageIsCapabilityAbsence() {
assertNull(providerUsageFromUpstreamBars(buildJsonObject {
put("ok", true)
put("available", false)
}))
}
@Test
fun relayEnhancementAddsProvidersAndEnrichesNous() {
val upstream = ProviderUsageResponse(
providers = listOf(
ProviderUsageProvider(
id = "nous",
displayName = "Nous",
status = ProviderUsageProvider.STATUS_AVAILABLE,
source = "upstream:usage.bars",
plan = "Pro",
windows = listOf(ProviderUsageWindow("plan", "Plan", usedPercent = 25.0)),
details = listOf("Total spendable: \$10.00"),
),
),
)
val enhanced = ProviderUsageResponse(
schemaVersion = 2,
capabilities = ProviderUsageResponse.RELAY_ENHANCED_CAPABILITIES,
providers = listOf(
ProviderUsageProvider(
id = "nous",
displayName = "Nous",
status = ProviderUsageProvider.STATUS_AVAILABLE,
source = "relay",
balances = listOf(ProviderUsageBalance("total", "Total usable", 10.0)),
),
ProviderUsageProvider(
id = "openai-codex",
displayName = "Codex",
status = ProviderUsageProvider.STATUS_AVAILABLE,
),
),
)
val merged = mergeProviderUsage(upstream, enhanced)!!
assertTrue(merged.relayEnhanced)
assertEquals(listOf("nous", "openai-codex"), merged.providers.map { it.id })
val nous = merged.providers.first()
assertEquals("Pro", nous.plan)
assertEquals(25.0, nous.windows.single().usedPercent!!, 0.001)
assertEquals(10.0, nous.balances.single().amount, 0.001)
assertEquals(listOf("Total spendable: \$10.00"), nous.details)
}
@Test
fun unavailableEnhancementDoesNotReplaceAvailableUpstream() {
val upstreamProvider = ProviderUsageProvider(
id = "nous",
displayName = "Nous",
status = ProviderUsageProvider.STATUS_AVAILABLE,
source = "upstream:usage.bars",
)
val merged = mergeProviderUsage(
ProviderUsageResponse(providers = listOf(upstreamProvider)),
ProviderUsageResponse(providers = listOf(
upstreamProvider.copy(
status = ProviderUsageProvider.STATUS_UNAVAILABLE,
source = "relay",
),
)),
)!!
assertEquals("upstream:usage.bars", merged.providers.single().source)
}
}
@@ -16,6 +16,7 @@ class HermesRuntimeReadinessTest {
chatReady = true,
standardAvailability = StandardVoiceAvailability.Ready,
relayReady = false,
relayConfigured = false,
profileSettled = true,
)
@@ -32,6 +33,7 @@ class HermesRuntimeReadinessTest {
chatReady = true,
standardAvailability = StandardVoiceAvailability.SignInRequired,
relayReady = true,
relayConfigured = true,
profileSettled = true,
)
@@ -45,6 +47,7 @@ class HermesRuntimeReadinessTest {
chatReady = true,
standardAvailability = StandardVoiceAvailability.Ready,
relayReady = true,
relayConfigured = true,
profileSettled = true,
)
@@ -61,6 +64,7 @@ class HermesRuntimeReadinessTest {
chatReady = false,
standardAvailability = StandardVoiceAvailability.Unknown,
relayReady = true,
relayConfigured = true,
profileSettled = false,
)
val ready = resolveVoiceActivationReadiness(
@@ -68,6 +72,7 @@ class HermesRuntimeReadinessTest {
chatReady = false,
standardAvailability = StandardVoiceAvailability.Unknown,
relayReady = true,
relayConfigured = true,
profileSettled = true,
)
@@ -77,4 +82,62 @@ class HermesRuntimeReadinessTest {
ready,
)
}
@Test
fun relayOnlySelections_fallBackToStandardWhenRelayWasRemoved() {
val realtime = resolveVoiceActivationReadiness(
settings = VoiceSettings(engineMode = VoiceEngineMode.RealtimeAgent.storageValue),
chatReady = true,
standardAvailability = StandardVoiceAvailability.Ready,
relayReady = false,
relayConfigured = false,
profileSettled = true,
)
val relayAudio = resolveVoiceActivationReadiness(
settings = VoiceSettings(audioRoute = VoiceAudioRoute.Relay.storageValue),
chatReady = true,
standardAvailability = StandardVoiceAvailability.Ready,
relayReady = false,
relayConfigured = false,
profileSettled = true,
)
assertEquals(
HermesVoiceActivationReadiness.Ready(HermesVoiceActivationRoute.Standard),
realtime,
)
assertEquals(
HermesVoiceActivationReadiness.Ready(HermesVoiceActivationRoute.Standard),
relayAudio,
)
}
@Test
fun configuredRelayOutage_preservesRelayOnlySelections() {
val realtime = resolveVoiceActivationReadiness(
settings = VoiceSettings(engineMode = VoiceEngineMode.RealtimeAgent.storageValue),
chatReady = true,
standardAvailability = StandardVoiceAvailability.Ready,
relayReady = false,
relayConfigured = true,
profileSettled = true,
)
val relayAudio = resolveVoiceActivationReadiness(
settings = VoiceSettings(audioRoute = VoiceAudioRoute.Relay.storageValue),
chatReady = true,
standardAvailability = StandardVoiceAvailability.Ready,
relayReady = false,
relayConfigured = true,
profileSettled = true,
)
assertEquals(
HermesVoiceActivationReadiness.Waiting("Waiting for the Relay realtime route"),
realtime,
)
assertEquals(
HermesVoiceActivationReadiness.Waiting("Waiting for Relay voice"),
relayAudio,
)
}
}
@@ -25,10 +25,10 @@ class ChangelogHistoryScreenshotTest {
ChangelogScreen(onClose = {})
}
}
compose.onNodeWithText("v1.14.0 — Connections, delegated work, Git, and voice").assertExists()
compose.onNodeWithText("v1.15.0 — Standard Hermes first, with clearer Relay boundaries").assertExists()
compose.onNodeWithText("Highlights").assertExists()
compose.onNodeWithText("Fixed").assertExists()
compose.onNodeWithText("Wake-word detection starts reliably").assertExists()
compose.onNodeWithText("Keep Standard voice after removing Relay").assertExists()
compose.onNodeWithText("Compatibility").assertExists()
compose.onNodeWithText("Installed").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/changelog-history.png")
@@ -96,6 +96,40 @@ class GitWorkspaceScreenshotTest {
compose.onRoot().captureRoboImage("build/store-shots/15_git_workspace.png")
}
@Test
fun standardSessionWorkspaceRendersWithRelayDiscoveryOff() {
enqueue("""{"branch":"main","changed":1,"staged":0,"unstaged":1,"untracked":0,"added":3,"removed":1,"files":[{"path":"app.kt","unstaged":true}]}""")
enqueue("""{"branch":"main","changed":1,"staged":0,"unstaged":1,"untracked":0,"added":3,"removed":1,"files":[{"path":"app.kt","unstaged":true}]}""")
enqueue("""{"files":[{"path":"app.kt","added":3,"removed":1,"staged":false}]}""")
enqueue("""{"branches":[{"name":"main","checkedOut":true}]}""")
val app = ApplicationProvider.getApplicationContext<Application>()
val viewModel = GitStateViewModel(app)
viewModel.configure(
DashboardApiClient(server.url("/").toString()),
"standard-owner",
scanningEnabled = false,
)
viewModel.setSessionWorkspace("/srv/projects/standard", null)
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
GitStateScreen(
viewModel = viewModel,
onScanningEnabledChange = {},
onBack = {},
)
}
}
compose.waitUntil(5_000) {
runCatching { compose.onNodeWithText("1 changes").assertExists() }.isSuccess
}
val paths = buildList {
repeat(4) { add(server.takeRequest().path.orEmpty()) }
}
org.junit.Assert.assertTrue(paths.none { it.contains("/api/plugins/") })
}
@Test
fun chatRailMatchesApprovedCompactTreatment() {
compose.setContent {
@@ -61,10 +61,10 @@ class WhatsNewToastScreenshotTest {
}
}
}
compose.onNodeWithText("Connections, delegated work, Git, and voice").assertExists()
compose.onNodeWithText("Also: 2 improvements · 10 fixes").assertExists()
compose.onNodeWithText("Standard Hermes first, with clearer Relay boundaries").assertExists()
compose.onNodeWithText("Also: 2 improvements · 9 fixes").assertExists()
compose.onNodeWithText(
"Release notes stay out of your way, Chat uses one consistent presentation…",
"See which features need Relay, Read the complete release record…",
).assertExists()
compose.onNodeWithText("View all").assertExists()
compose.onNodeWithContentDescription("Close").assertExists()
@@ -86,7 +86,7 @@ class WhatsNewToastScreenshotTest {
}
}
compose.onNodeWithText("Connections, delegated work, Git, and voice").performClick()
compose.onNodeWithText("Standard Hermes first, with clearer Relay boundaries").performClick()
expanded = false
compose.onNodeWithText("View all").performClick()
compose.onNodeWithContentDescription("Close").performClick()
@@ -108,7 +108,7 @@ class WhatsNewToastScreenshotTest {
}
}
compose.mainClock.advanceTimeBy(300L)
compose.onNodeWithText("Connections, delegated work, Git, and voice")
compose.onNodeWithText("Standard Hermes first, with clearer Relay boundaries")
.performTouchInput { swipeLeft(durationMillis = 300L) }
compose.mainClock.advanceTimeBy(300L)
@@ -0,0 +1,50 @@
package com.hermesandroid.relay.ui
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatResponsiveLayoutTest {
@Test
fun compactPhonesKeepExistingUnboundedLayout() {
val layout = chatResponsiveLayout(screenWidthDp = 599)
assertNull(layout.introMaxWidth)
assertNull(layout.avatarSize)
assertNull(layout.chromeMaxWidth)
assertNull(layout.transcriptMaxWidth)
assertNull(layout.focusVoiceMaxWidth)
}
@Test
fun mediumWindowsUseBoundedChatSurfaces() {
val layout = chatResponsiveLayout(screenWidthDp = 600)
assertEquals(600.dp, layout.introMaxWidth)
assertEquals(300.dp, layout.avatarSize)
assertEquals(760.dp, layout.chromeMaxWidth)
assertEquals(760.dp, layout.transcriptMaxWidth)
assertEquals(760.dp, layout.focusVoiceMaxWidth)
}
@Test
fun expandedTabletsUseReadableCenteredRails() {
val layout = chatResponsiveLayout(screenWidthDp = 1280)
assertEquals(720.dp, layout.introMaxWidth)
assertEquals(360.dp, layout.avatarSize)
assertEquals(960.dp, layout.chromeMaxWidth)
assertEquals(960.dp, layout.transcriptMaxWidth)
assertEquals(1120.dp, layout.focusVoiceMaxWidth)
}
@Test
fun focusVoiceSplitsOnlyOnExpandedLandscapeWindows() {
assertTrue(useSplitVoiceLayout(screenWidthDp = 1280, screenHeightDp = 800))
assertFalse(useSplitVoiceLayout(screenWidthDp = 800, screenHeightDp = 1280))
assertFalse(useSplitVoiceLayout(screenWidthDp = 839, screenHeightDp = 600))
}
}
@@ -0,0 +1,58 @@
package com.hermesandroid.relay.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class UiMessageBusTest {
@Test
fun keyedShowReplacesInPlaceAndClearRemovesOnlyExactOwner() {
val unrelated = message(1, "Network ready", key = "network")
val first = message(2, "Half used", key = "credits.usage")
val replacement = message(3, "Almost used", key = "credits.usage")
val replaced = reduceUiMessages(
listOf(unrelated, first),
UiMessageEvent.Show(replacement),
maxRetained = 6,
)
val cleared = reduceUiMessages(
replaced,
UiMessageEvent.Clear("credits.usage"),
maxRetained = 6,
)
assertEquals(listOf(unrelated, replacement), replaced)
assertEquals(listOf(unrelated), cleared)
}
@Test
fun unkeyedMessagesStillCoalesceByTextAndRespectBound() {
var state = emptyList<UiMessage>()
repeat(8) { index ->
state = reduceUiMessages(
state,
UiMessageEvent.Show(message(index.toLong(), "message-$index")),
maxRetained = 6,
)
}
state = reduceUiMessages(
state,
UiMessageEvent.Show(message(99, "message-7")),
maxRetained = 6,
)
assertEquals(6, state.size)
assertEquals(99L, state.last().id)
assertEquals(1, state.count { it.text == "message-7" })
assertTrue(state.none { it.text == "message-0" })
}
private fun message(id: Long, text: String, key: String? = null) = UiMessage(
id = id,
text = text,
severity = UiMessageSeverity.Info,
ttlMillis = UiMessageBus.DEFAULT_TTL_MS,
key = key,
)
}
@@ -86,6 +86,35 @@ class VoiceModeOverlayInteractionTest {
}
}
@Test
@Config(qualifiers = "w1280dp-h800dp-xhdpi")
fun expandedLandscape_usesSplitLayoutAndKeepsMicSemanticAction() {
var micTaps = 0
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
VoiceModeOverlay(
uiState = idleVoiceState(),
onMicTap = { micTaps += 1 },
onMicRelease = {},
onInterrupt = {},
onDismiss = {},
onModeChange = {},
onClearError = {},
)
}
}
compose.onNodeWithTag(VOICE_FOCUS_SPLIT_LAYOUT_TEST_TAG).assertExists()
compose.onNodeWithTag(VOICE_FOCUS_STACKED_LAYOUT_TEST_TAG).assertDoesNotExist()
compose.onNodeWithTag(VOICE_MODE_MIC_TEST_TAG)
.assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle { assertEquals(1, micTaps) }
}
@Test
fun focusMode_emptySpaceDoesNotClickThroughToChat() {
var backgroundTaps = 0
@@ -60,9 +60,20 @@ class DiagnosticsScreenTest {
assertEquals(
CheckStatus.Unknown,
checks.single {
it.name == context.getString(R.string.active_section_optional_relay)
it.name == context.getString(R.string.diag_relay_tools_optional)
}.status,
)
assertEquals(
context.getString(R.string.diag_relay_tools_not_paired),
checks.single { it.name == context.getString(R.string.diag_relay_tools_optional) }.reason,
)
assertFalse(
checks.any {
it.name == context.getString(R.string.diag_check_pairing_auth) ||
it.name == context.getString(R.string.active_section_optional_relay) ||
it.name == context.getString(R.string.diag_check_relay_plugin)
},
)
assertFalse(
"Absent optional API/Relay surfaces must not make chat fail",
checks.any {
@@ -27,12 +27,62 @@ import org.junit.Test
class PhoneStatusPromptBuilderTest {
private val defaultSettings = AppContextSettings()
private val phoneTools = setOf("android_phone_status", "android_tap")
@Test
fun missingToolCatalog_keepsNeutralMobileContextWithoutRelayClaims() {
val output = buildPromptBlock(
defaultSettings,
PhoneSnapshot(
bridgeBound = true,
masterEnabled = true,
blocklistCount = 4,
),
availableTools = null,
)
assertNotNull(output)
assertTrue(output!!.contains("Hermes-Relay Android app"))
assertFalse(output.contains("Phone bridge:"))
assertFalse(output.contains("android_phone_status"))
assertFalse(output.contains("Safety rails:"))
}
@Test
fun settingsPreviewFixture_keepsBridgeAndSafetyToggleExamplesVisible() {
val output = buildPromptBlock(
defaultSettings,
PhoneSnapshot(
blocklistCount = 3,
destructiveVerbCount = 5,
autoDisableMinutes = 15,
),
availableTools = PHONE_CONTEXT_PREVIEW_TOOLS,
)
assertNotNull(output)
assertTrue(output!!.contains("Phone bridge: not connected"))
assertTrue(output.contains("Safety rails: 3 blocked apps, 5 destructive verbs, 15m auto-disable."))
}
@Test
fun setupOnlyCatalog_doesNotAdvertisePhoneControl() {
val output = buildPromptBlock(
defaultSettings,
PhoneSnapshot(bridgeBound = true, masterEnabled = true),
availableTools = setOf("android_setup"),
)
assertNotNull(output)
assertFalse(output!!.contains("Phone bridge:"))
assertFalse(output.contains("android_phone_status"))
}
// --- Case 1: bridge not bound, all defaults off ---
@Test
fun defaultSnapshot_bridgeNotBound_saysNotConnected() {
val output = buildPromptBlock(defaultSettings, PhoneSnapshot())
val output = buildPromptBlock(defaultSettings, PhoneSnapshot(), phoneTools)
assertNotNull(
"master defaults to true so the block should render",
output,
@@ -63,7 +113,7 @@ class PhoneStatusPromptBuilderTest {
credentialLockDetected = true,
screenOn = true,
)
val output = buildPromptBlock(defaultSettings, snapshot)
val output = buildPromptBlock(defaultSettings, snapshot, phoneTools)
assertNotNull(output)
assertTrue(
"expected the disabled-by-user line; got: $output",
@@ -91,7 +141,7 @@ class PhoneStatusPromptBuilderTest {
credentialLockDetected = false,
screenOn = true,
)
val output = buildPromptBlock(defaultSettings, snapshot)
val output = buildPromptBlock(defaultSettings, snapshot, phoneTools)
assertNotNull(output)
assertTrue(
"expected 'Unattended access: off' advisory; got: $output",
@@ -123,7 +173,7 @@ class PhoneStatusPromptBuilderTest {
credentialLockDetected = false,
screenOn = false,
)
val output = buildPromptBlock(defaultSettings, snapshot)
val output = buildPromptBlock(defaultSettings, snapshot, phoneTools)
assertNotNull(output)
assertTrue(
"expected the 'Unattended access: on — the screen will wake' advisory; got: $output",
@@ -155,7 +205,7 @@ class PhoneStatusPromptBuilderTest {
credentialLockDetected = true,
screenOn = true,
)
val output = buildPromptBlock(defaultSettings, snapshot)
val output = buildPromptBlock(defaultSettings, snapshot, phoneTools)
assertNotNull(output)
assertTrue(
"agent MUST see the credential-lock warning string; got: $output",
@@ -188,6 +238,7 @@ class PhoneStatusPromptBuilderTest {
unattendedEnabled = true,
screenOn = true,
),
phoneTools,
)
assertNull(
"master=false must omit the system message entirely " +
@@ -213,11 +264,29 @@ class PhoneStatusPromptBuilderTest {
credentialLockDetected = false,
screenOn = true,
)
val output = buildPromptBlock(defaultSettings, snapshot)
val output = buildPromptBlock(defaultSettings, snapshot, phoneTools)
assertNotNull(output)
assertTrue(
"expected permissions list; got: $output",
output!!.contains("Permissions: accessibility, screen capture, overlay, notifications"),
)
}
@Test
fun bridgeToolWithoutStatusTool_omitsUnavailableStatusToolAdvice() {
val output = buildPromptBlock(
defaultSettings,
PhoneSnapshot(
bridgeBound = true,
masterEnabled = true,
accessibilityGranted = true,
screenOn = true,
),
availableTools = setOf("android_tap"),
)
assertNotNull(output)
assertTrue(output!!.contains("Phone bridge: enabled"))
assertFalse(output.contains("android_phone_status"))
}
}
@@ -0,0 +1,70 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.network.upstream.ToolsetInfo
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatContextCapabilityTest {
@Test
fun mediaHint_prefersUpstreamAndFramesRelayAsEnhancement() {
val hint = buildMediaCapabilityHint(
upstreamAvailable = true,
relayAvailable = true,
)
assertTrue(hint!!.startsWith("Media display: this client supports standard upstream Hermes"))
assertTrue(hint.contains("Relay enhancement:"))
assertTrue(hint.contains("Relay is not required"))
}
@Test
fun mediaHint_supportsStandardUpstreamWithoutRelay() {
val hint = buildMediaCapabilityHint(
upstreamAvailable = true,
relayAvailable = false,
)
assertTrue(hint!!.contains("authenticated upstream Dashboard file routes"))
assertFalse(hint.contains("Relay enhancement:"))
}
@Test
fun mediaHint_isAbsentWithoutAnyDeliveryRoute() {
assertNull(
buildMediaCapabilityHint(
upstreamAvailable = false,
relayAvailable = false,
),
)
}
@Test
fun sseToolCatalog_includesOnlyEnabledConfiguredToolsets() {
val names = eligibleSseToolNames(
listOf(
ToolsetInfo(
name = "android",
enabled = true,
configured = true,
tools = listOf("android_phone_status", "android_tap"),
),
ToolsetInfo(
name = "desktop",
enabled = true,
configured = false,
tools = listOf("desktop_screenshot"),
),
ToolsetInfo(
name = "terminal",
enabled = false,
configured = true,
tools = listOf("terminal"),
),
),
)
assertTrue(names == setOf("android_phone_status", "android_tap"))
}
}
@@ -3014,6 +3014,51 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(viewModel.queuedMessages.value.isEmpty())
}
@Test
fun queuedCorrectionDrainsOnceAfterOwnedTurnSettlesFromActiveSessionIdle() = runBlocking {
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
gatewayHarness.redirectStatus = "rejected"
viewModel.sendMessage("Original Android turn")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", "Answer without terminal") },
"live-resumed",
),
)
awaitCondition { handler.isStreaming.value }
viewModel.sendMessage("Queued correction")
gatewayHarness.awaitRpc("session.redirect")
awaitCondition { viewModel.queuedMessages.value == listOf("Queued correction") }
persistedHistory = persistedAnswerHistory("Answer without terminal", "settled-answer")
gatewayHarness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray {
add(buildJsonObject {
put("id", "live-resumed")
put("session_key", STORED_SESSION_ID)
put("status", "idle")
put("last_active", 1_777_000_000.0)
})
})
}
gatewayClient.listActiveSessions()
shadowOf(Looper.getMainLooper()).idle()
awaitCondition {
gatewayHarness.rpcLog.count { (method, params) ->
method == "prompt.submit" &&
params["text"] == JsonPrimitive("Queued correction") &&
params["queued"] == JsonPrimitive(true)
} == 1
}
assertTrue(viewModel.queuedMessages.value.isEmpty())
assertTrue(viewModel.steerableTurn.value)
}
@Test
fun multipleQueuedMessagesDrainAsAnOwnedRunChain() {
viewModel.switchProfileContext(
@@ -7,6 +7,7 @@ import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MediaSettingsRepository
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.util.MediaCacheWriter
import io.mockk.coEvery
@@ -30,6 +31,7 @@ import org.robolectric.annotation.Config
class ChatViewModelMediaStateTest {
private lateinit var server: MockWebServer
private lateinit var dashboardServer: MockWebServer
private lateinit var handler: ChatHandler
private lateinit var viewModel: ChatViewModel
private lateinit var cache: MediaCacheWriter
@@ -37,6 +39,7 @@ class ChatViewModelMediaStateTest {
@Before
fun setUp() {
server = MockWebServer().apply { start() }
dashboardServer = MockWebServer().apply { start() }
val context = RuntimeEnvironment.getApplication()
val relay = RelayHttpClient(
okHttpClient = OkHttpClient(),
@@ -46,6 +49,7 @@ class ChatViewModelMediaStateTest {
.trimEnd('/')
},
sessionTokenProvider = { "paired-session" },
pairedTokenSnapshot = { "paired-session" },
)
cache = mockk()
coEvery { cache.cache(any(), any(), any()) } returns
@@ -66,6 +70,7 @@ class ChatViewModelMediaStateTest {
@After
fun tearDown() {
server.shutdown()
dashboardServer.shutdown()
}
@Test
@@ -151,6 +156,175 @@ class ChatViewModelMediaStateTest {
assertEquals(windowsPath, request.requestUrl?.queryParameter("path"))
}
@Test
fun assistantBarePathPrefersAuthenticatedUpstreamDashboardDownload() {
val path = "/tmp/test-voice-message.mp3"
dashboardServer.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "audio/mpeg")
.setHeader("Content-Disposition", "attachment; filename=\"test-voice-message.mp3\"")
.setBody("voice-bytes"),
)
viewModel.cellularNetworkOverride = false
viewModel.initializeMedia(
context = RuntimeEnvironment.getApplication(),
relayHttpClient = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { server.url("/").toString().replaceFirst("http://", "ws://").trimEnd('/') },
sessionTokenProvider = { "paired-session" },
pairedTokenSnapshot = { "paired-session" },
),
mediaSettingsRepo = MediaSettingsRepository(RuntimeEnvironment.getApplication()),
mediaCacheWriter = cache,
dashboardMediaClientProvider = {
DashboardApiClient(baseUrl = dashboardServer.url("/").toString())
},
)
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-audio-upstream",
role = "assistant",
content = JsonPrimitive("Voice reply\nMEDIA:$path"),
),
),
)
val loaded = awaitMessage {
it.attachments.singleOrNull()?.state == AttachmentState.LOADED
}.attachments.single()
assertEquals("audio/mpeg", loaded.contentType)
assertEquals("test-voice-message.mp3", loaded.fileName)
val request = dashboardServer.takeRequest()
assertEquals("/api/files/download", request.requestUrl?.encodedPath)
assertEquals(path, request.requestUrl?.queryParameter("path"))
assertEquals(0, server.requestCount)
}
@Test
fun assistantBarePathWithoutUpstreamOrRelaySettlesAsNeutralHostFile() {
viewModel.cellularNetworkOverride = false
viewModel.initializeMedia(
context = RuntimeEnvironment.getApplication(),
relayHttpClient = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { null },
sessionTokenProvider = { null },
),
mediaSettingsRepo = MediaSettingsRepository(RuntimeEnvironment.getApplication()),
mediaCacheWriter = cache,
)
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-file-unavailable",
role = "assistant",
content = JsonPrimitive("MEDIA:/tmp/result.zip"),
),
),
)
val unavailable = awaitMessage {
it.attachments.singleOrNull()?.errorMessage == ChatViewModel.MEDIA_HOST_ONLY
}.attachments.single()
assertEquals(AttachmentState.FAILED, unavailable.state)
assertEquals("result.zip", unavailable.fileName)
}
@Test
fun assistantBarePathFallsBackToRelayWhenUpstreamRouteIsUnavailable() {
val path = "/tmp/legacy-host-image.png"
dashboardServer.enqueue(MockResponse().setResponseCode(404).setBody("not found"))
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "image/png")
.setHeader("Content-Disposition", "inline; filename=\"legacy-host-image.png\"")
.setBody("image-bytes"),
)
viewModel.cellularNetworkOverride = false
viewModel.initializeMedia(
context = RuntimeEnvironment.getApplication(),
relayHttpClient = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { server.url("/").toString().replaceFirst("http://", "ws://").trimEnd('/') },
sessionTokenProvider = { "paired-session" },
pairedTokenSnapshot = { "paired-session" },
),
mediaSettingsRepo = MediaSettingsRepository(RuntimeEnvironment.getApplication()),
mediaCacheWriter = cache,
dashboardMediaClientProvider = {
DashboardApiClient(baseUrl = dashboardServer.url("/").toString())
},
)
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-image-fallback",
role = "assistant",
content = JsonPrimitive("MEDIA:$path"),
),
),
)
val loaded = awaitMessage {
it.attachments.singleOrNull()?.state == AttachmentState.LOADED
}.attachments.single()
assertEquals("image/png", loaded.contentType)
assertEquals("/api/files/download", dashboardServer.takeRequest().requestUrl?.encodedPath)
assertEquals("/media/by-path", server.takeRequest().requestUrl?.encodedPath)
}
@Test
fun assistantBarePathDoesNotBypassUpstreamSensitiveFileDenial() {
val path = "/home/user/.ssh/id_ed25519"
dashboardServer.enqueue(MockResponse().setResponseCode(403).setBody("sensitive file"))
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/octet-stream")
.setBody("must-not-be-fetched"),
)
viewModel.cellularNetworkOverride = false
viewModel.initializeMedia(
context = RuntimeEnvironment.getApplication(),
relayHttpClient = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { server.url("/").toString().replaceFirst("http://", "ws://").trimEnd('/') },
sessionTokenProvider = { "paired-session" },
pairedTokenSnapshot = { "paired-session" },
),
mediaSettingsRepo = MediaSettingsRepository(RuntimeEnvironment.getApplication()),
mediaCacheWriter = cache,
dashboardMediaClientProvider = {
DashboardApiClient(baseUrl = dashboardServer.url("/").toString())
},
)
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-sensitive-denied",
role = "assistant",
content = JsonPrimitive("MEDIA:$path"),
),
),
)
val denied = awaitMessage {
it.attachments.singleOrNull()?.let { attachment ->
attachment.state == AttachmentState.FAILED && attachment.errorMessage != null
} == true
}.attachments.single()
assertEquals(AttachmentState.FAILED, denied.state)
assertEquals(1, dashboardServer.requestCount)
assertEquals(0, server.requestCount)
}
private fun awaitMessage(predicate: (ChatMessage) -> Boolean): ChatMessage {
val deadline = System.nanoTime() + 5_000_000_000L
while (System.nanoTime() < deadline) {
@@ -0,0 +1,67 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.network.upstream.GatewayAgentNotice
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
import org.junit.Assert.assertEquals
import org.junit.Test
class GatewayNoticePresentationTest {
@Test
fun stickyWarningStripsDuplicateGlyphAndKeepsKey() {
val presentation = gatewayNoticePresentation(
GatewayAgentNotice(
text = "⚠ Credits depleted",
level = "warn",
kind = "sticky",
key = "credits.depleted",
),
)
assertEquals("Credits depleted", presentation.text)
assertEquals(UiMessageSeverity.Warning, presentation.severity)
assertEquals(0L, presentation.ttlMillis)
assertEquals("credits.depleted", presentation.key)
}
@Test
fun ttlNoticeUsesIdFallbackAndBoundsLifetime() {
val presentation = gatewayNoticePresentation(
GatewayAgentNotice(
text = " ✓ Credits restored ",
level = "success",
kind = "ttl",
ttlMs = 600_000L,
id = "notice-2",
),
)
assertEquals("Credits restored", presentation.text)
assertEquals(UiMessageSeverity.Success, presentation.severity)
assertEquals(60_000L, presentation.ttlMillis)
assertEquals("notice-2", presentation.key)
}
@Test
fun ttlWithoutDurationUsesExistingBannerDefault() {
val presentation = gatewayNoticePresentation(
GatewayAgentNotice(text = "Account update", kind = "ttl"),
)
assertEquals(UiMessageBus.DEFAULT_TTL_MS, presentation.ttlMillis)
}
@Test
fun agentNoticePersistsUntilGatewayClearsItsKey() {
val presentation = gatewayNoticePresentation(
GatewayAgentNotice(
text = "Still starting agent",
kind = "agent",
key = "agent-startup",
),
)
assertEquals(0L, presentation.ttlMillis)
assertEquals("agent-startup", presentation.key)
}
}
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.data.GitRepositoryRoute
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -56,21 +57,29 @@ class GitStateViewModelTest {
}
@Test
fun `disabled configuration does not discover repositories until enabled`() = runBlocking {
enqueueJson("""{"repos":[]}""")
fun `disabled Relay discovery still loads standard session repository`() = runBlocking {
enqueueJson("""{"branch":"main","changed":0,"staged":0,"unstaged":0,"untracked":0,"files":[]}""")
val vm = GitStateViewModel(application)
vm.configure(
DashboardApiClient(server.url("/").toString()),
ownerKey,
scanningEnabled = false,
)
vm.setSessionWorkspace(repoRoot = "/workspace/repo", workingDirectory = null)
vm.loadRepos()
assertEquals(0, server.requestCount)
val standard = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
}
assertEquals(GitRepositoryRoute.UPSTREAM, standard.repos.single().route)
assertEquals("/api/git/status?path=%2Fworkspace%2Frepo", server.takeRequest().path)
assertEquals(1, server.requestCount)
enqueueJson("""{"branch":"main","changed":0,"staged":0,"unstaged":0,"untracked":0,"files":[]}""")
enqueueJson("""{"repos":[]}""")
vm.setScanningEnabled(true)
vm.loadRepos()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
assertEquals("/api/git/status?path=%2Fworkspace%2Frepo", server.takeRequest().path)
assertEquals("/api/plugins/hermes-relay/git/repos", server.takeRequest().path)
}
@@ -203,4 +212,141 @@ class GitStateViewModelTest {
}
assertTrue(error.message.contains("unknown repository"))
}
@Test
fun `active session repository uses upstream Git without Relay`() = runBlocking {
enqueueJson(
"""{"branch":"main","changed":2,"staged":0,"unstaged":1,"untracked":1,"added":7,"removed":2,"files":[]}""",
)
server.enqueue(
MockResponse().setResponseCode(404).setBody("""{"detail":"No such API endpoint"}"""),
)
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey, scanningEnabled = true)
vm.setSessionWorkspace(repoRoot = "/workspace/repo", workingDirectory = "/workspace/repo/src")
vm.loadRepos()
val ready = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
}
assertEquals(1, ready.repos.size)
assertEquals(GitRepositoryRoute.UPSTREAM, ready.repos.single().route)
assertEquals("/workspace/repo", ready.repos.single().root)
assertEquals("/api/git/status?path=%2Fworkspace%2Frepo", server.takeRequest().path)
assertEquals("/api/plugins/hermes-relay/git/repos", server.takeRequest().path)
}
@Test
fun `standard session repository can be selected while Relay discovery is off`() = runBlocking {
enqueueJson(
"""{"branch":"main","changed":1,"staged":0,"unstaged":1,"untracked":0,"added":2,"removed":0,"files":[{"path":"a.kt","unstaged":true}]}""",
)
enqueueJson(
"""{"branch":"main","changed":1,"staged":0,"unstaged":1,"untracked":0,"added":2,"removed":0,"files":[{"path":"a.kt","unstaged":true}]}""",
)
enqueueJson("""{"files":[{"path":"a.kt","added":2,"removed":0,"staged":false}]}""")
enqueueJson("""{"branches":[{"name":"main","checkedOut":true}]}""")
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey, scanningEnabled = false)
vm.setSessionWorkspace(repoRoot = "/workspace/repo", workingDirectory = null)
vm.loadRepos()
val repos = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
}
vm.selectRepo(repos.repos.single().id)
val detail = withTimeout(5_000) {
vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first()
}
assertEquals("a.kt", detail.status.modified.single().path)
assertNotNull(vm.currentTarget())
assertEquals(4, server.requestCount)
}
@Test
fun `upstream operational failure does not downgrade to Relay`() = runBlocking {
server.enqueue(MockResponse().setResponseCode(500).setBody("""{"detail":"git crashed"}"""))
enqueueJson(
"""{"repos":[{"id":"alpha","name":"alpha","root":"/workspace/repo"}]}""",
)
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey, scanningEnabled = true)
vm.setSessionWorkspace(repoRoot = "/workspace/repo", workingDirectory = null)
vm.loadRepos()
val error = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Error>().first()
}
assertTrue(error.message.contains("HTTP 500"))
assertEquals(1, server.requestCount)
}
@Test
fun `upstream session status maps official review and branch shapes`() = runBlocking {
enqueueJson(
"""{"branch":"feature/x","changed":2,"staged":1,"unstaged":1,"untracked":0,"added":9,"removed":3,"files":[{"path":"a.kt","staged":true},{"path":"b.kt","unstaged":true}]}""",
)
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"detail":"plugin absent"}"""))
enqueueJson(
"""{"branch":"feature/x","changed":2,"staged":1,"unstaged":1,"untracked":0,"added":9,"removed":3,"files":[{"path":"a.kt","staged":true},{"path":"b.kt","unstaged":true}]}""",
)
enqueueJson(
"""{"files":[{"path":"a.kt","added":5,"removed":1,"staged":true},{"path":"b.kt","added":4,"removed":2,"staged":false}],"base":null}""",
)
enqueueJson(
"""{"branches":[{"name":"feature/x","checkedOut":true,"isDefault":false,"isRemote":false,"worktreePath":"/workspace/repo"}]}""",
)
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey, scanningEnabled = true)
vm.setSessionWorkspace(repoRoot = "/workspace/repo", workingDirectory = null)
vm.loadRepos()
val repos = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
}
vm.selectRepo(repos.repos.single().id)
val detail = withTimeout(5_000) {
vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first()
}
assertEquals(2, detail.status.counts.changes)
assertEquals(9, detail.status.counts.additions)
assertEquals(3, detail.status.counts.deletions)
assertEquals("a.kt", detail.status.staged.single().path)
assertEquals("b.kt", detail.status.modified.single().path)
assertTrue(detail.branches.single().isCurrent)
}
@Test
fun `missing upstream read route falls back to matching Relay repository`() = runBlocking {
enqueueJson(
"""{"branch":"main","changed":1,"staged":0,"unstaged":1,"untracked":0,"files":[]}""",
)
enqueueJson(
"""{"repos":[{"id":"relay-alpha","name":"repo","root":"/workspace/repo","current_branch":"main","dirty":true}]}""",
)
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"detail":"route unavailable"}"""))
enqueueJson(
"""{"counts":{"staged":0,"modified":1,"untracked":0},"staged":[],"modified":[{"path":"a.kt"}],"untracked":[],"truncated":false}""",
)
enqueueJson(
"""{"branches":[{"name":"main","checkedOut":true,"isDefault":true,"isRemote":false,"worktreePath":"/workspace/repo"}]}""",
)
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey, scanningEnabled = true)
vm.setSessionWorkspace(repoRoot = "/workspace/repo", workingDirectory = null)
vm.loadRepos()
val repos = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
}
vm.selectRepo(repos.repos.single().id)
val detail = withTimeout(5_000) {
vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first()
}
assertEquals("a.kt", detail.status.modified.single().path)
val paths = buildList {
repeat(5) { add(server.takeRequest().path.orEmpty()) }
}
assertTrue(paths.contains("/api/plugins/hermes-relay/git/status?repo=relay-alpha"))
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+22 -9
View File
@@ -378,6 +378,14 @@ Key data classes: `MessageEvent` (inbound), `SendResult` (outbound), `SessionSou
### 14. Inbound Media via Plugin-Owned Relay Endpoint, Not Upstream Gateway (2026-04-11)
**Status (2026-08-31): Superseded for ordinary media.** Current upstream
Hermes Dashboard/Desktop provides authenticated file download, streaming, and
bounded preview routes. Android now prefers those upstream routes for ordinary
`MEDIA:` paths and file references. The token registry remains valid for
explicit Relay tokens, phone-control screenshots, sensitivity metadata, and
older-host compatibility; it is no longer a prerequisite for standard inbound
attachments. See [`upstream-surface-matrix.md`](upstream-surface-matrix.md).
**Decision:** Agent-initiated media (screenshots, and any future file-producing tool) is delivered to the phone via a new loopback-register + bearer-fetch pair of routes on our own relay server (`POST /media/register` → `GET /media/{token}`), not by relying on hermes-agent's upstream `extract_media()` / `send_document()` machinery. Tools emit a `MEDIA:hermes-relay://<token>` marker in their chat response text; the phone parses the marker out of the SSE stream and fetches bytes out-of-band over authenticated HTTPS.
**Why:**
@@ -3710,15 +3718,20 @@ live even when upstream had already persisted the final answer. The earlier
visible-chat Idle reattach fix covered a socket that closed after foreground
prewarm; it did not cover this already-active turn state.
**Decision.** A Gateway turn may settle from `session.activate` or exact-session
`session.info` only when the turn has already received turn-scoped activity and
upstream reports `running=false`. Pre-start idle snapshots are ignored because
they can race prompt admission. This backstop is a successful server-owned
settle, not cancellation or transport failure: Android completes the local
stream, keeps the durable session identity, performs bounded identity-fenced
history reconciliation, and never resubmits through API fallback. Cold open
continues to use `session.resume`; an authoritative resume rejection remains
visible and cannot create or switch to a replacement context.
**Decision.** A Gateway turn may settle from `session.activate`, exact-session
`session.info`, or an exact live/durable `session.active_list` row only when the
turn is Android-owned, has already received turn-scoped activity, and upstream
reports `running=false` or Idle. The active-list request captures the exact turn
and its progress generation; a session/profile switch, cancellation, newer turn,
or intervening live event rejects the delayed snapshot. Pre-start idle snapshots
and passively observed Desktop/TUI turns are never eligible. This backstop is a
successful server-owned settle, not cancellation or transport failure: Android
completes the local stream, keeps the durable session identity, performs bounded
identity-fenced history reconciliation, consumes one late terminal without
double-completion, and never resubmits through API fallback. A locally queued
correction drains once through its existing owner chain after settlement. Cold
open continues to use `session.resume`; an authoritative resume rejection
remains visible and cannot create or switch to a replacement context.
The recovery writes one bounded content-free diagnostic containing only route,
missing-terminal phase, and reconciliation action. It records no prompt or
+73 -39
View File
@@ -16,8 +16,8 @@
},
{
"type": "text", "id": "t_sub", "x": 120, "y": 88, "width": 960, "height": 22,
"text": "Chat, Manage & Voice run on a plain Hermes agent. The optional Relay plugin adds Terminal, Bridge & desktop tools.",
"originalText": "Chat, Manage & Voice run on a plain Hermes agent. The optional Relay plugin adds Terminal, Bridge & desktop tools.",
"text": "One standard path. Optional extensions when you want more.",
"originalText": "One standard path. Optional extensions when you want more.",
"fontSize": 14, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#64748b", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
@@ -27,7 +27,7 @@
{
"type": "text", "id": "z_machine", "x": 120, "y": 168, "width": 220, "height": 18,
"text": "YOUR MACHINE", "originalText": "YOUR MACHINE",
"text": "YOUR HERMES HOST", "originalText": "YOUR HERMES HOST",
"fontSize": 13, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#94a3b8", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
@@ -46,23 +46,23 @@
{
"type": "rectangle", "id": "r_hermes", "x": 110, "y": 200, "width": 290, "height": 132,
"strokeColor": "#334155", "backgroundColor": "#eef2f7", "fillStyle": "solid",
"strokeColor": "#1e3a5f", "backgroundColor": "#dbeafe", "fillStyle": "solid",
"strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100010, "version": 1, "versionNonce": 10, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "roundness": { "type": 3 }
},
{
"type": "text", "id": "tx_hermes1", "x": 132, "y": 240, "width": 250, "height": 28,
"text": "Hermes agent", "originalText": "Hermes agent",
"fontSize": 22, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"type": "text", "id": "tx_hermes1", "x": 132, "y": 230, "width": 250, "height": 50,
"text": "Hermes Dashboard\n+ Gateway", "originalText": "Hermes Dashboard\n+ Gateway",
"fontSize": 18, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#1e293b", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100011, "version": 1, "versionNonce": 11, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
},
{
"type": "text", "id": "tx_hermes2", "x": 132, "y": 278, "width": 250, "height": 18,
"text": "the platform on your machine", "originalText": "the platform on your machine",
"type": "text", "id": "tx_hermes2", "x": 132, "y": 294, "width": 250, "height": 18,
"text": "standard upstream connection", "originalText": "standard upstream connection",
"fontSize": 13, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#64748b", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
@@ -72,23 +72,23 @@
{
"type": "rectangle", "id": "r_plugin", "x": 110, "y": 404, "width": 290, "height": 120,
"strokeColor": "#6b4fb0", "backgroundColor": "#efeafb", "fillStyle": "solid",
"strokeColor": "#6d28d9", "backgroundColor": "#ddd6fe", "fillStyle": "solid",
"strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100020, "version": 1, "versionNonce": 20, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "roundness": { "type": 3 }
},
{
"type": "text", "id": "tx_plugin1", "x": 132, "y": 430, "width": 250, "height": 24,
"text": "+ Relay plugin", "originalText": "+ Relay plugin",
"text": "Relay plugin", "originalText": "Relay plugin",
"fontSize": 18, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#5b3fa0", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeColor": "#6d28d9", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100021, "version": 1, "versionNonce": 21, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
},
{
"type": "text", "id": "tx_plugin2", "x": 132, "y": 464, "width": 250, "height": 16,
"text": "optional · install on your", "originalText": "optional · install on your",
"text": "optional extensions +", "originalText": "optional extensions +",
"fontSize": 12, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#64748b", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
@@ -97,7 +97,7 @@
},
{
"type": "text", "id": "tx_plugin3", "x": 132, "y": 484, "width": 250, "height": 16,
"text": "Hermes host, then pair", "originalText": "Hermes host, then pair",
"text": "paired tools", "originalText": "paired tools",
"fontSize": 12, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#64748b", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
@@ -107,15 +107,15 @@
{
"type": "rectangle", "id": "r_app", "x": 1010, "y": 298, "width": 300, "height": 150,
"strokeColor": "#334155", "backgroundColor": "#eef2f7", "fillStyle": "solid",
"strokeColor": "#1e3a5f", "backgroundColor": "#dbeafe", "fillStyle": "solid",
"strokeWidth": 2, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100030, "version": 1, "versionNonce": 30, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "roundness": { "type": 3 }
},
{
"type": "text", "id": "tx_app1", "x": 1032, "y": 346, "width": 260, "height": 28,
"text": "Hermes-Relay app", "originalText": "Hermes-Relay app",
"fontSize": 22, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"text": "Hermes-Relay Android", "originalText": "Hermes-Relay Android",
"fontSize": 18, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#1e293b", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100031, "version": 1, "versionNonce": 31, "isDeleted": false, "groupIds": [],
@@ -123,7 +123,7 @@
},
{
"type": "text", "id": "tx_app2", "x": 1032, "y": 384, "width": 260, "height": 18,
"text": "your Android phone", "originalText": "your Android phone",
"text": "Google Play or sideload APK", "originalText": "Google Play or sideload APK",
"fontSize": 13, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#64748b", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
@@ -133,16 +133,16 @@
{
"type": "rectangle", "id": "r_sideload", "x": 1010, "y": 460, "width": 300, "height": 40,
"strokeColor": "#c2820c", "backgroundColor": "#fdf3e0", "fillStyle": "solid",
"strokeColor": "#c2410c", "backgroundColor": "#fed7aa", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100040, "version": 1, "versionNonce": 40, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "roundness": { "type": 3 }
},
{
"type": "text", "id": "tx_sideload", "x": 1022, "y": 472, "width": 276, "height": 16,
"text": "Device Control needs the sideload build", "originalText": "Device Control needs the sideload build",
"text": "Sideload adds Device Control", "originalText": "Sideload adds Device Control",
"fontSize": 12, "fontFamily": 3, "textAlign": "center", "verticalAlign": "middle",
"strokeColor": "#9a6313", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeColor": "#c2410c", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100041, "version": 1, "versionNonce": 41, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
@@ -150,23 +150,23 @@
{
"type": "rectangle", "id": "r_cli", "x": 1040, "y": 524, "width": 240, "height": 72,
"strokeColor": "#6b4fb0", "backgroundColor": "#efeafb", "fillStyle": "solid",
"strokeColor": "#6d28d9", "backgroundColor": "#ddd6fe", "fillStyle": "solid",
"strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100050, "version": 1, "versionNonce": 50, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "roundness": { "type": 3 }
},
{
"type": "text", "id": "tx_cli1", "x": 1058, "y": 540, "width": 200, "height": 22,
"text": "Relay CLI", "originalText": "Relay CLI",
"text": "Hermes-Relay CLI", "originalText": "Hermes-Relay CLI",
"fontSize": 16, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#5b3fa0", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeColor": "#6d28d9", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100051, "version": 1, "versionNonce": 51, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
},
{
"type": "text", "id": "tx_cli2", "x": 1058, "y": 566, "width": 214, "height": 16,
"text": "the agent's hands on any computer", "originalText": "the agent's hands on any computer",
"text": "Hands on any paired computer", "originalText": "Hands on any paired computer",
"fontSize": 11, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#64748b", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
@@ -176,7 +176,7 @@
{
"type": "arrow", "id": "a_vanilla", "x": 400, "y": 266, "width": 610, "height": 78,
"strokeColor": "#167a6e", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeColor": "#047857", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 3, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100060, "version": 1, "versionNonce": 60, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false,
@@ -185,25 +185,25 @@
},
{
"type": "text", "id": "tx_van_label", "x": 470, "y": 192, "width": 470, "height": 20,
"text": "VANILLA HERMES · no plugin needed", "originalText": "VANILLA HERMES · no plugin needed",
"text": "STANDARD PATH", "originalText": "STANDARD PATH",
"fontSize": 15, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#167a6e", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeColor": "#047857", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100061, "version": 1, "versionNonce": 61, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
},
{
"type": "text", "id": "tx_van_feats", "x": 470, "y": 218, "width": 400, "height": 24,
"text": "Chat · Manage · Voice", "originalText": "Chat · Manage · Voice",
"text": "Chat · Sessions · Manage · Voice", "originalText": "Chat · Sessions · Manage · Voice",
"fontSize": 18, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#0f5750", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeColor": "#047857", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100062, "version": 1, "versionNonce": 62, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
},
{
"type": "text", "id": "tx_van_ports", "x": 470, "y": 248, "width": 360, "height": 16,
"text": "dashboard :9119 · API :8642", "originalText": "dashboard :9119 · API :8642",
"text": "Direct Dashboard/Gateway connection", "originalText": "Direct Dashboard/Gateway connection",
"fontSize": 11, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#94a3b8", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
@@ -213,7 +213,7 @@
{
"type": "arrow", "id": "a_relay_app", "x": 400, "y": 452, "width": 610, "height": 44,
"strokeColor": "#6b4fb0", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeColor": "#6d28d9", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100070, "version": 1, "versionNonce": 70, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false,
@@ -222,7 +222,7 @@
},
{
"type": "arrow", "id": "a_relay_cli", "x": 400, "y": 486, "width": 640, "height": 74,
"strokeColor": "#6b4fb0", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeColor": "#6d28d9", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 2, "strokeStyle": "dashed", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100071, "version": 1, "versionNonce": 71, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false,
@@ -231,30 +231,64 @@
},
{
"type": "text", "id": "tx_rel_label", "x": 470, "y": 350, "width": 470, "height": 20,
"text": "+ RELAY PLUGIN · pair to unlock", "originalText": "+ RELAY PLUGIN · pair to unlock",
"text": "OPTIONAL RELAY", "originalText": "OPTIONAL RELAY",
"fontSize": 15, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#6b4fb0", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeColor": "#6d28d9", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100072, "version": 1, "versionNonce": 72, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
},
{
"type": "text", "id": "tx_rel_feats", "x": 470, "y": 376, "width": 540, "height": 22,
"text": "Terminal · Bridge · Relay voice · Desktop tools", "originalText": "Terminal · Bridge · Relay voice · Desktop tools",
"fontSize": 16, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#4b2f88", "backgroundColor": "transparent", "fillStyle": "solid",
"text": "Terminal · Media · Notifications · Enhanced voice", "originalText": "Terminal · Media · Notifications · Enhanced voice",
"fontSize": 15, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#6d28d9", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100073, "version": 1, "versionNonce": 73, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
},
{
"type": "text", "id": "tx_rel_ports", "x": 470, "y": 406, "width": 300, "height": 16,
"text": "WSS :8767", "originalText": "WSS :8767",
"text": "Paired and scoped by feature", "originalText": "Paired and scoped by feature",
"fontSize": 11, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#94a3b8", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100074, "version": 1, "versionNonce": 74, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
},
{
"type": "line", "id": "guarantee_line", "x": 110, "y": 636, "width": 1200, "height": 0,
"strokeColor": "#64748b", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100080, "version": 1, "versionNonce": 80, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "points": [[0, 0], [1200, 0]]
},
{
"type": "text", "id": "guarantee_chat", "x": 120, "y": 658, "width": 330, "height": 20,
"text": "Chat never routes through Relay", "originalText": "Chat never routes through Relay",
"fontSize": 14, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#047857", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100081, "version": 1, "versionNonce": 81, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
},
{
"type": "text", "id": "guarantee_api", "x": 510, "y": 658, "width": 360, "height": 20,
"text": "API server is compatibility fallback only", "originalText": "API server is compatibility fallback only",
"fontSize": 14, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#64748b", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100082, "version": 1, "versionNonce": 82, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
},
{
"type": "text", "id": "guarantee_device", "x": 930, "y": 658, "width": 380, "height": 20,
"text": "Device Control requires Relay + sideload", "originalText": "Device Control requires Relay + sideload",
"fontSize": 14, "fontFamily": 3, "textAlign": "left", "verticalAlign": "top",
"strokeColor": "#c2410c", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0,
"seed": 100083, "version": 1, "versionNonce": 83, "isDeleted": false, "groupIds": [],
"boundElements": null, "link": null, "locked": false, "containerId": null, "lineHeight": 1.25
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 542 KiB

+97 -53
View File
@@ -1,69 +1,113 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1240 596" width="1240" height="596" font-family="Inter, ui-sans-serif, -apple-system, 'Segoe UI', Roboto, sans-serif" role="img" aria-label="How Hermes-Relay connects: Vanilla Hermes (Chat, Manage, Voice) runs on a plain Hermes agent with no plugin; the optional Relay plugin adds Terminal, Bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build.">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1240 620" width="1240" height="620" role="img" aria-labelledby="architecture-title architecture-description" font-family="Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif">
<title id="architecture-title">How Hermes-Relay connects</title>
<desc id="architecture-description">The Hermes Dashboard and Gateway connect directly to Hermes-Relay Android for chat, sessions, management, and standard voice. The optional Relay plugin adds terminal, media, notifications, enhanced voice, and desktop tools. Sideload Android builds additionally enable Device Control. The API server is a compatibility fallback and chat never routes through Relay.</desc>
<defs>
<marker id="ahTeal" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#167a6e"/>
<linearGradient id="canvas" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#070a12"/>
<stop offset="0.56" stop-color="#0a0d18"/>
<stop offset="1" stop-color="#101126"/>
</linearGradient>
<linearGradient id="coreCard" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#10231f"/>
<stop offset="1" stop-color="#0d171a"/>
</linearGradient>
<linearGradient id="relayCard" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1a1630"/>
<stop offset="1" stop-color="#11131f"/>
</linearGradient>
<linearGradient id="deviceCard" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#15182a"/>
<stop offset="1" stop-color="#0d101a"/>
</linearGradient>
<marker id="coreArrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 10 5 0 10Z" fill="#37d5b0"/>
</marker>
<marker id="ahViolet" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6b4fb0"/>
<marker id="relayArrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 10 5 0 10Z" fill="#8b7cff"/>
</marker>
</defs>
<!-- background card (keeps it presentable on light or dark homepages) -->
<rect x="6" y="6" width="1228" height="584" rx="22" fill="#ffffff" stroke="#e6ebf0" stroke-width="1.5"/>
<rect width="1240" height="620" rx="24" fill="url(#canvas)"/>
<path d="M0 506C166 472 280 554 442 522S732 462 896 512s230 25 344-3v111H0Z" fill="#17143a" opacity=".35"/>
<path d="M0 544C170 511 301 570 463 540s269-61 427-15 239 21 350-2" fill="none" stroke="#6658d9" stroke-width="1.5" opacity=".32"/>
<!-- header -->
<text x="44" y="62" font-size="30" font-weight="700" fill="#1e293b">How Hermes-Relay connects</text>
<text x="46" y="90" font-size="14.5" fill="#64748b">Chat, Manage &amp; Voice run on a plain Hermes agent — the optional Relay plugin adds Terminal, Bridge &amp; desktop tools.</text>
<text x="54" y="58" fill="#f7f7fb" font-size="30" font-weight="750">How Hermes-Relay connects</text>
<text x="54" y="87" fill="#9ca3b5" font-size="15">One standard path. Optional extensions when you want more.</text>
<!-- zone labels -->
<text x="62" y="158" font-size="12" font-weight="700" letter-spacing="2" fill="#94a3b8">YOUR MACHINE</text>
<text x="882" y="158" font-size="12" font-weight="700" letter-spacing="2" fill="#94a3b8">YOUR DEVICES</text>
<g aria-label="Your Hermes host">
<text x="58" y="141" fill="#858da3" font-size="11" font-weight="700" letter-spacing="2.2">YOUR HERMES HOST</text>
<rect x="54" y="158" width="356" height="330" rx="22" fill="#0b0e17" stroke="#242a3d" stroke-width="1.5"/>
<!-- ===================== LANES (drawn under boxes) ===================== -->
<!-- Vanilla lane: Hermes <-> App -->
<path d="M362,244 L878,302" stroke="#167a6e" stroke-width="3" fill="none" marker-start="url(#ahTeal)" marker-end="url(#ahTeal)"/>
<!-- Relay lane: plugin -> App (optional, dashed) -->
<path d="M362,404 L878,362" stroke="#6b4fb0" stroke-width="2" stroke-dasharray="7 5" fill="none" marker-start="url(#ahViolet)" marker-end="url(#ahViolet)"/>
<!-- Relay lane: plugin -> CLI (fan-out) -->
<path d="M362,418 L903,482" stroke="#6b4fb0" stroke-width="2" stroke-dasharray="7 5" fill="none" marker-end="url(#ahViolet)"/>
<rect x="78" y="186" width="308" height="128" rx="16" fill="url(#coreCard)" stroke="#2bb895" stroke-width="2"/>
<rect x="98" y="206" width="40" height="40" rx="10" fill="#123b32" stroke="#37d5b0"/>
<rect x="108" y="215" width="20" height="5" rx="2.5" fill="#37d5b0"/>
<rect x="108" y="224" width="20" height="5" rx="2.5" fill="#37d5b0"/>
<rect x="108" y="233" width="20" height="5" rx="2.5" fill="#37d5b0"/>
<circle cx="124" cy="217.5" r="1.5" fill="#07130f"/>
<circle cx="124" cy="226.5" r="1.5" fill="#07130f"/>
<circle cx="124" cy="235.5" r="1.5" fill="#07130f"/>
<text x="152" y="216" fill="#f7f7fb" font-size="17" font-weight="700">Hermes Dashboard</text>
<text x="152" y="239" fill="#79e6ca" font-size="17" font-weight="700">+ Gateway</text>
<text x="98" y="262" fill="#9ca3b5" font-size="11.5">Standard upstream connection</text>
<rect x="98" y="276" width="154" height="21" rx="10.5" fill="#123b32"/>
<text x="175" y="291" fill="#79e6ca" font-size="9.5" font-weight="750" text-anchor="middle" letter-spacing=".7">NO PLUGIN REQUIRED</text>
<!-- vanilla lane label cluster (above the teal line) -->
<text x="432" y="180" font-size="14" font-weight="700" fill="#167a6e">VANILLA HERMES · no plugin needed</text>
<text x="432" y="208" font-size="18" font-weight="700" fill="#0f5750">Chat · Manage · Voice</text>
<text x="432" y="231" font-size="11" fill="#94a3b8">dashboard local :9119 · API :8642</text>
<rect x="78" y="342" width="308" height="122" rx="16" fill="url(#relayCard)" stroke="#7567e8" stroke-width="2" stroke-dasharray="8 6"/>
<rect x="98" y="362" width="40" height="40" rx="10" fill="#28204d" stroke="#8b7cff"/>
<path d="M108 382h20M118 372v20M111 375l14 14M125 375l-14 14" stroke="#a99eff" stroke-width="2" stroke-linecap="round"/>
<text x="152" y="375" fill="#f7f7fb" font-size="18" font-weight="700">Relay plugin</text>
<text x="152" y="399" fill="#9ca3b5" font-size="12.5">Optional extensions + paired tools</text>
<rect x="98" y="420" width="130" height="25" rx="12.5" fill="#28204d"/>
<text x="163" y="437" fill="#b9b1ff" font-size="10.5" font-weight="750" text-anchor="middle" letter-spacing=".8">PAIR TO UNLOCK</text>
</g>
<!-- relay lane label cluster (between the violet lines) -->
<text x="432" y="322" font-size="14" font-weight="700" fill="#6b4fb0">+ RELAY PLUGIN · pair to unlock</text>
<text x="432" y="348" font-size="15" font-weight="700" fill="#4b2f88">Terminal · Bridge · Relay voice · Desktop tools</text>
<text x="432" y="370" font-size="11" fill="#94a3b8">same dashboard origin · tailscale :10443 → local :9119</text>
<g aria-label="Connection paths">
<text x="452" y="176" fill="#79e6ca" font-size="11" font-weight="750" letter-spacing="1.8">STANDARD PATH</text>
<text x="452" y="200" fill="#f7f7fb" font-size="17" font-weight="700">Chat · Sessions · Manage · Voice</text>
<text x="452" y="222" fill="#858da3" font-size="12">Direct Dashboard/Gateway connection</text>
<path d="M386 248C530 248 672 245 848 246" fill="none" stroke="#37d5b0" stroke-width="4" marker-start="url(#coreArrow)" marker-end="url(#coreArrow)"/>
<!-- ===================== LEFT: your machine ===================== -->
<rect x="60" y="180" width="300" height="120" rx="14" fill="#eef2f7" stroke="#334155" stroke-width="2"/>
<text x="80" y="231" font-size="21" font-weight="700" fill="#1e293b">Hermes agent</text>
<text x="80" y="258" font-size="13" fill="#64748b">the platform on your machine</text>
<text x="452" y="339" fill="#a99eff" font-size="11" font-weight="750" letter-spacing="1.8">OPTIONAL RELAY</text>
<text x="452" y="363" fill="#f7f7fb" font-size="15" font-weight="700">Terminal · Media · Notifications · Enhanced voice</text>
<text x="452" y="385" fill="#858da3" font-size="12">One pairing grant, scoped by feature</text>
<path d="M386 403C552 403 650 425 740 425C790 425 815 354 850 330" fill="none" stroke="#8b7cff" stroke-width="3" stroke-dasharray="9 7" marker-start="url(#relayArrow)" marker-end="url(#relayArrow)"/>
<path d="M386 420C560 420 684 455 850 462" fill="none" stroke="#8b7cff" stroke-width="3" stroke-dasharray="9 7" marker-end="url(#relayArrow)"/>
</g>
<rect x="60" y="360" width="300" height="110" rx="14" fill="#efeafb" stroke="#6b4fb0" stroke-width="2" stroke-dasharray="7 5"/>
<text x="80" y="401" font-size="17" font-weight="700" fill="#5b3fa0">+ Relay plugin</text>
<text x="80" y="427" font-size="12" fill="#64748b">optional — install on your Hermes</text>
<text x="80" y="445" font-size="12" fill="#64748b">host, then pair to unlock</text>
<g aria-label="Your devices">
<text x="854" y="141" fill="#858da3" font-size="11" font-weight="700" letter-spacing="2.2">YOUR DEVICES</text>
<rect x="850" y="158" width="336" height="330" rx="22" fill="#0b0e17" stroke="#242a3d" stroke-width="1.5"/>
<!-- ===================== RIGHT: your devices ===================== -->
<rect x="880" y="250" width="300" height="130" rx="14" fill="#eef2f7" stroke="#334155" stroke-width="2"/>
<text x="900" y="303" font-size="21" font-weight="700" fill="#1e293b">Hermes-Relay app</text>
<text x="900" y="331" font-size="13" fill="#64748b">your Android phone</text>
<rect x="874" y="186" width="288" height="190" rx="18" fill="url(#deviceCard)" stroke="#4f5b7c" stroke-width="2"/>
<rect x="898" y="207" width="38" height="58" rx="9" fill="#0a0c13" stroke="#8b7cff" stroke-width="1.5"/>
<rect x="905" y="214" width="24" height="39" rx="4" fill="#171934"/>
<circle cx="917" cy="258" r="2.5" fill="#7567e8"/>
<text x="952" y="220" fill="#f7f7fb" font-size="18" font-weight="700">Hermes-Relay Android</text>
<text x="952" y="245" fill="#9ca3b5" font-size="12.5">Google Play or sideload APK</text>
<rect x="898" y="287" width="118" height="25" rx="12.5" fill="#123b32"/>
<text x="957" y="304" fill="#79e6ca" font-size="10.5" font-weight="750" text-anchor="middle">STANDARD READY</text>
<rect x="1026" y="287" width="112" height="25" rx="12.5" fill="#28204d"/>
<text x="1082" y="304" fill="#b9b1ff" font-size="10.5" font-weight="750" text-anchor="middle">RELAY PAIRED</text>
<rect x="898" y="327" width="240" height="28" rx="10" fill="#2f2111" stroke="#d78b29"/>
<text x="1018" y="346" fill="#f2bd72" font-size="11" font-weight="700" text-anchor="middle">Sideload adds Device Control</text>
<!-- sideload gate, attached to the device where Device Control lives -->
<rect x="880" y="392" width="300" height="36" rx="10" fill="#fdf3e0" stroke="#c2820c" stroke-width="1.5"/>
<text x="1030" y="415" font-size="12" font-weight="600" fill="#9a6313" text-anchor="middle">Device Control needs the sideload build</text>
<rect x="874" y="410" width="288" height="60" rx="14" fill="#151326" stroke="#7567e8" stroke-width="1.5"/>
<rect x="898" y="426" width="34" height="27" rx="5" fill="#28204d"/>
<path d="M906 434l5 5-5 5M915 444h8" fill="none" stroke="#b9b1ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<text x="948" y="436" fill="#f7f7fb" font-size="16" font-weight="700">Hermes-Relay CLI</text>
<text x="948" y="455" fill="#9ca3b5" font-size="11.5">Hands on any paired computer</text>
</g>
<rect x="905" y="446" width="250" height="64" rx="12" fill="#efeafb" stroke="#6b4fb0" stroke-width="2" stroke-dasharray="7 5"/>
<text x="924" y="478" font-size="16" font-weight="700" fill="#5b3fa0">Relay CLI</text>
<text x="924" y="498" font-size="11" fill="#64748b">the agent's hands on any computer</text>
<!-- footnote: the third axis, kept subtle -->
<text x="60" y="558" font-size="12.5" fill="#64748b">
<tspan font-weight="700" fill="#167a6e">Vanilla Hermes</tspan> works the moment you point the app at your server.
<tspan font-weight="700" fill="#6b4fb0">+ Relay plugin</tspan> adds power tools.
<tspan font-weight="700" fill="#9a6313">+ Sideload build</tspan> adds phone control.
</text>
<g aria-label="Architecture guarantees">
<rect x="54" y="526" width="1132" height="58" rx="16" fill="#0b0e17" stroke="#242a3d"/>
<circle cx="82" cy="555" r="5" fill="#37d5b0"/>
<text x="98" y="560" fill="#d9dce5" font-size="13" font-weight="650">Chat never routes through Relay</text>
<line x1="348" y1="542" x2="348" y2="568" stroke="#2b3042"/>
<circle cx="382" cy="555" r="5" fill="#768097"/>
<text x="398" y="560" fill="#d9dce5" font-size="13" font-weight="650">API server is compatibility fallback only</text>
<line x1="724" y1="542" x2="724" y2="568" stroke="#2b3042"/>
<circle cx="758" cy="555" r="5" fill="#f0a84f"/>
<text x="774" y="560" fill="#d9dce5" font-size="13" font-weight="650">Device Control requires Relay + sideload</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

+1
View File
@@ -73,6 +73,7 @@ the upstream contract identifiers it depends on.
| `queued_follow_up` | Two explicitly owned turns and ordered queue drainage |
| `scope_rejection_inputs` | Exact, foreign, and unscoped event inputs |
| `terminal_gap_activate` | Socket closes after live output; replacement `session.activate` reports `running=false`; history is authoritative |
| `terminal_gap_active_list` | An exact Android-owned turn receives deltas but no terminal; `session.active_list` reports the same live/durable owner idle; history is authoritative |
| `terminal_gap_session_info` | Scoped `session.info {running:false}` settles a turn without `message.complete` |
| `active_status_lifecycle` | `session.active_list` reports starting, working, waiting, and idle, then a complete empty process-wide snapshot permits removal of unambiguously owned prior rows |
| `active_status_profile_scope` | A row has no profile metadata and a caller profile hint has no effect; the client must use exact client-held ownership and reject invented attribution |
+26 -26
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "b4c1f6ecb44c77d5da585523788caf733180cebefed826446ec9acd2ad4a52ba",
"main": "227a53fc7a1fd3cf113b56b507c7d65372769db179f4772be892138ff0ac8060",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -25,12 +25,12 @@
"docs_locale": "de",
"docs_source_sha256": {
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "8e46128280d9db518ea0dcf13109929ff93cfe9b0b491db808ff72ad3be862a4",
"guide/getting-started.md": "de9312d211a694fec9b2a7707b406bd0010a25e21c7c345060073ba3da12e843",
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/quick-start.md": "57620215fb98a676f1b92d45596d44aa369dacc6e16dad17242f6e3e5cc38906",
"guide/getting-started.md": "a30d0553f962f780573054dfa148a5c1dd88e258cb8fcaee9fdaed63346efeba",
"guide/release-tracks.md": "5d9ba422975459581fdbbc0b9e6f9b9c7dc53f9196606455b418da11d63d3a63",
"guide/troubleshooting.md": "9ace84208d2109d3ae7b35a21838eae146db3171236568d3ae60fb8d9de882cf"
},
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
"website_source_sha256": "d46cda2f01180304190606db2f7817f84d47f49d33806898cda242b58b1e8f20"
},
"en": {
"native_name": "English",
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "b4c1f6ecb44c77d5da585523788caf733180cebefed826446ec9acd2ad4a52ba",
"main": "227a53fc7a1fd3cf113b56b507c7d65372769db179f4772be892138ff0ac8060",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -60,19 +60,19 @@
"docs_locale": "es",
"docs_source_sha256": {
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "8e46128280d9db518ea0dcf13109929ff93cfe9b0b491db808ff72ad3be862a4",
"guide/getting-started.md": "de9312d211a694fec9b2a7707b406bd0010a25e21c7c345060073ba3da12e843",
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/quick-start.md": "57620215fb98a676f1b92d45596d44aa369dacc6e16dad17242f6e3e5cc38906",
"guide/getting-started.md": "a30d0553f962f780573054dfa148a5c1dd88e258cb8fcaee9fdaed63346efeba",
"guide/release-tracks.md": "5d9ba422975459581fdbbc0b9e6f9b9c7dc53f9196606455b418da11d63d3a63",
"guide/troubleshooting.md": "9ace84208d2109d3ae7b35a21838eae146db3171236568d3ae60fb8d9de882cf"
},
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
"website_source_sha256": "d46cda2f01180304190606db2f7817f84d47f49d33806898cda242b58b1e8f20"
},
"ja": {
"native_name": "日本語",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "b4c1f6ecb44c77d5da585523788caf733180cebefed826446ec9acd2ad4a52ba",
"main": "227a53fc7a1fd3cf113b56b507c7d65372769db179f4772be892138ff0ac8060",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -84,19 +84,19 @@
"docs_locale": "ja",
"docs_source_sha256": {
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "8e46128280d9db518ea0dcf13109929ff93cfe9b0b491db808ff72ad3be862a4",
"guide/getting-started.md": "de9312d211a694fec9b2a7707b406bd0010a25e21c7c345060073ba3da12e843",
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/quick-start.md": "57620215fb98a676f1b92d45596d44aa369dacc6e16dad17242f6e3e5cc38906",
"guide/getting-started.md": "a30d0553f962f780573054dfa148a5c1dd88e258cb8fcaee9fdaed63346efeba",
"guide/release-tracks.md": "5d9ba422975459581fdbbc0b9e6f9b9c7dc53f9196606455b418da11d63d3a63",
"guide/troubleshooting.md": "9ace84208d2109d3ae7b35a21838eae146db3171236568d3ae60fb8d9de882cf"
},
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
"website_source_sha256": "d46cda2f01180304190606db2f7817f84d47f49d33806898cda242b58b1e8f20"
},
"pt-BR": {
"native_name": "Português (Brasil)",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "b4c1f6ecb44c77d5da585523788caf733180cebefed826446ec9acd2ad4a52ba",
"main": "227a53fc7a1fd3cf113b56b507c7d65372769db179f4772be892138ff0ac8060",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -108,19 +108,19 @@
"docs_locale": "pt-BR",
"docs_source_sha256": {
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "8e46128280d9db518ea0dcf13109929ff93cfe9b0b491db808ff72ad3be862a4",
"guide/getting-started.md": "de9312d211a694fec9b2a7707b406bd0010a25e21c7c345060073ba3da12e843",
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/quick-start.md": "57620215fb98a676f1b92d45596d44aa369dacc6e16dad17242f6e3e5cc38906",
"guide/getting-started.md": "a30d0553f962f780573054dfa148a5c1dd88e258cb8fcaee9fdaed63346efeba",
"guide/release-tracks.md": "5d9ba422975459581fdbbc0b9e6f9b9c7dc53f9196606455b418da11d63d3a63",
"guide/troubleshooting.md": "9ace84208d2109d3ae7b35a21838eae146db3171236568d3ae60fb8d9de882cf"
},
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
"website_source_sha256": "d46cda2f01180304190606db2f7817f84d47f49d33806898cda242b58b1e8f20"
},
"ru": {
"native_name": "Русский",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "b4c1f6ecb44c77d5da585523788caf733180cebefed826446ec9acd2ad4a52ba",
"main": "227a53fc7a1fd3cf113b56b507c7d65372769db179f4772be892138ff0ac8060",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "b4c1f6ecb44c77d5da585523788caf733180cebefed826446ec9acd2ad4a52ba",
"main": "227a53fc7a1fd3cf113b56b507c7d65372769db179f4772be892138ff0ac8060",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -147,12 +147,12 @@
"docs_locale": "zh-CN",
"docs_source_sha256": {
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "8e46128280d9db518ea0dcf13109929ff93cfe9b0b491db808ff72ad3be862a4",
"guide/getting-started.md": "de9312d211a694fec9b2a7707b406bd0010a25e21c7c345060073ba3da12e843",
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/quick-start.md": "57620215fb98a676f1b92d45596d44aa369dacc6e16dad17242f6e3e5cc38906",
"guide/getting-started.md": "a30d0553f962f780573054dfa148a5c1dd88e258cb8fcaee9fdaed63346efeba",
"guide/release-tracks.md": "5d9ba422975459581fdbbc0b9e6f9b9c7dc53f9196606455b418da11d63d3a63",
"guide/troubleshooting.md": "9ace84208d2109d3ae7b35a21838eae146db3171236568d3ae60fb8d9de882cf"
},
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
"website_source_sha256": "d46cda2f01180304190606db2f7817f84d47f49d33806898cda242b58b1e8f20"
}
}
}
+2 -2
View File
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.14.0 - Connections, delegated work, Git, and voice
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
Standard Chat, Voice, attachments, returned files, current-session Git, usage, and Hermes notices now prefer upstream Dashboard and Gateway support without requiring Relay. Returned media stays loaded, voice survives Relay removal, and Settings clearly separates standard Hermes from Relay tools. Supervised Mode also gains app-specific parent access and recovery.
```
## Category
+66 -53
View File
@@ -24,7 +24,7 @@ Current capabilities are split between vanilla upstream Hermes and optional Rela
| **Vanilla Hermes voice** | No | Dashboard `/api/audio/transcribe`, streaming `/api/audio/speak-stream`, and compatible `/api/audio/speak` fallback with the Manage session |
| **Terminal** | Yes | Secure remote shell access to the Hermes server via tmux |
| **Bridge / Device Control** | Yes | Agent controls the sideload phone with explicit safety gates |
| **Relay power features** | Yes | Remote access, notification companion, provider-native voice, desktop tooling, media relay |
| **Relay tools and enhancements** | Yes | Remote access, notification companion, provider-native voice, desktop tooling, media compatibility and metadata |
The standard Vanilla Hermes connection needs only the Dashboard/Gateway surface.
An API-server endpoint can be retained or added for explicit API-only chat and
@@ -139,11 +139,14 @@ sessions, and a timeout must not start another long read automatically.
Progressive-page failures likewise stop automatic near-end requests and expose
an explicit retry action; connection/profile changes cancel and reset page state.
Optional Relay plugin metadata is not a standard connection prerequisite. In
particular, Git repository discovery is a per-connection user opt-in, defaults
off, and starts only from the Git workspace. Its filesystem and Git subprocess
work runs outside the Dashboard event loop so an accessory scan cannot delay
auth tickets, Gateway readiness, sessions, or Manage routes.
Optional Relay plugin metadata is not a standard connection prerequisite.
Upstream session `cwd`, repository-root, and branch metadata own the active
session's Git context. Relay may enhance that context with repository discovery,
working-tree details, diffs, and guarded mutations. Host discovery is a
per-connection user opt-in, defaults off, and starts only from the Git workspace.
Its filesystem and Git subprocess work runs outside the Dashboard event loop so
an accessory scan cannot delay auth tickets, Gateway readiness, sessions, or
Manage routes.
### 3.2 Protocol
@@ -185,7 +188,7 @@ Connection lifecycle, auth, keepalive.
**Note:** Vanilla Hermes chat prefers the upstream dashboard `/api/ws` gateway when
Manage auth is ready, then falls back to Hermes API Server HTTP/SSE paths (see
Section 6.2). It does not traverse the Relay server. Relay voice, bridge,
terminal, notifications, and inbound media do go through Relay. Relay voice
terminal, notifications, and Relay-specific media enhancements do go through Relay. Relay voice
HTTP/WSS routes accept either a Relay session token with an active
`voice:config`, `voice:stt`, `voice:tts`, or `voice:realtime` grant, depending
on the route, or the Hermes API bearer token; that API bearer exception does not
@@ -600,7 +603,7 @@ Bottom navigation bar with 4 tabs:
3. Remaining top-bar actions (session drawer hamburger, ambient toggle, etc.).
- **Profile Shelf** — the active avatar/name/chevron capsule opens Agent Passport; inactive profiles are avatar-only 48 dp switch targets; a fixed overflow opens the canonical full switcher also used by Passport. The shelf scrolls horizontally, honors `ProfilePresentationStore` ordering/hidden preferences, keeps a hidden selected profile disclosed, and disappears when only one visible identity remains. Hermes-owned avatars win by default, followed by device-local icons and display initials; an explicit per-connection/profile **This phone only** override lets the local icon win without mutating Hermes. Server default uses a home glyph and remains distinct from a profile literally named `default`.
- **Hermes-owned profile identity** — on a current Gateway, Android calls `profiles.list {include_sessions:false}` and consumes bounded `ui_meta` plus `has_avatar`. A true avatar flag triggers `profiles.get_asset`; validated server bytes are cached per connection/profile and win over the older device-local `ProfileIconStore`. A false flag or successful clear removes only the server cache. Refresh generations and exact connection identity prevent a late fetch from repainting another connection or resurrecting a cleared avatar.
- **Separated shared and phone avatar controls** — **Shared across Hermes** directly selects or removes the upstream `profiles.set_asset` avatar without changing local presentation. **This phone only** is a persisted per-connection/profile override populated from a phone image or Relay-host `GET /api/profiles/{name}/avatar`; selecting an image enables the override, while disabling it immediately returns to the shared avatar. Phone-local PNG/JPEG/WebP/GIF bytes are magic-checked and capped at 8 MB; Coil renders animated GIF/WebP consistently anywhere the profile icon appears. The shared picker accepts any image Android can decode, applies its display orientation, and downscales/re-encodes when necessary while retaining the exact upstream PNG/JPEG/WebP and 2,000,000-byte storage contract. Pet sheets and Sphere skins never enter `ui_meta` or profile assets.
- **Separated shared and phone avatar controls** — **Shared across Hermes** directly selects or removes the upstream `profiles.set_asset` avatar without changing local presentation. **This phone only** is a persisted per-connection/profile override populated from a phone image. The Relay-host `GET /api/profiles/{name}/avatar` conventional-file importer is a legacy enhancement only; it does not own or replace upstream profile assets. Selecting a phone image enables the override, while disabling it immediately returns to the shared avatar. Phone-local PNG/JPEG/WebP/GIF bytes are magic-checked and capped at 8 MB; Coil renders animated GIF/WebP consistently anywhere the profile icon appears. The shared picker accepts any image Android can decode, applies its display orientation, and downscales/re-encodes when necessary while retaining the exact upstream PNG/JPEG/WebP and 2,000,000-byte storage contract. Pet sheets and Sphere skins never enter `ui_meta` or profile assets.
- **Upstream animated pets** — the agent sheet consumes the profile-scoped Gateway `pet.info`, `pet.gallery`, `pet.select`, and `pet.disable` contracts. Android caches the bounded PNG/WebP sprite sheet by connection, effective profile, and `spritesheetRevision`; it sends `knownRevision` on refresh and reuses the existing bounded pet renderer for the returned geometry, row taxonomy, and activity states. The active upstream pet becomes the phone companion unless the user explicitly selected a phone-local floating pet. Selection and disable write Hermes `display.pet.*` state and therefore follow the profile across current Hermes surfaces; a method-not-found response leaves older hosts on the established local pet flow.
- **Profile creation** — Manage uses `profiles.create` on current Gateways and labels authentication as shared sign-in, copied credential snapshot, or isolated/no-copy. Android serializes `mirror_credentials` and `share_auth` explicitly, reports best-effort SOUL/model/credential results without claiming full success, and never receives or logs credentials. The user may explicitly enable the authenticated Dashboard create route as an older-host fallback only for the legacy shared/default choice; explicit isolation never degrades to an ambiguous older mutation.
- **Deletion boundary** — Hermes exposes no `profiles.delete` Gateway RPC. Android continues to delete profiles only through authenticated Dashboard `DELETE /api/profiles/{name}`.
@@ -610,7 +613,7 @@ Bottom navigation bar with 4 tabs:
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. A profile switch marks the replacement list loading before clearing the previous profile's rows and keeps that state until the exact-profile fetch settles, so an empty-state claim never flashes before server truth arrives. The process-owned conversation binding is the single connection/profile/session identity for Chat; selecting an All Profiles row atomically makes its owner the selected agent and persists that profile/session, while merely browsing All Profiles changes no agent state. Lifecycle or locale-driven Activity recreation cannot replace an explicit binding with stale persisted state, and asynchronous list/history/mutation work is accepted only for the binding's exact namespace. A profile lock hides All Profiles and rejects stale/deep-linked cross-profile opens. The All Profiles browser mode otherwise survives Activity state restoration and refetches its rows after recreation. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
- **Cold profile hydration** — a persisted named profile scopes its Dashboard session directory and last-session restore immediately, before `/api/profiles` metadata is available. Server-default selection waits for the lightweight active-profile scope. Roster, avatars, pets, skills, and model metadata never precede the first directory result. The startup sphere releases after route selection; Chat keeps identity and cached rows mounted while its existing animated status surfaces show Gateway wake, session restore, and directory loading.
- **Authoritative session activity** — one composite registry keyed by connection, normalized profile, and durable session id drives the drawer, filters, grouping, animation, accessibility, and the visible composer. Exact pending approval/clarify/sudo/secret/MCP requests produce **Needs input**; the Gateway's process-wide `session.active_list` supplies **Starting**, **Working**, and **Idle**; exact terminal or `session.info {running:false}` can settle the matching generation. Because active-list rows normally have no profile metadata, Android assigns a row through exact foreground/detached ownership already held by that client, explicit profile metadata if a future upstream sends it, or the currently selected passive session when its durable id has exactly one owner in the current connection directory. Duplicate same-id owners across profiles remain unresolved and create no status. Resolved rows from a partial snapshot may update their exact owners, but disappearance settles a scope only when the successful process-wide snapshot was completely and unambiguously resolved for it. Restart/checkpoint recovery is **Checking**; a failed or unsupported live refresh is **Unavailable**, never inferred Idle. REST `is_active` remains recency metadata only. `process.list` may add a separate **Background work** indicator and never keeps the parent conversation Working. Old socket generations, ambiguous bare session ids, and delayed snapshots cannot revive newer settled state.
- **Authoritative session activity** — one composite registry keyed by connection, normalized profile, and durable session id drives the drawer, filters, grouping, animation, accessibility, and the visible composer. Exact pending approval/clarify/sudo/secret/MCP requests produce **Needs input**; the Gateway's process-wide `session.active_list` supplies **Starting**, **Working**, and **Idle**; an exact terminal, `session.info {running:false}`, or an exact live/durable active-list row reporting Idle can settle only the matching Android-owned turn and progress generation. Because active-list rows normally have no profile metadata, Android assigns a row through exact foreground/detached ownership already held by that client, explicit profile metadata if a future upstream sends it, or the currently selected passive session when its durable id has exactly one owner in the current connection directory. Duplicate same-id owners across profiles remain unresolved and create no status. Resolved rows from a partial snapshot may update their exact owners, but disappearance settles a scope only when the successful process-wide snapshot was completely and unambiguously resolved for it. Restart/checkpoint recovery is **Checking**; a failed or unsupported live refresh is **Unavailable**, never inferred Idle. REST `is_active` remains recency metadata only. `process.list` may add a separate **Background work** indicator and never keeps the parent conversation Working. Old socket generations, ambiguous bare session ids, delayed snapshots, and snapshots crossed by newer turn events cannot settle or revive a newer generation.
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible Android-owned turn without sending `session.interrupt`; each Android-owned running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Opening, foregrounding, or selecting a saved session without that exact checkpoint is read-only observation: Android warms only the socket, reads profile-scoped history, and polls `session.active_list` without `session.resume`, `session.activate`, `prompt.submit`, or `session.interrupt`. A Desktop/TUI-owned turn therefore remains owned by its producing client; Android refreshes persisted progress and performs one final history read when the runtime settles. Explicit send/config actions may resume the destination session, explicit Stop still interrupts, and Direct API compatibility chat stays single-stream and cancels on navigation.
- **Queued Gateway follow-ups** — every local queued item is immutably scoped to its originating connection, profile, stored session, transport, and run generation; only that run's completion can make it eligible, and switching sessions shows only that session's queue. Restored text queues retain the same scope, while unavailable/deleted destinations and non-restorable attachment queues fail visibly instead of following the current composer. Drained messages add `queued: true` to `prompt.submit`; ordinary sends omit the field. Authoritative submit rejections (`4004`, `4018`, `4028`, `4029`, `4030`, `4090`, `5008`, `5070`, and `5071`) preserve the server message and never fall through to API-server SSE.
- **Durable composer drafts** — each connection/profile/session owns one app-private draft containing text, quote/edit context, and pending attachment bytes. Metadata and content-addressed blobs live under Android's no-backup directory, are capped at 64 drafts and 128 MB of retained blobs outside the active draft, flush when Chat backgrounds, and are removed after a successful send. Session/profile/connection navigation saves the previous owner before restoring the destination; an opened cross-profile session uses its actual owning profile rather than the global picker.
@@ -665,6 +668,8 @@ The bridge UI drives — and is driven by — Tier 5 safety-rails (`BridgeSafety
- **Connections** (v0.6.0+) — lists every paired Hermes server with a per-card status chip. Actions: rename (inline), re-pair (reuses `ConnectionWizard` with `connectionId` nav arg), revoke, remove. Add-connection button launches the standard QR flow. Settings briefly treats a paired + disconnected relay as **Connecting** during the reconnect grace window, then promotes it to **Relay unreachable - tap to reconnect** if the live socket does not recover. API / Relay / Session detail sheets include compact sanitized recent-activity tails, and **Settings -> Diagnostics** shows the consolidated app-level API, relay, session, endpoint, voice, Pair-readiness, credential-store recovery, history-failure, and rejected-Send evidence without secrets. See `docs/decisions.md` §19.
- **Connection (single-server settings)** — summary-first detail for one Hermes installation. Dashboard/Gateway health drives standard Chat, Manage, Sessions, and Voice readiness. Direct API compatibility and Relay extensions appear as independently optional capabilities. Dashboard/Gateway address and network paths are edited under Routes. Advanced retains only the optional direct API credential, explicit direct Relay endpoint override, and insecure-development controls; missing API or Relay settings never make a healthy Dashboard/Gateway connection look broken. Every Relay QR, enter-code, and show-code method uses the shared connection-scoped Pair flow. Transport security posture and paired-device grants remain visible without leading the normal setup flow with ports or bearer keys.
- **Chat** — Show reasoning toggle, smooth auto-scroll toggle (live-follow streaming, default on), show token usage toggle, app context prompt toggle, tool call display (Off/Compact/Detailed), streaming endpoint selector (`auto` / `sessions` / `runs`), Stats for Nerds (analytics charts)
- **Media** — standard Chat settings for inbound attachment download policy, size limits, sensitive-media treatment, and the shared on-device cache. Authenticated upstream Dashboard file routes are preferred; pairing Relay adds compatibility delivery and sensitivity metadata but does not own the settings surface.
- **Threads** — Relay-tool settings for the opt-in `phone` platform, named proactive conversations, and delivery behavior. Standard Dashboard session history remains separate and does not require Relay.
- **Voice** — route-aware voice engine selector (`Vanilla Hermes` via dashboard audio, `Relay Voice Output`, and experimental `Realtime Agent`), global interaction mode (tap / hold / continuous), silence threshold slider, a final-answer-only speech policy, Auto-TTS toggle, selected-engine cards for dashboard or relay-backed settings, language picker, and a Test Current Engine card. Final-answer-only keeps tool/service progress and intermediate commentary visual while both voice engines wait to speak the settled answer; approvals, confirmation questions, and blocking failures remain actionable. Vanilla Hermes voice depends on Manage/dashboard auth; Relay-backed engines run a fast relay health preflight before uploading audio or opening a realtime provider session so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
- **Notification companion** — opt-in status, "Open Android Settings" action, test notification dump
- **Permissions** — central permission/capability review screen linked from Settings and onboarding. It makes the Vanilla Hermes path explicit ("Chat and Manage" need no Android runtime grant), lists optional camera/microphone/notification access with current status and Android Settings links, and shows sideload-only Device Control requirements only in the sideload flavor.
@@ -709,7 +714,7 @@ HTTP routes registered by `create_app()` in `plugin/relay/server.py`:
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code. Used by the pair command (`hermes pair`, `/hermes-relay-pair`, or compatibility `hermes-pair`) to inject codes that will appear in QR payloads. Request: `{"code": "ABCD12"}`. Rejects non-loopback peers with HTTP 403. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) for dashboard and CLI/tray pair/repair flows. Optional request field `dashboard_url` is copied into the QR payload for custom dashboard routes. |
| `/api/profiles/{name}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}`. Loopback callers receive the parsed `config.yaml` and absolute path. Remote callers require a relay session bearer and receive only the explicitly public `description` and `model.default` fields with `path: "config.yaml"`; arbitrary provider, platform, integration, and extension sections never cross the remote boundary. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
| `/api/profiles/{name}/avatar` | GET | Profile-scoped avatar discovery and image delivery. Searches direct children of the profile home for conventional names, preferring `avatar.*` then `profile.*` (`png`, `jpg`, `jpeg`, `webp`, `gif`; additional `profile-image`, `agent`, and `icon` stems are accepted). Synthetic `default` follows a valid sticky `active_profile` marker, matching its advertised identity. The resolved file must remain inside the profile home and satisfy the Relay media-size policy. Same loopback-or-session-bearer auth as the other profile reads. 404 when the profile or an image is absent. Android copies returned bytes into its existing device-local per-profile icon store. |
| `/api/profiles/{name}/avatar` | GET | Legacy Relay enhancement for importing a conventional host file into this phone's local profile-icon store. It does not represent the upstream shared profile asset, which remains owned by `profiles.set_asset`. Searches direct children of the profile home for conventional names, preferring `avatar.*` then `profile.*` (`png`, `jpg`, `jpeg`, `webp`, `gif`; additional `profile-image`, `agent`, and `icon` stems are accepted). The resolved file must remain inside the profile home and satisfy the Relay media-size policy. |
| `/api/profiles/{name}/skills` | GET | Profile-scoped skill enumeration. Walks `<profile>/skills/<category>/<skill>/SKILL.md` recursively; returns `{profile, skills: [{name, category, description, path, enabled: true}], total}`. Same auth model as `/config`. `name`/`description` come from YAML frontmatter when present, else directory basename. All skills report `enabled: true` today — see §22 for the toggle stub. |
| `/api/profiles/{name}/soul` | GET | Profile-scoped raw `SOUL.md` read. Returns `{profile, path, content, exists, size_bytes}` with optional `truncated: true` when content exceeds the 200KB inline cap. Absent SOUL.md returns 200 with `exists: false` and an empty content string so the Inspector can distinguish "no soul" from transport failure. Same auth model as `/config`. 404 on unknown profile; 500 `{error: "soul_read_failed"}` on decode error. See §22 in decisions.md. |
| `/api/profiles/{name}/memory` | GET | Profile-scoped memory listing. Returns `{profile, memories_dir, entries: [{name, filename, path, content, size_bytes, truncated}], total}` for `*.md` files directly under `<profile>/memories/` (non-recursive). Ordering: `MEMORY.md` first, `USER.md` second, remainder alphabetical. Each entry capped at 50KB inline with `truncated: true` when larger. Absent memories dir → 200 with empty `entries` array. Same auth model as `/config`. 404 on unknown profile. See §22 in decisions.md. |
@@ -802,58 +807,66 @@ app-root intent coordinator retains a cold-start request until the chat context
settles, while `ChatViewModel` owns the existing transport-aware new-chat
lifecycle and the one-shot composer prefill.
The relay server is **not involved** in chat streaming itself. It remains the home for bridge, terminal, and — as of 2026-04-11 — **inbound media delivery** (see 6.2a). As an optional compatibility enhancement, a paired phone may poll `GET /chat/image-activity?profile=<name>&session_id=<id>&since=<epoch>` during an active Standard Gateway turn. Relay reads the selected profile's Hermes `state.db` in read-only mode and reports persisted `image_generate` start/completion state. This fills only the animation lifecycle gap on upstream configurations that suppress tool progress; it does not proxy prompts, deltas, results, or chat control, and Android stops using it when the route is absent.
The relay server is **not involved** in chat streaming itself. It remains the home for bridge, terminal, and additive media compatibility (see 6.2a). As an optional compatibility enhancement, a paired phone may poll `GET /chat/image-activity?profile=<name>&session_id=<id>&since=<epoch>` during an active Standard Gateway turn. Relay reads the selected profile's Hermes `state.db` in read-only mode and reports persisted `image_generate` start/completion state. This fills only the animation lifecycle gap on upstream configurations that suppress tool progress; it does not proxy prompts, deltas, results, or chat control, and Android never starts the poller when Relay is not configured.
### 6.2a Inbound Media (Agent → Phone file delivery)
Tool-produced files (screenshots today, video/audio/PDF/other in the future) reach the phone via a plugin-owned file-serving surface on the relay, decoupled from the chat SSE stream itself. Only a short opaque token rides the chat stream; the bytes flow out-of-band over authenticated HTTPS.
Current upstream Hermes Desktop treats generated media and host-local files as a
standard Dashboard capability. Android follows the same ownership instead of
requiring the optional Relay plugin.
**Why this lives in the plugin, not upstream hermes-agent:** `APIServerAdapter.send()` (in upstream `gateway/platforms/api_server.py`) is an explicit no-op — the HTTP API adapter does not implement `send_document`. Upstream's `extract_media()` / `send_document()` pipeline only fires for push platforms (Telegram, Feishu, WeChat) and non-streaming paths. On our streaming HTTP surface, `MEDIA:` tags in tool output have always passed through as literal text. Rather than patch upstream, we added our own endpoints and marker format. See [docs/decisions.md §14](decisions.md) for the full trust and resource model.
**Source precedence:**
1. Use bounded `data:` content and explicit HTTP(S) sources directly.
2. Resolve host-local `MEDIA:` paths, `@image:` directives, file links, and
generated-file references through authenticated Dashboard
`/api/files/download`, enforce the Android media cap while reading, then play
or preview the bounded local cache. Current upstream also exposes
`/api/files/stream` and `/api/fs/read-data-url` for official Desktop's Range
playback and bounded preview paths; Android does not claim those routes yet.
3. Resolve explicit `MEDIA:hermes-relay://<token>` references through the paired
Relay. Relay `/media/by-path` remains an older-host compatibility fallback and
can add sensitivity metadata or cache-compatible delivery, but it never
outranks a compatible upstream route.
4. If neither owner is available, keep one stable, path-free attachment card.
Missing optional Relay configuration is not a failed transfer, is not
retryable until a usable route exists, and must not emit a global connection
error or re-fetch on every history reconciliation.
**Wire format:**
```
Screenshot captured (1280x720)
MEDIA:hermes-relay://<url-safe-16-byte-token>
```
Every Dashboard fetch uses the exact active connection/profile and authenticated
origin. Connection, profile, session, and generation ownership are checked again
before downloaded bytes may update message state. Upstream authentication or
policy rejection remains an actionable upstream error; capability absence on an
older host may fall back to a paired Relay without changing Chat readiness.
**Server:** media routes on `plugin/relay/server.py`:
- `POST /media/register` — **loopback-only**. Body `{"path", "content_type", "file_name"}`. Validates path is absolute, resolves (`os.path.realpath`) under an allowed root, exists, is a regular file, fits under `RELAY_MEDIA_MAX_SIZE_MB`. Generates `secrets.token_urlsafe(16)` (128 bits entropy), stores the token → entry mapping in an in-memory `OrderedDict` LRU (capped at `RELAY_MEDIA_LRU_CAP`, TTL `RELAY_MEDIA_TTL_SECONDS`). Returns `{ok, token, expires_at}`. Used when a host-local tool explicitly wants to publish a file.
- `GET /api/plugins/hermes-relay/provider-usage?profile=<id>&session_id=<id>` — authenticated Dashboard-plugin surface that resolves the active Codex pool entry directly from the live Gateway session, without requiring another turn. Android prefers this route when Dashboard auth is available.
- `GET /usage/providers?profile=<id>&session_id=<id>` — bearer-authenticated standalone Relay surface for Android provider account limits. Disabled unless `RELAY_PROVIDER_USAGE_ENABLED=1`. The validated profile ID scopes every credential/account lookup through Hermes's context-local home override. It reuses Hermes account snapshots for Codex and Nous, adds OpenCode Go percentage/reset windows, and returns no provider secrets. For Codex it reports every bounded pool entry with a safe label, hashed opaque id, effective status, and usage windows; the optional Gateway session id correlates the active entry from a secret-free profile-local hook snapshot. If that exact evidence is absent, active state is explicitly unknown. Android falls back to this route, then additive upstream Gateway `account.usage` as a single-account fallback.
- `GET /media/{token}` — requires `Authorization: Bearer <session_token>` against the existing `SessionManager` (same token WSS uses). Streams the file via `web.FileResponse` with the registered content type plus `Content-Disposition: inline; filename="..."` if the entry has a file name. 401 on missing/invalid bearer, 404 on unknown/expired token.
- `GET /media/by-path?path=<abs>&content_type=<optional>` — requires bearer auth. Shares the same sandbox validation as `/media/register` via a common `validate_media_path()` helper: absolute path, `realpath`-resolves under an allowed root, exists, is a regular file, fits under the size cap. Content-Type is the phone's hint if provided, otherwise guessed via `mimetypes.guess_type()`. This route exists specifically for **LLM-emitted bare-path markers** — upstream `agent/prompt_builder.py` instructs the model to include `MEDIA:/absolute/path/to/file` in its response text, so the bare-path form is the agent's native output, not just a fallback. 401 auth, 403 sandbox, 404 missing file.
- `POST /media/upload` — bearer-auth'd small upload route for phone-originated media. Accepts base64 content, writes a temp file, and registers it into the same media registry.
Downloaded bytes share the existing bounded `MediaCacheWriter` and
`FileProvider` path. The same cellular, maximum-size, cache-cap, and sensitive
media preferences apply regardless of whether upstream or Relay delivered the
file. `InboundAttachmentCard` therefore renders one source-neutral loading,
loaded, manual-download, unavailable, or failed state; it never labels a
standard attachment as a Relay connection failure.
**Phone:** parse → fetch → cache → render:
1. `ChatHandler.scanForMediaMarkers()` runs on every `onTextDelta`, unconditionally (not gated on `parseToolAnnotations`). Matches `MEDIA:hermes-relay://([A-Za-z0-9_-]+)` and fires `onMediaAttachmentRequested(messageId, token)`. A second regex matches the bare-path form `MEDIA:(/\S+)` and fires `onMediaBarePathRequested(messageId, path)` — the ViewModel then calls `RelayHttpClient.fetchMediaByPath()` to pull bytes via `GET /media/by-path`. A per-session `dispatchedMediaMarkers` set dedupes between real-time streaming scans and the post-stream `finalizeMediaMarkers` reconciliation pass. `loadMessageHistory` (invoked by the `session_end reload` pattern at every stream complete) re-runs the same parser on server-stored content so client-injected attachments survive the wholesale state replace. Both marker forms are stripped from the rendered message text.
2. `ChatViewModel` inserts a LOADING `Attachment` with `relayToken` set immediately (message updates via `ChatHandler.mutateMessage`).
3. On Wi-Fi, or on cellular when `autoFetchOnCellular` is true: `RelayHttpClient.fetchMedia(token)` issues `GET /media/{token}` with the bearer header. URL is derived by swapping `ws://`→`http://`, `wss://`→`https://` on the stored relay URL.
4. Bytes are checked against `maxInboundSizeMb`. If oversize → FAILED placeholder. Otherwise `MediaCacheWriter` writes them to `context.cacheDir/hermes-media/<sha1>.<ext>` with LRU eviction by mtime (capped at `cachedMediaCapMb`) and returns a `content://` URI via `FileProvider.getUriForFile(context, "${applicationId}.fileprovider", file)`.
5. The Attachment is flipped to LOADED with `cachedUri` set. `InboundAttachmentCard` dispatches by `(state × renderMode)`: `IMAGE` renders inline via `BitmapFactory.decodeByteArray` + `asImageBitmap`; `VIDEO`/`AUDIO`/`PDF`/`TEXT`/`GENERIC` render as tap-to-open file cards firing `ACTION_VIEW` with `FLAG_GRANT_READ_URI_PERMISSION` on the cached URI. Every message attachment group, including galleries and LOADING/FAILED cards, sits behind a compact collapse/expand header keyed by the message's stable UI identity. Collapsing changes presentation only: the header keeps the attachment count, first name/type, and restore affordance visible while existing retry, fetch, viewer, share, and save behavior remains mounted again after expansion.
6. On cellular with `autoFetchOnCellular` off: the initial LOADING placeholder settles to an actionable FAILED state with `errorMessage = "Tap to download"`, and `manualFetchAttachment()` re-runs the fetch ignoring the cellular gate. In-flight reads have a two-minute absolute timeout; every completed attempt publishes LOADED or an actionable FAILED state.
Relay token delivery remains available for phone-control screenshots and tools
that explicitly register a file. The registry retains its bearer authentication,
allowed-root validation, size cap, TTL, and LRU behavior. A stale token may be
unavailable after a Relay restart, but an ordinary upstream path remains
replayable through the Dashboard file routes.
**Fallback when relay isn't running:** the tool's `register_media()` call fails (connection refused / timeout / non-200) → tool logs a warning and returns the legacy bare-path form (`MEDIA:/tmp/...`). The phone's `onUnavailableMediaMarker` handler inserts a FAILED Attachment with `errorMessage = "Image unavailable — relay offline"`. Matches current behavior; placeholder is tidier than raw marker text.
Upstream already owns the general file-delivery instructions used by the agent.
Relay's profile-owned `register_system_prompt_section` entries stay additive:
they may describe Relay-only phone capabilities or sensitive-media metadata, but
must not tell the agent that Relay is required for ordinary media. The legacy
prompt wrapper remains only an older-host fallback and is fail-open.
Persisted USER history may also contain upstream-owned `@image:<absolute-path>`
directive lines. Android recognizes only bounded, full-line image directives
(including upstream's backtick/single-quote/double-quote path wrapping), removes
recognized host paths from visible text, and reconstructs at most eight
attachments. A paired Relay may resolve those paths through its authenticated
media route; a vanilla or unavailable route renders a path-free failed
attachment. Inline, relative, malformed, non-image, and unknown directives stay
as text and never trigger a fetch. Client-local outbound attachments win during
the immediate post-send reload, preventing a duplicate fetch/gallery entry.
**Known gap — session replay across relay restarts:** the `MediaRegistry` is in-memory. Restarting the relay invalidates all tokens. A user scrolling back into a session from yesterday sees FAILED placeholders for any now-stale token. Phone-side persistent cache (indexed by token or content hash) is the planned fix; filed as a DEVLOG follow-up.
**Known gap — auto-fetch threshold slider isn't enforced today.** The Settings → Inbound media → auto-fetch threshold knob is persisted but the fetch path currently only checks the cellular toggle + the hard max cap. Forward-compatibility placeholder; real enforcement needs a HEAD preflight or post-hoc byte rejection.
**Key classes:**
- **`MediaRegistry`** (`plugin/relay/media.py`) — in-memory token store, thread-safe via `asyncio.Lock`
- **`register_media()`** (`plugin/relay/client.py`) — stdlib `urllib.request` helper for in-process tool callers
- **`RelayHttpClient`** (Android) — OkHttp GET with Bearer auth + URL rewriting
- **`MediaCacheWriter`** (Android) — FileProvider-backed LRU cache in `cacheDir/hermes-media/`
- **`InboundAttachmentCard`** (Android) — single Compose component dispatched on `(state × renderMode)`, handles both inbound and outbound attachments
**Provider-usage enhancements:**
- `GET /api/plugins/hermes-relay/provider-usage?profile=<id>&session_id=<id>` is
the authenticated Dashboard-plugin surface that resolves an active Codex pool
entry from the live Gateway session without requiring another turn.
- `GET /usage/providers?profile=<id>&session_id=<id>` is the optional
bearer-authenticated standalone Relay fallback. It is disabled unless
`RELAY_PROVIDER_USAGE_ENABLED=1`, returns no provider secrets, and keeps
profile/account correlation explicitly unknown when exact evidence is absent.
Android falls back from these additive views to upstream Gateway
`account.usage` for the standard single-account surface.
### 6.2b Rich Cards (Agent → Phone structured UI, ADR 26)
+23 -11
View File
@@ -1,6 +1,6 @@
# Hermes-Relay Surface Matrix
Updated: 2026-08-29
Updated: 2026-08-31
This matrix records the v1.0.0 route ownership contract. It is meant to keep
future app, plugin, and agent work honest about what is vanilla upstream
@@ -29,6 +29,11 @@ Verified upstream source snapshot:
- Session activity contracts were rechecked against upstream `main` at
`d736f5d53f1d33fabad5a17cb070eb138b618fb8` in `tui_gateway/server.py`,
`tui_gateway/methods_session.py`, and `hermes_cli/web_routers/sessions.py`.
- Desktop media delivery was rechecked at upstream commit
`b20cc5f787ea816ea8645603b7b2ac8234dcb8b4` in
`apps/desktop/src/lib/media.ts`,
`apps/desktop/src/components/assistant-ui/markdown-text.tsx`, and
`hermes_cli/web_server.py`.
## Ownership
@@ -42,12 +47,13 @@ Verified upstream source snapshot:
| `/v1/skills`, `/v1/toolsets` | Upstream API server | No | Discovery | Authenticated read-only API-server skill/toolset inventory; Android Diagnostics summarizes enabled toolsets and Relay tool visibility. |
| Dashboard `/api/status`, `/api/auth/me` | Upstream dashboard | No | Manage auth and post-selection diagnostics | Dashboard cookie/session path; separate from API bearer. Optional status diagnostics include Nous bootstrap validity, resource pressure, and profile/gateway topology; these do not gate transport selection. |
| Dashboard `/api/auth/ws-ticket`, `/api/ws` | Upstream dashboard/tui_gateway | No | Preferred chat transport | Vanilla Hermes gateway chat path with live reasoning/thinking events. `message.complete` is the ordinary terminal event; `session.info {running:false}` is the authoritative settle backstop when a replacement socket missed that terminal frame. A reconnect reactivates the exact live runtime with `session.activate`; durable `session.resume` remains the cold-open path and an explicit rejection never creates a replacement context. |
| Gateway `session.active_list` | Upstream tui_gateway | No | Authoritative process-wide live activity | Returns attachable runtimes across the Gateway process, with live `id`, durable `session_key`, and `starting`, `working`, `waiting`, or `idle`. The only optional selector is `current_session_id`; rows normally carry no profile metadata. Android attributes a row from exact foreground/detached ownership already held by that client, explicit profile metadata if a future upstream sends it, or a unique match to the currently selected passive session in the current connection directory. Duplicate same-id owners across profiles remain unresolved. Unresolved rows stay unattributed, and absence settles a scope only after a complete, unambiguously resolved successful snapshot. Method-not-found or refresh failure is Unavailable, not Idle. Pending input outranks running work. |
| Gateway `session.active_list` | Upstream tui_gateway | No | Authoritative process-wide live activity | Returns attachable runtimes across the Gateway process, with live `id`, durable `session_key`, and `starting`, `working`, `waiting`, or `idle`. The only optional selector is `current_session_id`; rows normally carry no profile metadata. Android attributes a row from exact foreground/detached ownership already held by that client, explicit profile metadata if a future upstream sends it, or a unique match to the currently selected passive session in the current connection directory. Duplicate same-id owners across profiles remain unresolved. An exact live/durable Idle row may settle only the same Android-owned turn and unchanged progress generation when a terminal frame is missing; it never claims a passively observed Desktop/TUI turn. Unresolved rows stay unattributed, and absence settles a scope only after a complete, unambiguously resolved successful snapshot. Method-not-found or refresh failure is Unavailable, not Idle. Pending input outranks running work. |
| Dashboard `model.options` / `/api/model/*` | Upstream dashboard/tui_gateway | No | Provider/model inventory and selection | Source of truth for coherent provider/model identities. A reasoning boolean or exact effort list is consumed when present; clients do not infer provider identity from a model string alone. |
| Gateway `pet.info`, `pet.gallery`, `pet.select`, `pet.disable` | Upstream tui_gateway | No | Profile-scoped animated companion | `pet.info` supplies bounded PNG/WebP sheet bytes, revision, geometry, real frame counts, loop timing, scale, and row taxonomy. Android passes `knownRevision` to avoid duplicate sheet transfer, renders the active pet through its native activity-aware companion, and keeps phone-local pet packs separate. All four RPCs carry the effective profile. |
| Dashboard `/api/audio/transcribe`, `/api/audio/speak-stream`, `/api/audio/speak` | Upstream dashboard | No | Vanilla Hermes voice | Manage sign-in unlocks Vanilla Hermes voice. Assistant text streams into upstream speech when available; older hosts fall back to whole-request speech before audio starts. API server has no `/v1/audio/*` route today. |
| Dashboard `/api/files/download`, `/api/files/stream`, `/api/fs/read-data-url` | Upstream dashboard | No | Primary inbound files and official Desktop media/preview support | Android currently downloads host-local `MEDIA:` paths and file links through authenticated `/api/files/download`, then plays or previews the bounded local cache. Official Desktop additionally uses `/api/files/stream` for Range playback and `/api/fs/read-data-url` for bounded previews; Android does not claim those two routes yet. Direct HTTP/data URLs remain direct. |
| Dashboard `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*`, `/api/providers/custom-endpoints*` | Upstream dashboard | No | Manage | Do not proxy through Relay. MCP list/actions/OAuth carry Android's effective profile explicitly; Android detects hosted-OAuth support with a read-only missing-flow status GET and caches that capability per dashboard/profile. Hosted OAuth itself stays server-owned, opens only the returned HTTPS URL, and persists only the opaque flow id/server/profile plus a normalized non-secret dashboard/connection identity; polling is held whenever the active connection does not own that flow. Custom-endpoint routes are process-scoped in the current public contract, so Android does not claim or append profile scoping; credentials are write-only and blank edits preserve an existing key. |
| `/pairing/*`, `/sessions`, `/voice/*`, `/desktop/*`, `/media/*`, `/notifications/*` on Relay | Hermes-Relay plugin/server | Yes | Relay pairing, terminal, bridge, relay voice, desktop tools | Owned by `plugin/relay/server.py`; Android must gate behind Relay readiness/session grants. |
| `/pairing/*`, `/sessions`, `/voice/*`, `/desktop/*`, `/media/*`, `/notifications/*` on Relay | Hermes-Relay plugin/server | Yes | Relay pairing, terminal, bridge, relay voice, desktop tools, and additive media compatibility | Owned by `plugin/relay/server.py`; Android must gate these routes behind Relay readiness/session grants. Relay media tokens, sensitivity metadata, and older-host compatibility enhance the standard Dashboard media path; they do not make ordinary inbound files Relay-owned. |
| `POST /relay/model-capabilities` | Hermes-Relay plugin/server | Optional | Refine reasoning-effort choices for exact upstream provider/model pairs | Never supplies model inventory or gates chat. Missing, old, unpaired, malformed, or unreachable Relay falls back to standard advisory choices. Remote calls require a paired bearer with an active `chat` grant. |
| Dashboard `/api/plugins/hermes-relay/*` | Hermes-Relay dashboard plugin | Yes for live data | Relay dashboard tab and same-origin Relay ingress | FastAPI plugin backend proxies an explicit allowlist to the independently supervised loopback Relay. HTTP requires Dashboard auth plus `X-Hermes-Relay-Session`; WebSockets require a fresh Dashboard ticket before Relay's own pairing/session frame. Loopback administration routes are never exposed. |
| `hermes relay doctor` | Hermes-Relay plugin CLI | No for diagnostics | Operator/agent diagnostics | Reports vanilla upstream Hermes route reachability (including `/v1/toolsets`), dashboard Nous/topology state, sanitized gateway event-loop heartbeat state, plugin layout, Relay loopback state, and legacy bootstrap presence. |
@@ -60,7 +66,7 @@ second, orthogonal axis: the **build flavor**, a capability ceiling compiled
into the APK regardless of which routes the server exposes.
- `googlePlay` ("Bridge Core") ships the full Vanilla Hermes path plus the Relay
client (terminal, relay voice, notification companion, media, session
client (terminal, relay voice, notification companion, media enhancements, session
grants), but **no** AccessibilityService Device Control.
- `sideload` additionally compiles in phone Device Control — screen reading,
taps, typing, screenshots, overlays, unattended control — and the Tier-C
@@ -167,9 +173,9 @@ capabilities, not identity:
| Surface | Product role | Required for the standard path |
|---------|--------------|--------------------------------|
| Dashboard/Gateway | Primary chat, auth, sessions, Manage, and Vanilla Hermes voice | Yes |
| API server | Explicit API-only and advanced headless compatibility | No |
| Relay | Pairing, terminal, bridge/device control, media, and enhanced voice; normally reached through the Dashboard plugin ingress | No |
| Dashboard/Gateway | Primary chat, auth, sessions, Manage, Vanilla Hermes voice, and inbound files | Yes |
| API server | Explicit Direct API and advanced headless compatibility | No |
| Relay | Pairing, terminal, bridge/device control, media enhancements/legacy fallback, and enhanced voice; normally reached through the Dashboard plugin ingress | No |
Existing API-only records and headless deployments remain supported compatibility
configurations. They do not redefine normal onboarding or make an API key a
@@ -186,7 +192,10 @@ The app should present Vanilla Hermes as the default path:
4. Use API-server SSE only for a legacy API-only record or an explicit advanced
Direct API selection/new chat.
5. Use Vanilla Hermes dashboard voice when audio routes are present.
6. Offer Relay pairing only for Relay-owned power features. Prefer the
6. Resolve inbound files through direct sources or authenticated upstream
Dashboard file routes first. Use Relay only for Relay tokens, additive
metadata, or compatibility with older hosts.
7. Offer Relay pairing only for Relay-owned tools and enhancements. Prefer the
Dashboard-origin plugin ingress advertised by pairing; retain a direct Relay
listener only as an advanced/headless/Desktop compatibility route.
@@ -262,6 +271,9 @@ keeping route ownership explicit:
owner; Stop/session-switch cancels the initial request, pending delay, and
replacement together. A second drain or any post-event failure remains an
explicit retry so the client cannot duplicate an admitted turn.
- Gateway `session.create` and `session.resume` declare `source: webui`, the
existing upstream rich-chat platform hint. Hermes currently has no stable
`android` or `relay_desktop` platform hint; Relay clients must not invent one.
- Android currently preserves its explicit `source: webui` session field for
wire and history compatibility only. Current official Desktop does not treat
`webui` as a rich-chat prompt hint; upstream removed that unused path at the
verified `b20cc5f` snapshot. Hermes has no stable `android` or mobile platform
hint, so Android must not claim Desktop parity or invent one. The upstream
platform-hint follow-up is tracked in `TODO.md`.
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.14.0"
appVersionCode = "52"
appVersionName = "1.15.0"
appVersionCode = "53"
agp = "9.3.2"
kotlin = "2.4.10"
compose-bom = "2026.08.00"
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Hermes-Relay",
"description": "Paired devices, Bridge activity, media tokens, and remote access for Hermes-Relay",
"icon": "Activity",
"version": "1.11.0",
"version": "1.11.1",
"tab": {
"path": "/relay",
"position": "after:skills"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hermes-relay-dashboard",
"version": "1.11.0",
"version": "1.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hermes-relay-dashboard",
"version": "1.11.0",
"version": "1.11.1",
"devDependencies": {
"esbuild": "^0.25.12",
"qrcode": "^1.5.4"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-relay-dashboard",
"version": "1.11.0",
"version": "1.11.1",
"private": true,
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
"scripts": {
+62 -11
View File
@@ -63,8 +63,38 @@ def available_context_blocks() -> list[dict[str, str]]:
]
def get_injected_context_blocks() -> list[dict[str, str]]:
"""Return the blocks that would be injected on the next prompt build."""
def _session_tool_names(session_info: Mapping[str, Any] | None) -> set[str]:
"""Flatten the host's authoritative per-session tool catalog.
A host that exposes tools to native plugin prompt sections may use
``{toolset: [tool_name, ...]}`` or a flat list. Current upstream's native
callback metadata does not include tools, so it intentionally fails closed.
A context block must never advertise a tool merely because a related
platform is configured.
"""
if not isinstance(session_info, Mapping):
return set()
tools = session_info.get("tools")
if isinstance(tools, Mapping):
values = tools.values()
elif isinstance(tools, (list, tuple, set, frozenset)):
values = (tools,)
else:
return set()
names: set[str] = set()
for value in values:
if isinstance(value, str):
names.add(value)
elif isinstance(value, (list, tuple, set, frozenset)):
names.update(item for item in value if isinstance(item, str))
return names
def get_injected_context_blocks(
session_info: Mapping[str, Any] | None = None,
) -> list[dict[str, str]]:
"""Return the blocks that would be injected for this agent session."""
if not agent_context_enabled():
return []
@@ -76,9 +106,16 @@ def get_injected_context_blocks() -> list[dict[str, str]]:
"text": MEDIA_SENSITIVITY_INSTRUCTION,
}
)
# Only advertise proactive phone messaging when the platform is actually
# enabled (PHONE_ENABLED) and the per-block hint isn't suppressed.
if phone_platform_enabled() and context_phone_platform_enabled():
# Platform registration is not proof that the model can call a delivery
# tool. Current upstream deliberately keeps send_message out of the agent
# tool catalog; it remains an operator/cron/MCP transport. Older or custom
# hosts may expose a real callable. Advertise it only when the authoritative
# per-session tool catalog says that callable exists.
if (
phone_platform_enabled()
and context_phone_platform_enabled()
and "send_message" in _session_tool_names(session_info)
):
blocks.append(
{
"name": PHONE_PLATFORM_BLOCK_NAME,
@@ -103,13 +140,20 @@ def _fence_block(block: dict[str, str]) -> str:
return f"<!-- hermes-relay:{name} -->\n{text}\n<!-- /hermes-relay:{name} -->"
def _build_injection_text() -> str:
return "\n\n".join(_fence_block(block) for block in get_injected_context_blocks())
def _build_injection_text(
session_info: Mapping[str, Any] | None = None,
) -> str:
return "\n\n".join(
_fence_block(block) for block in get_injected_context_blocks(session_info)
)
def _native_section_content(block_name: str) -> str:
def _native_section_content(
block_name: str,
session_info: Mapping[str, Any] | None = None,
) -> str:
"""Resolve one section inside the active Hermes profile scope."""
for block in get_injected_context_blocks():
for block in get_injected_context_blocks(session_info):
if block["name"] == block_name:
return block["text"]
return ""
@@ -136,7 +180,7 @@ def register_context_sections(ctx: Any) -> bool:
*,
_block_name: str = block_name,
) -> str:
return _native_section_content(_block_name)
return _native_section_content(_block_name, _session_info)
register(
id=f"hermes-relay.{block_name}",
@@ -191,7 +235,14 @@ def apply_context_injection(agent_class: type[Any] | None = None) -> bool:
return base_prompt
try:
injection = _build_injection_text()
tool_names: list[str] = []
for tool in getattr(self, "tools", ()) or ():
if not isinstance(tool, Mapping):
continue
function = tool.get("function")
if isinstance(function, Mapping) and isinstance(function.get("name"), str):
tool_names.append(function["name"])
injection = _build_injection_text({"tools": tool_names})
except Exception:
logger.debug(
"Relay context injection failed while building blocks; returning base prompt",
+1 -1
View File
@@ -2,7 +2,7 @@ name: hermes-relay
# Temporary v1 shim for Hermes installers that reject manifests the runtime supports; see TODO.md.
manifest_version: 1
api_version: 1
version: 1.11.0
version: 1.11.1
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
author: Axiom Labs
license: MIT
+1 -1
View File
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
# CLI+UI releases use desktop/package.json and desktop-v* tags. The /health endpoint
# reports this plugin version, and stale values make live diagnosis harder than
# it should be.
__version__ = "1.11.0"
__version__ = "1.11.1"
from .server import create_app, main # noqa: E402 — must come after __version__
+29 -13
View File
@@ -271,7 +271,11 @@ class ContextInjectionTests(_IsolatedEnvMixin, unittest.TestCase):
work_media = work_ctx.sections["hermes-relay.media-sensitivity"]
work_phone = work_ctx.sections["hermes-relay.phone-platform"]
self.assertEqual(work_media({}), "") # type: ignore[operator]
self.assertEqual(work_phone({}), PHONE_PLATFORM_INSTRUCTION) # type: ignore[operator]
self.assertEqual(work_phone({}), "") # type: ignore[operator]
self.assertEqual(
work_phone({"tools": {"messaging": ["send_message"]}}),
PHONE_PLATFORM_INSTRUCTION,
) # type: ignore[operator]
# Unloading the disposable manager cannot erase root ownership.
work_ctx.sections.clear()
@@ -356,8 +360,8 @@ class ContextInjectedRouteTests(_IsolatedEnvMixin, unittest.IsolatedAsyncioTestC
class PhonePlatformContextBlockTests(_IsolatedEnvMixin, unittest.TestCase):
def _block_names(self) -> list[str]:
return [b["name"] for b in get_injected_context_blocks()]
def _block_names(self, session_info=None) -> list[str]:
return [b["name"] for b in get_injected_context_blocks(session_info)]
def test_phone_helpers_defaults(self) -> None:
# Platform off by default; the per-block hint defaults on (only matters
@@ -369,19 +373,32 @@ class PhonePlatformContextBlockTests(_IsolatedEnvMixin, unittest.TestCase):
# Context layer is on by default, but PHONE_ENABLED is unset → no hint.
self.assertNotIn(PHONE_PLATFORM_BLOCK_NAME, self._block_names())
def test_phone_block_present_when_platform_enabled(self) -> None:
def test_phone_block_absent_when_platform_enabled_without_callable(self) -> None:
self._set_env(plugin_config.PHONE_ENABLED, "1")
self.assertIn(PHONE_PLATFORM_BLOCK_NAME, self._block_names())
self.assertNotIn(PHONE_PLATFORM_BLOCK_NAME, self._block_names())
def test_phone_block_present_when_platform_and_callable_are_available(self) -> None:
self._set_env(plugin_config.PHONE_ENABLED, "1")
self.assertIn(
PHONE_PLATFORM_BLOCK_NAME,
self._block_names({"tools": {"messaging": ["send_message"]}}),
)
def test_phone_block_suppressed_by_per_block_flag(self) -> None:
self._set_env(plugin_config.PHONE_ENABLED, "1")
self._set_env(plugin_config.RELAY_CONTEXT_PHONE_PLATFORM, "0")
self.assertNotIn(PHONE_PLATFORM_BLOCK_NAME, self._block_names())
self.assertNotIn(
PHONE_PLATFORM_BLOCK_NAME,
self._block_names({"tools": {"messaging": ["send_message"]}}),
)
def test_phone_block_absent_when_context_layer_off(self) -> None:
self._set_env(plugin_config.PHONE_ENABLED, "1")
self._set_env(plugin_config.RELAY_AGENT_CONTEXT_ENABLED, "0")
self.assertEqual(get_injected_context_blocks(), [])
self.assertEqual(
get_injected_context_blocks({"tools": {"messaging": ["send_message"]}}),
[],
)
def test_phone_block_appends_labeled_fence(self) -> None:
agent_class = self._agent_class()
@@ -390,7 +407,9 @@ class PhonePlatformContextBlockTests(_IsolatedEnvMixin, unittest.TestCase):
self._set_env(plugin_config.RELAY_CONTEXT_MEDIA_SENSITIVITY, "0")
self._set_env(plugin_config.PHONE_ENABLED, "1")
prompt = agent_class()._build_system_prompt()
agent = agent_class()
agent.tools = [{"function": {"name": "send_message"}}]
prompt = agent._build_system_prompt()
self.assertIn(
"<!-- hermes-relay:phone-platform -->\n"
@@ -400,7 +419,7 @@ class PhonePlatformContextBlockTests(_IsolatedEnvMixin, unittest.TestCase):
)
self.assertEqual(prompt.count("<!-- hermes-relay:phone-platform -->"), 1)
def test_audit_payload_includes_phone_block(self) -> None:
def test_audit_payload_omits_session_specific_phone_block(self) -> None:
self._set_env(plugin_config.RELAY_AGENT_CONTEXT_ENABLED, "1")
self._set_env(plugin_config.RELAY_CONTEXT_MEDIA_SENSITIVITY, "0")
self._set_env(plugin_config.PHONE_ENABLED, "1")
@@ -408,10 +427,7 @@ class PhonePlatformContextBlockTests(_IsolatedEnvMixin, unittest.TestCase):
payload = injected_context_payload()
self.assertTrue(payload["enabled"])
self.assertEqual(
payload["blocks"],
[{"name": PHONE_PLATFORM_BLOCK_NAME, "text": PHONE_PLATFORM_INSTRUCTION}],
)
self.assertEqual(payload["blocks"], [])
def _agent_class(self) -> type:
class DummyAgent:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-relay"
version = "1.11.0"
version = "1.11.1"
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
requires-python = ">=3.11"
dependencies = [
@@ -239,6 +239,37 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(fixture.scenario.live_session_id, events[-1]["session_id"])
self.assertNotIn("message.complete", [event["type"] for event in events])
async def test_active_list_idle_settles_without_message_complete(self) -> None:
fixture, base_url = await self.start("terminal_gap_active_list")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "prompt.submit", {"text": "fixture"})
frames = await self.frames_until(
ws,
lambda frame: frame.get("params", {}).get("type") == "message.delta",
)
for _ in range(50):
async with self.session.get(f"{base_url}/__fixture__/state") as response:
state = await response.json()
if not state["running"]:
break
await asyncio.sleep(0.01)
self.assertFalse(state["running"])
await self.rpc(
ws,
2,
"session.active_list",
{"current_session_id": fixture.scenario.live_session_id},
)
snapshot = (await ws.receive_json())["result"]["sessions"]
self.assertEqual("idle", snapshot[0]["status"])
self.assertEqual(fixture.scenario.live_session_id, snapshot[0]["id"])
self.assertEqual(fixture.scenario.stored_session_id, snapshot[0]["session_key"])
self.assertNotIn(
"message.complete",
[frame.get("params", {}).get("type") for frame in frames],
)
async def test_queued_follow_up_runs_after_first_turn(self) -> None:
_, base_url = await self.start("queued_follow_up")
ws, _ = await self.connect(base_url)
@@ -353,6 +384,7 @@ class ScenarioTestCase(unittest.TestCase):
"rapid_tools_interims",
"subagent_child_preview",
"terminal_gap_activate",
"terminal_gap_active_list",
"terminal_gap_session_info",
"queued_follow_up",
"scope_rejection_inputs",
@@ -389,6 +421,10 @@ class ScenarioTestCase(unittest.TestCase):
),
load_scenario("terminal_gap_activate").contract_requirements,
)
self.assertEqual(
("gateway.session_active_list",),
load_scenario("terminal_gap_active_list").contract_requirements,
)
self.assertEqual(
("gateway.settled_session_info",),
load_scenario("terminal_gap_session_info").contract_requirements,
@@ -0,0 +1,33 @@
{
"name": "terminal_gap_active_list",
"live_session_id": "fixture-live-1",
"stored_session_id": "20260831_112003_fixture",
"profile": "default",
"contract_requirements": [
"gateway.session_active_list"
],
"turns": [
{
"steps": [
{"op": "event", "type": "message.start"},
{"op": "event", "type": "message.delta", "payload": {"text": "Persisted without a terminal frame."}},
{
"op": "persist",
"messages": [
{"id": 1, "role": "user", "content": "Exercise active-list settlement.", "timestamp": 1.0},
{"id": 2, "role": "assistant", "content": "Persisted without a terminal frame.", "timestamp": 2.0, "finish_reason": "stop"}
]
},
{"op": "set_running", "value": false}
]
}
],
"active_list": {
"supported": true,
"snapshots": [
[
{"id": "fixture-live-1", "session_key": "20260831_112003_fixture", "status": "idle", "current": true}
]
]
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Flavor Differences
Hermes-Relay ships as two product flavors from the same codebase: **googlePlay** and **sideload**. Both share chat, profiles, voice, relay pairing, terminal/TUI relay, media handoff, notification companion, sessions, and status. They diverge on Device Control.
Hermes-Relay ships as two product flavors from the same codebase: **googlePlay** and **sideload**. Both share chat, profiles, voice, standard inbound files, relay pairing, terminal/TUI relay, Relay media enhancements, notification companion, sessions, and status. They diverge on Device Control.
## Track model
+1 -1
View File
@@ -2,7 +2,7 @@
<ExpandableImage
src="/architecture-homepage.svg"
alt="How Hermes-Relay connects: upstream Hermes owns standard chat, Manage and voice; the encouraged Relay extension fills current gaps for terminal, notifications, media, desktop tools, enhanced voice and Relay sessions; Device Control also needs the sideload build."
alt="How Hermes-Relay connects: upstream Hermes owns standard chat, Manage, voice and inbound files; the encouraged Relay extension fills current gaps for terminal, notifications, desktop tools, enhanced voice and Relay sessions; Device Control also needs the sideload build."
caption="Select the diagram to inspect it at full size."
/>
+2 -2
View File
@@ -4,7 +4,7 @@ Hermes-Relay connects only to your own machines — no cloud accounts, no hosted
## Track split
- **Google Play:** Bridge Core only — chat, voice, terminal/TUI relay, notification companion, media handoff, relay sessions, and status. No AccessibilityService, screen reading, taps, typing, screenshots, SMS, calls, contacts, location, overlay, or unattended phone control.
- **Google Play:** Bridge Core only — chat, voice, standard inbound files, terminal/TUI relay, notification companion, Relay media enhancements, relay sessions, and status. No AccessibilityService, screen reading, taps, typing, screenshots, SMS, calls, contacts, location, overlay, or unattended phone control.
- **Sideload:** Device Control — the separate sideload track can include AccessibilityService-backed phone control and the extra Android permissions needed for that surface.
## What stays on your phone
@@ -27,7 +27,7 @@ Chat messages are **not cached** on device. They load from your Hermes server on
| Destination | Protocol | Purpose |
|-------------|----------|---------|
| Your Hermes API server | HTTP/SSE | Chat streaming |
| Your relay server | WSS | Terminal/TUI relay, Bridge Core status, media, notifications, sessions |
| Your relay server | WSS | Terminal/TUI relay, Bridge Core status, explicit Relay media, notifications, sessions |
| Your relay voice routes | HTTP(S)/WSS | Voice settings, STT, realtime voice, TTS |
HTTPS is enforced for non-localhost remote connections. No background pings or DNS prefetching to external services.
+4 -3
View File
@@ -23,8 +23,8 @@ Server-, TLS- und Betreiberoptionen stehen in der
|---|---|---|
| Empfohlen für | Die meisten Nutzer | Nutzer von Device Control |
| Updates | Automatisch | APK manuell aktualisieren |
| Chat, Voice, Manage | Enthalten | Enthalten |
| Terminal, Medien, Benachrichtigungen mit Relay | Enthalten | Enthalten |
| Chat, Voice, Manage, eingehende Dateien | Enthalten | Enthalten |
| Terminal, Medienerweiterungen, Benachrichtigungen mit Relay | Enthalten | Enthalten |
| Bildschirm lesen, tippen, schreiben, navigieren | Nicht enthalten | Enthalten |
<StoreBadge />
@@ -36,7 +36,8 @@ nicht die `.aab`-Datei herunter; sie ist nur für Google Play bestimmt.
## 2. Hermes erreichbar machen
Android verwendet standardmäßig das Hermes Dashboard/Gateway unter `:9119`.
Es stellt Chat, Sitzungen, Anmeldung, Manage und Standard-Voice bereit. Starte
Es stellt Chat, Sitzungen, Anmeldung, Manage, Standard-Voice und authentifizierte
eingehende Dateien bereit. Starte
es mit `hermes dashboard` und mache diese Adresse für das Telefon erreichbar.
Der API-Server unter `:8642` ist optional: Er dient als explizite Direct-API-
+24 -21
View File
@@ -5,8 +5,8 @@ canonical_source: /guide/quick-start
# Schnellstart
Installieren → verbinden → chatten. Der Standardweg bleibt upstream-basiert;
für die vollständige Hermes-Relay-Erfahrung wird das Relay-Plugin empfohlen.
Beginne mit der standardmäßigen Upstream-Verbindung. Kopple Relay anschließend,
wenn du dessen zusätzliche Werkzeuge und Erweiterungen nutzen möchtest.
<AndroidSetupPath mode="quick" />
@@ -29,10 +29,22 @@ gleichzeitig auf demselben Gerät installiert sein.
## 2. Hermes starten
Auf dem Host muss das Hermes Dashboard/Gateway laufen und vom Telefon erreichbar
sein. Starte es bei Bedarf mit `hermes dashboard`. Die ausführliche Einrichtung steht unter
sein. Starte es bei Bedarf mit `hermes dashboard`. Das ist die gesamte
serverseitige Einrichtung für Standard-Chat, Sitzungen, Manage, Voice und
eingehende Dateien. Die ausführliche Einrichtung steht unter
[Installation & Einrichtung](/de/guide/getting-started).
Für den empfohlenen vollständigen Weg installiere zusätzlich:
## 3. Standardverbindung hinzufügen {#other-supported-paths}
Öffne in Android **Connect**. Nutze **Find Hermes on LAN** oder trage die
Dashboard-Adresse, normalerweise `http://<host>:9119`, manuell ein. Melde dich
bei Aufforderung an. Damit entsteht eine vollständige Standardverbindung ohne
Plugin oder Relay-URL.
## 4. Optional: Relay installieren und koppeln
Installiere Relay für die empfohlene vollständige Erfahrung erst, nachdem die
Standardverbindung funktioniert:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
@@ -43,25 +55,16 @@ hermes relay start --no-ssl
Nutze `--no-ssl` nur in einem vertrauenswürdigen LAN oder VPN. Für den Zugriff
von unterwegs wird [Tailscale empfohlen](/guide/remote-access).
## 3. Verbinden {#other-supported-paths}
Öffne die App und gehe zu **Connect**. Nutze eine der folgenden Möglichkeiten:
1. Öffne im Web Dashboard **Relay → Connect mobile app** und scanne den
tokenlosen QR über Android **Connect → Scan Hermes setup QR**.
2. Öffne danach **Relay → Pair new device** und scanne den einmaligen QR über
**Settings → Connections → Pair Hermes Relay**.
3. Ohne Dashboard-Plugin nutze **Find Hermes on LAN** oder trage die
Dashboard-Adresse wie `http://<host>:9119` manuell ein.
4. Ohne Kamera erzeugt `hermes pair` denselben QR und eine kopierbare Einladung;
URL und Code bleiben als manueller Fallback verfügbar.
5. Melde dich bei Aufforderung über den konfigurierten Dashboard-Anbieter an.
Öffne danach im Web Dashboard **Relay → Pair new device** und scanne den
einmaligen QR über **Settings → Connections → Pair Hermes Relay**.
Der API-Server bleibt ein optionaler Fallback. Relay ist für den Upstream-Weg
nicht erforderlich, wird aber für Terminal/TUI, Benachrichtigungen, Medien,
Desktop-Werkzeuge, erweiterte Voice und Device Control empfohlen.
nicht erforderlich, wird aber für Terminal/TUI, Benachrichtigungen,
Desktop-Werkzeuge, erweiterte Voice, Relay-Sitzungen, Device Control sowie
Medienkompatibilität und -metadaten empfohlen. Gewöhnliche eingehende Dateien
nutzen die aktuellen Dashboard-Routen.
## 4. Status prüfen
## 5. Status prüfen
- **Chat · Ready** bedeutet, dass du Nachrichten senden kannst.
- **Manage** kann noch eine Dashboard-Anmeldung verlangen.
@@ -70,7 +73,7 @@ Desktop-Werkzeuge, erweiterte Voice und Device Control empfohlen.
- **Relay · Paired** bestätigt die empfohlenen Zusatzfunktionen; ein Relay-Ausfall
darf den Upstream-Standardweg nicht blockieren.
## 5. Erste Nachricht senden
## 6. Erste Nachricht senden
Öffne Chat und sende eine Nachricht. Ein grüner Verbindungspunkt im Kopfbereich
bestätigt, dass die aktive Hermes-Verbindung erreichbar ist.
+5 -4
View File
@@ -13,16 +13,17 @@ Apps stammen aus demselben Quellcode und können gleichzeitig installiert werden
| Frage | Google Play | Sideload |
|---|---|---|
| Einfache Installation und automatische Updates? | Ja | Nein |
| Chat, Profile, Manage und Voice? | Ja | Ja |
| Terminal, Medien und Benachrichtigungen mit Relay? | Ja | Ja |
| Chat, Profile, Manage, Voice und eingehende Dateien? | Ja | Ja |
| Terminal, Medienerweiterungen und Benachrichtigungen mit Relay? | Ja | Ja |
| Bildschirm lesen oder aufnehmen? | Nein | Ja |
| Tippen, schreiben, wischen und Apps bedienen? | Nein | Ja |
## App-Version und Relay sind getrennte Entscheidungen
Die **App-Version** bestimmt, ob Android Device Control enthält. Das optionale
**Relay-Plugin** verbindet Terminal, Medien, Benachrichtigungen und
Gerätekanäle mit dem Hermes-Host.
**Relay-Plugin** verbindet Terminal, Benachrichtigungen und Gerätekanäle mit dem
Hermes-Host und ergänzt Medienkompatibilität und -metadaten. Gewöhnliche
eingehende Dateien werden über das Standard-Dashboard übertragen.
Device Control funktioniert nur mit **Sideload + gepaartem Relay**. Chat,
Manage und Standard-Voice benötigen keines von beiden.
+4 -3
View File
@@ -22,8 +22,8 @@ TLS y operación están en la [guía completa en inglés](/guide/getting-started
|---|---|---|
| Recomendado para | La mayoría de usuarios | Usuarios de Device Control |
| Actualizaciones | Automáticas | Actualización manual del APK |
| Chat, Voice y Manage | Incluidos | Incluidos |
| Terminal, multimedia y notificaciones con Relay | Incluidos | Incluidos |
| Chat, Voice, Manage y archivos entrantes | Incluidos | Incluidos |
| Terminal, mejoras multimedia y notificaciones con Relay | Incluidos | Incluidos |
| Leer la pantalla, tocar, escribir y navegar | No incluido | Incluido |
<StoreBadge />
@@ -35,7 +35,8 @@ descargues el archivo `.aab`; está destinado a Google Play.
## 2. Haz que Hermes sea accesible
Android usa normalmente el Dashboard/Gateway de Hermes en `:9119`. Proporciona
Chat, sesiones, inicio de sesión, Manage y voz estándar. Inícialo con
Chat, sesiones, inicio de sesión, Manage, voz estándar y archivos entrantes
autenticados. Inícialo con
`hermes dashboard` y haz que esa dirección sea accesible desde el teléfono.
El servidor de API en `:8642` es opcional: sirve como conexión explícita de
+23 -20
View File
@@ -5,8 +5,8 @@ canonical_source: /guide/quick-start
# Inicio rápido
Instala → conecta → conversa. El recorrido estándar sigue siendo upstream; el
plugin Relay se recomienda para la experiencia completa de Hermes-Relay.
Empieza con la conexión estándar upstream. Después, empareja Relay cuando
quieras sus herramientas y mejoras adicionales.
<AndroidSetupPath mode="quick" />
@@ -31,8 +31,20 @@ estar instaladas a la vez.
El Dashboard/Gateway de Hermes debe estar activo y accesible desde el teléfono.
Si es necesario, inícialo con `hermes dashboard`. Consulta
[Instalación y configuración](/es/guide/getting-started) para preparar el servidor.
Esta es toda la configuración del servidor necesaria para Chat, sesiones,
Manage, voz y archivos entrantes estándar.
Para el recorrido completo recomendado, instala además:
## 3. Añade la conexión estándar {#other-supported-paths}
En Android, abre **Connect**. Usa **Find Hermes on LAN** o introduce manualmente
la dirección del Dashboard, normalmente `http://<host>:9119`. Inicia sesión
cuando se solicite. Así se crea una conexión estándar completa sin plugin ni
URL de Relay.
## 4. Opcional: instala y empareja Relay
Para la experiencia completa recomendada, instala Relay después de comprobar
que funciona la conexión estándar:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
@@ -43,25 +55,16 @@ hermes relay start --no-ssl
Usa `--no-ssl` solo en una LAN o VPN de confianza. Para acceder desde fuera de
casa, [se recomienda Tailscale](/guide/remote-access).
## 3. Conecta {#other-supported-paths}
Abre la aplicación y llega a **Connect**. Puedes:
1. En el Web Dashboard abre **Relay → Connect mobile app** y escanea ese QR sin
credenciales desde Android **Connect → Scan Hermes setup QR**.
2. Después abre **Relay → Pair new device** y escanea el QR de un solo uso desde
**Settings → Connections → Pair Hermes Relay**.
3. Sin el plugin del Dashboard, usa **Find Hermes on LAN** o introduce
manualmente la dirección como `http://<host>:9119`.
4. Sin cámara, `hermes pair` genera el mismo QR y una invitación copiable; URL y
código siguen disponibles como fallback manual.
5. Inicia sesión con el proveedor del dashboard cuando se solicite.
Después, abre **Relay → Pair new device** en el Web Dashboard y escanea el QR de
un solo uso desde **Settings → Connections → Pair Hermes Relay**.
El servidor de API sigue siendo un fallback opcional. Relay no es obligatorio
para upstream, pero se recomienda para Terminal/TUI, notificaciones, medios,
herramientas de escritorio, voz mejorada y Device Control.
para upstream, pero se recomienda para Terminal/TUI, notificaciones,
herramientas de escritorio, voz mejorada, sesiones Relay, Device Control y
compatibilidad o metadatos multimedia. Los archivos entrantes normales usan
las rutas actuales del Dashboard.
## 4. Comprueba el estado
## 5. Comprueba el estado
- **Chat · Ready** significa que ya puedes enviar mensajes.
- **Manage** puede pedir que inicies sesión en el dashboard.
@@ -70,7 +73,7 @@ herramientas de escritorio, voz mejorada y Device Control.
- **Relay · Paired** confirma las extensiones recomendadas; un fallo de Relay no
debe bloquear el recorrido upstream estándar.
## 5. Envía el primer mensaje
## 6. Envía el primer mensaje
Abre Chat y envía un mensaje. El indicador verde del encabezado confirma que la
conexión activa con Hermes está disponible.
+5 -4
View File
@@ -13,16 +13,17 @@ dos versiones proceden del mismo código y pueden convivir en el teléfono.
| Pregunta | Google Play | Sideload |
|---|---|---|
| ¿Instalación sencilla y actualizaciones automáticas? | Sí | No |
| ¿Chat, perfiles, Manage y Voice? | Sí | Sí |
| ¿Terminal, multimedia y notificaciones con Relay? | Sí | Sí |
| ¿Chat, perfiles, Manage, Voice y archivos entrantes? | Sí | Sí |
| ¿Terminal, mejoras multimedia y notificaciones con Relay? | Sí | Sí |
| ¿Leer o capturar la pantalla? | No | Sí |
| ¿Tocar, escribir, deslizar y manejar aplicaciones? | No | Sí |
## La versión y Relay son decisiones independientes
La **versión de la aplicación** decide si Android incluye Device Control. El
**complemento Relay** opcional conecta el terminal, el contenido multimedia,
las notificaciones y los canales de dispositivos con el host de Hermes.
**complemento Relay** opcional conecta el terminal, las notificaciones y los
canales de dispositivos con el host de Hermes, y añade compatibilidad y
metadatos multimedia. Los archivos entrantes normales usan el Dashboard estándar.
Device Control solo funciona con **Sideload + Relay emparejado**. Chat, Manage y
la voz estándar no necesitan ninguno de los dos.

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