Compare commits

...
Author SHA1 Message Date
Bailey Dixon 8bb503eb6d fix(android): use appearance shape for usage card 2026-08-24 21:03:18 -04:00
Bailey Dixon c223dc690d Merge remote-tracking branch 'origin/dev' into codex/pr-393-integration
# Conflicts:
#	CHANGELOG.md
#	app/src/main/res/values-b+pt+BR/strings.xml
#	app/src/main/res/values-b+zh+Hans/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-ru/strings.xml
#	docs/decisions.md
#	docs/localization-status.json
2026-08-24 20:51:41 -04:00
Bailey Dixon 44e3bb75cd Merge pull request #421 from Codename-11/fix/pre-release-install-site
feat: align pre-release install and onboarding surfaces
2026-08-24 20:45:36 -04:00
Bailey Dixon 1cec79517e Merge remote-tracking branch 'origin/dev' into codex/pr-421-integration
# Conflicts:
#	CHANGELOG.md
#	docs/localization-status.json
2026-08-24 20:28:51 -04:00
Bailey Dixon 957be876a0 Merge pull request #423 from Codename-11/fix/android-session-busy-state
fix(android): clear stale chat busy state
2026-08-24 20:26:12 -04:00
Bailey Dixon 6b32c7aeef Merge remote-tracking branch 'origin/dev' into codex/pr-423-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 20:25:44 -04:00
Bailey Dixon 29706e1548 Merge pull request #422 from Codename-11/feature/android-bot-mode
feat(android): add multi-gateway bot mode
2026-08-24 20:24:58 -04:00
Bailey Dixon 6579b621ff Merge pull request #420 from Codename-11/fix/android-power-audit-377
fix(android): animate visible idle Sphere efficiently
2026-08-24 20:23:29 -04:00
Bailey Dixon befe8399ab Merge remote-tracking branch 'origin/dev' into codex/pr-420-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 20:13:41 -04:00
Bailey Dixon c10b87b94c Merge pull request #398 from JackHunzicker/fix/gateway-history-attachments
fix: retry Windows media paths and honor HERMES_HOME
2026-08-24 20:12:20 -04:00
Bailey Dixon 5e9d8840ae fix(android): clear stale chat busy state 2026-08-24 19:17:22 -04:00
Bailey Dixon accf464911 test(desktop): refresh screenshots after dev merge 2026-08-24 17:24:38 -04:00
Bailey Dixon b26c2cc2a1 merge: refresh pre-release install site with dev
# Conflicts:
#	CHANGELOG.md
#	docs/localization-status.json
2026-08-24 17:04:42 -04:00
Bailey Dixon 28e0c34227 feat(site): align onboarding across product surfaces 2026-08-24 17:02:31 -04:00
Bailey Dixon 35e95da6a7 test(desktop): add deterministic UI screenshots 2026-08-24 17:01:57 -04:00
Bailey Dixon 484bfdc5dc fix(desktop): harden release and update plumbing 2026-08-24 17:01:27 -04:00
Bailey Dixon c7c24b2874 merge: refresh Android idle sphere fix with dev 2026-08-24 16:28:31 -04:00
Bailey Dixon 3e8e0728db merge: refresh PR #398 with current dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
2026-08-24 15:58:11 -04:00
Bailey Dixon 1658439d05 fix(android): animate visible idle sphere efficiently 2026-08-24 11:57:26 -04:00
Jack 90ab705a88 docs: record attachment and relay config fixes 2026-08-23 18:47:51 -05:00
Jack 054aab1c09 fix(server): honor HERMES_HOME for relay config 2026-08-23 18:47:51 -05:00
Jack bae1762951 fix(android): retry Windows media paths by path 2026-08-23 18:47:50 -05:00
Bailey Dixon da7ea8ffe0 feat: clarify Relay usage capabilities
Mark enhanced provider responses explicitly and explain in Settings which usage features require the matching Relay plugin.
2026-08-21 14:05:45 -04:00
Bailey Dixon 5c5c55d982 feat: support credential-aware provider usage
Resolve active Codex pool credentials from live Dashboard sessions, retain a secret-free standalone Relay fallback, and expose structured Nous balances.

Polish the Android Usage & limits surface with non-blocking skeletons, refresh controls, provider-specific landing visibility, and localized balance/status presentation.
2026-08-21 11:58:58 -04:00
Bailey Dixon 0208098687 feat: generalize provider usage settings 2026-08-21 10:11:03 -04:00
ophirhan 34fc4c4693 feat(android): show OpenCode Go subscription usage in Settings
Add an inline Settings card that displays the OpenCode Go subscription quota across its 5-hour (rolling), weekly, and monthly windows as progress bars with dollars used, the window cap, and a resets-in countdown.

The phone never sees the OpenCode Go API key. The relay host proxies GET /usage/opencode (bearer-authenticated to a paired session), reading OPENCODE_GO_API_KEY from the host .env and returning {usage, limits}. Hosts without OpenCode Go configured return 404, which the client renders as a quiet "not available" state instead of an error.

Verified: relay route + auth, upstream data shape, and 5 client unit tests; lint clean.
(cherry picked from commit 48251d0a36)
2026-08-21 08:56:25 -04:00
136 changed files with 6103 additions and 1175 deletions
+73 -4
View File
@@ -117,6 +117,9 @@ jobs:
- name: Build Linux x64
run: npm run build:bin:linux
- name: Build Linux arm64
run: npm run build:bin:linux-arm
- name: Build macOS x64
run: npm run build:bin:mac-x64
@@ -138,19 +141,27 @@ jobs:
- name: Smoke-test Linux binary
run: |
set -e
set -euo pipefail
chmod +x dist/bin/hermes-relay-linux-x64
for cmd in --version --help doctor; do
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1 || true)
set +e
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1)
exit_code=$?
if [ -z "$out" ] || [ ${#out} -lt 10 ]; then
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' produced no output (exit=$exit_code)"
set -e
if [ "$exit_code" -ne 0 ] || [ -z "$out" ] || [ ${#out} -lt 10 ]; then
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' failed or produced no output (exit=$exit_code)"
echo "Raw output was: [$out]"
exit 1
fi
echo " smoke OK: $cmd -> $(echo "$out" | head -1)"
done
- name: Verify Linux arm64 artifact architecture
run: |
set -euo pipefail
file dist/bin/hermes-relay-linux-arm64 | tee /tmp/hermes-relay-linux-arm64.file
grep -Eq 'ELF 64-bit.*(ARM aarch64|ARM64)' /tmp/hermes-relay-linux-arm64.file
- name: Upload CLI release assets
uses: actions/upload-artifact@v4
with:
@@ -158,6 +169,7 @@ jobs:
path: |
desktop/dist/bin/hermes-relay-win-x64.exe
desktop/dist/bin/hermes-relay-linux-x64
desktop/dist/bin/hermes-relay-linux-arm64
desktop/dist/bin/hermes-relay-darwin-x64
desktop/dist/bin/hermes-relay-darwin-arm64
retention-days: 7
@@ -196,6 +208,60 @@ jobs:
throw "Windows CLI smoke left $(@($leftovers).Count) process(es) behind"
}
smoke-macos-cli-release-asset:
name: Smoke exact macOS CLI release asset
runs-on: macos-latest
needs:
- validate-release
- build-cli-binaries
steps:
- uses: actions/download-artifact@v8
with:
name: cli-binaries
path: release-assets
- name: Launch native release asset and inspect both architectures
env:
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
run: |
set -euo pipefail
case "$(uname -m)" in
x86_64) native_asset=hermes-relay-darwin-x64 ;;
arm64) native_asset=hermes-relay-darwin-arm64 ;;
*) echo "Unsupported macOS runner architecture: $(uname -m)" >&2; exit 1 ;;
esac
chmod +x "release-assets/$native_asset"
version_output=$("release-assets/$native_asset" --version)
test "$version_output" = "hermes-relay $EXPECTED_DESKTOP_VERSION"
"release-assets/$native_asset" --help | grep -Fq 'Usage:'
file release-assets/hermes-relay-darwin-x64 | grep -Fq 'x86_64'
file release-assets/hermes-relay-darwin-arm64 | grep -Eq '(arm64|arm64e)'
smoke-linux-arm64-cli-release-asset:
name: Smoke exact Linux arm64 CLI release asset
runs-on: ubuntu-24.04-arm
needs:
- validate-release
- build-cli-binaries
steps:
- uses: actions/download-artifact@v8
with:
name: cli-binaries
path: release-assets
- name: Launch native arm64 release asset
env:
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
run: |
set -euo pipefail
asset=release-assets/hermes-relay-linux-arm64
test "$(uname -m)" = "aarch64"
chmod +x "$asset"
version_output=$("$asset" --version)
test "$version_output" = "hermes-relay $EXPECTED_DESKTOP_VERSION"
"$asset" --help | grep -Fq 'Usage:'
file "$asset" | grep -Eq 'ELF 64-bit.*(ARM aarch64|ARM64)'
build-windows-tray-installer:
name: Build Windows tray installer
runs-on: windows-latest
@@ -419,6 +485,8 @@ jobs:
needs:
- build-cli-binaries
- smoke-windows-cli-release-asset
- smoke-macos-cli-release-asset
- smoke-linux-arm64-cli-release-asset
- build-windows-tray-installer
steps:
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
@@ -466,6 +534,7 @@ jobs:
files: |
release-assets/cli-binaries/hermes-relay-win-x64.exe
release-assets/cli-binaries/hermes-relay-linux-x64
release-assets/cli-binaries/hermes-relay-linux-arm64
release-assets/cli-binaries/hermes-relay-darwin-x64
release-assets/cli-binaries/hermes-relay-darwin-arm64
release-assets/cli-windows-installer/hermes-relay-windows-x64-setup.exe
+1
View File
@@ -95,3 +95,4 @@ keystore.properties
desktop/tray/ui/vendor/
# Generated from assets/screenshots/02_chat.png before docs dev/build.
/user-docs/public/chat-demo.png
/user-docs/public/product/desktop-ui/
+9 -1
View File
@@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added
- **Android and Relay add top-level provider usage and limit settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden Settings presentation modes plus per-provider landing-page visibility. The authenticated Relay Dashboard plugin resolves the active Codex credential directly from the live session; paired standalone clients retain an explicitly enabled Relay fallback. The UI identifies Relay-plugin-enhanced data and explains which capabilities require the matching plugin. Provider credentials remain host-side.
- **Desktop releases now include a Linux ARM64 CLI artifact.** The one-line installer, updater, checksums, release publication, architecture validation, and platform documentation all recognize the same `linux-arm64` binary.
- **The public site now shows the real Windows CLI UI and guides each surface through first use.** Deterministic public-safe screenshots cover connection, host access, activity, computer control, and updates; Android and CLI paths now carry users from install through prerequisites, pairing, verification, and a concrete first-success action before the long-form reference material.
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
@@ -19,6 +22,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **README and Google Play onboarding now match the Dashboard-first product path.** Public setup copy names the two separate Dashboard QR actions, treats the API server as an advanced fallback, explains the encouraged Hermes-Relay extension without implying Play includes Device Control, and ships one current deterministic Android screenshot set.
- **Desktop install and update discovery remains reliable in a multi-surface release repository.** Every resolver paginates GitHub releases before choosing the SemVer maximum, Windows cooperative updates clean their released backup, unsigned preview installers retain the normal SmartScreen warning, and release smoke tests preserve real exit codes.
- **Android fresh chats no longer inherit a stale busy composer.** New-chat navigation settles visible streaming ownership even when a Gateway turn has already lost its live handle, and Stop remains an immediate escape hatch after the terminal bubble has settled. (#416, #418)
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
- **Android retries Windows-hosted `MEDIA:` attachments through Relay's by-path route.** A document deferred on cellular no longer treats `C:\...` as an opaque media token and reports it as expired.
- **Relay profile discovery follows `HERMES_HOME` by default.** Custom Hermes installations surface their real default profile and persist Relay sessions beside the active config while retaining the explicit `RELAY_HERMES_CONFIG` override.
- **Desktop daemon connections recover instead of exiting after an interrupted Relay socket.** Healthy daemons retry through Relay restarts and repeated failed reconnect attempts, oversized desktop-tool results fail within a bounded response instead of closing the shared WebSocket, and terminal failures leave an accurate stopped status for the tray.
- **Desktop computer control follows Hermes' current CUA Driver contract.** CUA Driver 0.20 and newer are accepted when their manifest, daemon/MCP arguments, required tools, and canonical path remain compatible, and Windows sessions use the manifest-declared direct standard-mode runtime instead of a potentially stale machine-wide daemon. Current 0.21 installations no longer fall back solely because of an obsolete upper version pin or daemon contract.
@@ -74,7 +83,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Android screen-on idle no longer continuously redraws the ASCII sphere.** Idle holds a stable frame while thinking, streaming, and voice states retain full-rate motion; inactive voice waveforms and closed session drawers also stop their frame loops.
- **Android capture and audio effects release power-sensitive resources at their actual lifecycle boundaries.** Screen capture attaches its MediaProjection surface only for a requested frame, unattended Bridge wake locks release when the command finishes, and barge-in AEC/noise suppression attach to the microphone capture session instead of playback.
- **Experimental wake-word listening reuses its PCM normalization buffer.** Continuous opt-in listening no longer allocates a new float frame for every inference call.
## [1.10.0] - 2026-08-18
### Added
+15 -5
View File
@@ -1,17 +1,26 @@
# Hermes-Relay CLI+UI v__VERSION__
**Release Date:** 2026-08-15
**Release Date:** 2026-08-22
This patch keeps the Windows management UI usable when the Relay daemon is stopped or its status cannot be read.
This release makes the Desktop connector resilient through Relay interruptions,
aligns Windows computer control with current CUA Driver releases, and adds a
native Linux ARM64 build.
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64; the management UI is Windows-only.
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64; the management UI is Windows-only.
## What's changed
### Added
- **Linux ARM64 is a first-class release target.** The one-line installer,
updater, checksums, and release artifacts now cover both Linux x64 and arm64.
### Fixed
- **Stopped daemons no longer block the management UI.** Missing, stale, malformed, or temporarily unavailable daemon status falls back to an explicit stopped state while hosts, settings, activity, CLI details, diagnostics, and daemon controls continue loading normally.
- **Starting the daemon restores live status without reopening the UI.** A valid running status continues through the same bounded, single-flight snapshot path introduced in beta.3.
- **The daemon reconnects instead of exiting after an interrupted Relay socket.** Relay restarts and repeated transient replacement failures stay on bounded automatic backoff, and terminal failures persist an accurate stopped reason for the UI.
- **Oversized desktop-tool output no longer closes the shared connection.** PowerShell output and every serialized desktop response stay inside the Relay WebSocket budget.
- **Current CUA Driver releases remain compatible by contract.** Driver 0.20 and newer are accepted when their manifest and required tools match Hermes, and Windows uses the manifest-declared direct standard-mode runtime instead of a stale machine-wide daemon.
- **Install and update discovery paginates the multi-surface release history.** Desktop releases remain discoverable after more Android and Server releases, Windows cooperative updates clean their released backup, and unsigned installers retain the normal SmartScreen warning.
## Install
@@ -42,6 +51,7 @@ hermes-relay --version
hermes-relay hosts list --json
hermes-relay daemon start
hermes-relay daemon status --json
hermes-relay computer-use status --json
```
On Windows, click the Hermes-Relay CLI UI notification-area icon to open the management popup directly above it.
+12
View File
@@ -62,6 +62,18 @@ automotive device verified foreground preservation, AssistStructure and screensh
delivery, immediate listening, contextual response, and one-shot consumption;
broader firmware certification remains tracked in `TODO.md`.
## 2026-08-23 — Windows attachment retry and Hermes-home resolution
Android now recognizes Windows absolute paths during manual inbound-media retry.
Cellular-deferred `MEDIA:C:\...` documents use Relay's authenticated
`/media/by-path` route instead of being sent to the opaque-token route and
misreported as expired. A Robolectric/MockWebServer regression covers a spaced
Markdown filename and asserts the exact route and decoded path query.
Relay configuration now derives its default `config.yaml` and session-persistence
paths from `HERMES_HOME` when present. `RELAY_HERMES_CONFIG` remains the explicit
override. Focused Python tests cover both resolution paths.
## 2026-08-23 — GitHub Discussions community surface
GitHub Discussions is enabled as the repository's lightweight community surface.
+50 -50
View File
@@ -17,7 +17,7 @@
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Android-8.0%2B-3DDC84.svg?logo=android&logoColor=white" alt="Android 8.0+"></a>
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
<a href="https://github.com/Codename-11/hermes-relay/releases"><img src="https://img.shields.io/github/v/release/Codename-11/hermes-relay?filter=android-v*&label=release&color=8B5CF6" alt="Latest release"></a>
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-alpha-orange.svg" alt="CLI (alpha)"></a>
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-beta-756cff.svg" alt="CLI (beta)"></a>
</p>
<p align="center">
@@ -36,12 +36,12 @@
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
- **📱 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** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
- **⌨️ 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 — chat, management, voice, Petdex, and ordinary installed-plugin pages need **no Relay plugin**. Add the optional Relay only when you want terminal, phone control, agent-created page drafts, or the CLI's tools. **Pair once from either surface; both work.**
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.**
<p align="center">
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — Vanilla Hermes (Chat, Manage, Voice) runs 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." width="900">
<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">
</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, Manage, 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, and Manage work with standard Hermes; pairing the Hermes-Relay plugin adds Terminal/TUI, media, notifications, and Relay sessions.
- **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.
@@ -71,27 +71,21 @@ an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/gui
covers Windows, remote access, and dashboard authentication. You do not need to
enable the separate API server or invent an API key for the standard path.
For plugin-enabled setups, optional **Hermes Secure Link** presents Relay, API,
and Dashboard routes through one pairing-pinned TLS origin. It protects traffic
to the paired endpoint while each service keeps its own authentication; it does
not provide reachability or independently identify the physical host. You still
use LAN routing, Tailscale or another VPN, or an operator-managed public route
to reach the listener. Secure Link is off by default and requires a fresh QR
pairing after it is enabled. See the
[remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/).
**Hermes Reach** is an experimental, advanced outbound-broker route. It remains
available for development and self-hosted evaluation, but it is disabled by
default, ordered after supported routes, and not recommended for normal remote
access. Use Tailscale for the easiest supported remote setup, or a public TLS
domain / Direct Secure Link when you want to own the complete network path.
Start on a trusted LAN. For away-from-home access, Tailscale is the recommended
path. Secure Link, public TLS, and experimental routing options are covered in
the [remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/).
### 3 · Connect and talk
Open the app, choose **Connect to Hermes**, and enter or discover the dashboard
address (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.
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
Dashboard's configured provider when prompted. The app probes the available
upstream capabilities and finishes with a connection summary.
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
@@ -106,49 +100,47 @@ The wizard probes everything and finishes with a capability card:
| **Manage** | Models, keys, skills, and profiles are available from the phone |
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
| **API fallback** | Optional API route available/unavailable |
| **Relay** | Optional extensions — fine to leave unpaired |
| **Relay** | Recommended extensions paired/unpaired; never blocks the upstream path |
One dashboard sign-in unlocks Chat, Manage, sessions, and standard voice. That's
the whole Vanilla Hermes setup.
> **Going places?** Add the Dashboard's Tailscale address — for example `http://100.x.y.z:9119` or a separately published `https://host.ts.net` URL — under **Settings → Connections → Routes**. Android tests it as a Dashboard route; no API server or API key is required. The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
### 4 · Optional: install Relay for power tools
### 4 · Recommended: pair Relay for the complete experience
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, the realtime voice engine, or approval-gated agent-created plugin-page drafts:
Install Relay for Terminal/TUI, notifications, media handoff, desktop tools,
enhanced voice, Relay sessions, approval-gated page drafts, and optional Device
Control:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
hermes relay start --no-ssl
hermes pair
```
Use the legacy installer instead if you also want the systemd user service,
shell shims, and the full clone/update workflow:
Use `--no-ssl` only on a trusted LAN or VPN. Use the
[remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/) before
exposing any Hermes surface beyond that network.
Refresh or restart the Dashboard/Gateway, open **Relay → Pair new device**, and
scan the one-time QR from Android **Settings → Connections → Pair Hermes Relay**.
Leave mode on **Auto** for the recommended route discovery. The same dialog
shows a copyable invite for Desktop CLI clients:
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
hermes-relay pair --pair-qr "hermes-relay://pair?payload=…" --grant-tools
```
Installed Hermes plugins can expose bounded, host-rendered pages to Android
through the authenticated Dashboard without running plugin code on the phone.
Relay 1.5.0 additionally supports approval-gated agent-created page drafts. The
plugin-manager install owns the plugin code, dashboard tab, CLI commands, and
agent tools. `hermes relay compat status/install/remove` manages only the
optional legacy API compatibility hook when an older Hermes build needs it. Scan
the QR from the phone's Connections screen — or use
`hermes pair --register-code ABCD12` with the manual code from Android
**Settings → Connections → Advanced**.
As alternatives, `hermes pair` renders the same Android QR and pasteable invite
in a terminal, while URL + six-character code and `--register-code` remain
manual fallbacks when QR or clipboard transfer is unavailable.
- **Plugin-manager uninstall:** `hermes relay compat remove --all` if you installed the optional hook, then `hermes plugins remove hermes-relay`.
- **Legacy installer update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
- **Legacy installer uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — removes the service, shims, clone, external skill path, editable package, and compat hook. It never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a **Relay** tab (paired devices, bridge activity, media tokens) appears in the web UI.
**Next:** [Android + Hermes-Relay Quick Start](https://hermes-relay.dev/docs/guide/quick-start) ·
[Desktop CLI pairing](https://hermes-relay.dev/docs/desktop/pairing) ·
[server, TLS, legacy install, and uninstall reference](https://hermes-relay.dev/docs/reference/relay-server)
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ on the server. The API server and Relay are optional.
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ when installing the Hermes-Relay plugin. The API fallback is optional; the Hermes-Relay plugin is encouraged for the complete experience.
## Screenshots
@@ -193,16 +185,16 @@ tracked independently so community corrections remain easy to contribute.
- **Hands-free voice** — talk on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice and an opt-in provider-native Realtime Agent with background task handoff.
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
- **Multi-Connection + profiles** — pair multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay a profile's model + `SOUL.md` per chat.
- **Phone control (bridge)** — with Relay paired, the agent reads the screen and acts: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros. Guarded by per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
- **Device Control (Sideload + Hermes-Relay required)** — the agent can read the screen and act: tap, type, swipe, scroll, screenshots, clipboard, media keys, and batched macros. This is not included in the Google Play build. It is guarded by a per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
- **Notification companion** — opt-in access so the agent can triage, summarize, and route incoming notifications.
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks).
## Hands on any machine — the Hermes-Relay CLI&nbsp;<sub>(alpha)</sub>
## Hands on any machine — the Hermes-Relay CLI&nbsp;<sub>(beta)</sub>
> **Alpha.** Self-contained CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64 — no Node required. Windows also has an optional compact management tray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
> **Beta.** Self-contained CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64 — no Node required. Windows also has an optional compact management tray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
@@ -220,6 +212,14 @@ It pairs against the **same relay and credential store** as the Android app —
On Windows, the default installer adds the optional compact **Hermes-Relay CLI UI** tray popup for host selection and pairing, connection and daemon state, per-host Ask/Trusted/Full Access, local grant dialogs, authorized-client revocation, activity, settings, and emergency stop. It is a management surface only—chat, TUI, plugins, voice, and agent sessions remain CLI/upstream concerns.
<table>
<tr>
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/overview.png" alt="Hermes-Relay CLI UI connected overview" width="100%"><br><sub><b>Connection &amp; activity</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/host-access.png" alt="Hermes-Relay CLI UI host access presets" width="100%"><br><sub><b>Per-host access</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/settings.png" alt="Hermes-Relay CLI UI computer control and updates" width="100%"><br><sub><b>Control &amp; maintenance</b></sub></td>
</tr>
</table>
Structured Windows computer control prefers a compatible local CUA Driver
runtime for window-targeted background actions and virtual per-session agent
cursors. It remains behind Hermes host policy, grants, targeting, audit, and
@@ -348,7 +348,7 @@ hermes-relay/
<br>
End users should install via the [one-liner](#4--optional-install-relay-for-power-tools) above. For local development:
End users should follow the [recommended Hermes-Relay setup](#4--recommended-pair-relay-for-the-complete-experience) above. For local development:
```bash
hermes relay start --no-ssl # if you installed the plugin
@@ -1,62 +1,59 @@
Hermes-Relay is the native Android client for the Hermes agent platform. Point it at your own Hermes instance and chat with your agent, talk to it hands-free, and manage models, keys, skills, and profiles from anywhere.
Hermes-Relay is the native Android companion for the Hermes agent you run. Chat, talk hands-free, continue sessions, and manage models, keys, skills, profiles, and automations from your phone.
It is not a hosted AI service. It is a companion app for the Hermes agent you run, and it talks only to the instances you configure.
It is not a hosted AI service. Your Hermes agent stays on infrastructure you control, and the app talks only to instances you configure.
QUICK START
1. Run hermes-agent with its API server and dashboard enabled on your computer or home server.
2. Install Hermes-Relay and enter your server address, for example http://192.168.1.100:8642.
3. The setup wizard checks what your server supports and shows a readiness card, then you are ready to chat.
1. Start the Hermes Dashboard/Gateway on your computer or home server with hermes dashboard.
2. Install Hermes-Relay from Google Play.
3. For the recommended full setup, install the Hermes-Relay plugin on the host and refresh the Web Dashboard. A Relay page will appear.
4. Scan Connect mobile app from Android Connect. Then scan Pair new device from Android Settings > Connections.
A plain Hermes install is enough. Chat, management, and voice work with no plugin or extra service.
The QR codes are separate on purpose. Connect mobile app adds the standard Dashboard/Gateway connection. Pair new device grants a time-limited Hermes-Relay session for the additional capabilities you approve.
Standard Hermes without the plugin is supported. Choose Find Hermes on LAN or enter the Dashboard address you open in a browser, normally http://<host>:9119. Pair the Hermes-Relay plugin later when you want the full experience.
HOW IT WORKS
Chat streams directly from your Hermes API Server or dashboard gateway in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
Chat, sessions, Manage, sign-in, and standard voice use the unmodified Hermes Dashboard/Gateway. The separate Hermes API server is an optional fallback for advanced or headless setups; it is not required for the normal Android connection.
GOOGLE PLAY BUILD
The encouraged Hermes-Relay plugin adds Terminal/TUI, notifications, media handoff, enhanced voice, Relay sessions, desktop-tool handoff, and time-limited per-feature grants. When upstream Hermes provides a compatible capability, Hermes-Relay prefers it instead of duplicating it.
The Google Play build ships Hermes Bridge Core only. It has no AccessibilityService Device Control: it cannot read your screen, tap, type, swipe, screenshot, send SMS, place calls, or access contacts or location. Device Control is reserved for sideload builds distributed outside Google Play.
GOOGLE PLAY AND SIDELOAD
The Google Play build includes Chat, voice, sessions, Manage, profiles, notifications, media, and Terminal/TUI when the Hermes-Relay plugin is paired.
Google Play does not include Android Device Control. It cannot read the phone screen, tap, type, swipe, take device screenshots, send SMS, place calls, or access contacts or location.
Device Control is available only in the signed Sideload build on this project's GitHub Releases. It requires the Sideload app, a paired Hermes-Relay plugin, explicit Android accessibility permission, and the app's safety controls.
FEATURES
- Streaming Chat: real-time responses with reasoning, markdown, tool-call visibility, attachments, mid-turn steering, edit-and-resend, and a searchable command palette.
- Manage Your Agent: use your Hermes dashboard from your phone to switch models, manage provider keys, edit profiles, and browse, install, and update skills.
- Voice Mode: talk hands-free using your server's speech providers. Relay-paired setups add per-profile voices and an experimental realtime engine.
- Works Away From Home: add LAN, Tailscale, or public routes and the app chooses the best available path on connect.
- Sessions: create, switch, rename, and delete chats. Message history loads on demand.
- Multiple Servers and Profiles: connect to more than one server and switch in a tap; overlay an agent profile or personality per conversation.
- Relay Power Tools: optional QR pairing for remote terminal, relay-session management, media handoff, and per-feature grants.
- Notification Companion: optionally forward notification metadata to your paired relay so your assistant can summarize it. Toggle it anytime in system settings.
- Stats for Nerds: local-only counters for response timing, token usage, cost, and stream health.
- Material You: Material 3 dynamic color, light/dark/system themes, and haptics.
- Streaming Chat with reasoning, markdown, tool progress, attachments, mid-turn steering, edit-and-resend, and searchable commands.
- Manage models and provider keys, edit profiles, and browse, install, or update skills through the Hermes Dashboard.
- Hands-free voice through your server's speech providers. Hermes-Relay pairing adds per-profile voices and an experimental realtime engine.
- Create, switch, search, rename, pin, archive, and continue sessions.
- Connect multiple Hermes servers and switch in one tap; add LAN, Tailscale, or public routes.
- Pair the Hermes-Relay plugin for Terminal/TUI, notifications, media, enhanced voice, Relay sessions, and per-feature grants.
- Inspect connection readiness, routes, response timing, token usage, and stream health without exposing credentials.
SECURITY AND PRIVACY
- API keys and relay tokens are stored in encrypted Android storage.
- HTTPS is enforced for remote connections; cleartext is limited to localhost or LAN setups.
- Dashboard sessions and Hermes-Relay tokens use encrypted Android storage.
- Cleartext is limited to trusted local-network setups. Use a VPN or HTTPS remotely.
- No telemetry, ads, tracking, or third-party analytics SDKs.
- Notification access and the microphone are optional and user-controlled.
- All app traffic goes only to servers you configure.
- Notification and microphone access are optional and user-controlled.
- App traffic goes only to servers you configure.
REQUIREMENTS
- Android 8.0 or later.
- A running Hermes agent for chat, management, and voice.
- Optional Hermes relay service for power tools such as terminal, notifications, and media.
- Network access to your server by local network, VPN, or internet.
- A reachable Hermes Dashboard/Gateway.
- The Hermes-Relay plugin is encouraged for the complete experience but never blocks standard Hermes.
- Network access through a local network, VPN, or operator-managed internet route.
OPEN SOURCE
Hermes-Relay is MIT licensed. Source, docs, and issue tracking are on GitHub.
Hermes-Relay is MIT licensed. Source, setup guides, downloads, and issue tracking are on GitHub.
This app is a community project and is not affiliated with or endorsed by NousResearch.
This community project is not affiliated with or endorsed by NousResearch.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 168 KiB

@@ -1 +1 @@
Your Hermes AI agent, in your pocket - chat, voice, and control.
Your Hermes agent on Android — chat, voice, sessions, and Manage.
@@ -0,0 +1,63 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
enum class ProviderUsageLandingMode(val storedValue: String) {
Summary("summary"),
Expanded("expanded"),
Hidden("hidden"),
;
companion object {
fun fromStoredValue(value: String?): ProviderUsageLandingMode =
entries.firstOrNull { it.storedValue == value } ?: Summary
}
}
data class ProviderUsagePreferences(
val landingMode: ProviderUsageLandingMode = ProviderUsageLandingMode.Summary,
val visibleProviders: Set<String> = DEFAULT_VISIBLE_PROVIDERS,
) {
companion object {
val DEFAULT_VISIBLE_PROVIDERS = setOf("openai-codex", "nous", "opencode-go")
}
}
class ProviderUsagePreferencesRepository(private val dataStore: DataStore<Preferences>) {
constructor(context: Context) : this(context.relayDataStore)
companion object {
internal val KEY_LANDING_MODE = stringPreferencesKey("provider_usage_landing_mode")
internal val KEY_VISIBLE_PROVIDERS = stringSetPreferencesKey("provider_usage_visible_providers")
}
val preferences: Flow<ProviderUsagePreferences> = dataStore.data
.map { prefs ->
ProviderUsagePreferences(
landingMode = ProviderUsageLandingMode.fromStoredValue(prefs[KEY_LANDING_MODE]),
visibleProviders = prefs[KEY_VISIBLE_PROVIDERS]
?: ProviderUsagePreferences.DEFAULT_VISIBLE_PROVIDERS,
)
}
.distinctUntilChanged()
suspend fun setLandingMode(mode: ProviderUsageLandingMode) {
dataStore.edit { it[KEY_LANDING_MODE] = mode.storedValue }
}
suspend fun setProviderVisible(providerId: String, visible: Boolean) {
dataStore.edit { prefs ->
val current = prefs[KEY_VISIBLE_PROVIDERS]
?: ProviderUsagePreferences.DEFAULT_VISIBLE_PROVIDERS
prefs[KEY_VISIBLE_PROVIDERS] = if (visible) current + providerId else current - providerId
}
}
}
@@ -9,6 +9,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
@@ -1444,4 +1445,86 @@ class RelayHttpClient(
val value = header?.trim()?.lowercase() ?: return false
return value == "1" || value == "true"
}
/** Provider-neutral compatibility fetch for gateways without `account.usage`. */
suspend fun fetchProviderUsage(
profile: String? = null,
sessionId: String? = null,
): Result<ProviderUsageResponse?> =
withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
return@withContext Result.success(null)
}
val sessionToken = sessionTokenProvider()
if (sessionToken.isNullOrBlank()) {
return@withContext Result.success(null)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val url = "$httpBase/usage/providers".toHttpUrlOrNull()
?.newBuilder()
?.apply {
profile?.trim()?.takeIf { it.isNotEmpty() }?.let {
addQueryParameter("profile", it)
}
sessionId?.trim()?.takeIf { it.isNotEmpty() }?.let {
addQueryParameter("session_id", it)
}
}
?.build()
?: return@withContext Result.failure(
IllegalArgumentException("Invalid relay URL: $httpBase")
)
val request = Request.Builder()
.url(url)
.get()
.header("Authorization", "Bearer $sessionToken")
.header("Accept", "application/json")
.build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (response.code == 404) {
// Older or operator-disabled hosts simply do not expose
// account usage. This is capability absence, not an error.
return@withContext Result.success(null)
}
if (!response.isSuccessful) {
val reason = when (response.code) {
401, 403 -> "Unauthorized — re-pair with the relay"
502 -> "Provider usage upstream error (HTTP ${response.code})"
in 500..599 -> "Relay error (HTTP ${response.code})"
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
}
return@withContext Result.failure(IOException(reason))
}
val body = response.body?.string().orEmpty()
if (body.isBlank()) {
return@withContext Result.failure(IOException("Empty response body"))
}
val parsed = runCatching {
sessionsJson.decodeFromString(
ProviderUsageResponse.serializer(),
body,
)
}.getOrElse {
Log.w(TAG, "fetchProviderUsage parse error: ${it.message}")
return@withContext Result.failure(IOException("Unrecognized usage payload"))
}
Result.success(parsed)
}
} catch (e: IOException) {
Log.w(TAG, "fetchProviderUsage failed: ${e.message}")
Result.failure(IOException("Relay unreachable: ${e.message ?: "IO error"}"))
} catch (e: Exception) {
Log.w(TAG, "fetchProviderUsage unexpected error: ${e.message}")
Result.failure(e)
}
}
}
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.shutdownOffMainThread
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
import com.hermesandroid.relay.network.upstream.models.SessionItem
@@ -448,6 +449,25 @@ class DashboardApiClient(
*/
suspend fun getConfig(): Result<JsonObject> = getJsonObject("/api/config")
suspend fun getProviderUsage(
profile: String? = null,
sessionId: String? = null,
): Result<ProviderUsageResponse?> {
val query = buildList {
profile?.trim()?.takeIf { it.isNotEmpty() }?.let {
add("profile=${queryValue(it)}")
}
sessionId?.trim()?.takeIf { it.isNotEmpty() }?.let {
add("session_id=${queryValue(it)}")
}
}
val suffix = query.joinToString(prefix = if (query.isEmpty()) "" else "?", separator = "&")
return getJsonObject("/api/plugins/hermes-relay/provider-usage$suffix")
.mapCatching { root ->
json.decodeFromJsonElement(ProviderUsageResponse.serializer(), root)
}
}
/**
* The config SCHEMA: `{fields: {<dot.path>: {type, description, category,
* options?}}, category_order: [...]}`. Describes how to render each field;
@@ -1426,6 +1426,21 @@ class GatewayChatClient(
.onSuccess { commandsCatalogCache = it }
}
/**
* 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.
*/
suspend fun providerUsage(): Result<JsonObject> {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
return rpc("account.usage", JsonObject(emptyMap()))
}
/**
* Create a schedule through upstream's authenticated `cron.manage` RPC.
* No Relay scheduler or compatibility endpoint is involved.
@@ -0,0 +1,89 @@
package com.hermesandroid.relay.network.usage
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ProviderUsageResponse(
@SerialName("schema_version") val schemaVersion: Int = 1,
@SerialName("fetched_at") val fetchedAt: String? = null,
val capabilities: Set<String> = emptySet(),
val providers: List<ProviderUsageProvider> = emptyList(),
) {
val relayEnhanced: Boolean
get() = capabilities.containsAll(RELAY_ENHANCED_CAPABILITIES)
companion object {
val RELAY_ENHANCED_CAPABILITIES = setOf(
"credential_pools",
"structured_balances",
"opencode_go",
)
}
}
@Serializable
data class ProviderUsageProvider(
val id: String,
@SerialName("display_name") val displayName: String,
val status: String,
val source: String? = null,
@SerialName("fetched_at") val fetchedAt: String? = null,
val plan: String? = null,
val windows: List<ProviderUsageWindow> = emptyList(),
val details: List<String> = emptyList(),
val balances: List<ProviderUsageBalance> = emptyList(),
@SerialName("renews_at") val renewsAt: String? = null,
@SerialName("action_url") val actionUrl: String? = null,
val credentials: List<ProviderUsageCredential> = emptyList(),
@SerialName("active_credential_id") val activeCredentialId: String? = null,
@SerialName("active_credential_state") val activeCredentialState: String = "unknown",
@SerialName("active_observed_at") val activeObservedAt: String? = null,
val message: String? = null,
) {
val available: Boolean get() = status == STATUS_AVAILABLE
companion object {
const val STATUS_AVAILABLE = "available"
const val STATUS_NOT_CONFIGURED = "not_configured"
const val STATUS_UNAVAILABLE = "unavailable"
}
}
@Serializable
data class ProviderUsageBalance(
val id: String,
val label: String,
val amount: Double,
val currency: String = "USD",
)
@Serializable
data class ProviderUsageCredential(
val id: String,
val label: String,
val active: Boolean = false,
val status: String,
@SerialName("pool_status") val poolStatus: String? = null,
@SerialName("last_status_at") val lastStatusAt: String? = null,
@SerialName("reset_at") val resetAt: String? = null,
val plan: String? = null,
val windows: List<ProviderUsageWindow> = emptyList(),
val details: List<String> = emptyList(),
val message: String? = null,
) {
companion object {
const val STATUS_AVAILABLE = "available"
const val STATUS_AT_LIMIT = "at_limit"
const val STATUS_UNAVAILABLE = "unavailable"
}
}
@Serializable
data class ProviderUsageWindow(
val id: String,
val label: String,
@SerialName("used_percent") val usedPercent: Double? = null,
@SerialName("reset_at") val resetAt: String? = null,
val detail: String? = null,
)
@@ -0,0 +1,46 @@
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
/** Relay-enhanced usage with an upstream fallback for hosts without Relay support. */
class ProviderUsageRepository(
private val gatewayClientProvider: () -> GatewayChatClient?,
private val dashboardClientProvider: () -> DashboardApiClient? = { null },
private val relayHttpClient: RelayHttpClient,
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 dashboard = dashboardClientProvider()
if (dashboard != null) {
val enhanced = dashboard.getProviderUsage(profile, session)
if (enhanced.isSuccess && enhanced.getOrNull() != null) return enhanced
}
val relay = relayHttpClient.fetchProviderUsage(
profile = profile,
sessionId = session,
)
if (relay.isSuccess && relay.getOrNull() != null) return relay
val gateway = gatewayClientProvider()
if (gateway != null) {
val upstream = gateway.providerUsage()
.mapCatching { json.decodeFromJsonElement<ProviderUsageResponse>(it) }
if (upstream.isSuccess) return upstream
}
return relay
}
}
@@ -170,6 +170,7 @@ import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
import com.hermesandroid.relay.ui.screens.SettingsScreen
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
import com.hermesandroid.relay.ui.screens.PluginsScreen
import com.hermesandroid.relay.ui.screens.PluginPageScreen
import com.hermesandroid.relay.ui.screens.TerminalScreen
@@ -530,6 +531,7 @@ sealed class Screen(
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
// above for the surviving route.)
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
data object ProviderUsage : Screen("settings/usage", "Usage & limits", Icons.Filled.Settings)
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
data object CustomTheme : Screen("settings/appearance/custom-theme", "Custom", Icons.Filled.Settings)
@@ -2387,6 +2389,9 @@ fun RelayApp() {
onNavigateToManage = {
navController.navigate(Screen.Manage.route)
},
onNavigateToProviderUsage = {
navController.navigate(Screen.ProviderUsage.route)
},
onNavigateToPlugins = {
navController.navigate(Screen.Plugins.route)
},
@@ -2445,6 +2450,13 @@ fun RelayApp() {
},
)
}
composable(Screen.ProviderUsage.route) {
UsageLimitsScreen(
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
onBack = { navController.popBackStack() },
)
}
composable(Screen.Plugins.route) {
PluginsScreen(
viewModel = pluginsViewModel,
@@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
@@ -25,6 +26,8 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.LocalBrand
import kotlinx.coroutines.delay
import kotlin.math.sin
/**
* ASCII morphing sphere — the visual embodiment of the AI agent.
@@ -53,6 +56,32 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
private const val SPHERE_TIME_UNITS_PER_SEC = 1f
private const val SPHERE_TWO_PI = 6.2832f
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
private const val SPHERE_IDLE_BREATH_RADIANS_PER_SEC = 0.72f
private const val SPHERE_IDLE_BREATH_SCALE = 0.012f
private const val SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS = 184L
internal enum class SphereMotionMode {
Still,
AmbientLayer,
Procedural,
}
internal fun sphereMotionMode(
state: SphereState,
voiceMode: Boolean,
motionVisible: Boolean,
fixedTime: Float?,
fixedColorPhase: Float?,
): SphereMotionMode {
if (!motionVisible || fixedTime != null || fixedColorPhase != null) {
return SphereMotionMode.Still
}
return if (state == SphereState.Idle && !voiceMode) {
SphereMotionMode.AmbientLayer
} else {
SphereMotionMode.Procedural
}
}
@Composable
fun MorphingSphere(
@@ -64,7 +93,8 @@ fun MorphingSphere(
voiceMode: Boolean = false,
skin: SphereSkin = LocalSphereSkin.current,
fixedTime: Float? = null,
fixedColorPhase: Float? = null
fixedColorPhase: Float? = null,
motionVisible: Boolean = true,
) {
val brand = LocalBrand.current
// Gate reactive inputs on what the skin declares it honors — this is the
@@ -104,16 +134,21 @@ fun MorphingSphere(
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
// Continuous motion runs only for active agent/voice states. Idle is a
// stable frame: the 58x34 text grid is expensive enough that even a
// throttled cosmetic drift dominated measured screen-on CPU. Active states
// retain full display-rate motion and dt-based timing.
// Active states retain the full procedural animation. Visible Idle uses a
// lightweight graphics-layer breath: redrawing the 58x34 glyph grid just
// for ambient drift was the measured screen-on hotspot, while transforming
// its cached layer preserves the intended living Sphere at far lower cost.
val animatedTime = remember { mutableFloatStateOf(0f) }
val animatedColorPhase = remember { mutableFloatStateOf(0f) }
val fullFrameRate = state != SphereState.Idle || effVoiceMode
val driveAnimation = (fixedTime == null || fixedColorPhase == null) && fullFrameRate
if (driveAnimation) {
LaunchedEffect(fullFrameRate) {
val motionMode = sphereMotionMode(
state = state,
voiceMode = effVoiceMode,
motionVisible = motionVisible,
fixedTime = fixedTime,
fixedColorPhase = fixedColorPhase,
)
if (motionMode == SphereMotionMode.Procedural) {
LaunchedEffect(motionMode) {
var lastNanos = withFrameNanos { it }
while (true) {
val now = withFrameNanos { it }
@@ -127,6 +162,25 @@ fun MorphingSphere(
}
}
}
val idleBreathPhase = remember { mutableFloatStateOf(0f) }
LaunchedEffect(motionMode) {
if (motionMode != SphereMotionMode.AmbientLayer) {
idleBreathPhase.floatValue = 0f
return@LaunchedEffect
}
var lastNanos = withFrameNanos { it }
while (true) {
val now = withFrameNanos { it }
val dtSec = (now - lastNanos).coerceAtLeast(0L) / 1_000_000_000f
lastNanos = now
idleBreathPhase.floatValue =
(idleBreathPhase.floatValue + dtSec * SPHERE_IDLE_BREATH_RADIANS_PER_SEC) %
SPHERE_TWO_PI
// The frame wait plus this delay caps the gentle layer-only pulse
// near 5fps while active procedural states retain display-rate motion.
delay(SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS)
}
}
val time = fixedTime ?: animatedTime.floatValue
val colorPhase = fixedColorPhase ?: animatedColorPhase.floatValue
@@ -138,7 +192,18 @@ fun MorphingSphere(
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
val glyphStrings = remember { HashMap<Char, String>(32) }
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
Canvas(
modifier = modifier
.fillMaxSize()
.graphicsLayer {
if (motionMode == SphereMotionMode.AmbientLayer) {
val scale = 1f + sin(idleBreathPhase.floatValue) * SPHERE_IDLE_BREATH_SCALE
scaleX = scale
scaleY = scale
}
}
.clipToBounds(),
) {
val canvasW = size.width
val canvasH = size.height
val cellW = canvasW / cols
@@ -1,9 +1,12 @@
package com.hermesandroid.relay.ui.components.avatar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.SphereReactivity
import com.hermesandroid.relay.util.AppForegroundTracker
/**
* Default ambient visualization — the ASCII [MorphingSphere].
@@ -34,6 +37,7 @@ object SphereAvatar : AgentAvatar {
@Composable
override fun Render(state: AvatarRenderState, modifier: Modifier) {
val appForeground by AppForegroundTracker.isForeground.collectAsState()
MorphingSphere(
modifier = modifier,
state = state.state,
@@ -46,6 +50,7 @@ object SphereAvatar : AgentAvatar {
// call did with fixedTime/fixedColorPhase = 0f.
fixedTime = if (state.paused) 0f else null,
fixedColorPhase = if (state.paused) 0f else null,
motionVisible = appForeground,
)
}
}
@@ -116,7 +116,10 @@ fun ConnectionsSettingsScreen(
val configured by connectionViewModel.relayConfigured.collectAsState()
configured
} else {
false
// Preview/screenshot hosts do not construct a ConnectionViewModel.
// Fall back to the persisted pairing metadata so their active card is
// honest instead of showing a connected Relay as "Optional".
connections.firstOrNull { it.id == activeConnectionId }?.hasConfiguredRelay() == true
}
val startupConnectionId: String? = if (connectionViewModel != null) {
val startupId by connectionViewModel.startupConnectionId.collectAsState()
@@ -566,7 +569,7 @@ private fun ConnectionSurfaceSummary(
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive) {
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive && activeConnectionViewModel != null) {
resolveChatRuntimeStatus(
gateway = when (gatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.NewReleases
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -70,6 +71,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -96,11 +98,17 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProviderUsageLandingMode
import com.hermesandroid.relay.data.ProviderUsagePreferences
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.ui.components.AgentAvatarFace
import com.hermesandroid.relay.ui.components.AgentInfoSheet
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
import com.hermesandroid.relay.ui.components.RelaySkeletonLine
import com.hermesandroid.relay.ui.components.pet.LocalPetCompanionCoordinator
import com.hermesandroid.relay.ui.components.pet.petObstacleSurface
import com.hermesandroid.relay.ui.components.pet.petPerchSurface
@@ -114,6 +122,7 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.delay
private const val SETTINGS_PET_SURFACE_ROUTE = "settings"
private val SETTINGS_PET_SURFACE_ROUTES = setOf(SETTINGS_PET_SURFACE_ROUTE)
@@ -170,6 +179,7 @@ fun SettingsScreen(
// expandable sections, so there's nothing left to link to twice.
onNavigateToConnections: () -> Unit,
onNavigateToManage: () -> Unit,
onNavigateToProviderUsage: () -> Unit,
onNavigateToPlugins: () -> Unit,
onNavigateToChatSettings: () -> Unit,
onNavigateToTerminal: () -> Unit,
@@ -200,9 +210,57 @@ fun SettingsScreen(
val isDarkTheme = LocalBrand.current.isDark
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val providerUsagePreferencesRepository = remember(context) {
ProviderUsagePreferencesRepository(context)
}
val providerUsagePreferences by providerUsagePreferencesRepository.preferences.collectAsState(
initial = ProviderUsagePreferences(),
)
val providerUsageRepository = remember(connectionViewModel) {
ProviderUsageRepository(
gatewayClientProvider = connectionViewModel::activeGatewayChatClient,
dashboardClientProvider = {
connectionViewModel.activeDashboardUrl()?.let(
connectionViewModel::dashboardClientForActive,
)
},
relayHttpClient = connectionViewModel.relayHttpClient,
profileProvider = { connectionViewModel.selectedProfile.value?.name },
sessionProvider = { chatViewModel.currentSessionId.value },
)
}
var providerUsageResponse by remember { mutableStateOf<ProviderUsageResponse?>(null) }
var providerUsageLoaded by remember { mutableStateOf(false) }
var providerUsageRefreshing by remember { mutableStateOf(false) }
var providerUsageRefreshKey by remember { mutableIntStateOf(0) }
LaunchedEffect(
activeConnection?.id,
selectedProfile?.name,
currentSessionId,
providerUsagePreferences.landingMode,
providerUsageRefreshKey,
) {
if (providerUsagePreferences.landingMode == ProviderUsageLandingMode.Hidden) {
providerUsageResponse = null
providerUsageLoaded = true
} else {
if (providerUsageResponse == null) providerUsageLoaded = false
providerUsageRefreshing = providerUsageResponse != null
providerUsageRepository.fetch().getOrNull()?.let { providerUsageResponse = it }
providerUsageLoaded = true
providerUsageRefreshing = false
}
}
LaunchedEffect(providerUsagePreferences.landingMode) {
while (providerUsagePreferences.landingMode != ProviderUsageLandingMode.Hidden) {
delay(300_000)
providerUsageRefreshKey++
}
}
// Active Agent card inputs — personality + profile drive the title,
// ring-accent, and subtitle.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
@@ -477,6 +535,16 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
ProviderUsageLandingCard(
response = providerUsageResponse,
loaded = providerUsageLoaded,
refreshing = providerUsageRefreshing,
preferences = providerUsagePreferences,
onDisplay = onNavigateToProviderUsage,
onRefresh = { providerUsageRefreshKey++ },
isDarkTheme = isDarkTheme,
)
SettingsSectionHeader(stringResource(R.string.settings_hermes))
SettingsCategoryRow(
@@ -1257,6 +1325,135 @@ private fun SettingsStatusPill(pill: SettingsStatusPillModel) {
}
}
@Composable
private fun ProviderUsageLandingCard(
response: ProviderUsageResponse?,
loaded: Boolean,
refreshing: Boolean,
preferences: ProviderUsagePreferences,
onDisplay: () -> Unit,
onRefresh: () -> Unit,
isDarkTheme: Boolean,
) {
val providers = response?.providers
?.filter { it.available && it.id in preferences.visibleProviders }
.orEmpty()
Card(
modifier = Modifier
.settingsPetSurface("settings-card:provider-usage")
.fillMaxWidth()
.gradientBorder(
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme,
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Analytics,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.provider_usage_title),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(
when (response?.relayEnhanced) {
true -> R.string.provider_usage_settings_desc_relay
false -> R.string.provider_usage_settings_desc_basic
null -> R.string.provider_usage_settings_desc
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onRefresh, enabled = !refreshing) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.provider_usage_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onDisplay) {
Text(stringResource(R.string.provider_usage_customize))
}
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
when {
preferences.landingMode == ProviderUsageLandingMode.Hidden -> {
Text(
text = stringResource(R.string.provider_usage_hidden_hint),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
!loaded -> {
ProviderUsageSkeleton(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
providers.isEmpty() -> {
Text(
text = stringResource(R.string.provider_usage_not_available_compact),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> providers.forEachIndexed { index, provider ->
if (index > 0) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
ProviderUsageContent(
provider = provider,
detailed = preferences.landingMode == ProviderUsageLandingMode.Expanded,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
}
}
}
@Composable
private fun ProviderUsageSkeleton(modifier: Modifier = Modifier) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
RelaySkeletonLine(width = 112.dp, height = 16.dp)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
RelaySkeletonLine(width = 86.dp)
RelaySkeletonLine(width = 58.dp)
}
RelaySkeletonLine(width = 260.dp, height = 6.dp)
RelaySkeletonLine(width = 92.dp, height = 10.dp)
}
}
@Composable
private fun SettingsSectionHeader(
label: String,
@@ -0,0 +1,700 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ProviderUsageLandingMode
import com.hermesandroid.relay.data.ProviderUsagePreferences
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
import com.hermesandroid.relay.network.usage.ProviderUsageProvider
import com.hermesandroid.relay.network.usage.ProviderUsageCredential
import com.hermesandroid.relay.network.usage.ProviderUsageBalance
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import com.hermesandroid.relay.network.usage.ProviderUsageWindow
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.ui.components.RelaySkeletonLine
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private sealed interface UsageLoadState {
data object Loading : UsageLoadState
data object Unsupported : UsageLoadState
data class Loaded(val response: ProviderUsageResponse) : UsageLoadState
data object Error : UsageLoadState
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UsageLimitsScreen(
connectionViewModel: ConnectionViewModel,
chatViewModel: ChatViewModel,
onBack: () -> Unit,
) {
val context = androidx.compose.ui.platform.LocalContext.current
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val preferencesRepository = remember(context) { ProviderUsagePreferencesRepository(context) }
val preferences by preferencesRepository.preferences.collectAsState(
initial = ProviderUsagePreferences(),
)
val repository = remember(connectionViewModel) {
ProviderUsageRepository(
gatewayClientProvider = connectionViewModel::activeGatewayChatClient,
dashboardClientProvider = {
connectionViewModel.activeDashboardUrl()?.let(
connectionViewModel::dashboardClientForActive,
)
},
relayHttpClient = connectionViewModel.relayHttpClient,
profileProvider = { connectionViewModel.selectedProfile.value?.name },
sessionProvider = { chatViewModel.currentSessionId.value },
)
}
var refreshKey by remember { mutableIntStateOf(0) }
var state by remember { mutableStateOf<UsageLoadState>(UsageLoadState.Loading) }
var refreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(activeConnection?.id, selectedProfile?.name, currentSessionId, refreshKey) {
val hadContent = state is UsageLoadState.Loaded
if (!hadContent) state = UsageLoadState.Loading else refreshing = true
val next = repository.fetch().fold(
onSuccess = { result ->
result?.let(UsageLoadState::Loaded) ?: UsageLoadState.Unsupported
},
onFailure = { UsageLoadState.Error },
)
if (!hadContent || next is UsageLoadState.Loaded) state = next
refreshing = false
}
LaunchedEffect(Unit) {
while (true) {
delay(300_000)
refreshKey++
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.provider_usage_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.provider_usage_back),
tint = MaterialTheme.colorScheme.primary,
)
}
},
actions = {
IconButton(onClick = { refreshKey++ }, enabled = !refreshing) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.provider_usage_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = activeConnection?.label ?: stringResource(R.string.settings_no_connection),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.provider_usage_intro),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
when (val current = state) {
UsageLoadState.Loading -> ProviderUsageLoading()
UsageLoadState.Unsupported -> ProviderUsageMessage(
text = stringResource(R.string.provider_usage_not_available),
)
UsageLoadState.Error -> ProviderUsageError(onRetry = { refreshKey++ })
is UsageLoadState.Loaded -> {
ProviderUsageCapabilityNotice(relayEnhanced = current.response.relayEnhanced)
val providers = current.response.providers
if (providers.none { it.available }) {
ProviderUsageMessage(
text = stringResource(R.string.provider_usage_none_configured),
)
}
providers.forEach { provider ->
ProviderUsageCard(
provider = provider,
detailed = true,
)
}
}
}
ProviderUsageDisplaySettings(
preferences = preferences,
providers = (state as? UsageLoadState.Loaded)?.response?.providers.orEmpty(),
onModeChanged = { mode ->
scope.launch { preferencesRepository.setLandingMode(mode) }
},
onProviderVisibilityChanged = { providerId, visible ->
scope.launch {
preferencesRepository.setProviderVisible(providerId, visible)
}
},
)
}
}
}
@Composable
private fun ProviderUsageCapabilityNotice(relayEnhanced: Boolean) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = stringResource(
if (relayEnhanced) R.string.provider_usage_capability_relay_title
else R.string.provider_usage_capability_basic_title,
),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(
if (relayEnhanced) R.string.provider_usage_capability_relay_body
else R.string.provider_usage_capability_basic_body,
),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@Composable
private fun ProviderUsageLoading() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(2) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
RelaySkeletonLine(width = 112.dp, height = 18.dp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
RelaySkeletonLine(width = 92.dp)
RelaySkeletonLine(width = 62.dp)
}
RelaySkeletonLine(width = 280.dp, height = 6.dp)
RelaySkeletonLine(width = 98.dp, height = 10.dp)
}
}
}
}
}
@Composable
private fun ProviderUsageMessage(text: String) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Text(
text = text,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ProviderUsageError(onRetry: () -> Unit) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.provider_usage_error),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onRetry) {
Text(stringResource(R.string.provider_usage_retry))
}
}
}
}
@Composable
fun ProviderUsageCard(
provider: ProviderUsageProvider,
detailed: Boolean,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
ProviderUsageContent(
provider = provider,
detailed = detailed,
modifier = Modifier.padding(16.dp),
)
}
}
@Composable
fun ProviderUsageContent(
provider: ProviderUsageProvider,
detailed: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = provider.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
provider.plan?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (!provider.available) {
Text(
text = providerUnavailableText(provider),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
if (provider.balances.isNotEmpty()) {
ProviderBalanceUsage(provider, detailed)
} else if (provider.credentials.isNotEmpty()) {
val shownCredentials = if (detailed) {
provider.credentials
} else {
provider.credentials.filter { it.active }.take(1)
}
if (shownCredentials.isEmpty()) {
Text(
text = stringResource(R.string.provider_usage_active_unknown),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
shownCredentials.forEach { credential ->
ProviderCredentialUsage(credential, detailed)
}
}
} else {
val windows = if (detailed) provider.windows else provider.windows.take(1)
windows.forEach { ProviderUsageWindowRow(it) }
}
if (detailed && provider.credentials.isEmpty() && provider.balances.isEmpty()) {
provider.details.forEach { detail ->
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ProviderBalanceUsage(
provider: ProviderUsageProvider,
detailed: Boolean,
) {
val uriHandler = LocalUriHandler.current
val total = provider.balances.firstOrNull { it.id == "total" }
?: provider.balances.first()
val supporting = provider.balances.filterNot { it.id == total.id }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = formatBalance(total),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = total.label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (detailed) {
supporting.forEach { balance ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = balance.label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = formatBalance(balance),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
}
}
formatRenewal(provider.renewsAt)?.let { renewal ->
Text(
text = stringResource(R.string.provider_usage_renews_on, renewal),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (detailed && !provider.actionUrl.isNullOrBlank()) {
TextButton(onClick = { uriHandler.openUri(provider.actionUrl) }) {
Text(stringResource(R.string.provider_usage_manage_credits))
}
}
if (detailed) {
provider.details.forEach { detail ->
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun ProviderCredentialUsage(
credential: ProviderUsageCredential,
detailed: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = credential.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (credential.active) FontWeight.SemiBold else FontWeight.Normal,
)
Text(
text = when {
credential.active && credential.status == ProviderUsageCredential.STATUS_AVAILABLE ->
stringResource(R.string.provider_usage_active_available)
credential.active && credential.status == ProviderUsageCredential.STATUS_AT_LIMIT ->
stringResource(R.string.provider_usage_active_at_limit)
credential.active -> stringResource(R.string.provider_usage_active)
credential.status == ProviderUsageCredential.STATUS_AVAILABLE ->
stringResource(R.string.provider_usage_available)
credential.status == ProviderUsageCredential.STATUS_AT_LIMIT ->
stringResource(R.string.provider_usage_at_limit)
else -> stringResource(R.string.provider_usage_unavailable_status)
},
style = MaterialTheme.typography.labelSmall,
color = when (credential.status) {
ProviderUsageCredential.STATUS_AT_LIMIT -> MaterialTheme.colorScheme.error
ProviderUsageCredential.STATUS_AVAILABLE -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
val windows = if (detailed) credential.windows else credential.windows.take(1)
windows.forEach { ProviderUsageWindowRow(it) }
if (detailed) {
credential.details.forEach { detail ->
Text(
text = detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ProviderUsageWindowRow(window: ProviderUsageWindow) {
var now by remember { mutableStateOf(Instant.now()) }
LaunchedEffect(window.resetAt) {
while (window.resetAt != null) {
delay(60_000)
now = Instant.now()
}
}
val percent = window.usedPercent?.coerceIn(0.0, 100.0)
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(window.label, style = MaterialTheme.typography.labelLarge)
Text(
text = percent?.let { stringResource(R.string.provider_usage_percent, it.toInt()) }
?: window.detail.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (percent != null) {
LinearProgressIndicator(
progress = { (percent / 100.0).toFloat() },
modifier = Modifier.fillMaxWidth().height(6.dp),
color = when {
percent >= 90 -> MaterialTheme.colorScheme.error
percent >= 75 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
},
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
}
formatReset(window.resetAt, now)?.let { reset ->
Text(
text = stringResource(R.string.provider_usage_resets, reset),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (percent != null && !window.detail.isNullOrBlank()) {
Text(
text = window.detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ProviderUsageDisplaySettings(
preferences: ProviderUsagePreferences,
providers: List<ProviderUsageProvider>,
onModeChanged: (ProviderUsageLandingMode) -> Unit,
onProviderVisibilityChanged: (String, Boolean) -> Unit,
) {
Text(
text = stringResource(R.string.provider_usage_display_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.provider_usage_display_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val modes = ProviderUsageLandingMode.entries
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
modes.forEachIndexed { index, mode ->
SegmentedButton(
selected = preferences.landingMode == mode,
onClick = { onModeChanged(mode) },
shape = SegmentedButtonDefaults.itemShape(index, modes.size),
) {
Text(
when (mode) {
ProviderUsageLandingMode.Summary -> stringResource(R.string.provider_usage_mode_summary)
ProviderUsageLandingMode.Expanded -> stringResource(R.string.provider_usage_mode_expanded)
ProviderUsageLandingMode.Hidden -> stringResource(R.string.provider_usage_mode_hidden)
}
)
}
}
}
Text(
text = stringResource(R.string.provider_usage_providers_title),
style = MaterialTheme.typography.labelLarge,
)
Text(
text = stringResource(R.string.provider_usage_providers_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val rows = if (providers.isEmpty()) {
listOf(
"openai-codex" to "Codex",
"nous" to "Nous",
"opencode-go" to "OpenCode Go",
)
} else {
providers.map { it.id to it.displayName }
}
rows.forEach { (id, label) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyLarge)
Switch(
checked = id in preferences.visibleProviders,
onCheckedChange = { onProviderVisibilityChanged(id, it) },
)
}
}
}
}
}
@Composable
private fun providerUnavailableText(provider: ProviderUsageProvider): String =
if (provider.status == ProviderUsageProvider.STATUS_NOT_CONFIGURED) {
stringResource(R.string.provider_usage_provider_not_configured)
} else {
stringResource(R.string.provider_usage_provider_unavailable)
}
private fun formatReset(raw: String?, now: Instant): String? = runCatching {
val reset = Instant.parse(raw ?: return null)
val duration = Duration.between(now, reset)
if (duration.isNegative || duration.isZero) return "now"
val days = duration.toDays()
val hours = duration.toHours() % 24
val minutes = duration.toMinutes() % 60
when {
days > 0 -> "${days}d ${hours}h"
hours > 0 -> "${hours}h ${minutes}m"
else -> "${minutes}m"
}
}.getOrNull()
private fun formatBalance(balance: ProviderUsageBalance): String = runCatching {
NumberFormat.getCurrencyInstance().apply {
currency = Currency.getInstance(balance.currency)
}.format(balance.amount)
}.getOrElse { "${balance.amount} ${balance.currency}" }
private fun formatRenewal(raw: String?): String? = runCatching {
val instant = Instant.parse(raw ?: return null)
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
.format(instant)
}.getOrNull()
@@ -444,6 +444,7 @@ class ChatViewModel : ViewModel() {
// === END PHASE3-status ===
const val MEDIA_TAP_TO_DOWNLOAD = "Tap to download"
private const val MEDIA_FETCH_TIMEOUT_MS = 120_000L
private val WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX = Regex("""^[A-Za-z]:[\\/].+""")
/** Upper bound on the rolling tool-call history flow. */
const val TOOL_CALL_HISTORY_LIMIT = 10
@@ -5958,6 +5959,13 @@ class ChatViewModel : ViewModel() {
activeStream?.cancel()
activeStream = null
activeStreamIsGateway = false
// Navigation owns the visible composer even when the live handle has
// already ended or could not be detached. Do not wait for a late
// cancel callback to clear a handler-wide busy bit after the new
// transcript has replaced its streaming bubble.
handler.clearStreamingStatus()
_steerableTurn.value = false
_steerNotice.value = null
}
/** Last-chance synchronous flush before the ViewModel scope is cancelled. */
@@ -8804,6 +8812,11 @@ class ChatViewModel : ViewModel() {
if (streamingMsg != null) {
handler.markStopped(streamingMsg.id)
handler.onStreamComplete(streamingMsg.id)
} else {
// The terminal bubble can settle before the handler-wide busy
// flag (or navigation can already have cleared the transcript).
// Stop must still be an unconditional escape hatch.
handler.clearStreamingStatus()
}
}
}
@@ -8890,11 +8903,10 @@ class ChatViewModel : ViewModel() {
* Re-run the fetch for an attachment that's in the "Tap to download"
* deferred state. Used by the inbound-media card's CTA on cellular.
*
* Works for both flavors of inbound attachment: if the stored key starts
* with `/` it's an absolute path (bare-media form, use
* [RelayHttpClient.fetchMediaByPath]); otherwise it's a relay token
* (use [RelayHttpClient.fetchMedia]). `secrets.token_urlsafe` never
* produces `/` so the prefix check is unambiguous.
* Works for both flavors of inbound attachment: POSIX paths start with `/`
* and Windows paths match `C:\...`; both use
* [RelayHttpClient.fetchMediaByPath]. Everything else is an opaque relay
* token and uses [RelayHttpClient.fetchMedia].
*/
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
val handler = chatHandler ?: return
@@ -8927,7 +8939,10 @@ class ChatViewModel : ViewModel() {
settings,
expectedRole = expectedRole,
) {
if (fetchKey.startsWith("/")) {
if (
fetchKey.startsWith("/") ||
WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX.matches(fetchKey)
) {
relay.fetchMediaByPath(fetchKey)
} else {
relay.fetchMedia(fetchKey)
@@ -9022,9 +9037,9 @@ class ChatViewModel : ViewModel() {
},
content = "",
state = AttachmentState.LOADING,
// Reuse relayToken as a generic inbound-fetch key. Paths always
// start with `/`, real tokens never do — downstream helpers
// that need to distinguish can check the prefix.
// Reuse relayToken as a generic inbound-fetch key. Downstream
// helpers distinguish POSIX or Windows absolute paths from opaque
// relay tokens.
relayToken = originalPath,
fileName = originalPath.substringAfterLast('/').substringAfterLast('\\').ifBlank { null }
)
+40 -1
View File
@@ -3881,7 +3881,7 @@
<string name="appearance_preview_voice">Voz</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 tokens · 137,2 mil</string>
<string name="appearance_preview_message_placeholder">Mensagem…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / perfil: padrão</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / perfil: padrão</string>
<string name="appearance_preview_live_note">Esta prévia é atualizada imediatamente com a predefinição, o modo, a fonte e a aparência da Sphere.</string>
<string name="appearance_customize_theme">Personalizar %1$s</string>
<string name="appearance_accent_preset">Cor de destaque predefinida</string>
@@ -4136,6 +4136,45 @@
<string name="bridge_timed_unlimited_warning">Sem limite de inatividade. O acesso continua após inatividade e reconexão até ser encerrado, a chave mestra ser desligada ou a política mudar. Ideal para um dispositivo dedicado.</string>
<string name="bss_screen_access_off_desc">O acesso à tela está desligado. Novo acesso finito usa %1$d minutos ocioso por padrão.</string>
<string name="bss_screen_access_unlimited_desc">Pelo menos um recurso de tela permanece ativo até ser desligado explicitamente.</string>
<string name="provider_usage_title">Uso e limites</string>
<string name="provider_usage_back">Voltar</string>
<string name="provider_usage_refresh">Atualizar uso</string>
<string name="provider_usage_intro">Limites de conta dos provedores configurados nesta conexão do Hermes.</string>
<string name="provider_usage_not_available">Esta conexão Hermes não expõe o uso dos provedores. Atualize o Hermes ou instale/atualize o plugin Relay.</string>
<string name="provider_usage_none_configured">Nenhum provedor visível tem dados de uso da conta disponíveis.</string>
<string name="provider_usage_loading">Carregando uso dos provedores…</string>
<string name="provider_usage_error">Não foi possível carregar o uso dos provedores.</string>
<string name="provider_usage_retry">Tentar novamente</string>
<string name="provider_usage_percent">%1$d%% usado</string>
<string name="provider_usage_resets">Redefine em %1$s</string>
<string name="provider_usage_display_title">Exibição nas Configurações</string>
<string name="provider_usage_display_desc">Escolha como o uso da conta aparece na tela principal de Configurações.</string>
<string name="provider_usage_mode_summary">Resumo</string>
<string name="provider_usage_mode_expanded">Expandido</string>
<string name="provider_usage_mode_hidden">Oculto</string>
<string name="provider_usage_providers_title">Mostrar nas Configurações principais</string>
<string name="provider_usage_providers_desc">Escolha quais cartões de provedores aparecem nas Configurações principais. Todos continuam visíveis aqui.</string>
<string name="provider_usage_settings_desc">Uso da conta e limites dos provedores</string>
<string name="provider_usage_settings_desc_relay">Uso e limites ampliados pelo plugin Relay</string>
<string name="provider_usage_settings_desc_basic">Uso básico do Hermes · Relay adiciona pools e mais</string>
<string name="provider_usage_customize">Exibição</string>
<string name="provider_usage_hidden_hint">Os cartões de uso estão ocultos nas Configurações.</string>
<string name="provider_usage_not_available_compact">O uso dos provedores está indisponível. Atualize o Hermes ou o plugin Relay.</string>
<string name="provider_usage_provider_not_configured">Não configurado neste host</string>
<string name="provider_usage_provider_unavailable">Uso temporariamente indisponível</string>
<string name="provider_usage_active_unknown">Esta sessão ainda não tem uma credencial ativa.</string>
<string name="provider_usage_active_available">Ativa · Disponível</string>
<string name="provider_usage_active_at_limit">Ativa · Limite atingido</string>
<string name="provider_usage_active">Ativa</string>
<string name="provider_usage_available">Disponível</string>
<string name="provider_usage_at_limit">Limite atingido</string>
<string name="provider_usage_unavailable_status">Indisponível</string>
<string name="provider_usage_renews_on">Renova em %1$s</string>
<string name="provider_usage_manage_credits">Gerenciar créditos</string>
<string name="provider_usage_capability_relay_title">Ampliado pelo plugin Relay</string>
<string name="provider_usage_capability_relay_body">Pools de credenciais, saldos estruturados da Nous e OpenCode Go são fornecidos pelo plugin Relay.</string>
<string name="provider_usage_capability_basic_title">Uso básico do Hermes</string>
<string name="provider_usage_capability_basic_body">Instale ou atualize o plugin Relay para pools de credenciais, saldos estruturados da Nous e OpenCode Go.</string>
<string name="custom_theme_title">Personalizado</string>
<string name="custom_theme_entry_summary">Crie e salve seus próprios temas</string>
<string name="custom_theme_your_presets">Seus temas</string>
+40 -1
View File
@@ -3969,7 +3969,7 @@
<string name="appearance_preview_voice">语音</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 个 token · 137.2K</string>
<string name="appearance_preview_message_placeholder">消息…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / 配置文件:默认</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / 配置文件:默认</string>
<string name="appearance_preview_live_note">更改预设、模式、字体或 Sphere 皮肤后,此预览会立即更新。</string>
<string name="appearance_customize_theme">自定义 %1$s</string>
<string name="appearance_accent_preset">预设强调色</string>
@@ -4221,6 +4221,45 @@
<string name="bridge_timed_unlimited_warning">无空闲超时。屏幕访问在空闲和重新连接后仍保持,直到结束访问、关闭主开关或更改策略。适合专用设备。</string>
<string name="bss_screen_access_off_desc">屏幕访问已关闭。新的有限访问默认使用 %1$d 分钟空闲限制。</string>
<string name="bss_screen_access_unlimited_desc">至少一项屏幕功能会保持有效,直到明确关闭。</string>
<string name="provider_usage_title">用量和限额</string>
<string name="provider_usage_back">返回</string>
<string name="provider_usage_refresh">刷新用量</string>
<string name="provider_usage_intro">此 Hermes 连接中已配置提供商的账户限额。</string>
<string name="provider_usage_not_available">此 Hermes 连接未提供服务商用量。请更新 Hermes,或安装/更新 Relay 插件。</string>
<string name="provider_usage_none_configured">当前显示的提供商均无可用账户用量。</string>
<string name="provider_usage_loading">正在加载提供商用量…</string>
<string name="provider_usage_error">无法加载提供商用量。</string>
<string name="provider_usage_retry">重试</string>
<string name="provider_usage_percent">已使用 %1$d%%</string>
<string name="provider_usage_resets">%1$s后重置</string>
<string name="provider_usage_display_title">设置页显示</string>
<string name="provider_usage_display_desc">选择账户用量在主设置屏幕中的显示方式。</string>
<string name="provider_usage_mode_summary">摘要</string>
<string name="provider_usage_mode_expanded">展开</string>
<string name="provider_usage_mode_hidden">隐藏</string>
<string name="provider_usage_providers_title">在主设置页显示</string>
<string name="provider_usage_providers_desc">选择要在主设置页显示的提供商卡片。此处仍会显示所有提供商。</string>
<string name="provider_usage_settings_desc">账户用量和提供商限额</string>
<string name="provider_usage_settings_desc_relay">由 Relay 插件增强的用量和限额</string>
<string name="provider_usage_settings_desc_basic">Hermes 基础用量 · Relay 可增加凭据池等功能</string>
<string name="provider_usage_customize">显示</string>
<string name="provider_usage_hidden_hint">设置页已隐藏用量卡片。</string>
<string name="provider_usage_not_available_compact">服务商用量不可用。请更新 Hermes 或 Relay 插件。</string>
<string name="provider_usage_provider_not_configured">此主机未配置</string>
<string name="provider_usage_provider_unavailable">用量暂时不可用</string>
<string name="provider_usage_active_unknown">此会话尚无当前凭据。</string>
<string name="provider_usage_active_available">当前 · 可用</string>
<string name="provider_usage_active_at_limit">当前 · 已达上限</string>
<string name="provider_usage_active">当前</string>
<string name="provider_usage_available">可用</string>
<string name="provider_usage_at_limit">已达上限</string>
<string name="provider_usage_unavailable_status">不可用</string>
<string name="provider_usage_renews_on">续期日期:%1$s</string>
<string name="provider_usage_manage_credits">管理额度</string>
<string name="provider_usage_capability_relay_title">已由 Relay 插件增强</string>
<string name="provider_usage_capability_relay_body">凭据池、结构化 Nous 余额和 OpenCode Go 由 Relay 插件提供。</string>
<string name="provider_usage_capability_basic_title">Hermes 基础用量</string>
<string name="provider_usage_capability_basic_body">安装或更新 Relay 插件即可使用凭据池、结构化 Nous 余额和 OpenCode Go。</string>
<string name="custom_theme_title">自定义</string>
<string name="custom_theme_entry_summary">创建并保存自己的主题</string>
<string name="custom_theme_your_presets">你的预设</string>
+40 -1
View File
@@ -4041,7 +4041,7 @@
<string name="appearance_preview_voice">Sprache</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 Token · 137,2K</string>
<string name="appearance_preview_message_placeholder">Nachricht…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / Profil: Standard</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / Profil: Standard</string>
<string name="appearance_preview_live_note">Diese Vorschau wird sofort mit Vorlage, Modus, Schrift und Sphere-Skin aktualisiert.</string>
<string name="appearance_customize_theme">%1$s anpassen</string>
<string name="appearance_accent_preset">Voreingestellte Akzentfarbe</string>
@@ -4296,6 +4296,45 @@
<string name="bridge_timed_unlimited_warning">Kein Leerlaufzeitlimit. Bildschirmzugriff bleibt bei Inaktivität und Wiederverbindung aktiv, bis er beendet, der Hauptschalter deaktiviert oder die Richtlinie geändert wird. Für ein dediziertes Gerät.</string>
<string name="bss_screen_access_off_desc">Bildschirmzugriff ist aus. Neuer begrenzter Zugriff verwendet standardmäßig %1$d Minuten Leerlauf.</string>
<string name="bss_screen_access_unlimited_desc">Mindestens eine Bildschirmfunktion bleibt bis zum ausdrücklichen Ausschalten aktiv.</string>
<string name="provider_usage_title">Nutzung &amp; Limits</string>
<string name="provider_usage_back">Zurück</string>
<string name="provider_usage_refresh">Nutzung aktualisieren</string>
<string name="provider_usage_intro">Kontolimits der Anbieter, die für diese Hermes-Verbindung konfiguriert sind.</string>
<string name="provider_usage_not_available">Diese Hermes-Verbindung stellt keine Anbieternutzung bereit. Aktualisieren Sie Hermes oder installieren/aktualisieren Sie das Relay-Plugin.</string>
<string name="provider_usage_none_configured">Für keinen sichtbaren Anbieter sind Kontonutzungsdaten verfügbar.</string>
<string name="provider_usage_loading">Anbieternutzung wird geladen…</string>
<string name="provider_usage_error">Anbieternutzung konnte nicht geladen werden.</string>
<string name="provider_usage_retry">Erneut versuchen</string>
<string name="provider_usage_percent">%1$d%% verwendet</string>
<string name="provider_usage_resets">Zurücksetzung in %1$s</string>
<string name="provider_usage_display_title">Anzeige in Einstellungen</string>
<string name="provider_usage_display_desc">Wählen Sie, wie die Kontonutzung in den Haupteinstellungen erscheint.</string>
<string name="provider_usage_mode_summary">Übersicht</string>
<string name="provider_usage_mode_expanded">Erweitert</string>
<string name="provider_usage_mode_hidden">Ausgeblendet</string>
<string name="provider_usage_providers_title">In den Haupteinstellungen anzeigen</string>
<string name="provider_usage_providers_desc">Wählen Sie, welche Anbieterkarten in den Haupteinstellungen erscheinen. Hier bleiben alle Anbieter sichtbar.</string>
<string name="provider_usage_settings_desc">Kontonutzung und Anbieterlimits</string>
<string name="provider_usage_settings_desc_relay">Durch Relay-Plugin erweiterte Nutzung und Limits</string>
<string name="provider_usage_settings_desc_basic">Hermes-Basisnutzung · Relay-Plugin ergänzt Pools und mehr</string>
<string name="provider_usage_customize">Anzeige</string>
<string name="provider_usage_hidden_hint">Nutzungskarten sind in den Einstellungen ausgeblendet.</string>
<string name="provider_usage_not_available_compact">Anbieternutzung ist nicht verfügbar. Aktualisieren Sie Hermes oder das Relay-Plugin.</string>
<string name="provider_usage_provider_not_configured">Auf diesem Host nicht konfiguriert</string>
<string name="provider_usage_provider_unavailable">Nutzung ist vorübergehend nicht verfügbar</string>
<string name="provider_usage_active_unknown">Für diese Sitzung gibt es noch keine aktiven Anmeldedaten.</string>
<string name="provider_usage_active_available">Aktiv · Verfügbar</string>
<string name="provider_usage_active_at_limit">Aktiv · Limit erreicht</string>
<string name="provider_usage_active">Aktiv</string>
<string name="provider_usage_available">Verfügbar</string>
<string name="provider_usage_at_limit">Limit erreicht</string>
<string name="provider_usage_unavailable_status">Nicht verfügbar</string>
<string name="provider_usage_renews_on">Verlängert sich am %1$s</string>
<string name="provider_usage_manage_credits">Guthaben verwalten</string>
<string name="provider_usage_capability_relay_title">Durch Relay-Plugin erweitert</string>
<string name="provider_usage_capability_relay_body">Anmeldedaten-Pools, strukturierte Nous-Guthaben und OpenCode Go werden vom Relay-Plugin bereitgestellt.</string>
<string name="provider_usage_capability_basic_title">Basisnutzung von Hermes</string>
<string name="provider_usage_capability_basic_body">Installieren oder aktualisieren Sie das Relay-Plugin für Anmeldedaten-Pools, strukturierte Nous-Guthaben und OpenCode Go.</string>
<string name="custom_theme_title">Benutzerdefiniert</string>
<string name="custom_theme_entry_summary">Eigene Themes erstellen und speichern</string>
<string name="custom_theme_your_presets">Deine Presets</string>
+40 -1
View File
@@ -3726,7 +3726,7 @@
<string name="appearance_preview_voice">Voz</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 tokens · 137,2K</string>
<string name="appearance_preview_message_placeholder">Mensaje…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / perfil: predeterminado</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / perfil: predeterminado</string>
<string name="appearance_preview_live_note">Esta vista previa se actualiza al instante con el ajuste, modo, fuente y aspecto de Sphere.</string>
<string name="appearance_customize_theme">Personalizar %1$s</string>
<string name="appearance_accent_preset">Color de acento predefinido</string>
@@ -3981,6 +3981,45 @@
<string name="bridge_timed_unlimited_warning">Sin límite de inactividad. El acceso continúa tras inactividad y reconexión hasta finalizarlo, desactivar el interruptor maestro o cambiar la política. Ideal para un dispositivo dedicado.</string>
<string name="bss_screen_access_off_desc">El acceso a pantalla está desactivado. El acceso finito nuevo usa %1$d minutos de inactividad por defecto.</string>
<string name="bss_screen_access_unlimited_desc">Al menos una capacidad de pantalla permanece activa hasta desactivarla explícitamente.</string>
<string name="provider_usage_title">Uso y límites</string>
<string name="provider_usage_back">Atrás</string>
<string name="provider_usage_refresh">Actualizar uso</string>
<string name="provider_usage_intro">Límites de cuenta de los proveedores configurados en esta conexión de Hermes.</string>
<string name="provider_usage_not_available">Esta conexión de Hermes no expone el uso de proveedores. Actualiza Hermes o instala/actualiza el complemento Relay.</string>
<string name="provider_usage_none_configured">Ningún proveedor visible tiene datos de uso de cuenta disponibles.</string>
<string name="provider_usage_loading">Cargando uso de proveedores…</string>
<string name="provider_usage_error">No se pudo cargar el uso de proveedores.</string>
<string name="provider_usage_retry">Reintentar</string>
<string name="provider_usage_percent">%1$d%% usado</string>
<string name="provider_usage_resets">Se restablece en %1$s</string>
<string name="provider_usage_display_title">Visualización en Ajustes</string>
<string name="provider_usage_display_desc">Elige cómo aparece el uso de cuenta en la pantalla principal de Ajustes.</string>
<string name="provider_usage_mode_summary">Resumen</string>
<string name="provider_usage_mode_expanded">Ampliado</string>
<string name="provider_usage_mode_hidden">Oculto</string>
<string name="provider_usage_providers_title">Mostrar en Ajustes principales</string>
<string name="provider_usage_providers_desc">Elige qué tarjetas de proveedores aparecen en Ajustes principales. Aquí siempre se muestran todos.</string>
<string name="provider_usage_settings_desc">Uso de cuenta y límites de proveedores</string>
<string name="provider_usage_settings_desc_relay">Uso y límites ampliados por el complemento Relay</string>
<string name="provider_usage_settings_desc_basic">Uso básico de Hermes · Relay añade grupos y más</string>
<string name="provider_usage_customize">Visualización</string>
<string name="provider_usage_hidden_hint">Las tarjetas de uso están ocultas en Ajustes.</string>
<string name="provider_usage_not_available_compact">El uso de proveedores no está disponible. Actualiza Hermes o el complemento Relay.</string>
<string name="provider_usage_provider_not_configured">No configurado en este host</string>
<string name="provider_usage_provider_unavailable">El uso no está disponible temporalmente</string>
<string name="provider_usage_active_unknown">Esta sesión aún no tiene una credencial activa.</string>
<string name="provider_usage_active_available">Activa · Disponible</string>
<string name="provider_usage_active_at_limit">Activa · Límite alcanzado</string>
<string name="provider_usage_active">Activa</string>
<string name="provider_usage_available">Disponible</string>
<string name="provider_usage_at_limit">Límite alcanzado</string>
<string name="provider_usage_unavailable_status">No disponible</string>
<string name="provider_usage_renews_on">Se renueva el %1$s</string>
<string name="provider_usage_manage_credits">Gestionar créditos</string>
<string name="provider_usage_capability_relay_title">Ampliado por el complemento Relay</string>
<string name="provider_usage_capability_relay_body">Los grupos de credenciales, los saldos estructurados de Nous y OpenCode Go los proporciona el complemento Relay.</string>
<string name="provider_usage_capability_basic_title">Uso básico de Hermes</string>
<string name="provider_usage_capability_basic_body">Instala o actualiza el complemento Relay para obtener grupos de credenciales, saldos estructurados de Nous y OpenCode Go.</string>
<string name="custom_theme_title">Personalizado</string>
<string name="custom_theme_entry_summary">Crea y guarda tus propios temas</string>
<string name="custom_theme_your_presets">Tus preajustes</string>
+40 -1
View File
@@ -4040,7 +4040,7 @@
<string name="appearance_preview_voice">音声</string>
<string name="appearance_preview_tool_meta">tool_executor · 206トークン · 137.2K</string>
<string name="appearance_preview_message_placeholder">メッセージ…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / プロファイル: デフォルト</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / プロファイル: デフォルト</string>
<string name="appearance_preview_live_note">このプレビューには、プリセット、モード、フォント、Sphereスキンの変更がすぐに反映されます。</string>
<string name="appearance_customize_theme">%1$sをカスタマイズ</string>
<string name="appearance_accent_preset">プリセットのアクセント</string>
@@ -4294,6 +4294,45 @@
<string name="bridge_timed_unlimited_warning">アイドルタイムアウトはありません。終了、マスター無効化、またはポリシー変更まで、非操作時や再接続後も画面アクセスが続きます。専用端末向けです。</string>
<string name="bss_screen_access_off_desc">画面アクセスはオフです。新しい有限アクセスの既定アイドル制限は %1$d 分です。</string>
<string name="bss_screen_access_unlimited_desc">少なくとも 1 つの画面機能が明示的にオフにするまで有効です。</string>
<string name="provider_usage_title">使用量と上限</string>
<string name="provider_usage_back">戻る</string>
<string name="provider_usage_refresh">使用量を更新</string>
<string name="provider_usage_intro">この Hermes 接続に設定されたプロバイダーのアカウント上限です。</string>
<string name="provider_usage_not_available">この Hermes 接続はプロバイダー使用量を公開していません。Hermes を更新するか、Relay プラグインをインストール/更新してください。</string>
<string name="provider_usage_none_configured">表示中のプロバイダーに利用可能なアカウント使用量がありません。</string>
<string name="provider_usage_loading">プロバイダー使用量を読み込み中…</string>
<string name="provider_usage_error">プロバイダー使用量を読み込めませんでした。</string>
<string name="provider_usage_retry">再試行</string>
<string name="provider_usage_percent">%1$d%% 使用済み</string>
<string name="provider_usage_resets">%1$s後にリセット</string>
<string name="provider_usage_display_title">設定での表示</string>
<string name="provider_usage_display_desc">メインの設定画面にアカウント使用量を表示する方法を選びます。</string>
<string name="provider_usage_mode_summary">概要</string>
<string name="provider_usage_mode_expanded">展開</string>
<string name="provider_usage_mode_hidden">非表示</string>
<string name="provider_usage_providers_title">メイン設定に表示</string>
<string name="provider_usage_providers_desc">メイン設定に表示するプロバイダーカードを選びます。ここではすべて表示されます。</string>
<string name="provider_usage_settings_desc">アカウント使用量とプロバイダー上限</string>
<string name="provider_usage_settings_desc_relay">Relay プラグインで拡張された使用量と上限</string>
<string name="provider_usage_settings_desc_basic">Hermes の基本使用量 · Relay でプールなどを追加</string>
<string name="provider_usage_customize">表示</string>
<string name="provider_usage_hidden_hint">設定では使用量カードが非表示です。</string>
<string name="provider_usage_not_available_compact">プロバイダー使用量を利用できません。Hermes または Relay プラグインを更新してください。</string>
<string name="provider_usage_provider_not_configured">このホストでは未設定です</string>
<string name="provider_usage_provider_unavailable">使用量は一時的に利用できません</string>
<string name="provider_usage_active_unknown">このセッションにはまだ使用中の認証情報がありません。</string>
<string name="provider_usage_active_available">使用中 · 利用可能</string>
<string name="provider_usage_active_at_limit">使用中 · 上限到達</string>
<string name="provider_usage_active">使用中</string>
<string name="provider_usage_available">利用可能</string>
<string name="provider_usage_at_limit">上限到達</string>
<string name="provider_usage_unavailable_status">利用不可</string>
<string name="provider_usage_renews_on">%1$s に更新</string>
<string name="provider_usage_manage_credits">クレジットを管理</string>
<string name="provider_usage_capability_relay_title">Relay プラグインで拡張</string>
<string name="provider_usage_capability_relay_body">認証情報プール、構造化された Nous 残高、OpenCode Go は Relay プラグインによって提供されます。</string>
<string name="provider_usage_capability_basic_title">Hermes の基本使用量</string>
<string name="provider_usage_capability_basic_body">認証情報プール、構造化された Nous 残高、OpenCode Go を利用するには Relay プラグインをインストールまたは更新してください。</string>
<string name="custom_theme_title">カスタム</string>
<string name="custom_theme_entry_summary">独自のテーマを作成して保存します</string>
<string name="custom_theme_your_presets">保存したテーマ</string>
+40 -1
View File
@@ -3762,7 +3762,7 @@
<string name="appearance_preview_voice">Голос</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 токенов · 137,2 тыс.</string>
<string name="appearance_preview_message_placeholder">Сообщение…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / профиль: по умолчанию</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / профиль: по умолчанию</string>
<string name="appearance_preview_live_note">Предпросмотр сразу обновляется при изменении шаблона, режима, шрифта и оформления Sphere.</string>
<string name="appearance_customize_theme">Настроить %1$s</string>
<string name="appearance_accent_preset">Предустановленный акцент</string>
@@ -4023,6 +4023,45 @@
<string name="bridge_timed_unlimited_warning">Без тайм-аута. Доступ сохраняется при бездействии и переподключении, пока не завершен, не выключен главный переключатель или не изменена политика. Для выделенного устройства.</string>
<string name="bss_screen_access_off_desc">Доступ к экрану выключен. Новый ограниченный доступ по умолчанию использует %1$d минут бездействия.</string>
<string name="bss_screen_access_unlimited_desc">Хотя бы одна экранная возможность активна до явного отключения.</string>
<string name="provider_usage_title">Использование и лимиты</string>
<string name="provider_usage_back">Назад</string>
<string name="provider_usage_refresh">Обновить использование</string>
<string name="provider_usage_intro">Лимиты учётных записей поставщиков, настроенных для этого подключения Hermes.</string>
<string name="provider_usage_not_available">Это подключение Hermes не предоставляет данные поставщиков. Обновите Hermes или установите/обновите плагин Relay.</string>
<string name="provider_usage_none_configured">Ни у одного видимого поставщика нет доступных данных об использовании.</string>
<string name="provider_usage_loading">Загрузка данных поставщиков…</string>
<string name="provider_usage_error">Не удалось загрузить данные поставщиков.</string>
<string name="provider_usage_retry">Повторить</string>
<string name="provider_usage_percent">Использовано %1$d%%</string>
<string name="provider_usage_resets">Сброс через %1$s</string>
<string name="provider_usage_display_title">Отображение в настройках</string>
<string name="provider_usage_display_desc">Выберите, как использование учётной записи отображается на главном экране настроек.</string>
<string name="provider_usage_mode_summary">Сводка</string>
<string name="provider_usage_mode_expanded">Развёрнуто</string>
<string name="provider_usage_mode_hidden">Скрыто</string>
<string name="provider_usage_providers_title">Показывать в основных настройках</string>
<string name="provider_usage_providers_desc">Выберите карточки поставщиков для главного экрана настроек. Здесь всегда видны все поставщики.</string>
<string name="provider_usage_settings_desc">Использование учётной записи и лимиты поставщиков</string>
<string name="provider_usage_settings_desc_relay">Расширенные данные и лимиты от плагина Relay</string>
<string name="provider_usage_settings_desc_basic">Базовые данные Hermes · Relay добавляет пулы и другое</string>
<string name="provider_usage_customize">Отображение</string>
<string name="provider_usage_hidden_hint">Карточки использования скрыты в настройках.</string>
<string name="provider_usage_not_available_compact">Данные поставщиков недоступны. Обновите Hermes или плагин Relay.</string>
<string name="provider_usage_provider_not_configured">Не настроено на этом хосте</string>
<string name="provider_usage_provider_unavailable">Данные временно недоступны</string>
<string name="provider_usage_active_unknown">Для этого сеанса ещё нет активных учётных данных.</string>
<string name="provider_usage_active_available">Активно · Доступно</string>
<string name="provider_usage_active_at_limit">Активно · Лимит исчерпан</string>
<string name="provider_usage_active">Активно</string>
<string name="provider_usage_available">Доступно</string>
<string name="provider_usage_at_limit">Лимит исчерпан</string>
<string name="provider_usage_unavailable_status">Недоступно</string>
<string name="provider_usage_renews_on">Продление: %1$s</string>
<string name="provider_usage_manage_credits">Управление кредитами</string>
<string name="provider_usage_capability_relay_title">Расширено плагином Relay</string>
<string name="provider_usage_capability_relay_body">Пулы учётных данных, структурированные балансы Nous и OpenCode Go предоставляются плагином Relay.</string>
<string name="provider_usage_capability_basic_title">Базовые данные Hermes</string>
<string name="provider_usage_capability_basic_body">Установите или обновите плагин Relay для пулов учётных данных, структурированных балансов Nous и OpenCode Go.</string>
<string name="custom_theme_title">Своя тема</string>
<string name="custom_theme_entry_summary">Создавайте и сохраняйте собственные темы</string>
<string name="custom_theme_your_presets">Ваши темы</string>
+40 -1
View File
@@ -1306,7 +1306,7 @@
<string name="appearance_preview_voice">Voice</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 tokens · 137.2K</string>
<string name="appearance_preview_message_placeholder">Message…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / profile: default</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / profile: default</string>
<string name="appearance_preview_live_note">This preview updates immediately with your preset, mode, font, and sphere skin.</string>
<string name="appearance_back">Back</string>
<string name="appearance_remove_pet_title">Remove pet?</string>
@@ -4339,4 +4339,43 @@
<string name="bridge_timed_allow">Allow access</string>
<string name="bridge_timed_end_now">End now</string>
<string name="bridge_timed_ended_snackbar">Screen access ended. Permanent grants are still available.</string>
<string name="provider_usage_title">Usage &amp; limits</string>
<string name="provider_usage_back">Back</string>
<string name="provider_usage_refresh">Refresh usage</string>
<string name="provider_usage_intro">Account limits from providers configured on this Hermes connection.</string>
<string name="provider_usage_not_available">This Hermes connection does not expose provider usage. Update Hermes or install/update the Relay plugin to enable it.</string>
<string name="provider_usage_none_configured">No visible provider has account usage available.</string>
<string name="provider_usage_loading">Loading provider usage…</string>
<string name="provider_usage_error">Couldn\'t load provider usage.</string>
<string name="provider_usage_retry">Retry</string>
<string name="provider_usage_percent">%1$d%% used</string>
<string name="provider_usage_resets">Resets in %1$s</string>
<string name="provider_usage_display_title">Settings display</string>
<string name="provider_usage_display_desc">Choose how account usage appears on the main Settings screen.</string>
<string name="provider_usage_mode_summary">Summary</string>
<string name="provider_usage_mode_expanded">Expanded</string>
<string name="provider_usage_mode_hidden">Hidden</string>
<string name="provider_usage_providers_title">Show on main Settings</string>
<string name="provider_usage_providers_desc">Choose which provider cards appear on the main Settings page. All providers remain visible here.</string>
<string name="provider_usage_settings_desc">Account usage and provider limits</string>
<string name="provider_usage_settings_desc_relay">Relay plugin enhanced usage and limits</string>
<string name="provider_usage_settings_desc_basic">Basic Hermes usage · Relay plugin adds pools and more</string>
<string name="provider_usage_customize">Display</string>
<string name="provider_usage_hidden_hint">Usage cards are hidden on Settings.</string>
<string name="provider_usage_not_available_compact">Provider usage is unavailable. Update Hermes or install/update the Relay plugin.</string>
<string name="provider_usage_provider_not_configured">Not configured on this host</string>
<string name="provider_usage_provider_unavailable">Usage is temporarily unavailable</string>
<string name="provider_usage_active_unknown">No active credential yet for this session.</string>
<string name="provider_usage_active_available">Active · Available</string>
<string name="provider_usage_active_at_limit">Active · At limit</string>
<string name="provider_usage_active">Active</string>
<string name="provider_usage_available">Available</string>
<string name="provider_usage_at_limit">At limit</string>
<string name="provider_usage_unavailable_status">Unavailable</string>
<string name="provider_usage_renews_on">Renews %1$s</string>
<string name="provider_usage_manage_credits">Manage credits</string>
<string name="provider_usage_capability_relay_title">Relay plugin enhanced</string>
<string name="provider_usage_capability_relay_body">Credential pools, structured Nous balances, and OpenCode Go are provided by the Relay plugin.</string>
<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>
</resources>
@@ -0,0 +1,69 @@
package com.hermesandroid.relay.data
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class ProviderUsagePreferencesTest {
@get:Rule
val tempFolder = TemporaryFolder()
private lateinit var file: File
private lateinit var scope: CoroutineScope
private lateinit var repository: ProviderUsagePreferencesRepository
@Before
fun setUp() {
file = tempFolder.newFile("provider_usage.preferences_pb").also { it.delete() }
scope = CoroutineScope(Dispatchers.IO + Job())
repository = ProviderUsagePreferencesRepository(
PreferenceDataStoreFactory.create(scope = scope, produceFile = { file }),
)
}
@After
fun tearDown() {
scope.cancel()
}
@Test
fun defaultsToSummaryWithSupportedProvidersVisible() = runTest {
val preferences = repository.preferences.first()
assertEquals(ProviderUsageLandingMode.Summary, preferences.landingMode)
assertEquals(
setOf("openai-codex", "nous", "opencode-go"),
preferences.visibleProviders,
)
}
@Test
fun persistsDisplayMode() = runTest {
repository.setLandingMode(ProviderUsageLandingMode.Expanded)
val preferences = repository.preferences.first()
assertEquals(ProviderUsageLandingMode.Expanded, preferences.landingMode)
}
@Test
fun persistsIndependentProviderVisibility() = runTest {
repository.setProviderVisible("nous", false)
val preferences = repository.preferences.first()
assertFalse("nous" in preferences.visibleProviders)
assertTrue("openai-codex" in preferences.visibleProviders)
assertTrue("opencode-go" in preferences.visibleProviders)
}
}
@@ -0,0 +1,99 @@
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.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class RelayHttpClientProviderUsageTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun parsesProviderNeutralPayloadAndAuthenticates() = runTest {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""
{
"schema_version": 2,
"capabilities": ["credential_pools", "structured_balances", "opencode_go"],
"providers": [
{
"id": "openai-codex",
"display_name": "Codex",
"status": "available",
"plan": "Plus",
"active_credential_id": "abc123",
"active_credential_state": "known",
"credentials": [{
"id": "abc123",
"label": "Work",
"active": true,
"status": "available",
"windows": []
}],
"windows": [{
"id": "session",
"label": "Session",
"used_percent": 42.5,
"reset_at": "2026-08-22T00:00:00Z"
}]
}
]
}
""".trimIndent(),
),
)
val response = client(token = "paired-token")
.fetchProviderUsage(profile = "victor", sessionId = "session-42")
.getOrThrow()!!
val request = server.takeRequest()
assertEquals("/usage/providers?profile=victor&session_id=session-42", request.path)
assertEquals("Bearer paired-token", request.getHeader("Authorization"))
assertEquals("Codex", response.providers.single().displayName)
assertEquals(42.5, response.providers.single().windows.single().usedPercent!!, 0.001)
assertEquals("Work", response.providers.single().credentials.single().label)
assertTrue(response.providers.single().credentials.single().active)
assertTrue(response.relayEnhanced)
}
@Test
fun unsupportedHostIsNullSuccess() = runTest {
server.enqueue(MockResponse().setResponseCode(404))
val response = client(token = "paired-token").fetchProviderUsage()
assertTrue(response.isSuccess)
assertNull(response.getOrNull())
}
@Test
fun unpairedIsUnsupportedAndDoesNotHitServer() = runTest {
val response = client(token = null).fetchProviderUsage()
assertTrue(response.isSuccess)
assertNull(response.getOrNull())
assertEquals(0, server.requestCount)
}
private fun client(token: String?) = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { server.url("/").toString() },
sessionTokenProvider = { token },
)
}
@@ -126,6 +126,44 @@ class DashboardApiClientTest {
assertEquals(listOf("default", "worker"), status.gateways.single().servedProfiles)
}
@Test
fun getProviderUsage_carriesSessionAndParsesCredentialPool() = runTest {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""
{
"schema_version": 2,
"capabilities": ["credential_pools", "structured_balances", "opencode_go"],
"providers": [{
"id": "openai-codex",
"display_name": "Codex",
"status": "available",
"active_credential_state": "known",
"credentials": [{
"id": "abc123",
"label": "bailey",
"active": true,
"status": "available"
}]
}]
}
""".trimIndent(),
),
)
val usage = DashboardApiClient(baseUrl = server.url("/").toString())
.getProviderUsage(profile = "victor", sessionId = "session/42")
.getOrThrow()!!
val request = server.takeRequest().requestUrl!!
assertEquals("/api/plugins/hermes-relay/provider-usage", request.encodedPath)
assertEquals("victor", request.queryParameter("profile"))
assertEquals("session/42", request.queryParameter("session_id"))
assertEquals("bailey", usage.providers.single().credentials.single().label)
assertTrue(usage.providers.single().credentials.single().active)
assertTrue(usage.relayEnhanced)
}
@Test
fun getModelOptions_alwaysRequestsUnconfiguredProviders() = runTest {
// HRUI-022: newer upstream hides unconfigured provider skeleton rows
@@ -0,0 +1,26 @@
package com.hermesandroid.relay.network.usage
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ProviderUsageModelsTest {
@Test
fun completeRelayCapabilitySetIsEnhanced() {
assertTrue(
ProviderUsageResponse(
capabilities = ProviderUsageResponse.RELAY_ENHANCED_CAPABILITIES,
).relayEnhanced,
)
}
@Test
fun missingOrPartialCapabilitiesRemainBasic() {
assertFalse(ProviderUsageResponse().relayEnhanced)
assertFalse(
ProviderUsageResponse(
capabilities = setOf("credential_pools", "structured_balances"),
).relayEnhanced,
)
}
}
@@ -67,6 +67,7 @@ import com.hermesandroid.relay.ui.components.ChatInputPickerControl
import com.hermesandroid.relay.ui.components.ChatInputTrailing
import com.hermesandroid.relay.ui.components.ContextMeterBar
import com.hermesandroid.relay.ui.components.ConversationVoiceDock
import com.hermesandroid.relay.ui.components.ConnectionStatusBadge
import com.hermesandroid.relay.ui.components.MessageBubble
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
@@ -183,6 +184,10 @@ class StoreScreenshotTest {
}
@Test fun s11_voice_conversation() {
compose.mainClock.autoAdvance = false
// RelayRefresh is a process-wide compatibility façade. Seed it before
// the first composition so this animation-pinned scene cannot inherit
// the palette left by a light-theme gallery test that ran earlier.
RelayRefresh.activePalette = AppThemes.byId("hermes-relay").paletteFor(dark = true)
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
CompositionLocalProvider(LocalSphereSkin provides SphereRegistry.Adaptive) {
@@ -280,7 +285,9 @@ class StoreScreenshotTest {
// The earlier frame scrolled past the pet controls and showed only the
// sphere-skin grid. Frame the independently selected floating companion,
// its real PetAvatar preview, Petdex/import actions, and tuning controls.
compose.onNodeWithText("Browse Petdex").performScrollTo()
compose.onNodeWithText(
"Scales the pet art, touch target, and safe routing footprint together. Larger pets may skip narrow perches.",
).performScrollTo()
compose.onRoot().captureRoboImage("build/store-shots/08_appearance.png")
}
@@ -361,8 +368,7 @@ private fun BlendChatScene() = StoreCockpit(contextUsage = 0.06f) {
}
@Composable
private fun BlendThread() {
val thread = MockChat.blendThread
private fun BlendThread(thread: List<ChatMessage> = MockChat.blendThread) {
Column(
Modifier.fillMaxSize().padding(start = 18.dp, top = 14.dp, end = 18.dp),
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.Bottom)
@@ -405,18 +411,40 @@ private fun StoreCockpit(
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(Modifier.size(34.dp), shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)) {
Image(painterResource(R.drawable.splash_icon), contentDescription = null, modifier = Modifier.padding(3.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Keep the Hermes logo as the public default agent identity,
// while matching ChatScreen's current live-status treatment.
Box(Modifier.size(40.dp)) {
Surface(
modifier = Modifier.size(40.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
) {
Image(
painter = painterResource(R.drawable.splash_icon),
contentDescription = null,
modifier = Modifier.padding(3.dp),
)
}
ConnectionStatusBadge(
isConnected = true,
isConnecting = false,
modifier = Modifier.size(10.dp).align(Alignment.BottomEnd),
size = 10.dp,
)
}
Column(Modifier.padding(start = 10.dp)) {
Column {
Text("Hermes", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("gpt-5.6-sol", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}
},
actions = {
RelayChromeIconButton(Icons.Filled.Bolt, "Approvals off", onClick = {}, tint = RelayRefresh.Amber, borderColor = RelayRefresh.Amber.copy(alpha = 0.5f), modifier = Modifier.padding(end = 4.dp))
// The production bolt appears only when approval bypass is
// active. The public marketing fixture uses the safe default.
RelayChromeIconButton(Icons.Filled.Code, "Terminal", onClick = {}, modifier = Modifier.padding(end = 4.dp))
RelayChromeIconButton(Icons.Filled.Tune, "Settings", onClick = {}, modifier = Modifier.padding(end = 4.dp))
RelayChromeIconButton(Icons.Filled.MoreVert, "More", onClick = {}, modifier = Modifier.padding(end = 4.dp))
@@ -534,7 +562,10 @@ private fun VoiceConversationScene() = StoreCockpit(
contextUsage = 0.05f,
conversationVoiceState = marketingVoiceUiState,
) {
BlendThread()
// The voice dock occupies more vertical space than the standard composer.
// Use its shorter one-line lead-in so the frame starts near the context
// meter while the final user reply remains fully visible.
BlendThread(MockChat.voiceBlendThread)
}
/** The real sphere renderer pinned to one frame for pixel-identical marketing output. */
@@ -687,9 +718,18 @@ private object MockChat {
),
)
// A grouped thread for the "Blend" capture: user → two-message assistant
// group (avatar once, code block in the first) → user follow-up.
// A grouped thread for the "Blend" capture. The short opening exchange
// intentionally fills the production conversation viewport so the first
// turn begins directly below the context meter instead of leaving a large,
// misleading empty band in the canonical marketing frame.
val blendThread = listOf(
ChatMessage(
id = "ba0",
role = MessageRole.ASSISTANT,
content = "I’ll keep cancellation and offline behavior intact. Show me the retry path.",
timestamp = 0L,
agentName = "Hermes",
),
ChatMessage(
id = "bu1",
role = MessageRole.USER,
@@ -728,6 +768,15 @@ private object MockChat {
timestamp = 0L,
),
)
val voiceBlendThread = listOf(
ChatMessage(
id = "voice-lead",
role = MessageRole.USER,
content = "Can you review this?",
timestamp = 0L,
),
) + blendThread.drop(1)
}
// ════════════════════════════════════════════════════════════════════════════
@@ -0,0 +1,44 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Test
class MorphingSphereMotionPolicyTest {
@Test
fun `visible idle sphere uses lightweight ambient motion`() {
assertEquals(
SphereMotionMode.AmbientLayer,
sphereMotionMode(
state = SphereState.Idle,
voiceMode = false,
motionVisible = true,
fixedTime = null,
fixedColorPhase = null,
),
)
}
@Test
fun `hidden or paused idle sphere is still`() {
assertEquals(
SphereMotionMode.Still,
sphereMotionMode(SphereState.Idle, false, false, null, null),
)
assertEquals(
SphereMotionMode.Still,
sphereMotionMode(SphereState.Idle, false, true, 0f, 0f),
)
}
@Test
fun `visible active and voice states keep procedural motion`() {
assertEquals(
SphereMotionMode.Procedural,
sphereMotionMode(SphereState.Thinking, false, true, null, null),
)
assertEquals(
SphereMotionMode.Procedural,
sphereMotionMode(SphereState.Idle, true, true, null, null),
)
}
}
@@ -1003,6 +1003,33 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(handler.messages.value.any { "Stopped" in it.badges })
}
@Test
fun stopClearsStaleBusyStateAfterTerminalBubbleAlreadySettled() {
handler.onTextDelta("stale-answer", "Finished answer")
handler.onTurnComplete("stale-answer")
assertTrue(handler.isStreaming.value)
assertFalse(handler.messages.value.single().isStreaming)
viewModel.cancelStream()
assertFalse(handler.isStreaming.value)
assertNull(handler.turnStatus.value)
}
@Test
fun newChatClearsStaleBusyStateWhenNoLiveGatewayTurnRemains() {
handler.onTextDelta("stale-answer", "Finished answer")
handler.onTurnComplete("stale-answer")
assertTrue(handler.isStreaming.value)
assertFalse(gatewayClient.hasActiveTurn())
viewModel.createNewChat()
assertFalse(handler.isStreaming.value)
assertTrue(handler.messages.value.isEmpty())
assertNull(handler.currentSessionId.value)
}
@Test
fun reopenedChatRestoresRichStateAndReattachesLiveGatewayTurn() {
val now = System.currentTimeMillis()
@@ -103,6 +103,54 @@ class ChatViewModelMediaStateTest {
assertEquals("content://com.axiomlabs.hermesrelay.fileprovider/hermes-media/photo.jpg", loaded.cachedUri)
}
@Test
fun assistantWindowsPathCellularRetryUsesByPathEndpoint() {
val windowsPath =
"C:\\Users\\Example\\AppData\\Local\\Temp\\Sovereign Intelligence copy.md"
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-file-1",
role = "assistant",
content = JsonPrimitive("MEDIA:$windowsPath"),
)
)
)
val deferred = awaitMessage {
it.attachments.singleOrNull()?.errorMessage == "Tap to download"
}
assertEquals(AttachmentState.FAILED, deferred.attachments.single().state)
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "text/markdown")
.setHeader(
"Content-Disposition",
"inline; filename=\"Sovereign Intelligence copy.md\"",
)
.setBody("# copy")
)
viewModel.cellularNetworkOverride = false
viewModel.manualFetchAttachment("assistant-file-1", 0)
val loaded = awaitMessage {
it.attachments.singleOrNull()?.state == AttachmentState.LOADED
}.attachments.single()
assertEquals("text/markdown", loaded.contentType)
assertEquals("Sovereign Intelligence copy.md", loaded.fileName)
val request = server.takeRequest()
assertEquals("/media/by-path", request.requestUrl?.encodedPath)
assertEquals(
"path=C%3A%5CUsers%5CExample%5CAppData%5CLocal%5CTemp%5C" +
"Sovereign%20Intelligence%20copy.md",
request.requestUrl?.encodedQuery,
)
assertEquals(windowsPath, request.requestUrl?.queryParameter("path"))
}
private fun awaitMessage(predicate: (ChatMessage) -> Boolean): ChatMessage {
val deadline = System.nanoTime() + 5_000_000_000L
while (System.nanoTime() < deadline) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+3
View File
@@ -120,6 +120,9 @@ $env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/C
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
Prebuilt Linux CLI assets support both x64 and arm64; the installer selects the
matching binary from the machine architecture.
Windows downloads and verifies `hermes-relay-windows-x64-setup.exe`. The installer
places the CLI and management UI together, adds `~/.hermes/bin` to the user PATH, and
lets the UI start at sign-in. CLI-only installs download the same prebuilt
+2 -1
View File
@@ -28,9 +28,10 @@
"version": "npm run sync:version",
"build": "npm run gen:version && tsc -p tsconfig.build.json",
"build:watch": "tsc -p tsconfig.build.json --watch",
"build:bin": "npm run build:bin:win && npm run build:bin:linux && npm run build:bin:mac-x64 && npm run build:bin:mac-arm",
"build:bin": "npm run build:bin:win && npm run build:bin:linux && npm run build:bin:linux-arm && npm run build:bin:mac-x64 && npm run build:bin:mac-arm",
"build:bin:win": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-windows-x64 src/bunWindowsEntry.ts --outfile dist/bin/hermes-relay-win-x64",
"build:bin:linux": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-linux-x64 src/cli.ts --outfile dist/bin/hermes-relay-linux-x64",
"build:bin:linux-arm": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-linux-arm64 src/cli.ts --outfile dist/bin/hermes-relay-linux-arm64",
"build:bin:mac-x64": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-darwin-x64 src/cli.ts --outfile dist/bin/hermes-relay-darwin-x64",
"build:bin:mac-arm": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-darwin-arm64 src/cli.ts --outfile dist/bin/hermes-relay-darwin-arm64",
"build:sums": "cd dist/bin && sha256sum hermes-relay-* > SHA256SUMS.txt",
+9 -6
View File
@@ -120,7 +120,13 @@ $resolvedVersion = $version
if ($version -eq 'latest') {
Say "-> resolving latest desktop-v* release..."
try {
$releases = Invoke-RestMethod -UseBasicParsing "https://api.github.com/repos/$repo/releases"
$releases = @()
$page = 1
do {
$releasePage = @(Invoke-RestMethod -UseBasicParsing "https://api.github.com/repos/$repo/releases?per_page=100&page=$page")
$releases += $releasePage
$page += 1
} while ($releasePage.Count -eq 100)
# Don't trust the API's first-element ordering — GitHub orders by the
# release row's created_at which shifts when the row is touched (re-tag,
# edit). Sort by parsed version components explicitly so a touched
@@ -209,10 +215,6 @@ if ($surface -eq 'tray') {
if ($expected -ne $actual) { Die "checksum mismatch (expected $expected, got $actual) - refusing to install" }
Say ' ok'
if (Get-Command Unblock-File -ErrorAction SilentlyContinue) {
Unblock-File $installer -ErrorAction SilentlyContinue
}
$installerArgs = @()
if ($env:HERMES_RELAY_TRAY_SILENT -eq '1') {
$installerArgs += '/S'
@@ -220,7 +222,8 @@ if ($surface -eq 'tray') {
# NSIS requires /D to be the final argument. Keeping the existing registered
# install directory avoids splitting an upgrade across two PATH locations.
$installerArgs += "/D=$dir"
Say '-> launching installer...'
Say '-> launching unsigned preview installer...'
Say ' Windows may show a SmartScreen publisher warning; review it before continuing.'
$proc = Start-Process -FilePath $installer -ArgumentList $installerArgs -Wait -PassThru
if ($proc.ExitCode -ne 0) {
Die "tray installer exited with code $($proc.ExitCode)"
+18 -9
View File
@@ -90,9 +90,10 @@ os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$os-$arch" in
linux-x86_64) asset="hermes-relay-linux-x64" ;;
linux-aarch64|linux-arm64) asset="hermes-relay-linux-arm64" ;;
darwin-x86_64) asset="hermes-relay-darwin-x64" ;;
darwin-arm64) asset="hermes-relay-darwin-arm64" ;;
*) die "unsupported platform: $os/$arch (published binaries: linux-x64, darwin-x64/arm64; Windows uses install.ps1)" ;;
*) die "unsupported platform: $os/$arch (published binaries: linux-x64/arm64, darwin-x64/arm64; Windows uses install.ps1)" ;;
esac
# Resolve "latest" to a concrete tag. GitHub's /releases/latest/download/ URL
@@ -103,16 +104,24 @@ esac
resolved_version="$VERSION"
if [ "$VERSION" = "latest" ]; then
say "-> resolving latest desktop-v* release..."
api_body=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" 2>/dev/null) \
|| die "could not query GitHub Releases API"
release_tags=""
page=1
while :; do
api_body=$(curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=100&page=$page" 2>/dev/null) \
|| die "could not query GitHub Releases API page $page"
page_tags=$(printf '%s\n' "$api_body" \
| grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' \
| sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)"/\1/' || true)
page_count=$(printf '%s\n' "$page_tags" | awk 'NF { count++ } END { print count + 0 }')
release_tags=$(printf '%s\n%s\n' "$release_tags" "$page_tags")
[ "$page_count" -eq 100 ] || break
page=$((page + 1))
done
# Extract every CLI-track tag_name and pick the SemVer-max. Don't trust the
# API's first-element ordering — GitHub orders by the release row's created_at
# which shifts when the row is edited or re-tagged. Each "tag_name": entry is
# on its own line in GitHub's JSON output, so line-oriented tooling is
# sufficient and avoids a jq dependency.
release_tags=$(printf '%s\n' "$api_body" \
| grep -E '"tag_name": *"(cli-v|desktop-v)' \
| sed -E 's/.*"tag_name": *"([^"]+)".*/\1/' || true)
# which shifts when the row is edited or re-tagged. Extract only tag_name
# fields from each page so this stays independent of JSON formatting and
# avoids a jq dependency.
resolved_version=$(printf '%s\n' "$release_tags" \
| awk '/^desktop-v/ { v=$0; sub(/^desktop-v/, "", v); print v "\t" $0 }' \
| sort -V \
+27 -19
View File
@@ -29,8 +29,8 @@ import { pipeline } from 'node:stream/promises'
import { VERSION } from './version.js'
const DEFAULT_REPO = 'Codename-11/hermes-relay'
const RELEASES_API = (repo: string): string =>
`https://api.github.com/repos/${repo}/releases`
const RELEASES_API = (repo: string, page: number): string =>
`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`
export interface UpdateInfo {
current: string
@@ -176,21 +176,24 @@ interface GhRelease {
}
async function fetchReleases(repo: string): Promise<GhRelease[]> {
const url = RELEASES_API(repo)
const res = await fetch(url, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': `hermes-relay-cli/${VERSION}`
const releases: GhRelease[] = []
for (let page = 1; ; page += 1) {
const res = await fetch(RELEASES_API(repo, page), {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': `hermes-relay-cli/${VERSION}`
}
})
if (!res.ok) {
throw new Error(`GitHub Releases API ${res.status} ${res.statusText}`)
}
})
if (!res.ok) {
throw new Error(`GitHub Releases API ${res.status} ${res.statusText}`)
const data = (await res.json()) as GhRelease[]
if (!Array.isArray(data)) {
throw new Error('unexpected Releases API response (not an array)')
}
releases.push(...data)
if (data.length < 100) return releases
}
const data = (await res.json()) as GhRelease[]
if (!Array.isArray(data)) {
throw new Error('unexpected Releases API response (not an array)')
}
return data
}
export async function checkForUpdate(opts: { repo?: string; assetName?: string } = {}): Promise<UpdateInfo | null> {
@@ -396,10 +399,19 @@ export async function finalizePendingUpdate(): Promise<void> {
// binary, nothing to swap.
return
}
await finalizePendingUpdateAt(target)
}
/** Path-level implementation used by the Windows startup hook and tests. */
export async function finalizePendingUpdateAt(target: string): Promise<void> {
const base = target.slice(0, -'.exe'.length)
const newPath = `${base}.new.exe`
const oldPath = `${base}.old.exe`
// Reap a backup from the prior cooperative swap even when no new update is
// staged. The prior process can keep the renamed image locked until exit.
try { await unlink(oldPath) } catch { /* absent or still locked */ }
try {
await stat(newPath)
} catch {
@@ -407,10 +419,6 @@ export async function finalizePendingUpdate(): Promise<void> {
}
try {
// Reap any leftover .old.exe from a previous swap. Windows lets us delete
// it once the file handle from the prior run is released.
try { await unlink(oldPath) } catch { /* either absent or still locked */ }
// Move running binary → .old.exe. This is allowed on Windows even for
// the actively-executing image (the OS keeps the handle; the file just
// gets a new name).
+25 -1
View File
@@ -118,8 +118,32 @@ test('tray update helper preserves the current install directory and cleans its
test('POSIX installer only advertises artifacts produced by the release workflow', async () => {
const script = await readFile(new URL('../scripts/install.sh', import.meta.url), 'utf8')
assert.doesNotMatch(script, /hermes-relay-linux-arm64/)
const packageJson = await readFile(new URL('../package.json', import.meta.url), 'utf8')
const workflow = await readFile(new URL('../../.github/workflows/release-cli.yml', import.meta.url), 'utf8')
assert.match(script, /linux-aarch64\|linux-arm64\) asset="hermes-relay-linux-arm64"/)
assert.match(script, /hermes-relay-linux-x64/)
assert.match(packageJson, /--target=bun-linux-arm64[^\n]+hermes-relay-linux-arm64/)
assert.match(workflow, /npm run build:bin:linux-arm/)
assert.match(workflow, /release-assets\/cli-binaries\/hermes-relay-linux-arm64/)
assert.doesNotMatch(workflow, /hermes-relay-linux-x64 "\$cmd" 2>&1 \|\| true/)
assert.match(workflow, /if \[ "\$exit_code" -ne 0 \]/)
assert.match(workflow, /Smoke exact macOS CLI release asset/)
assert.match(workflow, /release-assets\/\$native_asset" --version/)
})
test('bootstrap installers paginate release discovery beyond the first API page', async () => {
const posix = await readFile(new URL('../scripts/install.sh', import.meta.url), 'utf8')
const powershell = await readFile(new URL('../scripts/install.ps1', import.meta.url), 'utf8')
assert.match(posix, /releases\?per_page=100&page=\$page/)
assert.match(posix, /page=\$\(\(page \+ 1\)\)/)
assert.match(powershell, /releases\?per_page=100&page=\$page/)
assert.match(powershell, /while \(\$releasePage\.Count -eq 100\)/)
})
test('PowerShell tray bootstrap keeps unsigned publisher warnings visible', async () => {
const script = await readFile(new URL('../scripts/install.ps1', import.meta.url), 'utf8')
assert.doesNotMatch(script, /Unblock-File \$installer/)
assert.match(script, /Windows may show a SmartScreen publisher warning/)
})
test('PowerShell uninstaller delegates bundle cleanup to the NSIS uninstaller', async () => {
+67 -3
View File
@@ -1,12 +1,17 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { updateCommand } from '../src/commands/update.js'
import { checkForUpdate, downloadAndInstall } from '../src/updater.js'
import {
assetNameForPlatform,
checkForUpdate,
downloadAndInstall,
finalizePendingUpdateAt
} from '../src/updater.js'
import { VERSION } from '../src/version.js'
async function captureStdout(run: () => Promise<number>): Promise<{ code: number; output: string }> {
@@ -33,7 +38,7 @@ test('desktop installer selection downloads the requested asset to an exact veri
globalThis.fetch = async (input) => {
const url = String(input)
if (url.endsWith('/releases')) {
if (url.includes('/releases?')) {
return new Response(JSON.stringify([{
tag_name: 'desktop-v9.0.0',
prerelease: false,
@@ -65,6 +70,65 @@ test('desktop installer selection downloads the requested asset to an exact veri
}
})
test('release discovery paginates past mixed-surface releases', async () => {
const originalFetch = globalThis.fetch
const requested: string[] = []
const nonDesktopRelease = (index: number) => ({
tag_name: index % 2 === 0 ? `android-v1.${index}.0` : `server-v1.${index}.0`,
prerelease: false,
published_at: '2026-08-11T00:00:00Z',
assets: []
})
globalThis.fetch = async (input) => {
const url = String(input)
requested.push(url)
if (url.endsWith('page=1')) {
return new Response(JSON.stringify(Array.from({ length: 100 }, (_, index) => nonDesktopRelease(index))), { status: 200 })
}
if (url.endsWith('page=2')) {
return new Response(JSON.stringify([{
tag_name: 'desktop-v9.1.0',
prerelease: false,
published_at: '2026-08-12T00:00:00Z',
assets: []
}]), { status: 200 })
}
return new Response('not found', { status: 404 })
}
try {
const info = await checkForUpdate({ repo: 'example/hermes-relay', assetName: 'hermes-relay-linux-arm64' })
assert.ok(info)
assert.equal(info.latest_tag, 'desktop-v9.1.0')
assert.equal(info.asset_name, 'hermes-relay-linux-arm64')
assert.deepEqual(requested, [
'https://api.github.com/repos/example/hermes-relay/releases?per_page=100&page=1',
'https://api.github.com/repos/example/hermes-relay/releases?per_page=100&page=2'
])
} finally {
globalThis.fetch = originalFetch
}
})
test('Linux arm64 maps to the published Bun asset', () => {
assert.equal(assetNameForPlatform('linux', 'arm64'), 'hermes-relay-linux-arm64')
})
test('cooperative updater removes a released Windows backup without another staged update', async () => {
const scratch = await mkdtemp(join(tmpdir(), 'hermes-updater-old-cleanup-'))
const target = join(scratch, 'hermes-relay.exe')
const oldPath = join(scratch, 'hermes-relay.old.exe')
try {
await writeFile(target, 'current')
await writeFile(oldPath, 'previous')
await finalizePendingUpdateAt(target)
await assert.rejects(stat(oldPath), { code: 'ENOENT' })
assert.equal(await readFile(target, 'utf8'), 'current')
} finally {
await rm(scratch, { recursive: true, force: true })
}
})
test('installer check reports a newer local preview without treating it as an error', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify([{
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process'
import { createConnection } from 'node:net'
import { existsSync } from 'node:fs'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { computeDesktopUiSourceFingerprint } from './desktop-ui-source-fingerprint.mjs'
const trayRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const repositoryRoot = resolve(trayRoot, '..', '..')
const outputDir = resolve(repositoryRoot, 'assets', 'screenshots', 'desktop-ui')
const manifestPath = resolve(repositoryRoot, 'docs', 'media', 'desktop-ui-screenshots.json')
const session = `hermes-relay-desktop-screenshots-${process.pid}`
const baseUrl = 'http://127.0.0.1:1421/'
const playwrightCliVersion = '0.1.18'
const bundledNpx = resolve(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npx-cli.js')
const viteEntry = resolve(trayRoot, 'node_modules', 'vite', 'bin', 'vite.js')
const requireFromWebsite = createRequire(resolve(repositoryRoot, 'website', 'package.json'))
const sharp = requireFromWebsite('sharp')
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: trayRoot,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
...options
})
if (result.status !== 0 || result.error) {
throw new Error([result.error?.message, result.stdout, result.stderr].filter(Boolean).join('\n'))
}
return `${result.stdout ?? ''}${result.stderr ?? ''}`
}
function cli(...args) {
if (!existsSync(bundledNpx)) throw new Error(`npm/npx runtime not found at ${bundledNpx}`)
return run(process.execPath, [bundledNpx, '--yes', '--package', `@playwright/cli@${playwrightCliVersion}`, 'playwright-cli', `-s=${session}`, ...args], {
env: { ...process.env, TZ: 'UTC' }
})
}
async function waitForServer() {
const deadline = Date.now() + 30_000
while (Date.now() < deadline) {
const ready = await new Promise(resolveReady => {
const socket = createConnection({ host: '127.0.0.1', port: 1421 })
socket.once('connect', () => { socket.destroy(); resolveReady(true) })
socket.once('error', () => resolveReady(false))
})
if (ready) return
await new Promise(resolveWait => setTimeout(resolveWait, 100))
}
throw new Error(`screenshot Vite server did not start at ${baseUrl}`)
}
async function capture(filename) {
const destination = resolve(outputDir, filename)
const rawCapture = `${destination}.capture.png`
cli('screenshot', '--filename', rawCapture)
const normalized = await sharp(rawCapture)
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
.toBuffer()
let preserveExisting = false
try {
const previous = await readFile(destination)
const [before, after] = await Promise.all([
sharp(previous).raw().toBuffer({ resolveWithObject: true }),
sharp(normalized).raw().toBuffer({ resolveWithObject: true })
])
if (before.info.width === after.info.width && before.info.height === after.info.height && before.info.channels === after.info.channels) {
let differingChannels = 0
let maximumDelta = 0
for (let index = 0; index < before.data.length; index += 1) {
const delta = Math.abs(before.data[index] - after.data[index])
if (delta > 0) differingChannels += 1
if (delta > maximumDelta) maximumDelta = delta
}
preserveExisting = maximumDelta <= 1 && differingChannels / before.data.length <= 0.001
}
} catch { /* A missing or unreadable canonical image is replaced. */ }
if (!preserveExisting) await writeFile(destination, normalized)
await rm(rawCapture, { force: true })
console.log(`${preserveExisting ? 'retained' : 'captured'} ${destination}`)
}
await mkdir(outputDir, { recursive: true })
const vite = spawn(
process.execPath,
[viteEntry, '--config', 'scripts/vite.screenshots.config.mjs'],
{ cwd: trayRoot, stdio: ['ignore', 'pipe', 'pipe'] }
)
let viteOutput = ''
vite.stdout.on('data', chunk => { viteOutput += chunk })
vite.stderr.on('data', chunk => { viteOutput += chunk })
try {
await waitForServer()
cli('open', 'about:blank', '--browser', 'chrome')
cli('resize', '493', '785')
cli('run-code', "async page => { await page.context().addInitScript(() => { const fixed = Date.parse('2026-08-01T16:00:00Z'); Date.now = () => fixed }) }")
cli('goto', baseUrl)
cli('run-code', "async page => { await page.waitForSelector('.app-shell.window-visible'); await page.addStyleTag({ content: '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}' }); await page.evaluate(() => document.fonts.ready) }")
await capture('overview.png')
cli('run-code', "async page => { await page.getByRole('button', { name: 'Desktop access' }).click(); await page.waitForSelector('h1:text-is(\"Host access\")') }")
await capture('host-access.png')
cli('run-code', "async page => { await page.getByRole('button', { name: 'Overview', exact: true }).click(); await page.getByRole('button', { name: /PowerShell command/ }).click(); await page.waitForSelector('h1:text-is(\"PowerShell command\")') }")
await capture('activity-detail.png')
cli('run-code', "async page => { await page.getByRole('button', { name: 'Settings', exact: true }).click(); await page.waitForSelector('h1:text-is(\"Settings\")'); await page.waitForFunction(() => document.body.innerText.includes('CUA Driver 0.21.0')); await page.evaluate(() => { const content = document.querySelector('.content'); const control = [...document.querySelectorAll('.settings-group')].find(node => node.querySelector('h2')?.textContent === 'Computer control'); if (content && control) content.scrollTop = control.offsetTop - 10 }) }")
await capture('settings.png')
cli('run-code', "async page => { await page.evaluate(() => { const content = document.querySelector('.content'); const updates = [...document.querySelectorAll('.settings-group')].find(node => node.querySelector('h2')?.textContent === 'Updates'); if (content && updates) content.scrollTop = updates.offsetTop - 10 }) }")
await capture('settings-update.png')
const consoleOutput = cli('console', 'warning')
if (/\b(TypeError|ReferenceError|Uncaught)\b/i.test(consoleOutput)) {
throw new Error(`browser console reported a failure:\n${consoleOutput}`)
}
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
manifest.sourceFingerprint = await computeDesktopUiSourceFingerprint(repositoryRoot)
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
console.log(`updated ${manifestPath} source fingerprint`)
} catch (error) {
if (vite.exitCode !== null) console.error(viteOutput)
throw error
} finally {
try { cli('close') } catch { /* Best effort session cleanup. */ }
if (vite.exitCode === null) vite.kill()
await rm(resolve(trayRoot, '.playwright-cli'), { recursive: true, force: true })
}
@@ -0,0 +1,43 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { dirname, extname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const defaultRepositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
export const desktopUiScreenshotSourceFiles = Object.freeze([
'desktop/tray/ui/App.tsx',
'desktop/tray/ui/main.tsx',
'desktop/tray/ui/styles.css',
'desktop/tray/ui/types.ts',
'desktop/tray/index.html',
'desktop/tray/icons/icon-256.png',
'desktop/tray/package-lock.json',
'desktop/src/endpoint.ts',
'desktop/src/transportSecurity.ts',
'desktop/tray/scripts/vite.screenshots.config.mjs',
'desktop/tray/scripts/capture-desktop-ui.mjs',
'desktop/tray/scripts/desktop-ui-source-fingerprint.mjs'
])
const binaryExtensions = new Set(['.png'])
export async function computeDesktopUiSourceFingerprint(repositoryRoot = defaultRepositoryRoot) {
const hash = createHash('sha256')
for (const relativePath of desktopUiScreenshotSourceFiles) {
const bytes = await readFile(resolve(repositoryRoot, relativePath))
const normalized = binaryExtensions.has(extname(relativePath))
? bytes
: Buffer.from(bytes.toString('utf8').replace(/\r\n?/g, '\n'), 'utf8')
hash.update(relativePath)
hash.update('\0')
hash.update(normalized)
hash.update('\0')
}
return {
algorithm: 'sha256',
normalization: 'text-lf-v1',
digest: hash.digest('hex'),
files: [...desktopUiScreenshotSourceFiles]
}
}
@@ -0,0 +1,50 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const replacements = new Map([
["server_version: '1.6.3'", "server_version: '1.9.0'"],
["ui_version: '0.4.0-alpha.7'", "ui_version: '0.4.0-beta.4'"],
["cli_version: '0.4.0-alpha.4'", "cli_version: '0.4.0-beta.4'"],
["void getCurrentWindow().isVisible().then(visible => { if (visible) start() })", "start()"],
[
"selected: 'legacy', effective: 'legacy', available: false, state: 'not_installed',\n foreground_escalation_enabled: false, message: 'CUA Driver is not installed. Legacy input remains active.'",
"selected: 'cua', effective: 'cua', available: true, state: 'ready', version: '0.21.0', cursor_enabled: true, active_sessions: 0, active_backend: 'idle',\n foreground_escalation_enabled: false, message: 'CUA Driver 0.21.0 is compatible and healthy.'"
],
[
"return { current: '0.4.0-alpha.3', up_to_date: true, ahead_of_latest: true, latest_version: '0.4.0-alpha.2', installed: false, needs_restart: false } as T",
"return { current: '0.4.0-beta.4', up_to_date: true, ahead_of_latest: false, latest_version: '0.4.0-beta.4', installed: false, needs_restart: false } as T"
],
[
"return { installed: false, stale_path_shim: false, compatible: false, compatibility_reason: 'CUA Driver is not installed', supported_range: { minimum: '0.20.0', maximum_exclusive: null } } as T",
"return { installed: true, stale_path_shim: false, current_version: '0.21.0', compatible: true, compatibility_reason: 'Compatible with Hermes-Relay CLI UI', supported_range: { minimum: '0.20.0', maximum_exclusive: null }, update: { latest_version: '0.21.0', update_available: false, compatible: true } } as T"
],
[
"return { state: 'degraded', checkedAt: new Date().toISOString(), overall: 'degraded', reason: 'UI Automation desktop enumeration exceeded 2000ms.', temporaryWindowsCompatibility: true } as T",
"return { state: 'healthy', checkedAt: new Date().toISOString(), overall: 'healthy', reason: 'Accessibility and window discovery checks passed.', temporaryWindowsCompatibility: true } as T"
]
])
function publicScreenshotFixtures() {
return {
name: 'public-screenshot-fixtures',
enforce: 'pre',
transform(source, id) {
if (!id.endsWith('/ui/App.tsx') && !id.endsWith('\\ui\\App.tsx')) return null
let transformed = source
for (const [from, to] of replacements) transformed = transformed.replaceAll(from, to)
transformed = transformed.replace(
/selected: 'legacy', effective: 'legacy', available: false, state: 'not_installed',\s+foreground_escalation_enabled: false, message: 'CUA Driver is not installed\. Legacy input remains active\.'/,
"selected: 'cua', effective: 'cua', available: true, state: 'ready', version: '0.21.0', cursor_enabled: true, active_sessions: 0, active_backend: 'idle',\n foreground_escalation_enabled: false, message: 'CUA Driver 0.21.0 is compatible and healthy.'"
)
return { code: transformed, map: null }
}
}
}
export default defineConfig({
plugins: [publicScreenshotFixtures(), react()],
clearScreen: false,
server: { host: '127.0.0.1', port: 1421, strictPort: true },
envPrefix: ['VITE_', 'TAURI_'],
build: { target: ['es2021', 'chrome100', 'safari13'] }
})
+4 -4
View File
@@ -25,9 +25,9 @@ const isNoticeWindow = windowLabel === 'notice'
const isEvidenceWindow = windowLabel === 'evidence'
const demo: Snapshot = {
hosts: [{ url: 'wss://home-hermes.local:8767', name: 'Docker-Server', server_version: '1.6.3', endpoint_role: 'tailscale', paired_at: 1786458000, is_active: true, access_mode: 'full-access', capabilities: { commands: 'allow', files: 'allow', screen_input: 'allow', usb: 'allow', microphone: 'allow', camera: 'allow' } }],
active_url: 'wss://home-hermes.local:8767',
daemon: { state: 'connected', running: true, url: 'wss://home-hermes.local:8767', privilege: 'user', username: 'Local user' },
hosts: [{ url: 'wss://relay.example.test:8767', name: 'Hermes Home', server_version: '1.6.3', endpoint_role: 'tailscale', paired_at: 1786458000, is_active: true, access_mode: 'full-access', capabilities: { commands: 'allow', files: 'allow', screen_input: 'allow', usb: 'allow', microphone: 'allow', camera: 'allow' } }],
active_url: 'wss://relay.example.test:8767',
daemon: { state: 'connected', running: true, url: 'wss://relay.example.test:8767', privilege: 'user', username: 'Local user' },
startup_enabled: true,
daemon_autostart_enabled: true,
ui_version: '0.4.0-alpha.7',
@@ -57,7 +57,7 @@ async function call<T>(command: string, args?: Record<string, unknown>): Promise
] as T
if (command === 'check_desktop_update') return { current: '0.4.0-alpha.3', up_to_date: true, ahead_of_latest: true, latest_version: '0.4.0-alpha.2', installed: false, needs_restart: false } as T
if (command === 'install_desktop_update') return { current: '0.4.0-alpha.3', up_to_date: true, ahead_of_latest: false, installed: true, needs_restart: true } as T
if (command === 'test_host_route') return { best: { label: 'LAN', url: 'ws://172.16.24.250:8767', reachable: true, elapsed_ms: 36, encrypted: false, security: 'Unencrypted relay connection' }, routes: [] } as T
if (command === 'test_host_route') return { best: { label: 'Secure Link', url: 'wss://relay.example.test:8767', reachable: true, elapsed_ms: 36, encrypted: true, security: 'Encrypted relay connection' }, routes: [] } as T
if (command === 'computer_cua_status') return { installed: false, stale_path_shim: false, compatible: false, compatibility_reason: 'CUA Driver is not installed', supported_range: { minimum: '0.20.0', maximum_exclusive: null } } as T
if (command === 'computer_cua_health') return { state: 'degraded', checkedAt: new Date().toISOString(), overall: 'degraded', reason: 'UI Automation desktop enumeration exceeded 2000ms.', temporaryWindowsCompatibility: true } as T
return undefined as T
+57 -1
View File
@@ -777,7 +777,13 @@ Four sub-decisions captured together:
contains no tokens, secrets, config contents, or filesystem paths;
`/bridge/activity` and `/media/inspect` remain loopback-only.
4. **Dashboard backend is a thin proxy; relay is source of truth.** `plugin_api.py` exposes five routes at `/api/plugins/hermes-relay/{overview,sessions,bridge-activity,media,push}` and forwards to the relay over `httpx.AsyncClient` with a 5-second timeout. No business logic — the plugin never maintains its own state, never caches, never retries. Relay connect-error / timeout / 5xx translate to `HTTPException(502, detail=…)` carrying the relay address, so the UI can render a "relay unreachable at 127.0.0.1:8767" banner; 4xx passes through verbatim. The one exception is `/push` — since FCM isn't wired, this route is a static stub returning `{configured: false, reason: "FCM not yet wired; …"}` with no network call. Keeps the four-tab nav layout correct for when FCM lands; swapping in real data only touches `PushConsole.jsx` + `plugin_api.py::get_push`.
4. **Dashboard backend is a thin proxy; relay is source of truth.** `plugin_api.py` exposes five routes at `/api/plugins/hermes-relay/{overview,sessions,bridge-activity,media,push}` and forwards to the relay over `httpx.AsyncClient` with a 5-second timeout. It does not cache or retry. Relay connect-error / timeout / 5xx translate to `HTTPException(502, detail=…)` carrying the relay address, so the UI can render a "relay unreachable at 127.0.0.1:8767" banner; 4xx passes through verbatim. The `/push` route is a static stub until FCM is wired.
**2026-08-21 amendment:** the authenticated `/provider-usage` route is a
deliberate process-local exception. The Dashboard process owns the live
Gateway session, so this Relay-plugin route resolves its active credential
on demand and feeds the same provider-neutral Relay adapter without waiting
for another turn. It stores no provider secret or additional Dashboard state.
**Consequences:**
@@ -3737,3 +3743,53 @@ the active connection, and a group cannot gain a competing mobile writer.
Route-scoped Relay media, voice, background delivery notifications, autonomous
peer delivery, and writable room control remain separate capabilities rather
than implicit authority gained from appearing in the union roster.
---
## ADR 67 — Provider account usage is Relay-enhanced, normalized, and user-presented at top level
**Context.** Android had no account-limit surface even though current Hermes
already models Codex and Nous usage. A proposed OpenCode Go-only Settings card
introduced a Relay proxy, fixed provider windows, and inferred dollar spend
from rounded percentages. That shape could not represent multiple providers,
made changing plans look authoritative, and placed a provider-specific card on
the Settings landing page for hosts where it did not apply.
**Decision.** Android owns one top-level **Usage & limits** Settings destination,
parallel to Hermes Management rather than nested inside it. The device-level
landing presentation is Summary by default, with opt-in Expanded and Hidden
modes plus per-provider visibility. The full destination remains reachable in
all three modes.
When Dashboard auth is available, the client first calls the Relay-owned
Dashboard-plugin usage route so the live session's active credential can be
resolved directly. Paired standalone clients fall back to Relay
`GET /usage/providers`, which requires the operator to set
`RELAY_PROVIDER_USAGE_ENABLED=1`; additive upstream Gateway `account.usage`
remains the bounded single-account fallback. Relay reuses Hermes's existing
account model for Codex and Nous and supplies the missing OpenCode Go adapter. OpenCode Go
renders only the percentage and reset values returned by the provider; Android
does not infer dollars or embed plan caps. Provider keys stay host-side.
The active profile is carried on the compatibility request and validated before
Hermes's task-local home override scopes every account lookup, so concurrent
profiles never collapse onto the root account.
For Codex pools, Android also carries its current Gateway session id. The
authenticated Relay Dashboard-plugin route runs in the process that owns the
live agent, reads its authoritative stable pool-entry id on demand, and returns
the provider-neutral usage response without waiting for another turn. Relay
fetches each pool entry's usage host-side, returns safe labels and hashed opaque
ids, and marks the exact active entry. Turn hooks retain a secret-free
profile-local snapshot only for standalone Relay clients without Dashboard
access. Missing live-agent/session evidence is rendered as active unknown; it
is never inferred from the first credential or from the legacy singleton.
xAI and other providers remain absent until their account-level source and
credential scope can be represented honestly. Per-request or session spend is
not labeled as an account quota.
**Consequences.** Relay can evolve richer provider adapters without requiring
an upstream Hermes change, while vanilla/current Hermes retains a bounded
single-account fallback. Merely configuring a provider credential does not
expose tokens to paired devices. The Android UI can add providers without
adding provider-specific screens or silently treating missing data as zero usage.
+19
View File
@@ -68,6 +68,25 @@ Rule of thumb: where a surface is CI-gateable, write the **failing test first**
is the deliberate exception — CI only covers lint + unit there, so on-device
verification stays a manual maintainer step and a fix is never "done" from CI alone.
### Emulator UI evidence
Hermes Android is dark-mode-first. Before emulator screenshots, animation
review, or renderer performance measurements, explicitly enable Android dark
mode and restart the app so evidence is not captured in the emulator's light
default:
```bash
adb -s <emulator-serial> shell cmd uimode night yes
adb -s <emulator-serial> shell am force-stop com.axiomlabs.hermesrelay.sideload
adb -s <emulator-serial> shell am start -n \
com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity
adb -s <emulator-serial> shell cmd uimode night
```
Confirm the final command reports `Night mode: yes` before capturing evidence.
Use host GPU acceleration where available; software rendering is useful for
compatibility but is not representative performance evidence.
## Local bridge: `scripts/start-issue.sh`
```bash
+31 -31
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -24,13 +24,13 @@
},
"docs_locale": "de",
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "486fa0b9ea0f08c19be9407d8c30b2c2363182151c31d71ecd81dee996c19632",
"guide/getting-started.md": "1c2d5a9d04b86dc784802c1e0d9c5818de13f0b4792f6e000bf12f003f352b8d",
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
"website_source_sha256": "d4eeb605a56856e1f5002783268049870646bcea4dbccc907467cc0c2e011122"
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
},
"en": {
"native_name": "English",
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -59,20 +59,20 @@
},
"docs_locale": "es",
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "486fa0b9ea0f08c19be9407d8c30b2c2363182151c31d71ecd81dee996c19632",
"guide/getting-started.md": "1c2d5a9d04b86dc784802c1e0d9c5818de13f0b4792f6e000bf12f003f352b8d",
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
"website_source_sha256": "d4eeb605a56856e1f5002783268049870646bcea4dbccc907467cc0c2e011122"
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
},
"ja": {
"native_name": "日本語",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -83,20 +83,20 @@
},
"docs_locale": "ja",
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "486fa0b9ea0f08c19be9407d8c30b2c2363182151c31d71ecd81dee996c19632",
"guide/getting-started.md": "1c2d5a9d04b86dc784802c1e0d9c5818de13f0b4792f6e000bf12f003f352b8d",
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
"website_source_sha256": "d4eeb605a56856e1f5002783268049870646bcea4dbccc907467cc0c2e011122"
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
},
"pt-BR": {
"native_name": "Português (Brasil)",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -107,20 +107,20 @@
},
"docs_locale": "pt-BR",
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "486fa0b9ea0f08c19be9407d8c30b2c2363182151c31d71ecd81dee996c19632",
"guide/getting-started.md": "1c2d5a9d04b86dc784802c1e0d9c5818de13f0b4792f6e000bf12f003f352b8d",
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
"website_source_sha256": "d4eeb605a56856e1f5002783268049870646bcea4dbccc907467cc0c2e011122"
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
},
"ru": {
"native_name": "Русский",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"main": "0abcf7f4fb8accb1b9ee174243e369173591ee5abd105aac0d3b414e78916a6d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -146,13 +146,13 @@
},
"docs_locale": "zh-CN",
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "486fa0b9ea0f08c19be9407d8c30b2c2363182151c31d71ecd81dee996c19632",
"guide/getting-started.md": "1c2d5a9d04b86dc784802c1e0d9c5818de13f0b4792f6e000bf12f003f352b8d",
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
"website_source_sha256": "d4eeb605a56856e1f5002783268049870646bcea4dbccc907467cc0c2e011122"
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
}
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"version": 1,
"description": "Deterministic, public-safe Windows CLI UI screenshots captured from the production React surface.",
"capture": {
"command": "node desktop/tray/scripts/capture-desktop-ui.mjs",
"viewport": {
"width": 493,
"height": 785
},
"fixture": "Browser fallback with fixed UTC time and public example host data",
"websiteSync": "node website/scripts/desktop-ui-assets.mjs sync",
"websiteCheck": "node website/scripts/desktop-ui-assets.mjs check"
},
"scenes": [
{
"id": "overview",
"title": "Connected desktop overview",
"source": "assets/screenshots/desktop-ui/overview.png",
"website": "website/public/product/desktop-ui/overview.png",
"captureNote": "Connected public example host, transport, access policy, and local activity."
},
{
"id": "host-access",
"title": "Host access presets",
"source": "assets/screenshots/desktop-ui/host-access.png",
"website": "website/public/product/desktop-ui/host-access.png",
"captureNote": "Production host access screen with the four consent presets."
},
{
"id": "activity-detail",
"title": "Local activity detail",
"source": "assets/screenshots/desktop-ui/activity-detail.png",
"website": "website/public/product/desktop-ui/activity-detail.png",
"captureNote": "Sanitized PowerShell request timeline and locally retained result detail."
},
{
"id": "settings",
"title": "Computer control and updates",
"source": "assets/screenshots/desktop-ui/settings.png",
"website": "website/public/product/desktop-ui/settings.png",
"captureNote": "CUA Driver readiness, safety controls, maintenance status, and diagnostics."
},
{
"id": "settings-update",
"title": "Desktop bundle updates",
"source": "assets/screenshots/desktop-ui/settings-update.png",
"website": "website/public/product/desktop-ui/settings-update.png",
"captureNote": "Current CLI UI bundle version, update state, and local activity controls."
}
],
"sourceFingerprint": {
"algorithm": "sha256",
"normalization": "text-lf-v1",
"digest": "07cf8f3be17b2029f79bcbd4c291ce81986e8ce6e2ed08aad261bf8c80ce41f5",
"files": [
"desktop/tray/ui/App.tsx",
"desktop/tray/ui/main.tsx",
"desktop/tray/ui/styles.css",
"desktop/tray/ui/types.ts",
"desktop/tray/index.html",
"desktop/tray/icons/icon-256.png",
"desktop/tray/package-lock.json",
"desktop/src/endpoint.ts",
"desktop/src/transportSecurity.ts",
"desktop/tray/scripts/vite.screenshots.config.mjs",
"desktop/tray/scripts/capture-desktop-ui.mjs",
"desktop/tray/scripts/desktop-ui-source-fingerprint.mjs"
]
}
}
+2
View File
@@ -681,6 +681,8 @@ MEDIA:hermes-relay://<url-safe-16-byte-token>
**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.
+2 -2
View File
File diff suppressed because one or more lines are too long
+43 -3
View File
@@ -2,9 +2,10 @@
Loopback-only; mounted by hermes-agent at ``/api/plugins/hermes-relay/*``.
Each route is a thin pass-through to the already-running relay HTTP server
on ``127.0.0.1:{HERMES_RELAY_PORT}``. No business logic lives here — the
relay stays the source of truth.
Most routes are thin pass-throughs to the already-running relay HTTP server
on ``127.0.0.1:{HERMES_RELAY_PORT}``. Provider usage is the deliberate
exception: the Dashboard process owns the live Gateway session and can resolve
its active credential without waiting for another turn.
Route map
---------
@@ -13,6 +14,7 @@ Route map
- ``GET /bridge-activity`` → relay ``GET /bridge/activity`` (forwards ``limit``)
- ``GET /media`` → relay ``GET /media/inspect`` (forwards ``include_expired``)
- ``GET /agent-context`` → relay ``GET /context/injected`` + local env settings
- ``GET /provider-usage`` → live-session-aware provider usage from this plugin
- ``GET /push`` → static stub (no network call) until FCM is wired
Error translation
@@ -236,6 +238,44 @@ async def get_agent_context() -> dict[str, Any]:
}
@router.get("/provider-usage")
async def get_provider_usage(
profile: Optional[str] = Query(default=None),
session_id: Optional[str] = Query(default=None),
) -> dict[str, Any]:
"""Return provider usage with the live session's active pool entry.
This route runs inside the Dashboard process that owns ``tui_gateway``.
Unlike the standalone Relay server, it can read the already-instantiated
agent directly and therefore does not need a new turn to learn which
credential is active.
"""
provider_usage = _plugin_module("relay.provider_usage")
hooks = _plugin_module("hooks")
try:
profile_home = provider_usage.resolve_profile_home(
str(_hermes_home() / "config.yaml"),
profile,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
active_credential_id = None
live = hooks.resolve_live_active_credential(session_id or "")
if (
isinstance(live, dict)
and live.get("provider_id") == "openai-codex"
and FsPath(live.get("profile_home")).resolve() == profile_home
):
active_credential_id = str(live.get("credential_id") or "") or None
return await provider_usage.collect_provider_usage(
profile_home=profile_home,
session_id=session_id,
active_credential_id=active_credential_id,
)
@router.get("/phone/config")
async def get_phone_config() -> dict[str, Any]:
"""Phone-platform home-channel config for the Management tab.
@@ -76,7 +76,7 @@ export default function MobileConnectDialog({ open, onClose }) {
Connect mobile app
</h2>
<p id="hr-mobile-connect-description" className="text-sm text-muted-foreground mt-1">
Scan this from Hermes Relay to add this Dashboard.
Scan with Hermes-Relay Android to add this Dashboard.
</p>
</div>
<Button
@@ -235,7 +235,7 @@ export default function PairDialog({ open, onClose }) {
<div>
<h2 id="hr-pair-dialog-title" className="hr-modal-title">Pair new device</h2>
<p className="text-sm text-muted-foreground mt-1">
Scan from the Hermes-Relay Android app to pair.
Scan with Hermes-Relay Android, or copy the invite for Desktop CLI.
</p>
</div>
<Button variant="ghost" size="sm" className="hr-modal-close" onClick={onClose}>
+1 -1
View File
@@ -75,7 +75,7 @@ function RelayPluginRoot() {
<div>
<h1 className="text-2xl font-semibold">Relay</h1>
<p className="text-sm text-muted-foreground">
Paired devices, bridge activity, media, and remote access for hermes-relay.
Connect clients and manage Relay sessions, activity, media, and remote access.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
+38 -1
View File
@@ -7,8 +7,10 @@ Uses FastAPI's ``TestClient`` + ``httpx.MockTransport`` patched over
from __future__ import annotations
import unittest
from pathlib import Path
from types import SimpleNamespace
from typing import Callable, Optional
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import FastAPI
@@ -96,6 +98,41 @@ class SessionsTests(PluginApiTestCase):
self.assertEqual(resp.json(), payload)
class ProviderUsageTests(PluginApiTestCase):
def test_reads_active_credential_from_live_dashboard_session(self) -> None:
profile_home = Path("/profiles/victor").resolve()
provider_usage = SimpleNamespace(
resolve_profile_home=lambda _config, _profile: profile_home,
collect_provider_usage=AsyncMock(
return_value={"schema_version": 2, "providers": []}
),
)
hooks = SimpleNamespace(
resolve_live_active_credential=lambda _session: {
"profile_home": profile_home,
"provider_id": "openai-codex",
"credential_id": "entry-2",
}
)
with patch.object(
plugin_api,
"_plugin_module",
side_effect=lambda name: hooks if name == "hooks" else provider_usage,
):
response = self.client.get(
"/provider-usage",
params={"profile": "victor", "session_id": "session-2"},
)
self.assertEqual(response.status_code, 200)
provider_usage.collect_provider_usage.assert_awaited_once_with(
profile_home=profile_home,
session_id="session-2",
active_credential_id="entry-2",
)
class BridgeActivityTests(PluginApiTestCase):
def test_limit_param_is_forwarded(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
+100 -4
View File
@@ -1,8 +1,9 @@
"""Lifecycle hooks for the hermes-relay plugin.
Registered via ``ctx.register_hook(...)``. There is exactly one hook here:
``on_session_start``, which performs a single fast, fully-guarded loopback
probe of the relay's ``/health`` endpoint and caches the result.
Registered via ``ctx.register_hook(...)``. Session-start performs a single
fast, fully-guarded loopback health probe. Turn-boundary hooks also persist the
stable credential-pool entry selected by the live Gateway session so Relay can
report the correct account without ever receiving its token.
IMPORTANT — runs in the gateway process
---------------------------------------
@@ -30,6 +31,7 @@ import logging
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
@@ -113,6 +115,94 @@ def on_session_start(**kwargs: Any) -> None:
return None
def resolve_live_active_credential(session_id: str) -> dict[str, Any] | None:
"""Resolve a live Gateway session's active credential without exposing it."""
session_id = str(session_id or "").strip()
if not session_id:
return None
try:
from tui_gateway import server as gateway_server
sessions = getattr(gateway_server, "_sessions", {})
def resolve_record():
direct = sessions.get(session_id)
if isinstance(direct, dict):
return session_id, direct
for gateway_id, candidate in sessions.items():
if not isinstance(candidate, dict):
continue
agent = candidate.get("agent")
aliases = {
str(candidate.get("session_key") or ""),
str(getattr(agent, "session_id", "") or ""),
}
if session_id in aliases:
return str(gateway_id), candidate
return None, None
lock = getattr(gateway_server, "_sessions_lock", None)
if lock is None:
gateway_id, record = resolve_record()
else:
with lock:
gateway_id, record = resolve_record()
if not isinstance(record, dict):
return None
agent = record.get("agent")
credential_id = str(getattr(agent, "_credential_pool_entry_id", "") or "").strip()
pool = getattr(agent, "_credential_pool", None)
provider_id = str(getattr(pool, "provider", "") or "").strip()
if not credential_id or not provider_id:
return None
raw_home = record.get("profile_home")
if raw_home:
profile_home = Path(raw_home).expanduser().resolve()
else:
from hermes_constants import get_hermes_home
profile_home = Path(get_hermes_home()).expanduser().resolve()
aliases = {
value
for value in (
session_id,
str(gateway_id or ""),
str(record.get("session_key") or ""),
str(getattr(agent, "session_id", "") or ""),
)
if value
}
return {
"profile_home": profile_home,
"provider_id": provider_id,
"credential_id": credential_id,
"session_ids": aliases,
}
except Exception as exc: # noqa: BLE001 -- live lookup must fail open
logger.debug("active credential lookup skipped: %s", exc)
return None
def capture_active_credential(**kwargs: Any) -> None:
"""Persist the current live session's stable credential id, if available."""
try:
resolved = resolve_live_active_credential(str(kwargs.get("session_id") or ""))
if resolved is None:
return None
from .relay.active_credentials import record_active_credential_aliases
record_active_credential_aliases(
resolved["profile_home"],
session_ids=resolved["session_ids"],
provider_id=resolved["provider_id"],
credential_id=resolved["credential_id"],
)
except Exception as exc: # noqa: BLE001 -- this hook must always fail open
logger.debug("active credential capture skipped: %s", exc)
return None
def register_hooks(ctx) -> None:
"""Register the ``on_session_start`` hook with the plugin host.
@@ -121,5 +211,11 @@ def register_hooks(ctx) -> None:
"""
try:
ctx.register_hook("on_session_start", on_session_start)
except (AttributeError, TypeError) as exc:
except (AttributeError, TypeError, ValueError) as exc:
logger.debug("register_hook unavailable; skipping on_session_start: %s", exc)
return
for hook_name in ("pre_llm_call", "post_llm_call"):
try:
ctx.register_hook(hook_name, capture_active_credential)
except (AttributeError, TypeError, ValueError) as exc:
logger.debug("register_hook unavailable; skipping %s: %s", hook_name, exc)
+144
View File
@@ -0,0 +1,144 @@
"""Secret-free active credential snapshots shared by Gateway hooks and Relay.
The Gateway and Relay server commonly run in separate processes. Hooks record
only the stable pool-entry id selected by a live session; provider tokens never
leave the owning Gateway process.
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
import time
from pathlib import Path
from collections.abc import Iterable
from typing import Any
_STATE_FILE = "hermes-relay-active-credentials.json"
_MAX_SESSIONS = 64
_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
_LOCK = threading.Lock()
def state_path(profile_home: Path) -> Path:
return profile_home / _STATE_FILE
def _read(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, ValueError, TypeError):
return {"schema_version": 1, "sessions": {}}
sessions = payload.get("sessions") if isinstance(payload, dict) else None
return {
"schema_version": 1,
"sessions": sessions if isinstance(sessions, dict) else {},
}
def record_active_credential(
profile_home: Path,
*,
session_id: str,
provider_id: str,
credential_id: str,
) -> None:
"""Atomically record a bounded, secret-free active credential mapping."""
record_active_credential_aliases(
profile_home,
session_ids=(session_id,),
provider_id=provider_id,
credential_id=credential_id,
)
def record_active_credential_aliases(
profile_home: Path,
*,
session_ids: Iterable[str],
provider_id: str,
credential_id: str,
) -> None:
"""Atomically map every authoritative Gateway/session alias to one entry."""
aliases = {
str(session_id or "").strip()[:160]
for session_id in session_ids
if str(session_id or "").strip()
}
provider = str(provider_id or "").strip()[:80]
credential = str(credential_id or "").strip()[:160]
if not aliases or not provider or not credential:
return
path = state_path(profile_home)
now = time.time()
with _LOCK:
payload = _read(path)
sessions = payload["sessions"]
for session in aliases:
sessions[session] = {
"provider_id": provider,
"credential_id": credential,
"observed_at": now,
}
retained = sorted(
(
(key, row)
for key, row in sessions.items()
if isinstance(row, dict)
and isinstance(row.get("observed_at"), (int, float))
and now - float(row["observed_at"]) <= _MAX_AGE_SECONDS
),
key=lambda item: float(item[1]["observed_at"]),
reverse=True,
)[:_MAX_SESSIONS]
payload["sessions"] = dict(retained)
path.parent.mkdir(parents=True, exist_ok=True)
fd, raw_tmp = tempfile.mkstemp(prefix=f".{_STATE_FILE}.", dir=str(path.parent))
tmp = Path(raw_tmp)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, separators=(",", ":"), sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
finally:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
def read_active_credential(
profile_home: Path,
*,
session_id: str | None,
provider_id: str,
) -> dict[str, Any] | None:
"""Return the exact session mapping, or ``None`` when it is not proven."""
session = str(session_id or "").strip()
if not session:
return None
row = _read(state_path(profile_home))["sessions"].get(session)
if not isinstance(row, dict) or row.get("provider_id") != provider_id:
return None
observed_at = row.get("observed_at")
if not isinstance(observed_at, (int, float)):
return None
if time.time() - float(observed_at) > _MAX_AGE_SECONDS:
return None
credential_id = str(row.get("credential_id") or "").strip()
if not credential_id:
return None
return {"credential_id": credential_id, "observed_at": float(observed_at)}
+18 -1
View File
@@ -102,6 +102,11 @@ class RelayConfig:
trust_proxy_headers: bool = False
allow_insecure_api_bearer: bool = False
# Provider-account usage can expose billing and quota metadata to paired
# devices. Provider credentials alone are not consent to that disclosure;
# operators must explicitly enable the read-only mobile surface.
provider_usage_enabled: bool = False
# Provider-neutral voice output broker. This is the default assistant
# speech renderer: final Hermes text goes in, streamed provider PCM comes
# out. Realtime providers remain available separately as agent-mode tests.
@@ -160,6 +165,12 @@ class RelayConfig:
@classmethod
def from_env(cls) -> RelayConfig:
"""Build config from environment variables, falling back to defaults."""
hermes_home = (os.getenv("HERMES_HOME") or "").strip()
default_hermes_config = (
str(Path(hermes_home).expanduser() / "config.yaml")
if hermes_home
else cls.hermes_config_path
)
config = cls(
host=os.getenv("RELAY_HOST", cls.host),
port=int(os.getenv("RELAY_PORT", str(cls.port))),
@@ -167,7 +178,7 @@ class RelayConfig:
ssl_key=os.getenv("RELAY_SSL_KEY"),
webapi_url=os.getenv("RELAY_WEBAPI_URL", cls.webapi_url),
hermes_config_path=os.getenv(
"RELAY_HERMES_CONFIG", cls.hermes_config_path
"RELAY_HERMES_CONFIG", default_hermes_config
),
log_level=os.getenv("RELAY_LOG_LEVEL", cls.log_level),
terminal_shell=os.getenv("RELAY_TERMINAL_SHELL") or None,
@@ -283,6 +294,12 @@ class RelayConfig:
if insecure_api_bearer in ("1", "true", "yes", "on"):
config.allow_insecure_api_bearer = True
provider_usage = os.getenv(
"RELAY_PROVIDER_USAGE_ENABLED", ""
).strip().lower()
if provider_usage in ("1", "true", "yes", "on"):
config.provider_usage_enabled = True
apply_voice_output_config_file(config)
apply_realtime_voice_config_file(config)
+531
View File
@@ -0,0 +1,531 @@
"""Provider-neutral account usage snapshots for paired mobile clients.
Hermes already owns provider credentials and the canonical account-usage model.
Relay reuses that model, adds credential-pool and balance structure for Android,
and supplies the missing OpenCode Go adapter. Provider keys remain host-side
and are never serialized into the response.
"""
from __future__ import annotations
import asyncio
import hashlib
import math
import re
from urllib.parse import urlparse
from pathlib import Path
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable
import aiohttp
SCHEMA_VERSION = 2
RELAY_CAPABILITIES = (
"credential_pools",
"structured_balances",
"opencode_go",
)
_OPENCODE_GO_DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1"
_OPENCODE_GO_USER_AGENT = "curl/8.4.0"
_MAX_DETAIL_LENGTH = 240
_PROFILE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
def resolve_profile_home(config_path: str, requested_profile: str | None) -> Path:
"""Resolve an exact Hermes profile home without mutating process globals."""
root = Path(config_path).expanduser().resolve().parent
profile = str(requested_profile or "").strip().lower()
if profile in {"", "default"}:
try:
active = (root / "active_profile").read_text(encoding="utf-8").strip().lower()
except (OSError, UnicodeError):
active = ""
if _PROFILE_ID.fullmatch(active):
candidate = (root / "profiles" / active).resolve()
if candidate.parent == (root / "profiles").resolve() and (candidate / "config.yaml").is_file():
return candidate
return root
if not _PROFILE_ID.fullmatch(profile):
raise ValueError("invalid profile")
candidate = (root / "profiles" / profile).resolve()
if candidate.parent != (root / "profiles").resolve() or not (candidate / "config.yaml").is_file():
raise ValueError("unknown profile")
return candidate
def _set_home(profile_home: Path | None):
if profile_home is None:
return None
from hermes_constants import set_hermes_home_override
return set_hermes_home_override(profile_home)
def _reset_home(token) -> None:
if token is None:
return
from hermes_constants import reset_hermes_home_override
reset_hermes_home_override(token)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _bounded_text(value: Any, limit: int = _MAX_DETAIL_LENGTH) -> str | None:
text = str(value or "").strip()
return text[:limit] if text else None
def _iso(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
return _bounded_text(value, 80)
def _iso_epoch(value: Any) -> str | None:
if isinstance(value, (int, float)) and not isinstance(value, bool):
try:
return _iso(datetime.fromtimestamp(float(value), timezone.utc))
except (OverflowError, OSError, ValueError):
return None
return _iso(value)
def unavailable_provider(
provider_id: str,
display_name: str,
*,
status: str = "not_configured",
message: str | None = None,
) -> dict[str, Any]:
return {
"id": provider_id,
"display_name": display_name,
"status": status,
"source": None,
"fetched_at": None,
"plan": None,
"windows": [],
"details": [],
"balances": [],
"renews_at": None,
"action_url": None,
"credentials": [],
"active_credential_id": None,
"active_credential_state": "unknown",
"message": _bounded_text(message),
}
def serialize_account_snapshot(
snapshot: Any,
*,
provider_id: str,
display_name: str,
) -> dict[str, Any]:
"""Serialize upstream ``AccountUsageSnapshot`` without provider secrets."""
if snapshot is None or not bool(getattr(snapshot, "available", False)):
return unavailable_provider(provider_id, display_name)
windows: list[dict[str, Any]] = []
for index, window in enumerate(tuple(getattr(snapshot, "windows", ()) or ())[:8]):
raw_percent = getattr(window, "used_percent", None)
percent: float | None = None
if isinstance(raw_percent, (int, float)) and not isinstance(raw_percent, bool):
if math.isfinite(float(raw_percent)):
percent = max(0.0, min(100.0, float(raw_percent)))
label = _bounded_text(getattr(window, "label", None), 60) or f"Window {index + 1}"
windows.append(
{
"id": label.lower().replace(" ", "_")[:40],
"label": label,
"used_percent": percent,
"reset_at": _iso(getattr(window, "reset_at", None)),
"detail": _bounded_text(getattr(window, "detail", None)),
}
)
details = [
text
for item in tuple(getattr(snapshot, "details", ()) or ())[:8]
if (text := _bounded_text(item)) is not None
]
return {
"id": provider_id,
"display_name": display_name,
"status": "available",
"source": _bounded_text(getattr(snapshot, "source", None), 60),
"fetched_at": _iso(getattr(snapshot, "fetched_at", None)) or _now_iso(),
"plan": _bounded_text(getattr(snapshot, "plan", None), 80),
"windows": windows,
"details": details,
"balances": [],
"renews_at": None,
"action_url": None,
"credentials": [],
"active_credential_id": None,
"active_credential_state": "unknown",
"message": None,
}
def _public_credential_id(credential_id: str) -> str:
return hashlib.sha256(credential_id.encode("utf-8")).hexdigest()[:12]
def _effective_credential_status(entry: Any, snapshot: Any) -> str:
pool_status = str(getattr(entry, "last_status", "") or "").lower()
if pool_status in {"dead", "invalid"}:
return "unavailable"
if pool_status in {"exhausted", "rate_limited", "cooldown"}:
return "at_limit"
windows = tuple(getattr(snapshot, "windows", ()) or ()) if snapshot is not None else ()
if any(float(getattr(window, "used_percent", 0) or 0) >= 100 for window in windows):
return "at_limit"
return "available" if bool(getattr(snapshot, "available", False)) else "unavailable"
async def fetch_codex_usage(
profile_home: Path | None = None,
*,
session_id: str | None = None,
active_credential_id: str | None = None,
snapshot_fetcher: Callable[..., Any] | None = None,
pool_loader: Callable[[str], Any] | None = None,
) -> dict[str, Any]:
token = _set_home(profile_home)
try:
if pool_loader is None:
from agent.credential_pool import load_pool
pool_loader = load_pool
entries = pool_loader("openai-codex").entries()[:8]
except Exception:
return unavailable_provider(
"openai-codex",
"Codex",
status="unavailable",
message="Could not load Codex usage",
)
finally:
_reset_home(token)
if not entries:
return unavailable_provider("openai-codex", "Codex")
if snapshot_fetcher is None:
from agent.account_usage import _fetch_codex_account_usage
snapshot_fetcher = _fetch_codex_account_usage
def fetch_entry_snapshot(entry: Any) -> Any:
entry_token = _set_home(profile_home)
try:
return snapshot_fetcher(
base_url=getattr(entry, "runtime_base_url", None),
api_key=entry.runtime_api_key,
)
finally:
_reset_home(entry_token)
async def fetch_entry(entry: Any) -> tuple[Any, Any]:
try:
snapshot = await asyncio.to_thread(fetch_entry_snapshot, entry)
return entry, snapshot
except Exception:
return entry, None
fetched = await asyncio.gather(*(fetch_entry(entry) for entry in entries))
active_mapping = None
if active_credential_id:
active_raw_id = str(active_credential_id).strip()
elif profile_home is not None:
from .active_credentials import read_active_credential
active_mapping = read_active_credential(
profile_home,
session_id=session_id,
provider_id="openai-codex",
)
active_raw_id = active_mapping["credential_id"] if active_mapping else None
else:
active_raw_id = None
if active_raw_id not in {str(getattr(entry, "id", "")) for entry, _ in fetched}:
active_raw_id = None
active_state = "known" if active_raw_id else "unknown"
if len(fetched) == 1 and active_raw_id is None:
active_raw_id = str(getattr(fetched[0][0], "id", ""))
active_state = "single_credential"
credentials: list[dict[str, Any]] = []
active_provider: dict[str, Any] | None = None
for index, (entry, snapshot) in enumerate(fetched):
raw_id = str(getattr(entry, "id", ""))
public_id = _public_credential_id(raw_id)
serialized = serialize_account_snapshot(
snapshot,
provider_id="openai-codex",
display_name="Codex",
)
status = _effective_credential_status(entry, snapshot)
credential = {
"id": public_id,
"label": _bounded_text(getattr(entry, "label", None), 80) or f"Credential {index + 1}",
"active": raw_id == active_raw_id,
"status": status,
"pool_status": _bounded_text(getattr(entry, "last_status", None), 40),
"last_status_at": _iso_epoch(getattr(entry, "last_status_at", None)),
"reset_at": _iso_epoch(getattr(entry, "last_error_reset_at", None)),
"plan": serialized["plan"],
"windows": serialized["windows"],
"details": serialized["details"],
"message": serialized["message"],
}
credentials.append(credential)
if credential["active"]:
active_provider = serialized
available_count = sum(row["status"] == "available" for row in credentials)
limited_count = sum(row["status"] == "at_limit" for row in credentials)
summary = active_provider or {
"source": "credential_pool",
"fetched_at": _now_iso(),
"plan": None,
"windows": [],
"details": [],
}
return {
"id": "openai-codex",
"display_name": "Codex",
"status": "available",
"source": summary.get("source") or "credential_pool",
"fetched_at": summary.get("fetched_at") or _now_iso(),
"plan": summary.get("plan"),
"windows": summary.get("windows", []),
"details": [
f"{available_count} available · {limited_count} at limit · {len(credentials)} total"
],
"credentials": credentials,
"active_credential_id": (
_public_credential_id(active_raw_id) if active_raw_id else None
),
"active_credential_state": active_state,
"active_observed_at": (
_iso(datetime.fromtimestamp(active_mapping["observed_at"], timezone.utc))
if active_mapping and active_state == "known"
else None
),
"message": None,
}
async def fetch_nous_usage(
profile_home: Path | None = None,
*,
account_fetcher: Callable[..., Any] | None = None,
) -> dict[str, Any]:
token = _set_home(profile_home)
try:
from agent.account_usage import build_nous_credits_snapshot
from hermes_cli.nous_account import get_nous_portal_account_info, nous_portal_topup_url
if account_fetcher is None:
account_fetcher = get_nous_portal_account_info
account = await asyncio.to_thread(account_fetcher, force_fresh=True)
snapshot = build_nous_credits_snapshot(account)
result = serialize_account_snapshot(
snapshot,
provider_id="nous",
display_name="Nous",
)
if not result["status"] == "available":
return result
def balance(balance_id: str, label: str, value: Any) -> dict[str, Any] | None:
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
amount = float(value)
if not math.isfinite(amount):
return None
return {"id": balance_id, "label": label, "amount": amount, "currency": "USD"}
access = getattr(account, "paid_service_access_info", None)
subscription = getattr(account, "subscription", None)
result["balances"] = [
item
for item in (
balance("total", "Total usable", getattr(access, "total_usable_credits", None)),
balance(
"subscription",
"Subscription",
getattr(access, "subscription_credits_remaining", None),
),
balance(
"top_up",
"Top-up",
getattr(access, "purchased_credits_remaining", None),
),
balance("rollover", "Rollover", getattr(subscription, "rollover_credits", None)),
)
if item is not None
]
result["renews_at"] = _iso(getattr(subscription, "current_period_end", None))
action_url = _bounded_text(nous_portal_topup_url(account), 500)
parsed_action = urlparse(action_url or "")
result["action_url"] = (
action_url if parsed_action.scheme in {"http", "https"} and parsed_action.netloc else None
)
# Structured fields own mobile presentation. Preserve only genuinely
# additional status lines; never render raw URLs or ISO timestamps.
result["details"] = [
detail for detail in result["details"] if detail.startswith("Status:")
]
return result
except Exception:
return unavailable_provider(
"nous",
"Nous",
status="unavailable",
message="Could not load Nous usage",
)
finally:
_reset_home(token)
async def fetch_opencode_go_usage(
*,
profile_home: Path | None = None,
session_factory: Callable[[], Any] = aiohttp.ClientSession,
credential_resolver: Callable[[str], dict[str, Any]] | None = None,
) -> dict[str, Any]:
token = _set_home(profile_home)
try:
if credential_resolver is None:
from hermes_cli.auth import resolve_api_key_provider_credentials
credential_resolver = resolve_api_key_provider_credentials
credentials = await asyncio.to_thread(
credential_resolver,
"opencode-go",
)
except Exception:
credentials = {}
finally:
_reset_home(token)
api_key = str(credentials.get("api_key") or "").strip()
if not api_key:
return unavailable_provider("opencode-go", "OpenCode Go")
base_url = str(credentials.get("base_url") or _OPENCODE_GO_DEFAULT_BASE_URL).rstrip("/")
try:
async with session_factory() as session:
async with session.get(
f"{base_url}/usage",
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": _OPENCODE_GO_USER_AGENT,
},
timeout=aiohttp.ClientTimeout(total=15),
) as response:
if response.status != 200:
return unavailable_provider(
"opencode-go",
"OpenCode Go",
status="unavailable",
message=f"Provider returned HTTP {response.status}",
)
payload = await response.json()
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, TypeError):
return unavailable_provider(
"opencode-go",
"OpenCode Go",
status="unavailable",
message="Could not load OpenCode Go usage",
)
usage = payload.get("usage") if isinstance(payload, dict) else None
if not isinstance(usage, dict):
return unavailable_provider(
"opencode-go",
"OpenCode Go",
status="unavailable",
message="Provider returned an unsupported usage payload",
)
windows: list[dict[str, Any]] = []
for key, label in (
("rolling", "Session · 5h"),
("weekly", "Weekly"),
("monthly", "Monthly"),
):
raw = usage.get(key)
if not isinstance(raw, dict):
continue
raw_percent = raw.get("percent")
if not isinstance(raw_percent, (int, float)) or isinstance(raw_percent, bool):
continue
percent = float(raw_percent)
if not math.isfinite(percent):
continue
windows.append(
{
"id": key,
"label": label,
"used_percent": max(0.0, min(100.0, percent)),
"reset_at": _iso(raw.get("resetsAt")),
"detail": None,
}
)
if not windows:
return unavailable_provider(
"opencode-go",
"OpenCode Go",
status="unavailable",
message="Provider returned no usage windows",
)
return {
"id": "opencode-go",
"display_name": "OpenCode Go",
"status": "available",
"source": "provider_api",
"fetched_at": _now_iso(),
"plan": None,
"windows": windows,
"details": [],
"message": None,
}
async def collect_provider_usage(
*,
profile_home: Path | None = None,
session_id: str | None = None,
active_credential_id: str | None = None,
codex_fetcher: Callable[..., Awaitable[dict[str, Any]]] = fetch_codex_usage,
nous_fetcher: Callable[[Path | None], Awaitable[dict[str, Any]]] = fetch_nous_usage,
opencode_fetcher: Callable[..., Awaitable[dict[str, Any]]] = fetch_opencode_go_usage,
) -> dict[str, Any]:
providers = await asyncio.gather(
codex_fetcher(
profile_home,
session_id=session_id,
active_credential_id=active_credential_id,
),
nous_fetcher(profile_home),
opencode_fetcher(profile_home=profile_home),
)
return {
"schema_version": SCHEMA_VERSION,
"fetched_at": _now_iso(),
"capabilities": list(RELAY_CAPABILITIES),
"providers": providers,
}
+23
View File
@@ -93,6 +93,7 @@ from .model_capabilities import (
ModelCapabilityResolver,
SCHEMA_VERSION as MODEL_CAPABILITIES_SCHEMA_VERSION,
)
from .provider_usage import collect_provider_usage, resolve_profile_home
from .session_store import read_phone_threads
from .voice import VoiceHandler
from .voice_output import VoiceOutputHandler
@@ -1172,6 +1173,27 @@ async def handle_sessions_extend(request: web.Request) -> web.Response:
)
async def handle_provider_usage(request: web.Request) -> web.Response:
"""Return provider-neutral account usage to an authenticated paired device."""
_require_bearer_session(request)
server: RelayServer = request.app["server"]
if not server.config.provider_usage_enabled:
raise web.HTTPNotFound(text="provider usage is not enabled on this host")
try:
profile_home = resolve_profile_home(
server.config.hermes_config_path,
request.query.get("profile"),
)
except ValueError as exc:
raise web.HTTPBadRequest(text=str(exc)) from exc
return web.json_response(
await collect_provider_usage(
profile_home=profile_home,
session_id=request.query.get("session_id"),
)
)
# ── Desktop tool dispatch (loopback HTTP shim for desktop_tool.py) ──────────
@@ -4682,6 +4704,7 @@ def create_app(config: RelayConfig) -> web.Application:
app.router.add_get("/sessions", handle_sessions_list)
app.router.add_delete("/sessions/{token_prefix}", handle_sessions_revoke)
app.router.add_patch("/sessions/{token_prefix}", handle_sessions_extend)
app.router.add_get("/usage/providers", handle_provider_usage)
app.router.add_get("/chat/image-activity", handle_image_activity)
# Desktop tool dispatch — HTTP shim called by `plugin/tools/desktop_tool.py`
# running inside hermes-gateway. Both endpoints loopback-only.
+63
View File
@@ -0,0 +1,63 @@
"""Tests for Relay's fail-open Gateway lifecycle hooks."""
from __future__ import annotations
import sys
import threading
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest import mock
from plugin.hooks import capture_active_credential, register_hooks
class RelayHookTests(unittest.TestCase):
def test_capture_records_only_stable_identity_for_exact_session(self) -> None:
agent = SimpleNamespace(
_credential_pool_entry_id="entry-2",
_credential_pool=SimpleNamespace(provider="openai-codex"),
)
gateway_server = SimpleNamespace(
_sessions={
"gateway-ui-2": {
"agent": agent,
"session_key": "session-2",
"profile_home": "/profiles/victor",
}
},
_sessions_lock=threading.Lock(),
)
tui_gateway = ModuleType("tui_gateway")
tui_gateway.server = gateway_server
with (
mock.patch.dict(sys.modules, {"tui_gateway": tui_gateway}),
mock.patch(
"plugin.relay.active_credentials.record_active_credential_aliases"
) as record,
):
capture_active_credential(session_id="session-2")
record.assert_called_once_with(
Path("/profiles/victor").resolve(),
session_ids={"session-2", "gateway-ui-2"},
provider_id="openai-codex",
credential_id="entry-2",
)
self.assertNotIn("api_key", record.call_args.kwargs)
def test_registration_keeps_older_hosts_working(self) -> None:
registered: list[str] = []
def register(name, _callback):
if name != "on_session_start":
raise ValueError("unknown hook")
registered.append(name)
register_hooks(SimpleNamespace(register_hook=register))
self.assertEqual(registered, ["on_session_start"])
if __name__ == "__main__":
unittest.main()
+290
View File
@@ -0,0 +1,290 @@
"""Tests for provider-neutral account usage and the paired-device endpoint."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
import tempfile
import unittest
from unittest import mock
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
from plugin.relay.config import RelayConfig
from plugin.relay.provider_usage import (
collect_provider_usage,
fetch_codex_usage,
fetch_nous_usage,
fetch_opencode_go_usage,
resolve_profile_home,
serialize_account_snapshot,
unavailable_provider,
)
from plugin.relay.active_credentials import record_active_credential
from plugin.relay.server import create_app
class _FakeResponse:
def __init__(self, status: int = 200, payload: dict | None = None):
self.status = status
self._payload = payload or {}
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def json(self):
return self._payload
class _FakeSession:
def __init__(self, response: _FakeResponse):
self.response = response
self.headers: dict | None = None
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
def get(self, _url, *, headers=None, timeout=None):
self.headers = headers
return self.response
class ProviderUsageModelTests(unittest.IsolatedAsyncioTestCase):
def test_profile_home_is_exact_and_rejects_traversal(self) -> None:
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
(root / "config.yaml").write_text("model: {}\n", encoding="utf-8")
victor = root / "profiles" / "victor"
victor.mkdir(parents=True)
(victor / "config.yaml").write_text("model: {}\n", encoding="utf-8")
self.assertEqual(resolve_profile_home(str(root / "config.yaml"), "Victor"), victor)
with self.assertRaises(ValueError):
resolve_profile_home(str(root / "config.yaml"), "../victor")
def test_serializes_upstream_snapshot_without_credentials(self) -> None:
snapshot = SimpleNamespace(
available=True,
source="usage_api",
fetched_at=datetime(2026, 8, 21, tzinfo=timezone.utc),
plan="Plus",
windows=(
SimpleNamespace(
label="Session",
used_percent=42.5,
reset_at=datetime(2026, 8, 22, tzinfo=timezone.utc),
detail=None,
),
),
details=("Credits balance: $4.20",),
)
result = serialize_account_snapshot(
snapshot,
provider_id="openai-codex",
display_name="Codex",
)
self.assertEqual(result["status"], "available")
self.assertEqual(result["windows"][0]["used_percent"], 42.5)
self.assertEqual(result["plan"], "Plus")
self.assertNotIn("token", result)
async def test_opencode_missing_key_is_not_configured(self) -> None:
result = await fetch_opencode_go_usage(credential_resolver=lambda _provider: {})
self.assertEqual(result["id"], "opencode-go")
self.assertEqual(result["status"], "not_configured")
async def test_nous_exposes_structured_balances_without_raw_mobile_details(self) -> None:
account = SimpleNamespace(
logged_in=True,
paid_service_access=True,
paid_service_access_info=SimpleNamespace(
subscription_credits_remaining=31.98,
purchased_credits_remaining=0.0,
total_usable_credits=31.98,
),
subscription=SimpleNamespace(
plan="Plus",
monthly_credits=None,
credits_remaining=31.98,
rollover_credits=10.0,
current_period_end="2026-09-18T00:11:42.000Z",
),
portal_base_url="https://portal.nousresearch.com",
org_slug="example",
)
result = await fetch_nous_usage(
account_fetcher=lambda **_kwargs: account,
)
self.assertEqual(result["status"], "available")
self.assertEqual(result["plan"], "Plus")
self.assertEqual(result["balances"][0], {
"id": "total",
"label": "Total usable",
"amount": 31.98,
"currency": "USD",
})
self.assertEqual(result["renews_at"], "2026-09-18T00:11:42.000Z")
self.assertTrue(result["action_url"].endswith("/orgs/example/billing?topup=open"))
self.assertEqual(result["details"], [])
async def test_opencode_normalizes_windows_without_inventing_dollars(self) -> None:
fake = _FakeSession(
_FakeResponse(
payload={
"usage": {
"rolling": {"percent": 42, "resetsAt": "2026-08-22T00:00:00Z"},
"weekly": {"percent": 18},
}
}
)
)
result = await fetch_opencode_go_usage(
session_factory=lambda: fake,
credential_resolver=lambda _provider: {
"api_key": "secret",
"base_url": "https://opencode.ai/zen/go/v1",
},
)
self.assertEqual(result["status"], "available")
self.assertEqual([row["id"] for row in result["windows"]], ["rolling", "weekly"])
self.assertNotIn("limits", result)
self.assertEqual(fake.headers["Authorization"], "Bearer secret")
async def test_collection_keeps_provider_order_and_schema(self) -> None:
async def codex(_home, **_kwargs):
return unavailable_provider("openai-codex", "Codex")
async def nous(_home):
return unavailable_provider("nous", "Nous")
async def opencode(*, profile_home=None):
return unavailable_provider("opencode-go", "OpenCode Go")
result = await collect_provider_usage(
codex_fetcher=codex,
nous_fetcher=nous,
opencode_fetcher=opencode,
)
self.assertEqual(result["schema_version"], 2)
self.assertEqual(
result["capabilities"],
["credential_pools", "structured_balances", "opencode_go"],
)
self.assertEqual(
[row["id"] for row in result["providers"]],
["openai-codex", "nous", "opencode-go"],
)
async def test_codex_pool_marks_exact_live_session_credential_active(self) -> None:
with tempfile.TemporaryDirectory() as raw:
home = Path(raw)
record_active_credential(
home,
session_id="session-2",
provider_id="openai-codex",
credential_id="entry-2",
)
entries = [
SimpleNamespace(
id=f"entry-{index}",
label=f"Account {index}",
last_status="ok",
last_status_at=None,
last_error_reset_at=None,
runtime_base_url="https://chatgpt.com/backend-api/codex",
runtime_api_key=f"secret-{index}",
)
for index in (1, 2)
]
snapshots = {
"secret-1": SimpleNamespace(
available=True,
source="usage_api",
fetched_at=datetime(2026, 8, 21, tzinfo=timezone.utc),
plan="Pro",
windows=(SimpleNamespace(label="Session", used_percent=100, reset_at=None, detail=None),),
details=(),
),
"secret-2": SimpleNamespace(
available=True,
source="usage_api",
fetched_at=datetime(2026, 8, 21, tzinfo=timezone.utc),
plan="Pro",
windows=(SimpleNamespace(label="Session", used_percent=24, reset_at=None, detail=None),),
details=(),
),
}
result = await fetch_codex_usage(
home,
session_id="session-2",
pool_loader=lambda _provider: SimpleNamespace(entries=lambda: entries),
snapshot_fetcher=lambda *, api_key, base_url: snapshots[api_key],
)
self.assertEqual(result["active_credential_state"], "known")
active = next(row for row in result["credentials"] if row["active"])
self.assertEqual(active["label"], "Account 2")
self.assertEqual(active["windows"][0]["used_percent"], 24.0)
limited = next(row for row in result["credentials"] if row["label"] == "Account 1")
self.assertEqual(limited["status"], "at_limit")
self.assertNotIn("secret-2", str(result))
class ProviderUsageEndpointTests(AioHTTPTestCase):
async def get_application(self) -> web.Application:
return create_app(RelayConfig(provider_usage_enabled=self.usage_enabled))
@property
def usage_enabled(self) -> bool:
return True
def _server(self):
return self.app["server"]
async def _mint(self) -> str:
return self._server().sessions.create_session("phone", "device").token
async def test_requires_bearer(self) -> None:
response = await self.client.get("/usage/providers")
self.assertEqual(response.status, 401)
@mock.patch(
"plugin.relay.server.collect_provider_usage",
new=mock.AsyncMock(return_value={"schema_version": 1, "providers": []}),
)
async def test_returns_normalized_payload(self) -> None:
token = await self._mint()
response = await self.client.get(
"/usage/providers",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())["schema_version"], 1)
class ProviderUsageDisabledEndpointTests(ProviderUsageEndpointTests):
@property
def usage_enabled(self) -> bool:
return False
async def test_returns_normalized_payload(self) -> None:
token = await self._mint()
response = await self.client.get(
"/usage/providers",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(response.status, 404)
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
"""Relay environment-resolution regression tests."""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from plugin.relay.config import RelayConfig
class RelayConfigEnvironmentTests(unittest.TestCase):
def test_hermes_home_supplies_default_config_and_session_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
with mock.patch.dict(os.environ, {"HERMES_HOME": str(home)}, clear=False):
os.environ.pop("RELAY_HERMES_CONFIG", None)
os.environ.pop("RELAY_SESSIONS_FILE", None)
config = RelayConfig.from_env()
self.assertEqual(str(home / "config.yaml"), config.hermes_config_path)
self.assertEqual(
str(home / "hermes-relay-sessions.json"),
config.session_persistence_path,
)
def test_explicit_relay_config_override_wins_over_hermes_home(self) -> None:
with tempfile.TemporaryDirectory() as home_tmp, tempfile.TemporaryDirectory() as override_tmp:
home = Path(home_tmp)
override = Path(override_tmp) / "relay-config.yaml"
override.write_text("{}\n", encoding="utf-8")
with mock.patch.dict(
os.environ,
{
"HERMES_HOME": str(home),
"RELAY_HERMES_CONFIG": str(override),
},
clear=False,
):
os.environ.pop("RELAY_SESSIONS_FILE", None)
config = RelayConfig.from_env()
self.assertEqual(str(override), config.hermes_config_path)
self.assertEqual(
str(override.parent / "hermes-relay-sessions.json"),
config.session_persistence_path,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useData, withBase } from 'vitepress'
type Mode = 'overview' | 'quick' | 'reference'
const props = defineProps<{ mode: Mode }>()
const { lang } = useData()
const copy = {
en: {
overview: {
kicker: 'Start here', title: 'Choose the path that matches your setup.',
cards: [
['Recommended', 'Android + Relay', 'Connect the app, then pair Relay for the complete experience.', 'Follow the Quick Start', 'quick'],
['Standard Hermes', 'Android only', 'Use Chat, sessions, Manage, sign-in, and standard voice without Relay.', 'Use Hermes without Relay', 'upstream'],
['Manual + advanced', 'Installation reference', 'Choose Play or Sideload, configure a host, or connect without QR.', 'Open Installation & Setup', 'reference'],
],
},
quick: {
kicker: 'Recommended path', title: 'One app. Two QR codes. Three milestones.',
cards: [
['01', 'Install Android', 'Use Google Play for the everyday app. Choose Sideload only for Device Control.', '', ''],
['02', 'Connect Hermes', 'Scan Connect mobile app. This adds the standard Dashboard/Gateway connection.', '', ''],
['03', 'Pair Relay', 'Scan Pair new device. This unlocks Relay capabilities with a separate grant.', '', ''],
],
note: 'The QR codes are intentionally separate: connecting Hermes does not silently grant Relay access.',
},
reference: {
kicker: 'Manual + advanced setup', title: 'Use this page when the Quick Start does not fit.',
cards: [
['Build choice', 'Play or Sideload', 'Compare automatic updates with optional Device Control.', '', ''],
['Host setup', 'Make Hermes reachable', 'Install Hermes, enable the Dashboard, or configure the optional API fallback.', '', ''],
['Fallbacks', 'Connect without QR', 'Use LAN discovery, addresses, pairing codes, remote access, and verification.', '', ''],
],
note: 'Already have a reachable Hermes Dashboard? Use the Quick Start instead.',
action: 'Go to the recommended Quick Start',
},
},
de: {
overview: { kicker: 'Hier starten', title: 'Wähle den Weg, der zu deinem Setup passt.', cards: [['Empfohlen', 'Android + Relay', 'Verbinde die App und kopple danach Relay für den vollständigen Funktionsumfang.', 'Zum Schnellstart', 'quick'], ['Standard-Hermes', 'Nur Android', 'Nutze Chat, Sitzungen, Manage, Anmeldung und Standard-Voice ohne Relay.', 'Hermes ohne Relay verwenden', 'upstream'], ['Manuell + erweitert', 'Installationsreferenz', 'Wähle Play oder Sideload, richte einen Host ein oder verbinde dich ohne QR.', 'Installation & Setup öffnen', 'reference']] },
quick: { kicker: 'Empfohlener Weg', title: 'Eine App. Zwei QR-Codes. Drei Etappen.', cards: [['01', 'Android installieren', 'Google Play für die normale App; Sideload nur für Device Control.', '', ''], ['02', 'Hermes verbinden', '„Connect mobile app“ scannen. Das fügt die Dashboard/Gateway-Verbindung hinzu.', '', ''], ['03', 'Relay koppeln', '„Pair new device“ scannen. Das erteilt die getrennte Relay-Freigabe.', '', '']], note: 'Die QR-Codes sind bewusst getrennt: Die Hermes-Verbindung erteilt nicht automatisch Relay-Zugriff.' },
reference: { kicker: 'Manuelle + erweiterte Einrichtung', title: 'Nutze diese Seite, wenn der Schnellstart nicht passt.', cards: [['Build-Auswahl', 'Play oder Sideload', 'Automatische Updates mit optionaler Gerätesteuerung vergleichen.', '', ''], ['Host-Einrichtung', 'Hermes erreichbar machen', 'Hermes installieren, Dashboard aktivieren oder den optionalen API-Fallback konfigurieren.', '', ''], ['Alternativen', 'Ohne QR verbinden', 'LAN-Suche, Adressen, Kopplungscodes, Fernzugriff und Prüfung.', '', '']], note: 'Ist dein Hermes Dashboard bereits erreichbar? Nutze stattdessen den Schnellstart.', action: 'Zum empfohlenen Schnellstart' },
},
es: {
overview: { kicker: 'Empieza aquí', title: 'Elige la ruta que coincide con tu configuración.', cards: [['Recomendada', 'Android + Relay', 'Conecta la app y luego empareja Relay para la experiencia completa.', 'Seguir el inicio rápido', 'quick'], ['Hermes estándar', 'Solo Android', 'Usa Chat, sesiones, Manage, inicio de sesión y voz estándar sin Relay.', 'Usar Hermes sin Relay', 'upstream'], ['Manual + avanzado', 'Referencia de instalación', 'Elige Play o Sideload, configura un host o conecta sin QR.', 'Abrir Instalación y configuración', 'reference']] },
quick: { kicker: 'Ruta recomendada', title: 'Una app. Dos códigos QR. Tres etapas.', cards: [['01', 'Instala Android', 'Usa Google Play normalmente; elige Sideload solo para Device Control.', '', ''], ['02', 'Conecta Hermes', 'Escanea Connect mobile app para añadir Dashboard/Gateway.', '', ''], ['03', 'Empareja Relay', 'Escanea Pair new device para conceder Relay por separado.', '', '']], note: 'Los QR están separados a propósito: conectar Hermes no concede acceso a Relay.' },
reference: { kicker: 'Configuración manual + avanzada', title: 'Usa esta página cuando el inicio rápido no encaje.', cards: [['Versión', 'Play o Sideload', 'Compara actualizaciones automáticas con Device Control opcional.', '', ''], ['Host', 'Haz accesible Hermes', 'Instala Hermes, activa Dashboard o configura el API opcional.', '', ''], ['Alternativas', 'Conecta sin QR', 'Usa LAN, direcciones, códigos, acceso remoto y verificación.', '', '']], note: '¿Ya puedes abrir Hermes Dashboard? Usa el inicio rápido.', action: 'Ir al inicio rápido recomendado' },
},
ja: {
overview: { kicker: 'ここから開始', title: '環境に合うセットアップ方法を選びます。', cards: [['推奨', 'Android + Relay', 'アプリを接続し、Relay をペアリングして全機能を利用します。', 'クイックスタートへ', 'quick'], ['標準 Hermes', 'Android のみ', 'Relay なしで Chat、セッション、Manage、ログイン、標準 Voice を使います。', 'Relay なしで使う', 'upstream'], ['手動 + 詳細', 'インストール資料', 'Play / Sideload、ホスト設定、QR なしの接続を確認します。', 'インストールと設定', 'reference']] },
quick: { kicker: '推奨ルート', title: '1つのアプリ。2つのQR。3つの段階。', cards: [['01', 'Android をインストール', '通常は Google Play。Device Control が必要な場合のみ Sideload。', '', ''], ['02', 'Hermes を接続', 'Connect mobile app をスキャンして Dashboard/Gateway を追加します。', '', ''], ['03', 'Relay をペアリング', 'Pair new device をスキャンして Relay を別途許可します。', '', '']], note: 'QR は意図的に分かれています。Hermes への接続だけで Relay アクセスは許可されません。' },
reference: { kicker: '手動 + 詳細設定', title: 'クイックスタートが合わない場合に使うページです。', cards: [['ビルド選択', 'Play または Sideload', '自動更新とオプションの Device Control を比較します。', '', ''], ['ホスト設定', 'Hermes を到達可能にする', 'Hermes、Dashboard、任意の API フォールバックを設定します。', '', ''], ['代替手段', 'QR なしで接続', 'LAN、アドレス、コード、リモートアクセス、検証を使います。', '', '']], note: 'Hermes Dashboard に到達できる場合は、クイックスタートを使ってください。', action: '推奨クイックスタートへ' },
},
'pt-BR': {
overview: { kicker: 'Comece aqui', title: 'Escolha o caminho que combina com sua configuração.', cards: [['Recomendado', 'Android + Relay', 'Conecte o app e depois pareie o Relay para a experiência completa.', 'Seguir o Início rápido', 'quick'], ['Hermes padrão', 'Somente Android', 'Use Chat, sessões, Manage, login e voz padrão sem Relay.', 'Usar Hermes sem Relay', 'upstream'], ['Manual + avançado', 'Referência de instalação', 'Escolha Play ou Sideload, configure um host ou conecte sem QR.', 'Abrir Instalação e configuração', 'reference']] },
quick: { kicker: 'Caminho recomendado', title: 'Um app. Dois códigos QR. Três etapas.', cards: [['01', 'Instale o Android', 'Use o Google Play normalmente; escolha Sideload só para Device Control.', '', ''], ['02', 'Conecte o Hermes', 'Escaneie Connect mobile app para adicionar Dashboard/Gateway.', '', ''], ['03', 'Pareie o Relay', 'Escaneie Pair new device para conceder acesso ao Relay separadamente.', '', '']], note: 'Os QR são separados de propósito: conectar o Hermes não concede acesso ao Relay.' },
reference: { kicker: 'Configuração manual + avançada', title: 'Use esta página quando o Início rápido não servir.', cards: [['Versão', 'Play ou Sideload', 'Compare atualizações automáticas com Device Control opcional.', '', ''], ['Host', 'Torne o Hermes acessível', 'Instale o Hermes, ative o Dashboard ou configure a API opcional.', '', ''], ['Alternativas', 'Conecte sem QR', 'Use LAN, endereços, códigos, acesso remoto e verificação.', '', '']], note: 'O Hermes Dashboard já está acessível? Use o Início rápido.', action: 'Ir para o Início rápido recomendado' },
},
'zh-CN': {
overview: { kicker: '从这里开始', title: '选择适合当前环境的设置路径。', cards: [['推荐', 'Android + Relay', '先连接应用,再配对 Relay,获得完整体验。', '按照快速开始操作', 'quick'], ['标准 Hermes', '仅 Android', '无需 Relay 即可使用 Chat、会话、Manage、登录和标准 Voice。', '不使用 Relay', 'upstream'], ['手动 + 高级', '安装参考', '选择 Play 或 Sideload、配置主机,或在没有 QR 时连接。', '打开安装与设置', 'reference']] },
quick: { kicker: '推荐路径', title: '一个应用,两个 QR,三个阶段。', cards: [['01', '安装 Android', '日常使用请选择 Google Play;仅在需要 Device Control 时选择 Sideload。', '', ''], ['02', '连接 Hermes', '扫描 Connect mobile app,添加 Dashboard/Gateway。', '', ''], ['03', '配对 Relay', '扫描 Pair new device,单独授予 Relay 访问权。', '', '']], note: '两个 QR 有意分开:连接 Hermes 不会自动授予 Relay 访问权。' },
reference: { kicker: '手动 + 高级设置', title: '快速开始不适用时,请使用此页面。', cards: [['版本选择', 'Play 或 Sideload', '比较自动更新与可选的 Device Control。', '', ''], ['主机设置', '让 Hermes 可访问', '安装 Hermes、启用 Dashboard,或配置可选 API。', '', ''], ['备用方式', '无 QR 连接', '使用 LAN、地址、配对码、远程访问和验证。', '', '']], note: 'Hermes Dashboard 已可访问?请使用快速开始。', action: '前往推荐的快速开始' },
},
} as const
const current = computed(() => (copy[lang.value as keyof typeof copy] ?? copy.en)[props.mode])
const localePrefix = computed(() => lang.value === 'en' ? '' : `/${lang.value}`)
const quickUrl = computed(() => withBase(`${localePrefix.value}/guide/quick-start.html`))
const installUrl = computed(() => withBase(`${localePrefix.value}/guide/getting-started.html`))
function target(kind: string) {
if (kind === 'quick') return quickUrl.value
if (kind === 'upstream') return `${quickUrl.value}#other-supported-paths`
return installUrl.value
}
</script>
<template>
<section class="android-setup-path" :class="`android-setup-path--${mode}`" :aria-label="current.title">
<header>
<span>{{ current.kicker }}</span>
<h2>{{ current.title }}</h2>
</header>
<div class="android-setup-path__grid">
<article v-for="card in current.cards" :key="card[1]">
<small>{{ card[0] }}</small>
<h3>{{ card[1] }}</h3>
<p>{{ card[2] }}</p>
<a v-if="card[3]" :href="target(card[4])">{{ card[3] }} <span aria-hidden="true">→</span></a>
</article>
</div>
<footer v-if="'note' in current">
<span>{{ current.note }}</span>
<a v-if="'action' in current && current.action" :href="quickUrl">{{ current.action }} <span aria-hidden="true">→</span></a>
</footer>
</section>
</template>
<style scoped>
.android-setup-path { margin: 1.75rem 0 2.25rem; border: 1px solid var(--hr-line-strong); border-radius: 10px; overflow: hidden; background: var(--vp-c-bg-alt); }
.android-setup-path > header { padding: 20px 22px; border-bottom: 1px solid var(--hr-line); background: color-mix(in srgb, var(--vp-c-brand-soft) 34%, var(--vp-c-bg-alt)); }
.android-setup-path > header span, article small { font-family: var(--vp-font-family-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--vp-c-brand-1); }
.android-setup-path h2 { margin: 6px 0 0; border: 0; padding: 0; font-size: 1.35rem; line-height: 1.25; }
.android-setup-path__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.android-setup-path article { min-width: 0; padding: 20px 22px; border-right: 1px solid var(--hr-line); }
.android-setup-path article:last-child { border-right: 0; }
.android-setup-path article h3 { margin: 7px 0 8px; border: 0; padding: 0; font-size: 1rem; }
.android-setup-path article p { margin: 0; color: var(--vp-c-text-2); font-size: .9rem; line-height: 1.55; }
.android-setup-path article a { display: inline-block; margin-top: 14px; font-family: var(--vp-font-family-mono); font-size: .7rem; text-transform: uppercase; }
.android-setup-path footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 13px 22px; border-top: 1px solid var(--hr-line); color: var(--vp-c-text-2); font-size: .85rem; line-height: 1.5; }
.android-setup-path footer a { flex: none; font-family: var(--vp-font-family-mono); font-size: .7rem; text-transform: uppercase; }
@media (max-width: 720px) { .android-setup-path__grid { grid-template-columns: 1fr; } .android-setup-path article { border-right: 0; border-bottom: 1px solid var(--hr-line); } .android-setup-path article:last-child { border-bottom: 0; } .android-setup-path footer { align-items: flex-start; flex-direction: column; } }
</style>
+2
View File
@@ -13,6 +13,7 @@ import ExpandableImage from './components/ExpandableImage.vue';
import FirstRunPreview from './components/FirstRunPreview.vue';
import ReleaseTrackChooser from './components/ReleaseTrackChooser.vue';
import TroubleshootingNavigator from './components/TroubleshootingNavigator.vue';
import AndroidSetupPath from './components/AndroidSetupPath.vue';
export default {
extends: DefaultTheme,
@@ -29,6 +30,7 @@ export default {
app.component('FirstRunPreview', FirstRunPreview);
app.component('ReleaseTrackChooser', ReleaseTrackChooser);
app.component('TroubleshootingNavigator', TroubleshootingNavigator);
app.component('AndroidSetupPath', AndroidSetupPath);
},
Layout() {
return h(DefaultTheme.Layout, null, {
+6 -2
View File
@@ -2,13 +2,17 @@
<ExpandableImage
src="/architecture-homepage.svg"
alt="How Hermes-Relay connects: Vanilla Hermes runs chat, Manage and voice 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."
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."
caption="Select the diagram to inspect it at full size."
/>
## Connection Model
The app maintains independent connection paths — chat over the vanilla Hermes surfaces (preferring the dashboard gateway, falling back to API-server SSE), and persistent WSS for the optional relay channels.
The app maintains independent connection paths — chat over the upstream Hermes
surfaces (preferring the Dashboard Gateway, falling back to API-server SSE), and
persistent WSS for Relay extensions. Relay is optional for the upstream standard
path but encouraged for the full current feature set; compatible upstream
surfaces take precedence as they ship.
For a compact shareable reference covering connection paths, transport boundaries, pairing/session lifecycle, and operator controls, see the [Relay Architecture Spec](/architecture/relay-architecture-spec).
+14 -6
View File
@@ -5,8 +5,11 @@ canonical_source: /guide/getting-started
# Installation & Einrichtung
Drei Schritte: App installieren, mit Hermes verbinden, erste Nachricht senden.
Wenn Hermes bereits läuft, muss auf dem Server nichts zusätzlich installiert werden.
Diese Seite ist die ausführliche Referenz für Build-Auswahl, manuelle
Verbindung, Fernzugriff und Sicherheitsprüfungen. Wenn dein Hermes Dashboard
bereits erreichbar ist, nutze den [Schnellstart](./quick-start).
<AndroidSetupPath mode="reference" />
::: tip Übersetzungsstatus
Diese kompakte Übersetzung beschreibt den üblichen Einstieg. Erweiterte
@@ -64,13 +67,18 @@ veröffentlichte `.ts.net`-Adresse kann ohne API-Server oder API-Schlüssel als
Dieselbe Anmeldung schaltet Chat, Sitzungen, Manage und Voice frei. Ein
ungepaartes Relay und ein nicht verfügbarer API-Fallback sind normal.
## Optional: Relay-Werkzeuge hinzufügen
## Empfohlen: Mit Relay vervollständigen {#relay-server-optional}
Installiere das Plugin nur für Terminal, Device Control, Medien,
Benachrichtigungen oder erweiterte Remote-Werkzeuge. Die maßgeblichen Befehle
sind `hermes plugins install Codename-11/hermes-relay/plugin --enable`,
Der Upstream-Standardweg bleibt ohne Plugin funktionsfähig. Für Terminal/TUI,
Benachrichtigungen, Medien, Desktop-Werkzeuge, erweiterte Voice, Relay-Sitzungen
und optionales Device Control wird Relay empfohlen. Die maßgeblichen Befehle sind
`hermes plugins install Codename-11/hermes-relay/plugin --enable`,
`hermes relay doctor`, `hermes relay start --no-ssl` und `hermes pair`.
Bevorzugt im Web Dashboard unter **Relay** zuerst **Connect mobile app** und
danach **Pair new device** öffnen und beide QRs mit Android scannen. Ohne QR
bleiben die URL-/Code-Eingabe und `hermes pair --register-code` verfügbar.
Device Control benötigt **beides**: die Sideload-App und ein gepaartes Relay.
[App-Versionen vergleichen →](/de/guide/release-tracks) ·
+30 -10
View File
@@ -5,8 +5,10 @@ canonical_source: /guide/quick-start
# Schnellstart
Installieren → verbinden → chatten, in ungefähr zwei Minuten. Für diesen
Standardweg reicht ein normaler Hermes Agent; das Relay-Plugin ist nicht nötig.
Installieren → verbinden → chatten. Der Standardweg bleibt upstream-basiert;
für die vollständige Hermes-Relay-Erfahrung wird das Relay-Plugin empfohlen.
<AndroidSetupPath mode="quick" />
::: tip Übersetzungsstatus
Diese Seite wurde KI-gestützt übersetzt und technisch geprüft. Englisch bleibt
@@ -30,17 +32,34 @@ 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
[Installation & Einrichtung](/de/guide/getting-started).
## 3. Verbinden
Für den empfohlenen vollständigen Weg installiere zusätzlich:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
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. **Scan for Hermes on LAN** sucht den Server im lokalen Netz.
2. Trage die Dashboard/Gateway-Adresse wie `http://<host>:9119` ein.
3. Nutze **Scan setup QR**; ältere API-first-QRs bleiben für erweiterte Kompatibilität gültig.
4. Melde dich bei Aufforderung über den konfigurierten Dashboard-Anbieter an.
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.
Der API-Server ist ein optionaler automatischer Fallback oder eine erweiterte
headless Kompatibilitätsoption; Relay bleibt für Zusatzfunktionen optional.
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.
## 4. Status prüfen
@@ -48,7 +67,8 @@ headless Kompatibilitätsoption; Relay bleibt für Zusatzfunktionen optional.
- **Manage** kann noch eine Dashboard-Anmeldung verlangen.
- **Voice** wird mit derselben Dashboard-Anmeldung freigeschaltet.
- **API fallback** darf als nicht verfügbar angezeigt werden, ohne Chat zu blockieren.
- **Relay** darf ungepaart bleiben und blockiert den Standardweg nicht.
- **Relay · Paired** bestätigt die empfohlenen Zusatzfunktionen; ein Relay-Ausfall
darf den Upstream-Standardweg nicht blockieren.
## 5. Erste Nachricht senden
+3 -3
View File
@@ -8,7 +8,7 @@ titleTemplate: Hermes-Relay Dokumentation auf Deutsch
hero:
name: Dokumentation
text: Beginne mit dem Gerät, das du verbinden möchtest.
tagline: Verbinde die Android-App oder die CLI mit deinem bestehenden Hermes Agent. Der optionale Relay-Weg kommt erst hinzu, wenn du zusätzliche Werkzeuge brauchst.
tagline: Verbinde Android und CLI mit deinem Hermes Agent und ergänze die empfohlene Relay-Erweiterung für den vollständigen aktuellen Funktionsumfang.
actions:
- theme: brand
text: Android-Schnellstart
@@ -25,8 +25,8 @@ features:
details: Chat, Manage und Standard-Voice verbinden sich direkt mit einem unveränderten Hermes Agent.
- title: Eine klare App-Auswahl
details: Google Play ist der empfohlene Weg. Sideload ergänzt Device Control für Bildschirmlesen, Tippen und Navigation.
- title: Relay bleibt optional
details: Installiere das Relay-Plugin nur für Terminal, Gerätesteuerung, Medien, Benachrichtigungen oder erweiterte Remote-Werkzeuge.
- title: Relay wird für den vollen Umfang empfohlen
details: Upstream bleibt Standard für Chat, Manage und Voice; Relay ergänzt Terminal/TUI, Benachrichtigungen, Medien, Desktop-Werkzeuge und Device Control.
---
## Umfang dieser Übersetzung
+113 -23
View File
@@ -6,14 +6,102 @@
It also includes a terminal escape hatch for when *you* want to drive: bare `hermes-relay` attaches your server's own Hermes TUI over a PTY, tmux-backed so disconnects lose nothing.
## Install and pair
### 1. Install the client
::: code-group
```powershell [Windows · CLI + management UI]
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
```bash [macOS / Linux · CLI]
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
:::
The Windows installer includes the CLI and compact management UI. macOS and
Linux install the same remote-hands CLI without the Windows-only tray surface.
See [Installation](./installation.md) for checksums, pinned versions, updates,
platform support, and the CLI-only Windows option.
### 2. Copy a one-time pairing invite
Use whichever Hermes operator surface is already open:
- **Web Dashboard:** open **Relay → Pair new device → Copy invite**.
- **Official Hermes Desktop:** open the **Relay** pane, click **Pair new device**,
then **Copy**.
- **Hermes host terminal:** run `hermes pair` and copy the printed
`hermes-relay://pair?...` invite URL.
Then pair this computer with the complete invite:
```bash
hermes-relay pair --pair-qr "hermes-relay://pair?payload=…" --grant-tools
```
This is the recommended path. The invite is one-time and can contain ordered
LAN, Tailscale, public, and pinned secure candidates; the client probes them and
stores the first trusted reachable route. `--grant-tools` asks locally before
making desktop tools available to the daemon.
### 3. Use it
```bash
hermes-relay # open the paired Hermes TUI
hermes-relay status # confirm host and route
hermes-relay daemon start # keep approved desktop tools available
hermes-relay ui # Windows: open the management UI
```
::: details Manual URL + code fallback
If you cannot copy the invite, mint one in the Dashboard, Hermes Desktop, or
with `hermes pair`, then enter its shown Relay URL and six-character code:
```bash
hermes-relay pair ABC123 --remote wss://relay.example.com --grant-tools
```
Manual URL, code, and route overrides are fallbacks. Prefer the full invite so
certificate pins and multi-endpoint candidates survive the trust ceremony.
:::
[Pairing details and recovery →](./pairing.md)
<div class="desktop-ui-doc-gallery">
<figure>
<img src="/product/desktop-ui/overview.png" alt="Hermes-Relay CLI UI overview with connected host, access preset, and recent activity" />
<figcaption>Connection, trust, capabilities, and activity at a glance.</figcaption>
</figure>
<figure>
<img src="/product/desktop-ui/settings.png" alt="Hermes-Relay CLI UI settings with daemon, CUA Driver, diagnostics, and update controls" />
<figcaption>Daemon, computer control, diagnostics, and bundle updates.</figcaption>
</figure>
</div>
::: warning Experimental phase
Prebuilt CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64. The optional compact management UI is Windows-only. Assets are unsigned, so SmartScreen or Gatekeeper warnings are expected. Wire protocol details may shift between alphas, and multi-client routing remains a single-client MVP. [File an issue](https://github.com/Codename-11/hermes-relay/issues) when something does not behave as documented.
Prebuilt CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64. The optional compact management UI is Windows-only. Assets are unsigned, so SmartScreen or Gatekeeper warnings are expected. Wire protocol details may shift between prereleases, and multi-client routing remains a single-client MVP. [File an issue](https://github.com/Codename-11/hermes-relay/issues) when something does not behave as documented.
:::
::: info Where this track is headed
This surface is focusing into a **remote-hands connector** — remote control, filesystem, and terminal access for the agent on machines you install it to. Desktop chat and management UX belong to [hermes-desktop](https://github.com/NousResearch/hermes-agent); this CLI's `chat` mode keeps working for scripting but isn't where new features land. "Desktop" is shorthand, not a constraint — the same binary runs on laptops and headless servers (`daemon` mode needs no display at all). New release tags use the `desktop-v*` track; historical releases used `cli-v*`.
:::
## Which surface adds what?
| Surface | What it adds | What it needs |
|---------|--------------|---------------|
| **Android from Google Play** | Chat, standard voice, Manage, profiles, sessions | Unmodified upstream Hermes Dashboard/Gateway |
| **CLI + Windows UI** | Remote TUI, files, commands, jobs, clipboard, screenshots, audit, optional CUA computer control | Relay plugin, one paired host; Windows only for the UI/CUA layer |
| **Android Sideload** | Device Control: inspect, tap, type, scroll, and capture the phone | Sideload build, Relay plugin, and explicit local capability grants |
| **Away from home** | Reaches those same surfaces without exposing Relay publicly | Tailscale recommended, or pinned Hermes Secure Link |
The Hermes host remains the brain: models, secrets, sessions, memory, and agent
state stay there. Paired devices lend it narrowly controlled hands.
## The point — the agent works on *your* machine
Ask your agent — from your phone, from the attached TUI, from anywhere — to "check whether that build passes on my desktop" or "grab the error from my clipboard," and it reaches through the relay to do it: read your notes, grep your codebase, run a build, patch a file, capture a screenshot — while the brain and conversation state stay on the host. [Read how →](./tools.md)
@@ -93,28 +181,6 @@ Both are valid. Pick based on where the agent's compute, models, and state shoul
Hermes-Relay is for the first case. If you're in the second case, you don't need this CLI at all — just install hermes-agent and use `hermes` directly. The two paths are complements, not alternatives.
## Quick start
::: code-group
```powershell [Windows]
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
hermes-relay pair --remote ws://<host>:8767
hermes-relay
```
```bash [macOS / Linux]
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
hermes-relay pair --remote ws://<host>:8767
hermes-relay
```
:::
The third command (`hermes-relay` with no args) drops you into `shell` mode — the full Hermes TUI verbatim, running in tmux on the server. Try `Ctrl+A v` after a `Win+Shift+S` and you'll see the [native-paste demo](#demo-native-paste-into-the-attached-tui) in action.
See **[Installation](./installation.md)** for the full walkthrough (Bun-compiled binaries, version-aware install, `hermes` alias, self-update flow) and **[Pairing](./pairing.md)** for minting a 6-char code on the server.
## Windows management UI
The optional **Hermes-Relay CLI UI** is a compact popup anchored above its notification-area icon. Click the icon to open or hide it. It manages paired Hermes hosts, connection and daemon state, per-host access, local approvals, activity, updates, and settings. Pairing can be completed directly from **Hosts → Pair host** with the relay URL and six-character code. Prefer a `wss://` URL: the UI labels `ws://` connections as unencrypted instead of implying that connectivity provides transport security. It deliberately does not embed chat, the Hermes TUI, a terminal emulator, plugins, voice, or agent sessions.
@@ -156,3 +222,27 @@ Use `shell` when you want to drive interactively; use `chat --json` from scripts
- [Herm](https://github.com/liftaris/herm) — optional terminal dashboard plugin installable from the CLI.
- [CLI GitHub source](https://github.com/Codename-11/hermes-relay/tree/main/desktop) — `@hermes-relay/cli` package.
- [Release notes](https://github.com/Codename-11/hermes-relay/releases?q=desktop) — tagged `desktop-v*` (separate track from Android); historical releases are under `cli-v*`.
<style>
.desktop-ui-doc-gallery {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin: 1.5rem 0 2rem;
}
.desktop-ui-doc-gallery figure { margin: 0; }
.desktop-ui-doc-gallery img {
display: block;
width: 100%;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
}
.desktop-ui-doc-gallery figcaption {
margin-top: .55rem;
color: var(--vp-c-text-2);
font-size: .8rem;
}
@media (max-width: 640px) {
.desktop-ui-doc-gallery { grid-template-columns: 1fr; }
}
</style>
+24 -4
View File
@@ -1,6 +1,6 @@
# Installing the CLI <ExperimentalBadge />
Prebuilt, self-contained CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64 — **no Node or Python required**. Windows also has an optional compact management UI.
Prebuilt, self-contained CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64 — **no Node or Python required**. Windows also has an optional compact management UI.
## Prerequisites
@@ -15,6 +15,20 @@ If you'd rather install from source, see the [source install](#install-from-sour
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
::: warning Review the bootstrap you trust
The convenience command executes the current installer bootstrap from the
repository's `main` branch. That bootstrap verifies the downloaded release
asset against its published SHA-256, but the bootstrap itself is mutable and
the preview installer is not yet code-signed. For an inspect-first flow:
```powershell
$script = Join-Path $env:TEMP 'hermes-relay-install.ps1'
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 -OutFile $script
Get-Content $script
Get-Content $script -Raw | Invoke-Expression
```
:::
By default the script installs the Windows CLI **and** management UI through the checksum-verified NSIS package. It:
1. Detects architecture (x64; ARM64 lands once Bun's cross-compile target stabilizes).
@@ -112,8 +126,11 @@ Code signing (EV cert) is a v1.0 milestone — the experimental phase doesn't ju
### Pin a specific version
Replace `desktop-vMAJOR.MINOR.PATCH` with an exact tag from the
[Desktop releases](https://github.com/Codename-11/hermes-relay/releases?q=desktop) page.
```powershell
$env:HERMES_RELAY_VERSION = 'desktop-v0.4.0-alpha.2'
$env:HERMES_RELAY_VERSION = 'desktop-vMAJOR.MINOR.PATCH'
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
@@ -131,7 +148,7 @@ curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/deskt
The script:
1. Detects OS/arch (published assets: `linux-x64`, `darwin-x64`, and `darwin-arm64`).
1. Detects OS/arch (published assets: `linux-x64`, `linux-arm64`, `darwin-x64`, and `darwin-arm64`).
2. Resolves the latest `desktop-v*` release via the Releases API + `sort -V`, with a migration fallback to historical `cli-v*` releases (prerelease-aware, no shell deps beyond `curl` / `sort`).
3. Downloads the matching binary + `SHA256SUMS.txt` and verifies SHA256 (`sha256sum` on Linux, `shasum -a 256` on macOS).
4. Reads the existing binary's `--version` if present and prints `upgrading X → Y` / `reinstalling X` / `installing fresh`.
@@ -166,8 +183,11 @@ Apple Developer ID signing + notarization is a v1.0 milestone.
### Pin a specific version
Replace `desktop-vMAJOR.MINOR.PATCH` with an exact tag from the
[Desktop releases](https://github.com/Codename-11/hermes-relay/releases?q=desktop) page.
```bash
HERMES_RELAY_VERSION=desktop-v0.4.0-alpha.2 \
HERMES_RELAY_VERSION=desktop-vMAJOR.MINOR.PATCH \
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
+72 -2
View File
@@ -2,7 +2,51 @@
Pairing exchanges a one-time 6-character code for a long-lived session token, stored at `~/.hermes/remote-sessions.json` (mode 0600). This is the same file the [Android client](../guide/getting-started.md) uses — **pair once from either, both work**. The token survives reboots, picks up automatic reconnects with TOFU cert pinning on `wss://`, and is revocable from any other paired client (see [`hermes-relay devices`](./subcommands.md#hermes-relay-devices)).
## Step 1 — mint a code on the server
::: warning The Hermes host needs the Relay plugin
The desktop CLI runs on Windows, macOS, and Linux, but the Hermes machine you
pair with must have the Relay plugin installed, enabled, and running. On the
Hermes host, complete this once before minting a pairing code:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
hermes relay start --no-ssl
```
`--no-ssl` is for a trusted LAN or VPN path. For access outside that network,
use the [recommended Tailscale or TLS setup](../guide/remote-access.md).
:::
## Recommended — paste the complete invite
Create the invite from whichever Hermes surface is already open:
1. **Web Dashboard:** open **Relay → Pair new device → Copy invite**.
2. **Official Hermes Desktop:** open the **Relay** pane, click **Pair new
device**, then **Copy**.
3. **Host terminal:** run `hermes pair` and copy the printed
`hermes-relay://pair?...` invite URL.
On the computer you are pairing:
```bash
hermes-relay pair --pair-qr "hermes-relay://pair?payload=…" --grant-tools
```
Despite the flag name, `--pair-qr` accepts the pasted invite URL or the raw QR
payload; the desktop client does not need a camera. The full invite is preferred
because it carries the operator-reviewed certificate pin and ordered endpoint
candidates. The client probes secure routes first, stores the selected route,
and retains the remaining candidates for reconnect.
The invite and six-character code are single-use. Generate a fresh invite when
one expires or has already been consumed.
## Manual fallback — Relay URL + code
Use this only when the full invite cannot be copied.
### 1. Mint a code on the server
SSH into your Hermes host (or use any terminal already on it):
@@ -21,7 +65,7 @@ The code is valid for **10 minutes** (the default) and **single-use**. After fir
If you don't have shell access to the host, run this from a Hermes chat session (any client, including Android): `/hermes-relay-pair`.
## Step 2 — pair on the client
### 2. Pair on the client
On your laptop/workstation:
@@ -62,6 +106,32 @@ Subsequent `hermes-relay` commands reuse the stored token. When that token nears
A bare `ws://<host>` with no port defaults to `:8767` (the relay's default), and the CLI tells you it did. A `wss://<host>` is left untouched — it's usually a reverse-proxy / Tailscale Serve front on `:443` — so include the port explicitly if your secure relay listens elsewhere.
:::
## First use
Pairing is complete when the CLI reports that its token was stored. Pick the
first result you want:
```bash
# Open the paired Hermes TUI now
hermes-relay
# Confirm the selected host and connection state
hermes-relay status
# Windows: open the management UI from the same installation
hermes-relay ui
```
To keep approved desktop tools available in the background, include
`--grant-tools` when pairing and then start the daemon:
```bash
hermes-relay daemon start
```
The grant is host-scoped and remains subject to the access policy you select
locally. Start with the TUI if you only want to confirm that pairing works.
## Paste safety — what if the code comes out garbled?
Some terminals (Windows Terminal, WezTerm, older iTerm2) wrap pasted content in **bracketed paste** escape markers (`\x1b[200~...\x1b[201~`). The CLI disables bracketed paste before the prompt and defensively strips ANSI + control chars, but a few terminals ignore the disable flag. The `→ using code: F3W7EY` confirmation line is your sanity check — if the echoed code doesn't match what you pasted, type it manually instead.
+13 -5
View File
@@ -5,8 +5,11 @@ canonical_source: /guide/getting-started
# Instalación y configuración
Tres pasos: instala la aplicación, conéctala a Hermes y envía el primer mensaje.
Si Hermes ya está funcionando, no necesitas instalar nada adicional en el servidor.
Esta es la referencia detallada para elegir versión, conectar manualmente,
configurar acceso remoto y revisar la seguridad. Si Hermes Dashboard ya es
accesible, usa el [Inicio rápido](./quick-start).
<AndroidSetupPath mode="reference" />
::: tip Estado de la traducción
Esta guía resumida cubre la ruta habitual. Las opciones avanzadas de servidor,
@@ -63,13 +66,18 @@ Puedes añadir y probar una dirección Dashboard de Tailscale como
La misma sesión habilita Chat, sesiones, Manage y Voice. Es normal que Relay
esté sin emparejar y que API fallback no esté disponible.
## Opcional: añade las herramientas de Relay
## Recomendado: completa la configuración con Relay {#relay-server-optional}
Instala el complemento solo para terminal, Device Control, multimedia,
notificaciones o herramientas remotas avanzadas. Los comandos canónicos son
El recorrido upstream sigue funcionando sin plugin. Relay se recomienda para
Terminal/TUI, notificaciones, multimedia, herramientas de escritorio, voz
mejorada, sesiones Relay y Device Control opcional. Los comandos canónicos son
`hermes plugins install Codename-11/hermes-relay/plugin --enable`,
`hermes relay doctor`, `hermes relay start --no-ssl` y `hermes pair`.
En el Web Dashboard, abre **Relay**, usa primero **Connect mobile app** y luego
**Pair new device**, y escanea ambos QR con Android. Si no puedes usar QR,
siguen disponibles la entrada de URL/código y `hermes pair --register-code`.
Device Control necesita **las dos cosas**: la aplicación Sideload y un Relay emparejado.
[Comparar versiones →](/es/guide/release-tracks) ·
+30 -10
View File
@@ -5,8 +5,10 @@ canonical_source: /guide/quick-start
# Inicio rápido
Instala → conecta → conversa, en unos dos minutos. Este recorrido funciona con
un Hermes Agent normal; no requiere el complemento Relay.
Instala → conecta → conversa. El recorrido estándar sigue siendo upstream; el
plugin Relay se recomienda para la experiencia completa de Hermes-Relay.
<AndroidSetupPath mode="quick" />
::: tip Estado de la traducción
Esta página se tradujo con asistencia de IA y pasó las comprobaciones técnicas.
@@ -30,17 +32,34 @@ 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.
## 3. Conecta
Para el recorrido completo recomendado, instala además:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
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. Usar **Scan for Hermes on LAN** para buscar el servidor en tu red local.
2. Introducir la dirección del Dashboard/Gateway, como `http://<host>:9119`.
3. Usar **Scan setup QR**; los QR API-first antiguos siguen admitidos para compatibilidad avanzada.
4. Iniciar sesión con el proveedor del dashboard cuando se solicite.
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.
El servidor de API es un fallback automático opcional o una opción avanzada
para compatibilidad headless; Relay sigue siendo opcional para extensiones.
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.
## 4. Comprueba el estado
@@ -48,7 +67,8 @@ para compatibilidad headless; Relay sigue siendo opcional para extensiones.
- **Manage** puede pedir que inicies sesión en el dashboard.
- **Voice** se habilita con esa misma sesión del dashboard.
- **API fallback** puede no estar disponible sin bloquear Chat.
- **Relay** puede seguir sin emparejar y no bloquea el funcionamiento estándar.
- **Relay · Paired** confirma las extensiones recomendadas; un fallo de Relay no
debe bloquear el recorrido upstream estándar.
## 5. Envía el primer mensaje
+3 -3
View File
@@ -8,7 +8,7 @@ titleTemplate: Documentación de Hermes-Relay en español
hero:
name: Documentación
text: Empieza por el dispositivo que quieres conectar.
tagline: Conecta la aplicación Android o la CLI con tu Hermes Agent actual. Añade Relay solo cuando necesites herramientas avanzadas.
tagline: Conecta Android y la CLI con tu Hermes Agent y añade la extensión Relay recomendada para el conjunto completo de funciones actuales.
actions:
- theme: brand
text: Inicio rápido de Android
@@ -25,8 +25,8 @@ features:
details: Chat, Manage y la voz estándar se conectan directamente a un Hermes Agent sin modificar.
- title: Dos versiones claras
details: Google Play es la opción recomendada. Sideload añade Device Control para leer la pantalla, tocar y navegar.
- title: Relay sigue siendo opcional
details: Instala el complemento Relay solo para terminal, control del teléfono, contenido multimedia, notificaciones o herramientas remotas.
- title: Relay se recomienda para la experiencia completa
details: Upstream sigue siendo estándar para Chat, Manage y voz; Relay añade Terminal/TUI, notificaciones, medios, herramientas de escritorio y Device Control.
---
## Alcance de esta traducción
+82 -16
View File
@@ -6,16 +6,26 @@ profile-scoped backend; neither creates a second Relay service or state store.
## What It Is
If your Hermes server runs the Dashboard Plugin System (upstream `axiom` branch), Hermes-Relay ships a plugin that auto-registers through the same `~/.hermes/plugins/hermes-relay` symlink created by `install.sh`. Restart the gateway and a new **Relay** tab appears alongside Chat, Skills, Memory, and the other dashboard tabs. Inside that tab you get four sub-tabs grouping the four things only the relay knows about.
If your Hermes server runs the Dashboard Plugin System, the unified
Hermes-Relay plugin contributes a **Relay** page alongside Chat, Skills, Memory,
and the other Dashboard pages. The same package also contributes the official
Hermes Desktop pane and the `hermes relay` / `hermes pair` CLI commands.
The plugin is a thin observer — it never modifies state, never writes to your config, and can't do anything a phone-side operator can't already do. Its job is to save you from SSHing into the server to check "is my phone still paired?" or "what did the agent just do?".
The plugin is the browser operator surface for Relay. It reads health, sessions,
activity, media, and remote-access state, and performs explicit scoped actions:
minting invites, revoking sessions, changing Relay-owned settings, and managing
remote-access helpers. It never turns a viewed card into an implicit mutation;
pairing, revocation, and configuration remain labeled user actions.
## Requirements
**On your server:**
- hermes-agent with the Dashboard Plugin System. `hermes dashboard start` must already work for you; the Relay tab uses the dashboard plugin mount and does not depend on the legacy session API branch.
- The canonical Hermes-Relay install — if you ran the one-liner on the [Quick Start](/guide/getting-started), you're done. The installer symlinks `~/.hermes/plugins/hermes-relay` → the plugin subtree and the dashboard scanner picks up `plugin/dashboard/manifest.json` automatically.
- The canonical plugin install:
`hermes plugins install Codename-11/hermes-relay/plugin --enable`. The
Dashboard scanner discovers `dashboard/manifest.json` from that unified
package.
- A gateway restart after install: `systemctl --user restart hermes-gateway`.
**On your phone:**
@@ -77,6 +87,46 @@ via the dashboard); memory file editing remains in the paired profile inspector.
Server-side dashboard auth is owned by upstream Hermes. For current provider registration, Nous OAuth, username/password, and remote dashboard guidance, use the Hermes [Web Dashboard docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/web-dashboard).
## Connect and pair clients
The Relay page exposes two different setup actions. They intentionally do not
share credentials:
### Connect mobile app — standard upstream connection
Use this first for Android:
1. Click **Connect mobile app** in the Relay page header.
2. In Android **Connect**, choose **Scan Hermes setup QR**.
3. Scan the tokenless QR and sign in if prompted.
The QR contains only the canonical Dashboard address. Android verifies that
origin, then uses the upstream Dashboard/Gateway for Chat, sessions, Manage,
sign-in, and standard voice. It contains no token, cookie, API key, password, or
Relay pairing code.
### Pair new device — Relay session
Use this after the standard Android connection, or whenever pairing Android,
the Desktop CLI, or another Relay client:
1. Click **Pair new device** on the Management tab.
2. Keep **Auto** mode unless you specifically want LAN-only, Tailscale-only, or
a pinned public route.
3. Android scans the QR from **Settings → Connections → Pair Hermes Relay**.
4. Desktop CLI users click **Copy invite**, then run:
```bash
hermes-relay pair --pair-qr "hermes-relay://pair?payload=…" --grant-tools
```
Official Hermes Desktop exposes the same backend in its **Relay** pane. Its
**Pair new device** action shows the one-time code and copyable invite for a CLI
or UI client; it does not need to render a camera QR.
The invite is one-time and credential-bearing. Keep it private and mint a new
one when it expires or has already been consumed.
## The Four Tabs
### Relay Management
@@ -92,22 +142,31 @@ The landing tab. Shows:
#### Pairing a new device
The **Pair new device** button on the Relay Management tab is an alternative to `/hermes-relay-pair` and the `hermes pair` CLI — same underlying pairing flow, just driven from a browser on your laptop instead of a chat or shell. Useful when you're already in the dashboard reviewing session state and want to onboard a phone without bouncing out to a terminal.
The **Pair new device** button on Relay Management uses the same signed pairing
contract as `/hermes-relay-pair` and `hermes pair`, driven from the browser
instead of a chat or shell.
**Click the button to open a PairDialog with:**
- **The QR code** — freshly minted, signed, ready to scan from the Android app's onboarding or Settings → Connections → Add connection flow.
- **The 6-character pairing code** — shown plain-text above the QR. Type this into the app's manual-entry path if your phone can't scan, or read it aloud if someone else is holding the phone.
- **A 10-minute expiry countdown** — the code is one-shot and single-use. When it expires or after the phone claims it, close the dialog and click Pair new device again for a fresh code.
- **A "reveal/hide" toggle on the QR** — defaults to hidden so bystanders in a shared screen can't silently scan it behind your back. Reveal explicitly when you're ready to scan.
- **Mode** — defaults to **Auto**, which derives every configured reachable
candidate. LAN-only, Tailscale-only, and public-only modes remain available.
- **Prefer role** — optionally promotes LAN, Tailscale, or public without
removing fallback candidates.
- **A freshly minted QR** — scan it from Android **Settings → Connections →
Pair Hermes Relay**.
- **The six-character code and copyable invite** — use these for manual Android
entry or Desktop CLI `--pair-qr` pairing.
- **Endpoint receipt and expiry** — the invite is one-time and single-use. Mint
a fresh one after it expires or is consumed.
**The "Override host / port / TLS" section** is what most non-default deploys need. By default the relay fills the QR with its own LAN-visible address (`http://<LAN-IP>:8642` for the API server + `ws://<LAN-IP>:8767` for the relay), which is correct for a straight home-LAN install. You need to override when:
Leave **Auto** and natural ordering selected for the common case. Configure
Tailscale and a pinned public URL on the **Remote Access** tab; PairDialog folds
those server-owned values into the invite automatically.
- **Reverse proxy in front of the relay** — e.g. a Traefik-fronted `wss://relay.example.com:443`, where the dashboard itself sees `127.0.0.1:8767` but the phone needs to reach the public hostname. Set Host to `relay.example.com`, Port to `443`, TLS to `on`, and the minted QR carries those coordinates while the relay still registers the pairing code locally.
- **Tailscale / Wireguard VPN** — phone and server are both on the tailnet but the dashboard is rendering on a different network interface. Override Host to the tailnet IP / MagicDNS name so the phone connects over the tunnel.
- **Multi-homed server** — the relay auto-detection picks one IP but you want the phone on a different interface (e.g. a separate VLAN for IoT devices). Override to pin the address.
**Override persistence.** The dashboard stores the last-used host/port/TLS values in `localStorage` per-browser, so returning to the Pair dialog in the same browser session pre-fills your overrides. Different browsers (or cleared storage) start with the relay's auto-detected defaults. The overrides are never persisted server-side — they only shape the next QR's payload.
The advanced host, port, and TLS override is for a deliberately pinned API
fallback or unusual multi-homed/proxy topology. It is not the normal way to set
the Relay URL and should remain collapsed unless automatic server configuration
is wrong.
**What the minted QR contains.** The current Relay-pairing payload retains its
legacy top-level API fields for older phones and headless compatibility. New
@@ -116,7 +175,10 @@ endpoint nor its key is required for dashboard-primary chat. The nested `relay`
block carries the Relay WSS URL and pairing code. See `docs/spec.md` §3.3.1 for
the full backward-compatible wire format.
**If the minted QR "doesn't do anything" when scanned**, the most common cause is that the host-side API port in the override section points at the wrong service — e.g. you accidentally entered `8767` (the relay's port) in the API Host/Port fields, so the phone tries to reach the API at the relay's address. The relay validates that the URL parses but can't verify the port is actually an API gateway, so this mistake surfaces as a silent pair failure. Double-check that Host points at something serving `/v1/runs` / `/v1/chat/completions`, not your relay.
**If a manually overridden QR does not pair**, clear the advanced override and
mint again with **Auto**. Port `8642` is the optional API fallback; port `8767`
is Relay. Putting the Relay port in the API override sends the phone to the
wrong service.
<!-- TODO: replace with real screenshot — PairDialog with QR and override fields expanded -->
@@ -174,7 +236,11 @@ For the full wire-shape of each route (query params, response schemas, redaction
**"Relay unreachable at 127.0.0.1:8767" on every tab.** The gateway can't see your relay process. Check `systemctl --user status hermes-relay` on the server; if the unit is inactive, `systemctl --user restart hermes-relay`. If you run the relay manually, confirm it's bound to `127.0.0.1:8767` and hasn't moved to a different port (override via `HERMES_RELAY_PORT` — the plugin reads this at import time).
**No "Relay" tab appears after gateway restart.** Confirm the symlink resolves: `ls -lL ~/.hermes/plugins/hermes-relay/dashboard/manifest.json` should show the file. If it doesn't, the installer symlink was broken — re-run `hermes-relay-update` or the `install.sh` one-liner. Check the gateway log (`journalctl --user -u hermes-gateway -f`) for plugin-load errors during startup.
**No "Relay" tab appears after gateway restart.** Confirm the unified plugin is
enabled with `hermes plugins list`, then re-run
`hermes plugins install Codename-11/hermes-relay/plugin --enable` and refresh or
restart the Dashboard/Gateway plugin catalog. Check the gateway log for
plugin-load errors if the manifest is installed but the page is absent.
**The Relay tab appears but text, colors, or cards are hard to read.** Update the Hermes-Relay plugin and restart or rescan the dashboard plugin list. The plugin stylesheet is loaded by the upstream dashboard and follows its active theme tokens; stale `dist/style.css` files from older installs can render poorly after Hermes dashboard theme changes.
+1 -1
View File
@@ -9,7 +9,7 @@ Phone (WS) → Hermes dashboard (:9119) [preferred — gateway chat, li
Phone (HTTP/SSE) → Hermes API Server (:8642) [fallback — sessions / runs / completions]
```
Both paths are **vanilla upstream Hermes** surfaces. The dashboard gateway `/api/ws` is *not* the Hermes-Relay relay (`:8767`); it's a vanilla dashboard endpoint, reached with a short-lived ticket minted from your Manage dashboard session. The optional Relay plugin is never involved in chat — it only adds terminal, device control, media, and the like.
Both paths are **vanilla upstream Hermes** surfaces. The dashboard gateway `/api/ws` is *not* the Hermes-Relay relay (`:8767`); it's a vanilla dashboard endpoint, reached with a short-lived ticket minted from your Manage dashboard session. The Relay plugin is not the owner of standard chat. It is the encouraged extension for current upstream gaps such as Terminal/TUI, notifications, media handoff, desktop tools, Relay sessions, enhanced voice, and optional Device Control; compatible upstream surfaces take precedence as they become available.
## Share into a new chat
+62 -40
View File
@@ -1,14 +1,11 @@
# Installation & Setup
Three steps — install the app, point it at your Hermes, say hello. If your
Hermes agent is already running, this takes about two minutes and needs nothing
installed on the server.
This is the detailed reference for choosing a build, preparing a Hermes host,
connecting without QR, remote access, and security checks. If your Hermes
Dashboard already runs and your phone can reach it, the [Quick Start](./quick-start)
is the shorter recommended path.
<ol class="gs-steps" aria-label="Setup progress">
<li><strong>01</strong><span>Install the app</span></li>
<li><strong>02</strong><span>Point it at Hermes</span></li>
<li><strong>03</strong><span>Connect &amp; chat</span></li>
</ol>
<AndroidSetupPath mode="reference" />
## 1. Install the app
@@ -300,33 +297,76 @@ Dashboard/Gateway chat route is ready. If the dot is red:
More: [Troubleshooting](/guide/troubleshooting) · [Chat guide](/guide/chat) ·
[Connections](/features/connections).
## 4. Optional — add Relay power tools {#relay-server-optional}
## 4. Recommended — complete the setup with Relay {#relay-server-optional}
Skip this unless you want **Terminal**, **Bridge** device control, **Relay
sessions**, channel grants, or relay-backed device-control features. Chat, voice,
and Manage all work without it.
The unmodified Hermes Dashboard/Gateway remains authoritative for Chat,
sessions, Manage, sign-in, and standard voice. The Relay plugin is an encouraged
extension for capabilities upstream Hermes does not yet expose: Terminal/TUI,
notifications, media handoff, desktop tools, Relay sessions, enhanced voice,
and optional Device Control.
Hermes-Relay follows an upstream-first rule: when upstream Hermes ships a
compatible capability, the standard path should move there and Relay should
stop duplicating it. Relay can be unavailable without blocking the upstream
connection, but pairing it provides the intended full product experience today.
### Install the server plugin {#install-the-server-plugin}
::::details Install the Relay plugin + pair
On the Hermes host:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
hermes relay start --no-ssl
```
`hermes pair` and `hermes relay` are supplied by the plugin through upstream
Hermes' plugin CLI support; they are not Hermes core commands. Use `--no-ssl`
only on a trusted LAN or VPN.
### Recommended: pair from the Hermes Web Dashboard
Restart or refresh the Dashboard/Gateway after installing the plugin, then:
1. Open the Hermes Web Dashboard and select **Relay**.
2. If the phone does not have its standard connection yet, click **Connect
mobile app** and scan that tokenless QR from Android **Connect → Scan Hermes
setup QR**. It contains only the Dashboard address.
3. Click **Pair new device**. Leave mode on **Auto** for the usual LAN plus
configured remote candidates.
4. In Android, open **Settings → Connections → Pair Hermes Relay → Scan QR** and
scan the one-time invite.
These are deliberately separate actions: **Connect mobile app** configures the
upstream Dashboard/Gateway connection; **Pair new device** grants a Relay
session. Neither converts Dashboard credentials into Relay credentials.
The Dashboard also shows the one-time code and a copyable invite for clients
without a camera. Pairing codes expire and are single-use; mint a fresh invite
instead of retrying a consumed code.
### Alternative: generate the same QR from a terminal
```bash
hermes pair
```
`hermes pair` is provided by the Hermes-Relay plugin through upstream Hermes'
plugin CLI support; it is not a built-in Hermes core command. Then scan the QR in
Android from **Settings → Connections → Pair Relay**, or from onboarding's **Scan
setup QR** path. If the Relay isn't running, the plugin may still print a legacy
API-first compatibility QR; dashboard-primary Chat does not depend on that
payload and Relay can be added later.
The QR should include `dashboard_url` for current dashboard-primary and custom
reverse-proxy layouts; legacy payloads without it may derive the dashboard from
the API host on port `9119`.
The command prints a text receipt, a terminal QR, a PNG path, and a pasteable
`hermes-relay://pair?...` invite. Scan the QR from Android. This uses the same
signed pairing contract as the Web Dashboard.
### Manual fallback when QR scanning is unavailable
- In Android, choose **Enter a Relay pairing code** and enter the Relay URL plus
the code shown by the Dashboard or `hermes pair`.
- Or choose **Show Relay code** in Android, run the displayed
`hermes pair --register-code <code>` command on the host, then tap **Connect**.
Keep manual URL, port, TLS, API fallback, and route-priority overrides under the
advanced path. The Dashboard's **Auto** pairing mode and the app's confirmed QR
receipt should be the default.
::::details Legacy installer and compatibility-only options
Use the legacy installer only when you also want the systemd user service, shell
shims, external skill-path registration, and the old clone/update workflow:
@@ -346,26 +386,8 @@ hermes relay compat install
hermes relay compat remove
```
::: tip Start the relay
```bash
# If you installed the hermes-relay plugin (recommended):
hermes relay start --no-ssl
# Or directly from a repo checkout:
python -m plugin.relay --no-ssl
```
Run this on the same machine as hermes-agent. On current upstream Hermes installs
with the plugin enabled, the plugin-provided `hermes pair` is available — when the
relay is running, its URL and a fresh pairing code are embedded in the QR
automatically.
:::
For persistent deployment, Docker, systemd, and TLS options, see the
[Relay Server docs](/reference/relay-server).
If you only saw an API-first QR earlier, start Relay and re-run `hermes pair` —
the new QR will include the Relay block. Your Dashboard/Gateway connection stays
the standard Chat path.
::::
::: tip Multiple Hermes servers
+15 -34
View File
@@ -1,42 +1,16 @@
# Hermes-Relay — Android
This section covers the **Android client** for [Hermes Agent](https://hermes-agent.nousresearch.com): Dashboard/Gateway-first setup, chat, Manage, voice, optional API fallback, optional Relay pairing, terminal/TUI relay, notifications, and optional sideload Device Control.
Hermes-Relay puts the [Hermes Agent](https://hermes-agent.nousresearch.com) you already run on Android. Start with the standard Dashboard/Gateway connection for Chat, sessions, Manage, sign-in, and voice. Pair the encouraged Relay extension for Terminal/TUI, notifications, media, desktop tools, enhanced voice, Relay sessions, and optional Device Control.
::: tip Want the agent to have hands on your other machines too?
The Hermes-Relay CLI gives your Hermes agent consent-gated filesystem, terminal, and screenshot access on any Windows, macOS, or Linux machine you pair — plus a terminal escape hatch for you. Windows can also install the optional compact management UI: **[CLI →](/desktop/)**. Both surfaces share the same relay pairing and `~/.hermes/remote-sessions.json`.
:::
<AndroidSetupPath mode="overview" />
Hermes-Relay is a native Android app for [Hermes Agent](https://hermes-agent.nousresearch.com). Chat, sessions, Manage, and standard voice use the upstream Dashboard/Gateway. The API server is an optional fallback/headless compatibility surface, and Relay is optional for terminal/TUI and bridge power tools. The Google Play build ships Bridge Core only; sideload builds add AccessibilityService-backed Device Control.
## What the two connections do
## Quick Start
- **Connect mobile app** adds the standard Hermes Dashboard/Gateway connection.
- **Pair new device** adds a separate, consent-scoped Relay grant.
1. Install Hermes and run the Dashboard/Gateway on your host.
2. Install the Android app.
3. Choose **Hermes**, enter or discover its Dashboard/Gateway address, and sign in when prompted.
4. Add Relay pairing later only if you want Terminal, Bridge, Relay sessions, or device-control power tools.
See [Installation & Setup](/guide/getting-started) for copy/paste host commands and upstream Hermes links.
If you installed the optional Relay plugin and want to uninstall it later:
```bash
hermes relay compat remove --all # optional legacy compatibility hook cleanup
hermes plugins remove hermes-relay
```
If you used the legacy installer instead:
```bash
bash ~/.hermes/hermes-relay/uninstall.sh
```
Or via curl if the clone is already gone:
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/uninstall.sh | bash
```
The uninstaller is idempotent and never touches state shared with other Hermes tools. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
The two QR codes are deliberately separate. You can use standard Hermes without
Relay, and pairing Relay never replaces the upstream connection.
## Connection Model
@@ -76,8 +50,15 @@ voice routes. Sideload builds additionally expose Android Device Control routes.
## Quick Links
- [Installation & Setup](/guide/getting-started) — Get the app running
- [Quick Start](/guide/quick-start) — Recommended Android + Relay setup
- [Installation & Setup](/guide/getting-started) — Builds, manual setup, and fallbacks
- [Chat Guide](/guide/chat) — Using the chat interface
- [Sessions](/guide/sessions) — Managing conversations
- [Features](/features/) — All features at a glance
- [Architecture](/architecture/) — How it works under the hood
::: tip Want Hermes to work on another computer?
The [Desktop CLI](/desktop/) pairs through the same Relay grant and gives Hermes
consent-gated filesystem, terminal, process, clipboard, and screenshot tools on
Windows, macOS, or Linux. The optional management UI is Windows-only.
:::
+100 -45
View File
@@ -1,67 +1,122 @@
# Quick Start
Install → connect → talk, in about two minutes. This page is deliberately
short: every step links to a detail page if you want the full story, and
nothing here requires the Relay plugin — a vanilla Hermes install is enough.
The recommended setup uses the Hermes Web Dashboard and two clearly separated
QR actions: first add the standard upstream connection, then pair Relay for the
capabilities Hermes does not yet expose upstream.
## 1. Install the app
<AndroidSetupPath mode="quick" />
For most people, the fastest path is the Google Play build. It installs in one
tap, updates automatically, and includes Chat, Voice, Manage, and the optional
Relay-powered features that do not require phone control.
::: tip Upstream first, Relay encouraged
Chat, sessions, Manage, sign-in, and standard voice use the unmodified Hermes
Dashboard/Gateway. The Relay plugin is an encouraged extension for Terminal and
TUI, notifications, media handoff, desktop tools, Relay sessions, enhanced
voice, and optional Device Control. When upstream Hermes provides a compatible
surface, Hermes-Relay prefers it instead of duplicating it.
:::
## Recommended full setup
### 1. Install the Android app
For most people, install the Google Play build. It updates automatically and
includes the everyday Hermes experience plus Relay capabilities that do not
require Android Device Control.
<StoreBadge />
Need Hermes to read, tap, type, or navigate on your phone? Install the signed
**Sideload** APK instead. [Compare the two builds and install either one →](./getting-started#_1-install-the-app)
Choose the signed **Sideload** APK only when you also want Hermes to read and
operate the phone screen. [Compare the builds and verify the APK →](./getting-started#_1-install-the-app)
## 2. Have Hermes running
### 2. Start Hermes and install the Relay extension
You need a reachable [hermes-agent](https://github.com/NousResearch/hermes-agent)
with its Dashboard/Gateway enabled. The API server is optional fallback and
headless compatibility. If yours isn't running yet,
[Installation & Setup](./getting-started) has the copy/paste commands for the
host machine.
The Dashboard/Gateway must be running and reachable from the phone:
## 3. Connect
```bash
hermes dashboard
```
Open the app and swipe through to **Connect**. Enter or discover the
Dashboard/Gateway address — conventionally `http://<host>:9119` — and sign in
through its configured provider when prompted. The wizard probes everything for
you, finishing with a capability card:
For the recommended full setup, install and start Relay on the Hermes host:
> Don't want to type it? Tap **Scan for Hermes on LAN** to auto-find the server.
> Existing API-first setup QRs remain supported for advanced compatibility.
> Full host setup is in [Installation & Setup](./getting-started).
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
hermes relay start --no-ssl
```
| Line | What it means |
|---|---|
| **Chat** | Dashboard/Gateway ready — you can talk |
| **Manage** | Skills, models, keys, profiles from the phone |
| **Voice** | Speech ready via your server (or one sign-in away) |
| **API fallback** | Optional API route available/unavailable |
| **Relay** | Optional power tools — fine to leave unpaired |
Use `--no-ssl` only on a trusted LAN or VPN. For away-from-home access,
[Tailscale is the recommended route](./remote-access).
Refresh or restart the Dashboard/Gateway plugin catalog after installation; a
**Relay** page should appear before you continue.
## 4. Sign in to Manage (only if asked)
### 3. Add the standard Hermes connection by QR
If your dashboard requires sign-in, complete it during setup or once under the
**Manage** tab. That same session unlocks Chat, sessions, Manage, and voice.
1. Open the Hermes Web Dashboard in a browser.
2. Open **Relay** and click **Connect mobile app**.
3. In Android, open **Connect** and choose **Scan Hermes setup QR**.
4. Scan the Dashboard QR and sign in if prompted.
## 5. Talk
This tokenless QR contains only the Dashboard address. The app verifies it and
uses the upstream Dashboard/Gateway for Chat, sessions, Manage, sign-in, and
standard voice. It does **not** contain a password, cookie, API key, or Relay
pairing code.
Type a message, or tap the mic and speak. That's the whole Vanilla Hermes setup.
### 4. Pair Relay by QR
::: tip You are connected when…
The capability card shows **Chat · Ready** and the Chat header carries a green
connection pulse. **Manage**, **Voice**, and **Relay** may still show optional or
sign-in states without blocking your first message.
Back in the Web Dashboard's **Relay** page:
1. Click **Pair new device** and leave mode on **Auto** unless you need a
specific route.
2. In Android, open **Settings → Connections → Pair Hermes Relay → Scan QR**.
3. Scan the one-time QR and confirm the routes and session duration.
The QR is single-use and can carry LAN, Tailscale, and public candidates so the
phone can choose the best reachable route. Pairing unlocks Terminal/TUI,
notifications, media handoff, Relay sessions, enhanced voice, desktop-tool
handoff, and—on the Sideload build—Device Control.
### 5. Confirm success and talk
You are ready when:
- **Chat · Ready** appears and the Chat header has a green connection pulse.
- **Manage** and **Voice** are ready, or show the one sign-in action they need.
- **Relay · Paired** appears when you completed step 4.
Open Chat and send a message. Pairing Relay is encouraged for the complete
experience, but an unavailable Relay must not block upstream Chat, Manage, or
standard voice.
## Other supported paths
::: details Use upstream Hermes without Relay
In Android **Connect**, choose **Find Hermes on LAN**. If discovery cannot find
the host, choose **Enter your Hermes address** and enter the Dashboard URL you
open in a browser, normally `http://<host>:9119`. Sign in when prompted.
This path provides Chat, sessions, Manage, and standard voice without installing
the Relay plugin. You can pair Relay later from **Settings → Connections**.
:::
[See the exact first-run screens and detailed setup →](./getting-started#_3-connect-chat)
::: details Generate the QR from a terminal
On the Hermes host, run:
::: details Want more? Power tools via Relay
Pairing the optional [Hermes-Relay plugin](./getting-started#relay-server-optional)
adds Terminal (a real tmux on your server), Bridge device control (sideload
builds), realtime provider-native voice, and per-profile voice providers.
Everything above keeps working without it.
```bash
hermes pair
```
The command prints connection details, a scannable one-time QR, a PNG path, and
a pasteable `hermes-relay://pair?...` invite. Scan the QR from Android. This is
the same pairing contract used by the Web Dashboard.
:::
::: details No camera or QR available
- **Standard connection:** enter the Dashboard address manually in Android.
- **Relay code:** create **Pair new device** in the Web Dashboard, then use
Android's **Enter a Relay pairing code** path with the shown URL and code.
- **Phone-generated code:** choose **Show Relay code** in Android, run the shown
`hermes pair --register-code <code>` command on the host, then tap **Connect**.
:::
[Detailed Android setup and security notes →](./getting-started) ·
[Dashboard and Desktop plugin pairing →](../features/dashboard) ·
[Troubleshooting →](./troubleshooting)

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