Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bb503eb6d | ||
|
|
c223dc690d | ||
|
|
44e3bb75cd | ||
|
|
1cec79517e | ||
|
|
957be876a0 | ||
|
|
6b32c7aeef | ||
|
|
29706e1548 | ||
|
|
6579b621ff | ||
|
|
befe8399ab | ||
|
|
c10b87b94c | ||
|
|
5e9d8840ae | ||
|
|
accf464911 | ||
|
|
b26c2cc2a1 | ||
|
|
28e0c34227 | ||
|
|
35e95da6a7 | ||
|
|
484bfdc5dc | ||
|
|
c7c24b2874 | ||
|
|
3e8e0728db | ||
|
|
1658439d05 | ||
|
|
90ab705a88 | ||
|
|
054aab1c09 | ||
|
|
bae1762951 | ||
|
|
da7ea8ffe0 | ||
|
|
5c5c55d982 | ||
|
|
0208098687 | ||
|
|
34fc4c4693 |
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <sub>(alpha)</sub>
|
||||
## Hands on any machine — the Hermes-Relay CLI <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 & 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 & 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.
|
||||
|
||||
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
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 }
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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) {
|
||||
|
||||
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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([{
|
||||
|
||||
@@ -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'] }
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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, {
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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) ·
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) ·
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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.
|
||||
:::
|
||||
|
||||
@@ -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)
|
||||
|
||||