Compare commits

...
Author SHA1 Message Date
Bailey Dixon 97cb30c927 merge: back-merge main release history into dev 2026-07-18 09:23:01 -04:00
Bailey Dixon ed41be3390 merge: reconcile Android localization into dev 2026-07-18 09:22:39 -04:00
Bailey Dixon 08816cfe63 Merge pull request #222 from Codename-11/fix/smooth-stream-rendering
fix(android): smooth streamed reply rendering
2026-07-17 14:44:40 -04:00
Bailey Dixon 01a0cde589 fix(android): smooth streamed reply rendering 2026-07-17 14:35:12 -04:00
Bailey Dixon ed6742afe4 Merge pull request #219 from Codename-11/fix/smooth-stream-finalization
fix(android): smooth streamed reply finalization
2026-07-17 09:22:45 -04:00
Bailey Dixon a6264df910 fix(android): smooth streamed reply finalization 2026-07-17 09:14:34 -04:00
Bailey Dixon 64e2e2eca6 Merge pull request #218 from Codename-11/fix/post-stream-history-scroll
fix(android): preserve chat anchor across history reload
2026-07-17 07:42:46 -04:00
Bailey Dixon 1ccaf2c4f1 fix(android): preserve chat anchor across history reload 2026-07-17 07:33:40 -04:00
Bailey Dixon 7686bb41e7 Merge pull request #217 from Codename-11/fix/stream-final-scroll-anchor
fix(android): retain chat bottom after stream completion
2026-07-16 21:03:11 -04:00
Bailey Dixon a940b4b8ea fix(android): retain chat bottom after stream completion 2026-07-16 20:55:01 -04:00
Bailey Dixon 46afdeab59 Merge pull request #216 from Codename-11/fix/critical-relay-security
fix(security): enforce Relay privileged boundaries
2026-07-16 20:23:16 -04:00
Bailey Dixon d4a8aad050 fix(ci): classify PR paths from merge commit 2026-07-16 19:38:00 -04:00
Bailey Dixon 0a6e95ae74 fix(ci): retry transient path classification failures 2026-07-16 19:36:05 -04:00
Bailey Dixon a6fc53e5cf docs: record critical relay hardening 2026-07-16 19:33:11 -04:00
Bailey Dixon c013daacda fix(security): prevent relay session self-upgrade 2026-07-16 19:27:39 -04:00
Bailey Dixon f5b1d377a4 fix(security): enforce terminal session grants 2026-07-16 19:26:12 -04:00
Bailey Dixon bb1e406f3f fix(security): redact remote profile config 2026-07-16 19:26:06 -04:00
Bailey Dixon 10213ca8ed fix(security): authorize Android bridge HTTP routes 2026-07-16 19:25:52 -04:00
Bailey Dixon cbfccd8ccf fix(security): keep voice provider origins host-controlled 2026-07-16 19:21:17 -04:00
Bailey Dixon c3c98caa31 fix(security): require host-authorized pairing 2026-07-16 19:18:02 -04:00
Bailey Dixon ed60abd57c Merge pull request #215 from Codename-11/fix/docs-docker-assets
fix(website): restore production docs build context
2026-07-16 17:42:30 -04:00
Bailey Dixon cab0d90530 fix(website): restore production docs build context 2026-07-16 17:40:03 -04:00
Bailey Dixon d977600f9d Merge pull request #214 from Codename-11/dev
merge: promote localized public experience
2026-07-16 15:51:48 -04:00
Bailey Dixon c902c00101 Merge pull request #213 from Codename-11/feature/docs-home-hub
feat: modernize and localize public experience
2026-07-16 15:38:49 -04:00
Bailey Dixon aa6b48a068 merge: sync latest main into public experience work
# Conflicts:
#	DEVLOG.md
2026-07-16 15:31:18 -04:00
Bailey Dixon 50297d1496 feat: modernize and localize public experience 2026-07-16 15:29:39 -04:00
Bailey Dixon d80f36a087 merge: add Android German Portuguese and Japanese 2026-07-16 08:46:29 -04:00
Bailey Dixon b6117c2d41 Merge pull request #212 from Codename-11/fix/docs-clean-urls
fix(website): serve VitePress clean URLs
2026-07-16 08:04:39 -04:00
Bailey Dixon b0ee6935fe fix(website): serve VitePress clean URLs 2026-07-16 08:02:26 -04:00
Bailey Dixon 33538fde0c Merge pull request #211 from Codename-11/fix/legacy-docs-redirect
fix(docs): add temporary legacy redirects
2026-07-16 07:58:37 -04:00
Bailey Dixon 603919c8ff fix(docs): add temporary legacy redirects 2026-07-16 07:56:09 -04:00
Bailey Dixon 52df3adbf6 Merge pull request #210 from Codename-11/fix/retire-github-pages
fix(docs): retire GitHub Pages
2026-07-16 07:42:52 -04:00
Bailey Dixon 3eab11c639 fix(docs): retire GitHub Pages 2026-07-15 21:48:20 -04:00
Bailey Dixon 2673f228bb Merge pull request #209 from Codename-11/fix/website-coolify-deployment
fix(website): add Coolify root-context build
2026-07-15 20:37:28 -04:00
Bailey Dixon 53b8f6a418 fix(website): add Coolify root-context build 2026-07-15 20:35:51 -04:00
Bailey Dixon c452c25148 feat(android): integrate expanded language support 2026-07-15 11:24:54 -04:00
Bailey Dixon 0db5c02722 feat(android): add Japanese localization 2026-07-15 10:57:40 -04:00
Bailey Dixon 4630695c17 feat(android): add Brazilian Portuguese localization 2026-07-15 10:52:15 -04:00
Bailey Dixon f4ee440015 feat(android): add German localization 2026-07-15 10:52:15 -04:00
135 changed files with 15808 additions and 1498 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ contact_links:
url: https://github.com/Codename-11/hermes-relay/security/advisories/new
about: Report privately via GitHub Security Advisories — do not open a public issue. See SECURITY.md for the full policy.
- name: User documentation
url: https://codename-11.github.io/hermes-relay/
url: https://hermes-relay.dev/docs/
about: Read setup, pairing, remote access, and troubleshooting docs.
- name: Contributing guide
url: https://github.com/Codename-11/hermes-relay/blob/main/CONTRIBUTING.md
+7 -7
View File
@@ -33,6 +33,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 2
- name: Test path classifier
run: node .github/scripts/classify-ci-paths.test.cjs
@@ -42,13 +44,11 @@ jobs:
uses: actions/github-script@v8
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
const paths = files.map((file) => file.filename);
const { stdout } = await exec.getExecOutput(
'git',
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
);
const paths = stdout.split(/\r?\n/).filter(Boolean);
const { classifyCiPaths } = require(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/classify-ci-paths.cjs`,
);
-75
View File
@@ -1,75 +0,0 @@
# Hermes-Relay — Docs Deployment
#
# Builds VitePress docs and deploys to GitHub Pages.
# Triggers on pushes to main that change user-docs/ content,
# or manually via workflow_dispatch.
name: Deploy Docs
on:
push:
branches: [main]
paths:
- 'user-docs/**'
- '.github/workflows/docs.yml'
workflow_dispatch:
# Allow only one concurrent deployment
concurrency:
group: pages
cancel-in-progress: false
# Sets permissions for GITHUB_TOKEN to enable Pages deployment
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
name: Build Docs
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0 # Full history for lastUpdated timestamps
- name: Setup Node.js
uses: actions/setup-node@v7
with:
# Node 24 ships npm 11, matching the npm that generates
# user-docs/package-lock.json. On npm 10 (Node 20), `npm ci` rejects
# the lock over the optional `search-insights` peer dep of bundled
# docsearch. Keep this aligned with the npm used to write the lock.
node-version: 24
cache: npm
cache-dependency-path: user-docs/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: user-docs
- name: Build VitePress site
run: npm run build
working-directory: user-docs
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: user-docs/.vitepress/dist
deploy:
name: Deploy to GitHub Pages
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
@@ -0,0 +1,81 @@
name: Deploy legacy docs redirects
on:
pull_request:
paths:
- "legacy-pages-redirect/**"
- ".github/workflows/legacy-docs-redirect.yml"
push:
branches: [main]
paths:
- "legacy-pages-redirect/**"
- ".github/workflows/legacy-docs-redirect.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: legacy-docs-pages
cancel-in-progress: true
jobs:
build:
name: Build redirect artifact
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Build redirect-only site
shell: bash
run: |
set -euo pipefail
source_file="legacy-pages-redirect/redirect.html"
output_dir="legacy-pages-redirect/_site"
rm -rf "$output_dir"
mkdir -p \
"$output_dir/guide/getting-started" \
"$output_dir/reference/relay-server" \
"$output_dir/architecture"
for target in \
index.html \
404.html \
guide/getting-started.html \
guide/getting-started/index.html \
reference/relay-server.html \
reference/relay-server/index.html \
architecture/connection-security.html; do
cp "$source_file" "$output_dir/$target"
done
touch "$output_dir/.nojekyll"
test "$(find "$output_dir" -type f | wc -l)" -eq 8
if grep -R -E '<title>VitePress|<div id="app">' "$output_dir"; then
echo "Full documentation content must not be deployed by this workflow." >&2
exit 1
fi
- name: Configure Pages
if: github.event_name != 'pull_request'
uses: actions/configure-pages@v6
- name: Upload redirect artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v5
with:
path: legacy-pages-redirect/_site
deploy:
name: Deploy redirect shim
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
+2
View File
@@ -93,3 +93,5 @@ keystore.properties
# Legacy generated desktop tray assets may remain after upgrading a worktree.
desktop/tray/ui/vendor/
# Generated from assets/screenshots/02_chat.png before docs dev/build.
/user-docs/public/chat-demo.png
+9
View File
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Added
- **Android adds German, Brazilian Portuguese, and Japanese.** Complete AI-assisted catalogs cover both product flavors, with language-picker integration and freshness validation against the canonical English resources.
### Fixed
- **Long streamed replies grow smoothly and remain at the latest text.** Android frame-paces bursty token delivery, expands the active bubble within clipped bounds, preserves bottom-following through completion, and avoids replacing the visible live transcript while readers who intentionally scroll up remain undisturbed.
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
## [Android 1.4.6] - 2026-07-15
### Added
+1 -1
View File
@@ -60,4 +60,4 @@ hermes-relay daemon status
On Windows, open **Hermes Relay Systray** from the Start menu and right-click its notification-area icon. No separate desktop window is installed.
See the [CLI and systray guide](https://codename-11.github.io/hermes-relay/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
See the [CLI and systray guide](https://hermes-relay.dev/docs/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
+142
View File
@@ -1,5 +1,147 @@
# Hermes-Relay — Dev Log
## 2026-07-17 — Smooth streamed-reply rendering and finalization
Uninterrupted Gateway turns treat their structured live assistant, reasoning, and
tool events as authoritative instead of immediately republishing the transcript
through a full history read. Rejoined sockets, Sessions SSE, detached turns,
profile-aware resume, errors, and missing-data recovery retain their required
authoritative reconciliation paths.
Bursty provider deltas now enter a main-thread frame pacer that publishes adaptive
UTF-16-safe slices at a display-sized cadence. The visible live tail uses one stable
plain-text node, ignores leading blank transport lines, and expands inside a short
clipped size animation. Tool, thinking, completion, cancellation, and error
boundaries still flush buffered content immediately and preserve event order.
Bottom-following is driven by stable row identity, real drag interactions, measured
positive tail growth, and structural anchors. The active response retains its live
renderer through completion, settles the exact footer for two frames, and releases
to full Markdown after another row becomes the tail or the session is revisited.
This prevents both the completion-time top snap and the transient scroll-to-bottom
button without interrupting readers who intentionally move into history.
Focused stream-pacing, Unicode-boundary, leading-whitespace, Gateway reconnect,
completion-policy, and scroll regressions passed. Repeated sideload builds and live
phone tests verified smooth following, stable completion, exact-bottom settling,
frame-paced text insertion, and clipped bubble growth.
## 2026-07-17 — Stable chat rows across post-turn history reconciliation
Android chat now separates the stable Compose identity of a visible message row
from its authoritative server message ID. The post-turn history reconcile can
adopt persisted IDs and rebuild message boundaries without making LazyColumn
remove and reinsert the long answer currently anchoring the viewport.
Regression coverage exercises both the same-count user/assistant ID adoption
and a list-expansion reconcile that inserts persisted rows around a matched live
tail. The focused ChatHandler and scroll-snapshot unit tests passed.
## 2026-07-16 — Stable chat position after stream completion
Android chat now observes the assistant message identity and the streaming-to-final
transition as conversation-tail changes. When a reader is already following the
response, completion performs an instant multi-frame bottom settle after the
streaming renderer is replaced by the final Markdown layout. The existing
user-scroll gate remains authoritative, so reading older messages is not
interrupted.
Focused snapshot regression coverage verifies completion detection, ordinary
stream growth, stream startup, and server message-ID reconciliation.
## 2026-07-16 — Critical Relay authorization hardening
Relay privileged interfaces now enforce host-authorized policy at every shared
dispatch boundary. Anonymous pairing-code minting was removed; pairing clients
can no longer choose session lifetime or grants; Android bridge HTTP routes and
terminal messages require live sessions with active route grants; ordinary
session bearers can only reduce their own lifetime and existing grants; remote
profile config reads expose an explicit public schema without host paths; and
voice requests cannot override credential-bearing provider origins.
Six bounded exploit harnesses stopped at the restored boundaries. The combined
security regression set passed 96 tests, Python compilation passed, Ruff passed
for changed modules and tests apart from the pre-existing unused `signal`
import in `server.py`, and `git diff --check` passed. The broad plugin
discovery run progressed through unrelated suites but was interrupted by the
existing Windows async-suite `KeyboardInterrupt` behavior, so the focused
security and neighboring route suites remain the authoritative local result.
The required-check path classifier now reads changed paths from the checked-out
PR merge commit instead of GitHub's PR-files API, so an API outage cannot skip
every surface check.
## 2026-07-16 — Fix production docs asset context
Updated the production docs Docker stage to build from the same full repository
checkout used by CI rather than a hand-maintained file allowlist. This supplies
the canonical screenshot manifest, localization registry and validators, route
contract source, version metadata, and preview renderer without creating
Docker-only `ENOENT` failures when those build inputs grow. Both Node build
stages now install Python 3 so the localized website and docs validators run
inside the production image build as they do in CI.
## 2026-07-16 — Localized marketing site
The Astro product site now publishes German, Spanish, Japanese, Brazilian
Portuguese, and Simplified Chinese routes from one typed copy contract. Each
route localizes marketing copy, navigation, accessibility labels, metadata, and
links into the matching first-run documentation while retaining canonical
screenshots, command examples, and UI recreations as shipped-product evidence.
Locale-aware canonical URLs, alternate-language links, Open Graph locale data,
structured-data language, sitemap entries, and a responsive language selector
were added. The localization registry records English-source freshness, and the
website development and build commands reject missing or stale translations.
Astro diagnostics, deterministic asset checks, the six-page production build,
built-site validation, desktop/mobile browser checks, and `git diff --check`
passed.
## 2026-07-16 — Temporary redirect shim for pre-migration Android builds
Restored GitHub Pages only as a redirect-only compatibility endpoint for app
versions that still open `https://codename-11.github.io/hermes-relay/`. The
shim preserves known paths, query strings, and fragments while forwarding to
`https://hermes-relay.dev/docs/`; it does not publish the VitePress site.
The production Nginx configuration resolves VitePress clean URLs to their
`.html` artifacts so both legacy and corrected in-app links reach real pages.
Removal criteria and the operator review date are tracked in `TODO.md`.
## 2026-07-15 — German, Brazilian Portuguese, and Japanese localization
Android now includes complete German, Brazilian Portuguese, and Japanese
catalogs across the Google Play and sideload flavors. German and Brazilian
Portuguese were recovered from unfinished translation drafts and refreshed
against the current English resource contract; Japanese was generated through
the same deterministic translation harness. The in-app picker, Android locale
configuration, localization registry, contributor references, and user-facing
language lists now describe the expanded set consistently.
The catalogs remain marked as AI-translated until fluent review is recorded.
Structural validation covers resource parity, placeholders, plurals, arrays,
formatting flags, XML parsing, and canonical source hashes. The 10-catalog
validator, five focused `AppLanguageTest` cases, both flavor Kotlin/resource
compilations, sideload debug lint, and `git diff --check` passed.
## 2026-07-15 — Retire GitHub Pages and move docs to hermes-relay.dev
Disabled and removed the GitHub Pages deployment path, changed the repository
homepage to `https://hermes-relay.dev`, and moved the existing VitePress guide
to `https://hermes-relay.dev/docs/` inside the production Coolify image. Active
README, website, Android, release-note, and pet-schema links now target the new
docs origin while historical DEVLOG entries remain unchanged.
## 2026-07-15 — Coolify root-context website deployment hotfix
Added a repository-owned multi-stage Dockerfile for the Astro marketing site
and corrected its Coolify instructions. Production builds now keep the
repository root as Docker context, run the existing `build:production` gate
from `website/`, and serve the generated static output with Nginx. This keeps
the site's canonical screenshot comparison against `docs/media/` intact while
avoiding Nixpacks' incorrect Android/Gradle provider selection at monorepo root.
Verification: local website checks, production build, link validation, Docker
image build, and Nginx-served smoke checks passed before deployment.
## 2026-07-15 — Android 1.4.6 and Plugin 1.4.2 release preparation
The profile-continuity and profile-image work was prepared as a two-surface
+18 -18
View File
@@ -22,7 +22,7 @@
<p align="center">
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a><br>
<a href="https://codename-11.github.io/hermes-relay/">Documentation</a> ·
<a href="https://hermes-relay.dev/docs/">Documentation</a> ·
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
@@ -50,13 +50,13 @@ 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.
- **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://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
- **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://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
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.
### 2 · Have Hermes running
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
```bash
hermes setup --portal # install / log in / pick a provider — skip if already done
@@ -77,7 +77,7 @@ hermes gateway
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://hermes-relay.dev/docs/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://hermes-relay.dev/docs/guide/getting-started).
### 3 · Connect and talk
@@ -99,7 +99,7 @@ The wizard probes everything and finishes with a capability card:
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole Vanilla Hermes setup.
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). 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
@@ -162,12 +162,12 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
</tr>
</table>
The Android app also ships a complete AI-assisted Spanish catalog. Choose
**Español** from **Settings → Appearance → Language**; translation status and
fluent review are tracked independently so community corrections remain easy
to contribute.
The Android app ships complete AI-assisted catalogs for **Deutsch**, **Español**,
**日本語**, **Português (Brasil)**, and **简体中文**. Choose a language from
**Settings → Appearance → Language**; translation status and fluent review are
tracked independently so community corrections remain easy to contribute.
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
<p align="center"><sub>▶ <a href="https://hermes-relay.dev/docs/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
## Features
@@ -183,7 +183,7 @@ to contribute.
- **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://codename-11.github.io/hermes-relay/guide/release-tracks).
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks).
## Hands on any machine — the Hermes-Relay CLI&nbsp;<sub>(alpha)</sub>
@@ -205,7 +205,7 @@ It pairs against the **same relay and credential store** as the Android app —
On Windows, the default installer adds the optional right-click-only systray: no dashboard or app window, just TUI launch, User/Administrator-aware daemon controls, pairing, local grant review, audit, diagnostics, logs, desktop-use status/cancellation, sign-in startup, and emergency stop.
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
- **Docs:** [CLI guide](https://hermes-relay.dev/docs/desktop/) · [`desktop/README.md`](desktop/README.md)
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
## How It Works
@@ -229,11 +229,11 @@ configure API, dashboard, and relay routes without merging their auth models.
| | |
|---|---|
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, features, configuration — start here** |
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | Pairing, subcommands, local tool routing |
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
| **[User Guide](https://hermes-relay.dev/docs/)** | **Quick start, features, configuration — start here** |
| [Android](https://hermes-relay.dev/docs/guide/) | Android install + setup + features |
| [Hermes-Relay CLI](https://hermes-relay.dev/docs/desktop/) | Pairing, subcommands, local tool routing |
| [Architecture](https://hermes-relay.dev/docs/architecture/) | How the system works under the hood |
| [API Reference](https://hermes-relay.dev/docs/reference/api.html) | Hermes API endpoints used by both surfaces |
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `plugin-v*`, `cli-v*`) |
+2 -2
View File
@@ -9,7 +9,7 @@
<p align="center">
<strong>简体中文</strong> · <a href="README.md">English</a><br>
<a href="https://codename-11.github.io/hermes-relay/zh-CN/">中文文档</a> ·
<a href="https://hermes-relay.dev/docs/zh-CN/">中文文档</a> ·
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
<a href="CHANGELOG.md">更新日志</a>
</p>
@@ -76,7 +76,7 @@ hermes relay start --no-ssl
hermes pair
```
完整说明请阅读[中文快速开始](https://codename-11.github.io/hermes-relay/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
完整说明请阅读[中文快速开始](https://hermes-relay.dev/docs/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
## 中文界面
+1 -1
View File
@@ -8,7 +8,7 @@
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://codename-11.github.io/hermes-relay/guide/sideload) for installation help.
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://hermes-relay.dev/docs/guide/sideload) for installation help.
## Summary
+42 -14
View File
@@ -6,6 +6,36 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Active — Remove temporary GitHub Pages docs redirects
PR #210 moved current source and production documentation to
`https://hermes-relay.dev/docs/`, but Android 1.4.0 and earlier releases still
contain hardcoded `https://codename-11.github.io/hermes-relay/` links. GitHub
Pages therefore serves a redirect-only compatibility shim from
`legacy-pages-redirect/`; it must never regain full documentation content.
Retire the shim only after the first Android release containing merge commit
`52df3adbf6d61d0ddbfb69671546f7c4953f956a` has been available for at least
90 days **and** at least two Android releases containing the corrected links
have shipped. If either condition is unmet at review time, retain it and set a
new review date.
Removal checklist:
- Remove `.github/workflows/legacy-docs-redirect.yml` and
`legacy-pages-redirect/` through a reviewed PR.
- Delete/disable the repository Pages site after that PR merges.
- Verify `https://codename-11.github.io/hermes-relay/` no longer serves the
shim and `https://hermes-relay.dev/docs/` plus representative deep links
still return HTTP 200.
- Update `DEVLOG.md` and the canonical Obsidian Hermes-Relay project note.
A one-shot operator reminder is scheduled for **2026-10-15 at 09:00 ET** to
review these gates; it is a review trigger, not authorization for automatic
removal.
---
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
Android profile hot-swap and concurrent Gateway turns are separate from proactive
@@ -615,13 +645,12 @@ every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
conversation gets its own beat; long-press haptic on the action menu; streaming dots
gated to pre-first-token. Deferred:
- **Streaming↔final render parity — conservative 1.4.1 slice implemented; live
reflow check remains.** Blank-terminated, unambiguous top-level prose/headings use
the final Markdown renderer during generation while the active tail stays raw.
Lists, quotes, tables, HTML, and fences intentionally remain lightweight until the
final parse because partial CommonMark containers can re-parent earlier blocks.
Verify that the chosen boundary removes the common heading/prose pop without
introducing partial-fence or list flicker.
- **Streaming↔final render parity — live reflow check remains.** Blank-terminated,
unambiguous top-level prose/headings use the final Markdown renderer during
generation while the active tail stays lightweight. Completion intentionally
parses one full CommonMark document so global link references, indentation, and
nested containers remain correct; the viewport now anchors that same remeasure.
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
@@ -643,13 +672,12 @@ gated to pre-first-token. Deferred:
kebab). Needs on-device confirmation of the current conflict first.
- **Drop the no-op tap ripple on bubbles.** The 1.4.1 jump-to-bottom unread badge is
code-complete; `combinedClickable(onClick={})` still ripples on a normal bubble tap.
- **Sessions-transport `animateItem` flash.** Stream-complete rebuilds the list with
new ids → every visible bubble replays its enter animation (gateway transport,
stable id, is unaffected). Reuse the streaming bubble's id for the final message.
- **Viewport re-pin on the `isStreaming` true→false height growth** (gateway
transport): `ChatScreen` early-returns on `onlyStreamingFlagChanged`; issue one
`withFrameNanos{}` + instant `scrollToItem(last)` when the flag flips and the user
isn't scrolled away. Largely neutralized once render parity removes the height delta.
- **Sessions per-turn reconciliation.** Current upstream includes assistant/tool
rows in `run.completed.messages`, but Android still uses a full profile-aware
history read for successful Sessions turns so older servers and persisted message
boundaries remain safe. Replace it only with a bounded partial-turn merge that
preserves the prior transcript and client-only fields, with a full-history fallback
when the completion payload is absent or incomplete.
- **Full 15-role `Typography` + metadata contrast.** Type.kt declares only 7 roles at
0 tracking; the rest inherit M3 defaults with 0.1–0.5sp tracking (ChatScreen uses
several) — declare all 15 for one coherent scale. Separately, floor muted-metadata
@@ -7,6 +7,9 @@ import java.util.Locale
enum class AppLanguage(val languageTag: String) {
SYSTEM_DEFAULT(""),
ENGLISH("en"),
GERMAN("de"),
BRAZILIAN_PORTUGUESE("pt-BR"),
JAPANESE("ja"),
SIMPLIFIED_CHINESE("zh-Hans"),
SPANISH("es"),
;
@@ -27,8 +30,11 @@ enum class AppLanguage(val languageTag: String) {
val locale = Locale.forLanguageTag(primaryTag)
return when (locale.language.lowercase(Locale.ROOT)) {
"de" -> GERMAN
"en" -> ENGLISH
"es" -> SPANISH
"ja" -> JAPANESE
"pt" -> BRAZILIAN_PORTUGUESE
"zh" -> {
val simplified = locale.script.equals("Hans", ignoreCase = true) ||
locale.script.isEmpty() ||
@@ -129,6 +129,19 @@ data class ChatMessage(
* the live message can be matched to its server row.
*/
val backgroundTask: BackgroundTaskState? = null,
/**
* Stable identity for Compose list rendering.
*
* Gateway/user rows start with client UUIDs, then post-turn history
* reconciliation adopts the server message id into [id]. That server-id
* adoption must not make a visible bubble look removed and reinserted to
* LazyColumn: doing so discards its scroll anchor, which is especially
* disruptive when the row is a long answer occupying the viewport.
*
* New rows default to their current [id]. Reconciled rows retain this key
* through `copy`, while [id] remains the authoritative lookup/wire id.
*/
val uiKey: String = id,
)
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
@@ -1315,7 +1315,9 @@ class ChatHandler {
// `id = messageId` adopts the server id: for an id-matched (SSE)
// row it's a no-op, but for a positionally reconciled (gateway /
// user) row whose `prior` still carries a client UUID it swaps in
// the server id so EVERY future reload matches by id.
// the server id so EVERY future reload matches by id. `uiKey` is
// deliberately not overwritten: Compose must continue treating
// this as the same visible row across the post-turn reload.
prior.copy(
id = messageId,
role = role,
@@ -2103,9 +2103,19 @@ class GatewayChatClient(
private val rejoinAttempts = java.util.concurrent.atomic.AtomicInteger(0)
/** True if this socket loss should be answered with a rejoin attempt. */
fun beginRejoin(): Boolean =
!ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
@Volatile
private var reconcileRequired = false
/**
* True if this socket loss should be answered with a rejoin attempt.
* Mark reconciliation before reconnecting so a terminal event arriving
* immediately after `gateway.ready` cannot race ahead of the signal.
*/
fun beginRejoin(): Boolean {
val shouldRejoin = !ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
if (shouldRejoin) reconcileRequired = true
return shouldRejoin
}
private var watchdog: Job? = null
@@ -2132,6 +2142,12 @@ class GatewayChatClient(
// Ask requests block with no further events, so they arm with
// their own (longer) duration via watchdogTimeoutFor.
armWatchdog(watchdogTimeoutFor(type))
// Queue this immediately before the terminal callbacks. Both are
// marshalled through the same dispatcher, preserving callback order
// even when the WebSocket reader and reconnect coroutine differ.
if (type == "message.complete" && reconcileRequired) {
callbacks.onReconcileRequired()
}
mapper.onEvent(type, payload)
if (mapper.turnEnded) {
disarmWatchdog()
@@ -2305,6 +2321,7 @@ class GatewayChatClient(
onToolCallFailed = { a, b -> callbackDispatcher { callbacks.onToolCallFailed(a, b) } },
onToolOutputRisk = { v -> callbackDispatcher { callbacks.onToolOutputRisk(v) } },
onTurnComplete = { callbackDispatcher { callbacks.onTurnComplete() } },
onReconcileRequired = { callbackDispatcher { callbacks.onReconcileRequired() } },
onComplete = { callbackDispatcher { callbacks.onComplete() } },
onUsage = { v -> callbackDispatcher { callbacks.onUsage(v) } },
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
@@ -335,6 +335,12 @@ class GatewayTurnCallbacks(
/** Attach deterministic output-risk metadata to the matching tool card. */
val onToolOutputRisk: (GatewayToolOutputRisk) -> Unit = { _ -> },
val onTurnComplete: () -> Unit,
/**
* Fired before [onComplete] when this turn rejoined after a socket gap.
* Events emitted while the socket was unavailable are not replayed, so
* the caller must reconcile the durable transcript after completion.
*/
val onReconcileRequired: () -> Unit,
val onComplete: () -> Unit,
val onUsage: (UsageInfo?) -> Unit,
val onError: (String) -> Unit,
@@ -32,7 +32,7 @@ import com.hermesandroid.relay.data.ConnectionSecurityLevel
import com.hermesandroid.relay.data.SurfaceSecurity
private const val LEARN_MORE_URL =
"https://codename-11.github.io/hermes-relay/architecture/connection-security.html"
"https://hermes-relay.dev/docs/architecture/connection-security.html"
/**
* Per-surface "Connection security" detail sheet — the tap target for the
@@ -882,8 +882,8 @@ private data class StandardConnectionDraft(
val routeCandidates: List<EndpointCandidate>? = null,
)
private const val SetupGuideUrl = "https://codename-11.github.io/hermes-relay/guide/getting-started"
private const val RelaySetupDocsUrl = "https://codename-11.github.io/hermes-relay/reference/relay-server"
private const val SetupGuideUrl = "https://hermes-relay.dev/docs/guide/getting-started"
private const val RelaySetupDocsUrl = "https://hermes-relay.dev/docs/reference/relay-server"
private const val HermesApiDocsUrl = "https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server"
private fun openExternalUrl(context: android.content.Context, url: String) {
@@ -11,34 +11,21 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.semantics.CollectionInfo
import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontFamily
@@ -69,7 +56,6 @@ import com.mikepenz.markdown.model.markdownExtendedSpans
import com.hermesandroid.relay.ui.theme.LocalBrand
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import kotlinx.coroutines.delay
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMElementTypes.HEADER
import org.intellij.markdown.flavours.gfm.GFMElementTypes.ROW
@@ -303,285 +289,42 @@ fun StreamingMarkdownContent(
isStreaming: Boolean = true,
modifier: Modifier = Modifier,
) {
val blocks = remember(content, isStreaming) {
if (isStreaming) {
parseStreamingMarkdownBlocks(content)
} else {
listOf(StreamingMarkdownBlock.Markdown(content))
}
}
Column(
modifier = modifier,
// Match the final renderer's block spacer so moving the active tail
// into the settled Markdown prefix does not add a second layout jump.
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
blocks.forEach { block ->
when (block) {
is StreamingMarkdownBlock.Markdown -> MarkdownContent(
content = block.content,
textColor = textColor,
)
is StreamingMarkdownBlock.Text -> Text(
text = block.text,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
)
is StreamingMarkdownBlock.Code -> StreamingCodeBlock(block)
}
}
}
}
@Composable
private fun StreamingCodeBlock(block: StreamingMarkdownBlock.Code) {
// Discord-like fenced block: a contrasting inset surface with a thin header
// (language label + copy), and a horizontally-scrollable monospace body.
// Header is shown whenever there's a language to label or code to copy, so
// even a bare ``` fence gets the copy affordance once it has content.
val hasHeader = block.language.isNotBlank() || block.code.isNotBlank()
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceContainerLowest,
border = androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
),
) {
Column {
if (hasHeader) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 4.dp, top = 2.dp, bottom = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = block.language.ifBlank { "code" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
CodeCopyButton(code = block.code)
}
}
Text(
text = block.code.ifEmpty { " " },
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
),
softWrap = false,
)
}
}
}
/**
* Small copy affordance for a code block — copies [code] to the clipboard and
* briefly flips to a check for feedback. No-op while [code] is blank.
*/
@Composable
private fun CodeCopyButton(code: String) {
val clipboard = LocalClipboardManager.current
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(1500)
copied = false
}
}
IconButton(
onClick = {
if (code.isNotBlank()) {
clipboard.setText(AnnotatedString(code))
copied = true
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy code",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
if (isStreaming) {
// Keep one stable layout node for the entire live turn. Promoting each
// blank-terminated paragraph into Markdown replaced the Text subtree
// repeatedly; LazyColumn then exposed its fallback anchor for a frame,
// which looked like the whole chat reloaded. Updating this Text value
// only remeasures the growing bubble. Full Markdown is parsed once the
// owning row releases its stable live-tail layout.
Text(
// CommonMark ignores blank lines before the first block. The live
// Text renderer must do the same or a response whose transport
// prefix contains newlines appears to start several lines down.
// Preserve indentation on the first non-blank line so indented
// code and deliberately spaced prose are not altered.
text = content.withoutLeadingBlankLines(),
modifier = modifier,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
)
}
}
internal sealed interface StreamingMarkdownBlock {
data class Markdown(val content: String) : StreamingMarkdownBlock
data class Text(val text: String) : StreamingMarkdownBlock
data class Code(val language: String, val code: String) : StreamingMarkdownBlock
}
/**
* Splits an in-flight response into stable Markdown and one structurally
* incomplete tail.
*
* Only conservative, blank-terminated top-level prose/heading blocks promote
* to the real renderer. Lists, quotes, tables, indented blocks, HTML, and code
* remain on the lightweight streaming surface until the message settles; those
* containers can legally absorb later lines, so promoting them early causes a
* visible re-parenting jump when the final CommonMark tree is parsed.
*/
internal fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
if (content.isBlank()) return emptyList()
val normalized = content
.replace("\r\n", "\n")
.replace('\r', '\n')
val blocks = mutableListOf<StreamingMarkdownBlock>()
val settledEnd = findStableMarkdownBoundary(normalized).coerceAtLeast(0)
if (settledEnd > 0) {
normalized.substring(0, settledEnd).trimEnd().let { stable ->
if (stable.isNotBlank()) blocks += StreamingMarkdownBlock.Markdown(stable)
}
}
val activeTail = normalized.substring(settledEnd)
if (activeTail.isNotBlank()) {
blocks += parseActiveStreamingTail(activeTail)
}
return blocks
}
/** Last unambiguous blank-line boundary in a contiguous simple-markdown prefix. */
private fun findStableMarkdownBoundary(content: String): Int {
var offset = 0
var blockStart = 0
var activeFence: StreamingFence? = null
var lastStableBoundary = 0
while (offset < content.length) {
val newline = content.indexOf('\n', offset)
val lineEnd = if (newline >= 0) newline else content.length
val line = content.substring(offset, lineEnd)
activeFence = when (val current = activeFence) {
null -> streamingFence(line)
else -> if (isClosingFence(line, current)) null else current
}
// Whitespace-only lines can be meaningful indentation inside a list.
// Require a truly empty delimiter and stop at the first ambiguous
// container so every promoted prefix remains structurally final.
if (activeFence == null && newline >= 0 && line.isEmpty()) {
val candidate = content.substring(blockStart, offset).trimEnd()
if (candidate.isNotBlank() && !isConservativeStableBlock(candidate)) break
lastStableBoundary = newline + 1
blockStart = lastStableBoundary
}
if (newline < 0) break
offset = newline + 1
}
return lastStableBoundary
}
private fun isConservativeStableBlock(block: String): Boolean = block
.lineSequence()
.filter { it.isNotEmpty() }
.none { line ->
line.firstOrNull()?.isWhitespace() == true ||
AMBIGUOUS_STREAMING_BLOCK.matches(line)
}
private fun parseActiveStreamingTail(content: String): List<StreamingMarkdownBlock> {
val blocks = mutableListOf<StreamingMarkdownBlock>()
val paragraph = StringBuilder()
val code = StringBuilder()
var activeFence: StreamingFence? = null
var language = ""
fun flushParagraph() {
val text = paragraph.toString().trim('\n').trimEnd()
if (text.isNotBlank()) {
blocks += StreamingMarkdownBlock.Text(text)
}
paragraph.clear()
}
fun flushCode() {
blocks += StreamingMarkdownBlock.Code(
language = language,
code = code.toString().trimEnd('\n'),
)
code.clear()
}
val lines = content.split('\n')
lines.forEachIndexed { index, line ->
val lineWithBreak = if (index == lines.lastIndex) line else "$line\n"
if (activeFence == null) {
val fence = streamingFence(line)
if (fence != null) {
flushParagraph()
activeFence = fence
language = streamingFenceLanguage(line, fence)
} else {
paragraph.append(lineWithBreak)
}
} else if (isClosingFence(line, activeFence)) {
flushCode()
activeFence = null
language = ""
} else {
code.append(lineWithBreak)
}
}
if (activeFence != null) {
flushCode()
} else {
flushParagraph()
MarkdownContent(
content = content,
textColor = textColor,
modifier = modifier,
)
}
return blocks
}
private data class StreamingFence(
val marker: Char,
val length: Int,
)
internal fun String.withoutLeadingBlankLines(): String {
var contentStart = 0
while (contentStart < length) {
val lineEnd = indexOf('\n', startIndex = contentStart)
if (lineEnd < 0) break
private fun streamingFence(line: String): StreamingFence? {
val trimmed = line.trimStart()
val marker = trimmed.firstOrNull()?.takeIf { it == '`' || it == '~' } ?: return null
val length = trimmed.takeWhile { it == marker }.length
return length.takeIf { it >= 3 }?.let { StreamingFence(marker, it) }
val line = substring(contentStart, lineEnd).removeSuffix("\r")
if (line.isNotBlank()) break
contentStart = lineEnd + 1
}
return substring(contentStart)
}
private fun isClosingFence(line: String, activeFence: StreamingFence): Boolean {
val trimmed = line.trimStart()
if (trimmed.firstOrNull() != activeFence.marker) return false
val markerLength = trimmed.takeWhile { it == activeFence.marker }.length
return markerLength >= activeFence.length && trimmed.drop(markerLength).isBlank()
}
private fun streamingFenceLanguage(line: String, fence: StreamingFence): String {
val tail = line.trimStart().drop(fence.length).trim()
return tail
.takeWhile { !it.isWhitespace() && it != fence.marker }
.take(32)
}
private val AMBIGUOUS_STREAMING_BLOCK = Regex(
"""^(?:[-+*]\s|\d{1,9}[.)]\s|>|```|~~~|<|\||(?:-{3,}|={3,})\s*$).*""",
)
@@ -1,5 +1,7 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
@@ -83,6 +85,12 @@ fun MessageBubble(
showThinking: Boolean = true,
isFirstInGroup: Boolean = true,
isLastInGroup: Boolean = true,
/**
* Keeps a just-completed live tail on its stable Text layout. The owning
* list releases this once another row becomes the tail, allowing full
* Markdown to render without disturbing the visible bottom anchor.
*/
retainStreamingLayout: Boolean = false,
onCopyMessage: (String) -> Unit = {},
/**
* Quote this message into the input field. Null hides the Quote entry in
@@ -361,6 +369,26 @@ fun MessageBubble(
shape = bubbleShape,
color = backgroundColor,
modifier = Modifier
.then(
if (!isUser && !isSystem &&
(message.isStreaming || retainStreamingLayout)
) {
// The frame-paced text node is already measured at its
// new size. Animate and clip the owning surface so a
// newly wrapped line is revealed inside the expanding
// bubble instead of drawing below the previous bounds
// for one frame. TopStart keeps existing prose fixed.
Modifier.animateContentSize(
animationSpec = tween(
durationMillis = 72,
easing = LinearOutSlowInEasing,
),
alignment = Alignment.TopStart,
)
} else {
Modifier
}
)
.then(
if (!isUser && !isSystem && isDarkTheme) {
Modifier.leftEdgeGlow(
@@ -396,15 +424,16 @@ fun MessageBubble(
color = textColor
)
} else {
// Settled blocks keep the real Markdown renderer while
// only the structurally incomplete tail stays raw. The
// same composable settles the final tail so retained
// parser state survives the streaming -> final handoff.
// Keep one plain Text node stable while content grows
// and while this completed response remains the visible
// tail. Full Markdown renders once another row becomes
// the tail (or the session is revisited), where its
// remeasure cannot reset the active viewport.
if (markdownBody.isNotEmpty()) {
StreamingMarkdownContent(
content = markdownBody,
textColor = textColor,
isStreaming = message.isStreaming,
isStreaming = message.isStreaming || retainStreamingLayout,
)
}
}
@@ -538,7 +567,11 @@ fun MessageBubble(
// burst of fragments doesn't stack three near-touching time labels.
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
if (isLastInGroup) {
// Keep the live bubble's footer structurally quiet. Showing a
// timestamp while text is still growing makes it chase every
// token and exaggerates any single-frame layout lag. Reveal it
// once the message settles into its final layout.
if (isLastInGroup && !message.isStreaming) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = timeFormat.format(Date(message.timestamp)),
@@ -359,7 +359,7 @@ private fun WelcomePage() {
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/guide/getting-started"))
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/guide/getting-started"))
)
},
modifier = Modifier.weight(1f)
@@ -394,7 +394,7 @@ fun AboutScreen(
}
OutlinedButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/"))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/"))
context.startActivity(intent)
},
modifier = Modifier.weight(1f)
@@ -244,6 +244,9 @@ fun AppearanceSettingsScreen(
val languageLabels = mapOf(
AppLanguage.SYSTEM_DEFAULT to stringResource(R.string.appearance_language_system),
AppLanguage.ENGLISH to stringResource(R.string.appearance_language_english),
AppLanguage.GERMAN to stringResource(R.string.appearance_language_german),
AppLanguage.BRAZILIAN_PORTUGUESE to stringResource(R.string.appearance_language_brazilian_portuguese),
AppLanguage.JAPANESE to stringResource(R.string.appearance_language_japanese),
AppLanguage.SIMPLIFIED_CHINESE to stringResource(R.string.appearance_language_simplified_chinese),
AppLanguage.SPANISH to stringResource(R.string.appearance_language_spanish),
)
@@ -6,6 +6,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.MutatePriority
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -37,6 +38,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.verticalScroll
@@ -76,6 +78,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -85,7 +88,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -218,7 +220,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val DEFAULT_CHAR_LIMIT = 4096
/**
* A same-author run breaks into a new visual group once the gap to the
* neighboring message exceeds this — so a conversation resumed after a pause
@@ -233,19 +234,38 @@ private const val GROUP_GAP_MS = 5 * 60_000L
*
* Captured inside a `snapshotFlow { ... }` so distinctUntilChanged can
* detect any meaningful change (new message, longer text, longer reasoning,
* new tool card, streaming on/off) and re-trigger an auto-follow scroll.
* new tool card, message-id reconciliation, streaming on/off) and re-trigger
* an auto-follow scroll.
*
* `equals` is auto-generated by `data class`, which gives field-wise
* comparison — exactly the behavior distinctUntilChanged needs.
*/
private data class ChatScrollSnapshot(
internal data class ChatScrollSnapshot(
val messageCount: Int,
val lastMessageId: String?,
val lastMessageUiKey: String?,
val lastContentLength: Int,
val lastThinkingLength: Int,
val lastToolCallCount: Int,
val isStreaming: Boolean
)
internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?): Boolean =
previous?.isStreaming == true &&
!isStreaming &&
previous.messageCount == messageCount &&
previous.lastMessageUiKey == lastMessageUiKey
private class ChatTailTransitionRef(
var snapshot: ChatScrollSnapshot? = null,
)
private data class ChatTailLayoutSnapshot(
val uiKey: String?,
val measuredSizePx: Int?,
val shouldFollowGrowth: Boolean,
)
private fun LazyListState.isAtConversationBottom(slopPx: Int): Boolean {
val layout = layoutInfo
if (layout.totalItemsCount == 0) return true
@@ -267,9 +287,9 @@ private suspend fun LazyListState.scrollToConversationBottom(
if (attempt == 0 || !isAtConversationBottom(slopPx)) {
if (animateNext) {
animateNext = false
animateScrollToItem(lastIndex, Int.MAX_VALUE)
animateScrollToItem(lastIndex)
} else {
scrollToItem(lastIndex, Int.MAX_VALUE)
scrollToItem(lastIndex)
}
}
withFrameNanos { }
@@ -991,7 +1011,10 @@ fun ChatScreen(
// user back to the latest token while they are reading history.
// Reset to false the moment the user returns to the bottom.
var userScrolledAway by remember(currentSessionId) { mutableStateOf(false) }
var isUserDragging by remember(currentSessionId) { mutableStateOf(false) }
var programmaticBottomScroll by remember { mutableStateOf(false) }
var retainedLiveTailUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
var completionSettlingUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
val currentUnreadSnapshot = remember(messages) { messages.toUnreadSnapshot() }
var lastReadSnapshot by remember(currentSessionId) {
mutableStateOf(currentUnreadSnapshot)
@@ -1024,24 +1047,27 @@ fun ChatScreen(
}
}
// Decide "is the user reading history" from GENUINE scroll gestures only.
// A bare isAtBottom flip — the streaming bubble grew and pushed content
// below the fold — must NOT count as the user scrolling away. That false
// signal was the bounce: it popped the scroll-to-bottom FAB and (worse)
// aborted auto-follow even though the user never touched the screen.
// Content growth never sets isScrollInProgress, so keying off the scroll's
// falling edge ignores it; only a real drag/fling that ENDS above the
// bottom flips userScrolledAway. (Programmatic animated scrolls also set
// isScrollInProgress, so they're excluded via programmaticBottomScroll.)
// Decide "is the user reading history" from actual touch drags only.
// isScrollInProgress also becomes true for our own animated bottom scroll;
// if that animation is cancelled by the next stream batch, its falling
// edge can race the programmatic flag and falsely disable auto-follow for
// the rest of the turn. LazyListState's interaction source emits only real
// drag gestures, so it cleanly separates user intent from app scrolling.
// Pause follow at drag start so a new token cannot fight the finger, but do
// not classify the user as reading history until the gesture actually ends
// above the bottom. A tiny/cancelled touch must not poison the next turn.
LaunchedEffect(listState) {
var wasScrolling = false
snapshotFlow { listState.isScrollInProgress }
.collect { scrolling ->
if (wasScrolling && !scrolling && !programmaticBottomScroll) {
userScrolledAway = !isAtBottom
listState.interactionSource.interactions.collect { interaction ->
when (interaction) {
is DragInteraction.Start -> {
isUserDragging = true
}
is DragInteraction.Stop, is DragInteraction.Cancel -> {
isUserDragging = false
userScrolledAway = !listState.isAtConversationBottom(atBottomSlopPx)
}
wasScrolling = scrolling
}
}
}
// Reaching the bottom by any means (user, follow-pin, content shrank)
// always re-arms auto-follow.
@@ -1061,10 +1087,14 @@ fun ChatScreen(
// suppression lifts and the button appears.
val showScrollToBottom by remember {
derivedStateOf {
val retainingVisibleTail = retainedLiveTailUiKey != null &&
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
messages.isNotEmpty() &&
!isAtBottom &&
!programmaticBottomScroll &&
!(isStreaming && smoothAutoScroll && !userScrolledAway)
!((isStreaming || retainingVisibleTail) &&
smoothAutoScroll &&
!userScrolledAway)
}
}
@@ -1196,124 +1226,148 @@ fun ChatScreen(
}
}
// Auto-scroll to bottom while streaming.
//
// Bugs the previous versions had:
// 1. Keys only watched messages.size and content.length — so growth of
// the thinking block (thinkingContent) and tool-card additions
// (toolCalls) silently froze the auto-follow during long reasoning
// and tool execution phases.
// 2. animateScrollToItem(lastIndex) defaults scrollOffset = 0, which
// aligns the TOP of the item with the top of the viewport. For a
// tall streaming bubble (reasoning + tool cards + text) that means
// the user gets snapped back to the start of the message instead
// of staying at the latest token. Fix: scrollOffset = Int.MAX_VALUE
// pins the bottom of the item to the bottom of the viewport.
// 3. There was no userScrolledAway gate, so any delta would yank a
// user reading history back to the bottom.
// 4. The isStreaming flag was a snapshot key, so the stream-complete
// transition (true → false) re-triggered animateScrollToItem even
// when no content actually changed — producing a visible jiggle.
// 5. Sessions endpoint reloads the entire message list on stream
// complete via loadMessageHistory(), and the resulting animateItem()
// placement animations on every bubble fought with our concurrent
// animateScrollToItem — producing a flash where the viewport
// visibly settled twice.
//
// The fix below uses snapshotFlow on a snapshot of every meaningful
// streaming-state field. distinctUntilChanged debounces identical
// emissions; collectLatest cancels any in-flight scroll animation when
// a newer delta arrives, preventing animation pile-ups during rapid
// SSE bursts. The pref `smoothAutoScroll` (default true) gates the
// entire effect — when off, only manual scrolling occurs.
//
// The previous-snapshot var inside the LaunchedEffect coroutine lets us
// distinguish "content arrived" from "state flipped" and "single
// append" from "list rebuild", and pick the right scroll strategy.
LaunchedEffect(listState, smoothAutoScroll) {
if (!smoothAutoScroll) return@LaunchedEffect
var previousSnapshot: ChatScrollSnapshot? = null
val tailMessage = messages.lastOrNull()
val tailTransition = ChatScrollSnapshot(
messageCount = messages.size,
lastMessageId = tailMessage?.id,
lastMessageUiKey = tailMessage?.uiKey,
lastContentLength = tailMessage?.content?.length ?: 0,
lastThinkingLength = tailMessage?.thinkingContent?.length ?: 0,
lastToolCallCount = tailMessage?.toolCalls?.size ?: 0,
isStreaming = tailMessage?.isStreaming == true,
)
val tailTransitionRef = remember(currentSessionId) { ChatTailTransitionRef() }
// New rows and streaming -> final Markdown are structural transitions.
// Anchor their trailing spacer in SideEffect so the request participates in
// the very next remeasure instead of correcting an already-drawn frame.
SideEffect {
val previous = tailTransitionRef.snapshot
val streamStarted = tailTransition.isStreaming && previous?.isStreaming != true
val completed = tailTransition.isCompletionAfter(previous)
val tailStructureChanged = tailTransition.lastMessageUiKey != null &&
(previous == null ||
previous.messageCount != tailTransition.messageCount ||
previous.lastMessageUiKey != tailTransition.lastMessageUiKey)
if (streamStarted) {
// Sending a turn means "follow my new answer" even if the idle
// transcript had previously been left above the bottom. Do not
// clear isUserDragging: a real finger keeps priority until release.
userScrolledAway = false
retainedLiveTailUiKey = tailTransition.lastMessageUiKey
} else if (completed) {
completionSettlingUiKey = tailTransition.lastMessageUiKey
} else if (
tailStructureChanged &&
retainedLiveTailUiKey != null &&
retainedLiveTailUiKey != tailTransition.lastMessageUiKey
) {
retainedLiveTailUiKey = null
}
val shouldAnchor = smoothAutoScroll &&
!isUserDragging &&
(!userScrolledAway || streamStarted) &&
(streamStarted || completed || tailStructureChanged)
if (shouldAnchor) {
listState.requestScrollToItem(tailTransition.messageCount + 1)
}
tailTransitionRef.snapshot = tailTransition
}
// Completion adds the timestamp/footer after the final token. Keep the
// stable live renderer, then consume any small remaining forward range for
// two settled frames. scrollBy preserves the current item anchor and is
// visually inert when already at the exact bottom; unlike scrollToItem it
// cannot align the top of a tall response with the viewport.
LaunchedEffect(
completionSettlingUiKey,
smoothAutoScroll,
userScrolledAway,
isUserDragging,
) {
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
completionSettlingUiKey = null
return@LaunchedEffect
}
var settledFrames = 0
repeat(6) {
withFrameNanos { }
if (messages.lastOrNull()?.uiKey != settlingKey) {
completionSettlingUiKey = null
return@LaunchedEffect
}
if (listState.canScrollForward) {
settledFrames = 0
val viewportHeight = listState.layoutInfo.viewportSize.height
if (viewportHeight > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(viewportHeight.toFloat())
}
}
} else {
settledFrames += 1
if (settledFrames >= 2) {
completionSettlingUiKey = null
return@LaunchedEffect
}
}
}
completionSettlingUiKey = null
}
// Ordinary streaming growth keeps the same row and Text node. Advance the
// existing scroll position by exactly the measured positive height delta;
// never replace the logical anchor with scrollToItem(). User input has a
// higher mutation priority and cancels this work naturally.
LaunchedEffect(listState, smoothAutoScroll, userScrolledAway, isUserDragging) {
if (!smoothAutoScroll || userScrolledAway || isUserDragging) return@LaunchedEffect
var previousLayout: ChatTailLayoutSnapshot? = null
snapshotFlow {
val last = messages.lastOrNull()
// Snapshot every field that can grow during a single turn.
// Any change here means "more content arrived, try to follow".
ChatScrollSnapshot(
messageCount = messages.size,
lastContentLength = last?.content?.length ?: 0,
lastThinkingLength = last?.thinkingContent?.length ?: 0,
lastToolCallCount = last?.toolCalls?.size ?: 0,
isStreaming = last?.isStreaming == true
val tail = messages.lastOrNull()
val tailSize = tail?.uiKey?.let { uiKey ->
listState.layoutInfo.visibleItemsInfo
.firstOrNull { item -> item.key == uiKey }
?.size
}
ChatTailLayoutSnapshot(
uiKey = tail?.uiKey,
measuredSizePx = tailSize,
shouldFollowGrowth = tail?.isStreaming == true ||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey),
)
}
.distinctUntilChanged()
.collectLatest { snapshot ->
val prev = previousSnapshot
previousSnapshot = snapshot
.collect { current ->
val previous = previousLayout
previousLayout = current
val previousSize = previous?.measuredSizePx ?: return@collect
val currentSize = current.measuredSizePx ?: return@collect
if (!current.shouldFollowGrowth || previous.uiKey != current.uiKey) return@collect
if (messages.isEmpty()) return@collectLatest
if (userScrolledAway) return@collectLatest
// Skip "state-only" snapshot deltas where the only thing
// that changed is the isStreaming flag. The viewport is
// already at the right position from the last content
// delta — animating again on the state flip causes a
// visible flash, especially in sessions mode where the
// StreamingDots row vanishes when isStreaming flips false.
val onlyStreamingFlagChanged = prev != null
&& prev.messageCount == snapshot.messageCount
&& prev.lastContentLength == snapshot.lastContentLength
&& prev.lastThinkingLength == snapshot.lastThinkingLength
&& prev.lastToolCallCount == snapshot.lastToolCallCount
&& prev.isStreaming != snapshot.isStreaming
if (onlyStreamingFlagChanged) return@collectLatest
// Sessions endpoint reloads the entire message list on
// stream complete (one streaming message → multiple final
// messages with proper boundaries + tool call cards).
// animateScrollToItem during a list rebuild conflicts with
// the items' animateItem() placement animations and produces
// a visible flash. Use the instant scrollToItem path so the
// viewport snaps to the new bottom while the items animate
// into their final positions independently.
val isListRebuild = prev != null
&& snapshot.messageCount - prev.messageCount > 1
// Growth of the bubble we're already following (thinking /
// content / tool cards on the same message) arrives at token
// frequency on the gateway transport. animateScrollToItem
// per delta is a cancel/restart storm — each collectLatest
// cancellation strands the viewport mid-animation (showing
// earlier content) before the next one yanks it back:
// visible stutter when parked at the bottom during long
// reasoning. Pin instantly instead; reserve the animation
// for the discrete new-bubble event.
val isSameTurnGrowth = prev != null
&& snapshot.messageCount == prev.messageCount
if (isSameTurnGrowth) {
// Tail-follow: a single atomic pin to the clamped bottom.
// The helper's multi-frame settle loop gets cancelled by
// collectLatest on the very next token (streaming arrives
// ~every frame), stranding the viewport mid-settle → the
// bounce. One withFrameNanos to let the grown content lay
// out, then one scrollToItem — instant and cancellation-safe.
withFrameNanos { }
val lastIndex = listState.layoutInfo.totalItemsCount - 1
if (lastIndex >= 0) listState.scrollToItem(lastIndex, Int.MAX_VALUE)
} else {
// Discrete events (new bubble, list rebuild, history load):
// scrollOffset = Int.MAX_VALUE clamps to the deepest offset;
// the helper retries across frames so late markdown/code
// measurement can't leave us anchored above the real bottom.
// Instant for a rebuild (avoids fighting animateItem()).
scrollConversationToBottom(animated = !isListRebuild)
val growthPx = currentSize - previousSize
if (growthPx > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(growthPx.toFloat())
}
}
}
}
// Haptic on stream complete
// Completion haptic only; scroll ownership remains with the transition and
// measured-growth paths above.
var observedActiveStream by remember { mutableStateOf(false) }
LaunchedEffect(isStreaming) {
if (!isStreaming && messages.isNotEmpty()) {
if (isStreaming) {
observedActiveStream = true
} else if (observedActiveStream) {
observedActiveStream = false
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
@@ -2102,8 +2156,15 @@ fun ChatScreen(
) {
item { Spacer(modifier = Modifier.height(8.dp).animateItem()) }
items(messages.size, key = { messages[it].id }) { index ->
// `id` can legitimately change once after a Gateway turn:
// the history reconcile adopts the persisted server id.
// Keep Compose identity stable across that data update so
// LazyColumn retains the visible row and its scroll anchor.
items(messages.size, key = { messages[it].uiKey }) { index ->
val message = messages[index]
val retainLiveLayout =
index == messages.lastIndex &&
message.uiKey == retainedLiveTailUiKey
val processNotification = message.hermesProcessNotificationOrNull()
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
@@ -2142,112 +2203,112 @@ fun ChatScreen(
message.cards.isNotEmpty()
message.backgroundTask?.let { task ->
val taskModifier = Modifier.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
)
BackgroundTaskCard(
task = task,
toolCalls = message.toolCalls,
showTimeline = toolDisplay != "off",
modifier = Modifier
.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
)
.animateItem(),
modifier = taskModifier,
)
}
if (processNotification != null) {
val notificationModifier = Modifier.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
)
SyntheticProcessNotificationNotice(
notification = processNotification,
modifier = Modifier
.padding(top = if (isFirstInGroup) 6.dp else 2.dp)
.animateItem(),
modifier = notificationModifier,
)
} else if (shouldRenderBubble) MessageBubble(
message = message,
modifier = Modifier
.padding(
top = if (hasBackgroundTask) 1.dp
} else if (shouldRenderBubble) {
val bubbleModifier = Modifier.padding(
top = if (hasBackgroundTask) 1.dp
else if (isFirstInGroup) 6.dp
else 1.dp,
)
.animateItem(),
maxBubbleWidth = maxBubbleWidth,
showThinking = showThinking,
isFirstInGroup = isFirstInGroup,
isLastInGroup = isLastInGroup,
recoveringAnswer = recoveringAnswer,
onAttachmentRetry = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onAttachmentManualFetch = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onCardAction = { msgId, cardKey, action ->
// OPEN_URL is resolved at the UI layer
// because launching ACTION_VIEW needs a
// Context. We record the dispatch FIRST
// via the ViewModel so the card collapses
// even if the browser launch throws.
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
com.hermesandroid.relay.ui.components.handleCardActionExternally(
context, action
)
)
MessageBubble(
message = message,
modifier = bubbleModifier,
maxBubbleWidth = maxBubbleWidth,
showThinking = showThinking,
isFirstInGroup = isFirstInGroup,
isLastInGroup = isLastInGroup,
retainStreamingLayout = retainLiveLayout,
recoveringAnswer = recoveringAnswer,
onAttachmentRetry = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onAttachmentManualFetch = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onCardAction = { msgId, cardKey, action ->
// OPEN_URL is resolved at the UI layer
// because launching ACTION_VIEW needs a
// Context. Record the dispatch first so
// the card collapses even if launch fails.
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
com.hermesandroid.relay.ui.components.handleCardActionExternally(
context,
action,
)
} else {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
}
},
onCardInput = { msgId, cardKey, value ->
chatViewModel.answerAsk(msgId, cardKey, value)
},
onEditMessage = if (
isGatewayTransport &&
!isStreaming &&
message.role == MessageRole.USER &&
!message.id.startsWith("voice-intent-") &&
!message.id.startsWith("steer-")
) {
{ msg ->
editingMessage = msg
inputText = msg.content.take(charLimit)
}
} else {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
}
},
onCardInput = { msgId, cardKey, value ->
chatViewModel.answerAsk(msgId, cardKey, value)
},
onEditMessage = if (
isGatewayTransport &&
!isStreaming &&
message.role == MessageRole.USER &&
!message.id.startsWith("voice-intent-") &&
!message.id.startsWith("steer-")
) {
{ msg ->
editingMessage = msg
inputText = msg.content.take(charLimit)
}
} else {
null
},
onQuoteMessage = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val quoted = text.take(600)
.trim()
.lines()
.joinToString("\n") { line -> "> $line" }
inputText = if (inputText.isBlank()) {
"$quoted\n\n"
} else {
"$inputText\n$quoted\n\n"
}
},
onCopyMessage = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
// The new Clipboard API is suspend-based, so the
// setClipEntry call has to live inside a coroutine.
// We piggyback on the same scope.launch that posts
// the snackbar — they are sequential anyway.
scope.launch {
clipboard.setClipEntry(
ClipEntry(
ClipData.newPlainText(
hermesMessageLabel,
text
null
},
onQuoteMessage = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val quoted = text.take(600)
.trim()
.lines()
.joinToString("\n") { line -> "> $line" }
inputText = if (inputText.isBlank()) {
"$quoted\n\n"
} else {
"$inputText\n$quoted\n\n"
}
},
onCopyMessage = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
// The new Clipboard API is suspend-based, so the
// setClipEntry call has to live inside a coroutine.
scope.launch {
clipboard.setClipEntry(
ClipEntry(
ClipData.newPlainText(
hermesMessageLabel,
text,
),
)
)
)
snackbarHostState.showSnackbar(
message = copiedToClipboardMsg,
duration = SnackbarDuration.Short
)
}
}
)
snackbarHostState.showSnackbar(
message = copiedToClipboardMsg,
duration = SnackbarDuration.Short,
)
}
},
)
}
// Steered sends live inside a server-side tool
// result, not a user message — flag the local
@@ -120,6 +120,21 @@ data class ContextWindowUsage(
get() = if (maxTokens > 0) (usedTokens.toFloat() / maxTokens).coerceIn(0f, 1f) else 0f
}
/**
* A successful Sessions SSE turn still needs the server-authoritative transcript
* because that transport does not stream every persisted message boundary.
* Gateway turns already deliver the assistant, reasoning, and tool lifecycle
* directly; reloading their full transcript only republishes a healthy live turn.
* A successful socket rejoin is the exception because events emitted during the
* gap cannot be replayed.
*/
internal fun shouldReloadHistoryAfterSuccessfulTurn(
actualTransport: String,
gatewayReconcileRequired: Boolean,
): Boolean =
actualTransport == "sessions" ||
(actualTransport == "gateway" && gatewayReconcileRequired)
class ChatViewModel : ViewModel() {
private var apiClient: HermesApiClient? = null
@@ -132,6 +147,9 @@ class ChatViewModel : ViewModel() {
*/
private var activeStream: ActiveTurnHandle? = null
/** Token bursts awaiting their next UI-sized publication window. */
private var activeStreamDeltas: StreamDeltaCoalescer? = null
/**
* True when [activeStream] is a GATEWAY turn (vs an SSE EventSource). A
* gateway turn runs on the gateway client, which survives a same-connection
@@ -1254,6 +1272,9 @@ class ChatViewModel : ViewModel() {
onTurnComplete = {
if (acceptsEvent()) handler.onTurnComplete(messageId)
},
// Server-initiated turns already take the bounded durable-history
// reconcile below on every completion.
onReconcileRequired = { },
onComplete = {
val canWriteTranscript = acceptsEvent()
val expectedText = handler.messages.value
@@ -2255,6 +2276,10 @@ class ChatViewModel : ViewModel() {
// same-connection route blip and reconnects its own socket, keeping the
// live session), so cancelling here would needlessly kill a recoverable
// turn. Leave it running; it completes on the gateway client.
if (!activeStreamIsGateway) {
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
}
val droppedSseCheckpoint = if (!activeStreamIsGateway) buildTurnCheckpoint() else null
if (!activeStreamIsGateway) {
activeStream?.cancel()
@@ -2293,6 +2318,8 @@ class ChatViewModel : ViewModel() {
historyLoadGeneration.incrementAndGet()
sessionRefreshGeneration.incrementAndGet()
intentionallyCancelled = true
activeStreamDeltas?.discard()
activeStreamDeltas = null
activeStream?.cancel()
activeStream = null
cancelAnswerRecovery(settleUi = false)
@@ -3339,7 +3366,8 @@ class ChatViewModel : ViewModel() {
* message among role==USER messages (excluding phone-local traces the
* server never saw), truncates the local list from it, and dispatches a
* gateway turn carrying `truncate_before_user_ordinal`. Local/server
* divergence self-heals via the post-turn history reload.
* divergence self-heals through the gateway's authoritative truncate and
* live turn events; recovery paths still perform a full history reconcile.
*
* @return false when the edit could not be dispatched (turn in flight,
* non-gateway endpoint, missing client, ordinal failure) — the caller
@@ -3827,6 +3855,8 @@ class ChatViewModel : ViewModel() {
val gateway = gatewayClient
val canBackground = streamingEndpoint == "gateway" &&
activeStreamIsGateway && activeStream != null && gateway != null
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
val checkpoint = if (canBackground) buildTurnCheckpoint() else null
if (canBackground && gateway.backgroundActiveTurn()) {
if (checkpoint != null) {
@@ -4036,6 +4066,8 @@ class ChatViewModel : ViewModel() {
scheduleCheckpointWrite(immediate = true)
}
},
// Recovered turns already reload their authoritative history below.
onReconcileRequired = { },
onComplete = {
if (owns()) {
finalizeTurnSideEffects(handler, messageId)
@@ -5394,6 +5426,8 @@ class ChatViewModel : ViewModel() {
// finalizes the previous turn's leftover streaming placeholder so it
// can't pulse forever next to this turn's fresh one.
cancelAnswerRecovery()
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
// A new turn is starting: clear any leftover cancellation flag so a
// stale `true` from a PRIOR cancelled turn (the flag is sticky — a
@@ -5414,6 +5448,7 @@ class ChatViewModel : ViewModel() {
// stream answer recovery (issue #166) on "sessions": the other
// endpoints keep their existing error behavior.
var dispatchedSseEndpoint: String? = null
var gatewayHistoryReconcileRequired = false
val assistantTimestamp = System.currentTimeMillis()
beginTurnCheckpoint(
@@ -5440,13 +5475,26 @@ class ChatViewModel : ViewModel() {
// interfaceContextPrompt today, so it marks this turn as spoken.
// Tag the reply with a "Voice" chip (parity with "Realtime
// Agent"); ChatHandler.loadMessageHistory preserves it across
// the post-turn history reload.
// history and recovery reconciliation.
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
)
)
val streamDeltas = StreamDeltaCoalescer(
scope = viewModelScope,
onTextDelta = { delta -> handler.onTextDelta(currentMessageId, delta) },
onThinkingDelta = { delta -> handler.onThinkingDelta(currentMessageId, delta) },
)
activeStreamDeltas = streamDeltas
fun flushAndReleaseStreamDeltas() {
streamDeltas.flushNow()
if (activeStreamDeltas === streamDeltas) activeStreamDeltas = null
}
// Shared callbacks for both endpoints
val onMessageStartedCb = { serverMsgId: String ->
streamDeltas.flushNow()
// Replace the placeholder's ID so subsequent deltas/tool calls attach
// to it instead of creating a duplicate orphan bubble with streaming dots.
// Only replaces empty+streaming messages (the placeholder), not completed turns.
@@ -5459,34 +5507,41 @@ class ChatViewModel : ViewModel() {
firstTokenNotified = true
AppAnalytics.onFirstTokenReceived()
}
handler.onTextDelta(currentMessageId, delta)
streamDeltas.appendText(delta)
}
val onThinkingDeltaCb = { delta: String ->
handler.onThinkingDelta(currentMessageId, delta)
streamDeltas.appendThinking(delta)
}
val onToolCallStartCb = { toolCallId: String, toolName: String ->
streamDeltas.flushNow()
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
scheduleCheckpointWrite(immediate = true)
}
val onToolCallDoneCb = { toolCallId: String, resultPreview: String? ->
streamDeltas.flushNow()
handler.onToolCallComplete(currentMessageId, toolCallId, resultPreview)
scheduleCheckpointWrite(immediate = true)
}
val onToolCallFailedCb = { toolCallId: String, errorMsg: String? ->
streamDeltas.flushNow()
handler.onToolCallFailed(currentMessageId, toolCallId, errorMsg)
scheduleCheckpointWrite(immediate = true)
}
// Turn complete — one assistant message finished, but the run may continue
val onTurnCompleteCb = {
streamDeltas.flushNow()
handler.onTurnComplete(currentMessageId)
scheduleCheckpointWrite(immediate = true)
}
val onCompleteCb = {
flushAndReleaseStreamDeltas()
// Double-finalize guard: if a straggler completion arrives while
// the answer-recovery poller is running, the normal completion
// wins — stop the poller before finalizing so the turn can't
// finish twice.
cancelAnswerRecovery(settleUi = false)
val completedTransport = dispatchedSseEndpoint
?: if (activeStreamIsGateway) "gateway" else streamingEndpoint
finalizeTurnSideEffects(handler, currentMessageId)
AppAnalytics.onStreamComplete(lastInputTokens, lastOutputTokens)
@@ -5504,15 +5559,13 @@ class ChatViewModel : ViewModel() {
refreshReasoningSettings()
}
// Sessions endpoint doesn't emit structured tool events during streaming —
// tool calls are only available as JSON on the stored messages. Reload the
// server-authoritative history to get proper message boundaries + tool_calls.
// Gateway turns reconcile the same way: live tool events are gated by the
// server's display.tool_progress config, so a turn that ran tools silently
// (config off, or events lost in a mid-turn rejoin gap) still gets its tool
// cards + persisted reasoning right after the turn — not on the next app
// restart. By message.complete the server has persisted the turn, so the
// REST read is authoritative.
// Sessions SSE does not stream every persisted message boundary, so it
// still needs the server-authoritative transcript after success. A healthy
// Gateway turn is already authoritative in memory through its structured
// assistant/reasoning/tool events; reloading the full transcript here would
// republish the entire visible list and cause a completion flash. A Gateway
// socket rejoin explicitly flags this completion for the same profile-aware
// history reconcile because events emitted during the gap may be missing.
val sid = handler.currentSessionId.value
// A turn that ended in an error (gateway ❌ lifecycle → "Error" badge)
// has NO assistant message persisted server-side, so reconciling the
@@ -5523,9 +5576,13 @@ class ChatViewModel : ViewModel() {
val turnErrored = handler.messages.value
.lastOrNull { it.id == currentMessageId }
?.badges?.contains("Error") == true
if (sid != null && (streamingEndpoint == "sessions" || streamingEndpoint == "gateway")) {
if (sid != null && (completedTransport == "sessions" || completedTransport == "gateway")) {
viewModelScope.launch {
if (!turnErrored) {
if (!turnErrored && shouldReloadHistoryAfterSuccessfulTurn(
completedTransport,
gatewayHistoryReconcileRequired,
)
) {
// Profile-aware read: a gateway turn on a non-default profile
// persists into THAT profile's own state.db, so the bare
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
@@ -5587,6 +5644,7 @@ class ChatViewModel : ViewModel() {
}
}
val onErrorCb = { errorMsg: String ->
flushAndReleaseStreamDeltas()
val errorSessionId = handler.currentSessionId.value
if (intentionallyCancelled) {
intentionallyCancelled = false
@@ -5943,18 +6001,24 @@ class ChatViewModel : ViewModel() {
onToolCallDone = onToolCallDoneCb,
onToolCallFailed = onToolCallFailedCb,
onToolOutputRisk = { risk ->
streamDeltas.flushNow()
handler.onToolOutputRisk(currentMessageId, risk)
scheduleCheckpointWrite(immediate = true)
},
onTurnComplete = onTurnCompleteCb,
onReconcileRequired = {
gatewayHistoryReconcileRequired = true
},
onComplete = onCompleteCb,
onUsage = onUsageCb,
onError = onErrorCb,
onToolGenerating = { name ->
streamDeltas.flushNow()
handler.onToolGenerating(currentMessageId, name)
scheduleCheckpointWrite(immediate = true)
},
onSubagentEvent = { event ->
streamDeltas.flushNow()
handler.onSubagentEvent(currentMessageId, event)
scheduleCheckpointWrite(immediate = true)
},
@@ -6076,6 +6140,8 @@ class ChatViewModel : ViewModel() {
// false: the Stopped-badge block below finalizes the placeholder
// itself (completing it here first would hide it from findLast).
cancelAnswerRecovery(settleUi = false)
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
activeStream?.cancel()
activeStream = null
_queuedMessages.value = emptyList()
@@ -6610,6 +6676,8 @@ class ChatViewModel : ViewModel() {
}
override fun onCleared() {
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
flushTurnCheckpointForTeardown()
gatewayClient?.setUnsolicitedTurnProvider(null)
gatewayClient?.setColdPrewarmSessionReadyListener(null)
@@ -0,0 +1,169 @@
package com.hermesandroid.relay.viewmodel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** Frame-paced publication cadence for streaming text. */
internal const val STREAM_DELTA_FRAME_MS = 16L
private const val STREAM_DELTA_MIN_CHARS_PER_FRAME = 8
private const val STREAM_DELTA_MAX_CHARS_PER_FRAME = 48
private const val STREAM_DELTA_TARGET_DRAIN_FRAMES = 8
private enum class StreamDeltaKind {
TEXT,
THINKING,
}
private data class PendingStreamDelta(
val kind: StreamDeltaKind,
val content: StringBuilder,
)
/**
* Main-thread-confined stream-delta frame pacer.
*
* Providers often deliver many token events in one scheduler burst, followed
* by a network gap. Publishing that burst as one Compose state update makes
* text appear in large steps even when rendering itself is fast. This buffer
* drains a bounded slice every display-sized interval instead. The slice grows
* with backlog, keeping presentation close to real time without returning to
* hundreds of full-message republishes per second.
*
* Adjacent deltas of the same kind retain their bytes and their ordering
* relative to thinking/text transitions. Lifecycle boundaries call [flushNow]
* so no buffered content can arrive after a tool event, completion,
* cancellation, or error has settled the message.
*/
internal class StreamDeltaCoalescer(
private val scope: CoroutineScope,
private val onTextDelta: (String) -> Unit,
private val onThinkingDelta: (String) -> Unit,
private val frameMs: Long = STREAM_DELTA_FRAME_MS,
) {
private val pending = mutableListOf<PendingStreamDelta>()
private var flushJob: Job? = null
init {
require(frameMs >= 0L) { "frameMs must be non-negative" }
}
fun appendText(delta: String) {
enqueue(StreamDeltaKind.TEXT, delta)
}
fun appendThinking(delta: String) {
enqueue(StreamDeltaKind.THINKING, delta)
}
fun flushNow() {
flushJob?.cancel()
flushJob = null
flushPending()
}
fun discard() {
flushJob?.cancel()
flushJob = null
pending.clear()
}
private fun enqueue(kind: StreamDeltaKind, delta: String) {
if (delta.isEmpty()) return
val tail = pending.lastOrNull()
if (tail?.kind == kind) {
tail.content.append(delta)
} else {
pending += PendingStreamDelta(kind, StringBuilder(delta))
}
scheduleFrame()
}
private fun scheduleFrame() {
if (flushJob != null || pending.isEmpty()) return
flushJob = scope.launch {
delay(frameMs)
flushJob = null
publishFrame()
scheduleFrame()
}
}
private fun publishFrame() {
if (pending.isEmpty()) return
var remainingBudget = streamDeltaFrameBudget(
pending.sumOf { it.content.length },
)
val frame = mutableListOf<Pair<StreamDeltaKind, String>>()
while (remainingBudget > 0 && pending.isNotEmpty()) {
val head = pending.first()
val requestedLength = minOf(remainingBudget, head.content.length)
val safeLength = head.content.codePointSafePrefixLength(requestedLength)
if (safeLength == 0) break
val content = head.content.substring(0, safeLength)
head.content.delete(0, safeLength)
remainingBudget -= safeLength
if (head.content.isEmpty()) pending.removeAt(0)
val previous = frame.lastOrNull()
if (previous?.first == head.kind) {
frame[frame.lastIndex] = head.kind to (previous.second + content)
} else {
frame += head.kind to content
}
}
publish(frame)
}
private fun flushPending() {
if (pending.isEmpty()) return
// Copy before invoking callbacks so a callback that indirectly queues
// more work starts a fresh window instead of mutating this drain.
val batch = pending.map { it.kind to it.content.toString() }
pending.clear()
publish(batch)
}
private fun publish(batch: List<Pair<StreamDeltaKind, String>>) {
batch.forEach { (kind, content) ->
when (kind) {
StreamDeltaKind.TEXT -> onTextDelta(content)
StreamDeltaKind.THINKING -> onThinkingDelta(content)
}
}
}
}
internal fun streamDeltaFrameBudget(pendingChars: Int): Int {
if (pendingChars <= 0) return 0
val adaptiveBudget =
(pendingChars + STREAM_DELTA_TARGET_DRAIN_FRAMES - 1) /
STREAM_DELTA_TARGET_DRAIN_FRAMES
return adaptiveBudget
.coerceIn(STREAM_DELTA_MIN_CHARS_PER_FRAME, STREAM_DELTA_MAX_CHARS_PER_FRAME)
.coerceAtMost(pendingChars)
}
private fun StringBuilder.codePointSafePrefixLength(requestedLength: Int): Int {
if (requestedLength <= 0) return 0
if (requestedLength >= length) return length
return if (
Character.isHighSurrogate(this[requestedLength - 1]) &&
Character.isLowSurrogate(this[requestedLength])
) {
requestedLength - 1
} else {
requestedLength
}
}
File diff suppressed because it is too large Load Diff
@@ -1054,6 +1054,9 @@
<string name="appearance_language_desc">选择 Hermes-Relay 使用的语言。跟随系统会使用设备的语言设置。</string>
<string name="appearance_language_system">跟随系统</string>
<string name="appearance_language_english">English</string>
<string name="appearance_language_german">Deutsch</string>
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="appearance_appearance">外观</string>
File diff suppressed because it is too large Load Diff
+3
View File
@@ -2812,6 +2812,9 @@
<string name="appearance_language_desc">Elige el idioma de Hermes-Relay. La opción predeterminada del sistema sigue la configuración del dispositivo.</string>
<string name="appearance_language_system">Predeterminado del sistema</string>
<string name="appearance_language_english">English</string>
<string name="appearance_language_german">Deutsch</string>
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="chat_profile_history_unavailable">No se pudo acceder al historial de conversaciones del perfil activo. Vuelve a conectarte e inténtalo de nuevo.</string>
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1057,6 +1057,9 @@
<string name="appearance_language_desc">Choose the language used by Hermes-Relay. System default follows your device setting.</string>
<string name="appearance_language_system">System default</string>
<string name="appearance_language_english">English</string>
<string name="appearance_language_german">Deutsch</string>
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="appearance_appearance">Appearance</string>
+4 -1
View File
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="zh-Hans" />
<locale android:name="de" />
<locale android:name="es" />
<locale android:name="ja" />
<locale android:name="pt-BR" />
<locale android:name="zh-Hans" />
</locale-config>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Hermes Dev</string>
<string name="a11y_service_label">Hermes-Bridge Dev</string>
<string name="notification_companion_label">assistente de notificações do Hermes Dev</string>
<string name="a11y_description_sideload">O Hermes Bridge concede ao agente acesso completo de leitura e gravação no celular para controle sem usar as mãos por voz e visão. Todas as ações são registradas no Registro de atividades, e as ações destrutivas exigem sua confirmação.</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sideload flavor strings.
`a11y_description_sideload` is the full-surface description users see when
enabling Hermes Bridge on the direct-install track. Unlike the Google Play
flavor we can be explicit about voice + vision + full device control here:
the sideload user is assumed to be a power user who installed an APK by
hand, not a Play Store customer.
Do NOT reuse these strings in the googlePlay flavor — Play reviewers may
flag any mention of "full read/write access" or "hands-free control" as
outside the declared use case.
-->
<resources>
<string name="app_name">Hermes Dev</string>
<string name="a11y_service_label">Hermes-Bridge Dev</string>
<string name="notification_companion_label">Hermes Dev-Benachrichtigungsassistent</string>
<string name="a11y_description_sideload">Hermes Bridge gewährt dem Agenten vollständigen Lese- und Schreibzugriff auf das Smartphone zur freihändigen Steuerung per Sprache und Bilderkennung. Alle Aktionen werden im Aktivitätsprotokoll erfasst; destruktive Aktionen erfordern deine Bestätigung.</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sideload flavor strings.
`a11y_description_sideload` is the full-surface description users see when
enabling Hermes Bridge on the direct-install track. Unlike the Google Play
flavor we can be explicit about voice + vision + full device control here:
the sideload user is assumed to be a power user who installed an APK by
hand, not a Play Store customer.
Do NOT reuse these strings in the googlePlay flavor — Play reviewers may
flag any mention of "full read/write access" or "hands-free control" as
outside the declared use case.
-->
<resources>
<string name="app_name">Hermes Dev</string>
<string name="a11y_service_label">Hermes-Bridge Dev</string>
<string name="notification_companion_label">Hermes Dev 通知コンパニオン</string>
<string name="a11y_description_sideload">Hermes Bridge は、エージェントに電話機への完全な読み取り/書き込みアクセス権を与え、音声と視覚によるハンズフリー制御を可能にします。すべてのアクションはアクティビティ ログに記録され、破壊的なアクションにはユーザーの確認が必要です。</string>
</resources>
@@ -26,6 +26,10 @@ class AppLanguageTest {
@Test
fun addedLocaleTagsResolveToTheirPickerOptions() {
assertEquals(AppLanguage.GERMAN, AppLanguage.fromLanguageTags("de-DE"))
assertEquals(AppLanguage.BRAZILIAN_PORTUGUESE, AppLanguage.fromLanguageTags("pt-BR"))
assertEquals(AppLanguage.BRAZILIAN_PORTUGUESE, AppLanguage.fromLanguageTags("pt-PT"))
assertEquals(AppLanguage.JAPANESE, AppLanguage.fromLanguageTags("ja-JP"))
assertEquals(AppLanguage.SPANISH, AppLanguage.fromLanguageTags("es-MX"))
}
@@ -33,6 +37,9 @@ class AppLanguageTest {
fun languageOptionsProduceExpectedLocaleLists() {
assertTrue(AppLanguage.SYSTEM_DEFAULT.toLocaleList().isEmpty)
assertEquals("en", AppLanguage.ENGLISH.toLocaleList().toLanguageTags())
assertEquals("de", AppLanguage.GERMAN.toLocaleList().toLanguageTags())
assertEquals("pt-BR", AppLanguage.BRAZILIAN_PORTUGUESE.toLocaleList().toLanguageTags())
assertEquals("ja", AppLanguage.JAPANESE.toLocaleList().toLanguageTags())
assertEquals("zh-Hans", AppLanguage.SIMPLIFIED_CHINESE.languageTag)
assertEquals("es", AppLanguage.SPANISH.toLocaleList().toLanguageTags())
assertEquals(
@@ -1046,6 +1046,11 @@ class ChatHandlerTest {
// Server ids adopted onto the live rows.
assertEquals("srv-1", user.id)
assertEquals("srv-2", assistant.id)
// Compose identity stays on the live rows. A same-count post-turn
// reload must not remove/reinsert the two bubbles just because their
// authoritative ids arrived.
assertEquals("uuid-user", user.uiKey)
assertEquals("uuid-assistant", assistant.uiKey)
// State carried by id, in place.
assertEquals(1, user.attachments.size)
assertEquals("outb64", user.attachments[0].content)
@@ -1056,6 +1061,39 @@ class ChatHandlerTest {
assertFalse(assistant.isStreaming)
}
@Test
fun loadMessageHistory_listRebuildPreservesMatchedTailUiKey() {
// Some persisted turns rebuild one live streaming bubble into several
// server rows (for example, restored message boundaries/tool output).
// The reconciled tail must retain its UI identity even while new rows
// are inserted around it, otherwise LazyColumn loses the viewport
// anchor on a long answer.
handler.addPlaceholderMessage(
ChatMessage(
id = "uuid-live-tail",
role = MessageRole.ASSISTANT,
content = "final chunk",
timestamp = 3L,
isStreaming = false,
)
)
handler.loadMessageHistory(
listOf(
MessageItem(id = "srv-user", role = "user", content = JsonPrimitive("question"), timestamp = 1.0),
MessageItem(id = "srv-prefix", role = "assistant", content = JsonPrimitive("earlier chunk"), timestamp = 2.0),
MessageItem(id = "srv-tail", role = "assistant", content = JsonPrimitive("final chunk"), timestamp = 3.0),
)
)
val messages = handler.messages.value
assertEquals(3, messages.size)
assertEquals("uuid-live-tail", messages.single { it.id == "srv-tail" }.uiKey)
assertEquals("srv-user", messages.single { it.id == "srv-user" }.uiKey)
assertEquals("srv-prefix", messages.single { it.id == "srv-prefix" }.uiKey)
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
}
@Test
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
// Once the first reload adopts the server id, subsequent reloads match by
@@ -382,6 +382,7 @@ class GatewayChatClientTest {
val toolGenerating = ConcurrentLinkedQueue<String>()
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
val usages = ConcurrentLinkedQueue<UsageInfo>()
val reconcileRequests = AtomicInteger(0)
val completeLatch = CountDownLatch(1)
val preflightFailures = ConcurrentLinkedQueue<String>()
@@ -394,6 +395,7 @@ class GatewayChatClientTest {
onToolCallDone = { id, result -> toolDone += id to result },
onToolCallFailed = { _, _ -> },
onTurnComplete = { },
onReconcileRequired = { reconcileRequests.incrementAndGet() },
onComplete = { completeLatch.countDown() },
onUsage = { it?.let(usages::add) },
onError = { errors += it; completeLatch.countDown() },
@@ -503,6 +505,7 @@ class GatewayChatClientTest {
assertEquals(listOf("Hi!"), r.textDeltas.toList())
assertEquals(listOf("20260612_120000_abc123"), r.sessionIds.toList())
assertEquals(5, r.usages.firstOrNull()?.resolvedInputTokens)
assertEquals(0, r.reconcileRequests.get())
assertTrue(r.errors.isEmpty())
assertTrue(r.preflightFailures.isEmpty())
}
@@ -978,6 +981,7 @@ class GatewayChatClientTest {
assertTrue("turn never completed after rejoin", r.completeLatch.await(10, TimeUnit.SECONDS))
assertEquals(listOf("after rejoin"), r.textDeltas.toList())
assertEquals(1, r.reconcileRequests.get())
assertTrue("rejoined turn must not error, got ${r.errors}", r.errors.isEmpty())
// The fix's core invariant: a mid-turn rejoin must NEVER session.resume.
assertTrue(
@@ -32,6 +32,7 @@ class GatewayEventMapperTest {
val sessionIds = mutableListOf<String>()
var starts = 0
var turnCompletes = 0
var reconcileRequests = 0
var completes = 0
var usage: UsageInfo? = null
var usageCalls = 0
@@ -47,6 +48,7 @@ class GatewayEventMapperTest {
onToolCallFailed = { id, err -> toolFails += id to err },
onToolOutputRisk = { toolOutputRisks += it },
onTurnComplete = { turnCompletes++ },
onReconcileRequired = { reconcileRequests++ },
onComplete = { completes++ },
onUsage = { usage = it; usageCalls++ },
onError = { errors += it },
@@ -1,147 +0,0 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MarkdownStreamingParserTest {
@Test
fun activeParagraph_remainsRawUntilAStableBlockBoundary() {
assertEquals(
listOf(StreamingMarkdownBlock.Text("A paragraph still arriving")),
parseStreamingMarkdownBlocks("A paragraph still arriving"),
)
// A single newline is a Markdown soft break, not a stable block split.
assertEquals(
listOf(StreamingMarkdownBlock.Text("line one\nline two")),
parseStreamingMarkdownBlocks("line one\nline two"),
)
}
@Test
fun blankLine_promotesSettledPrefixToRealMarkdown() {
assertEquals(
listOf(
StreamingMarkdownBlock.Markdown("## Stable heading"),
StreamingMarkdownBlock.Text("- one\n- two\n\nTail still arriving"),
),
parseStreamingMarkdownBlocks(
"## Stable heading\n\n- one\n- two\n\nTail still arriving",
),
)
}
@Test
fun openFence_keepsBlankLinesInsideTheActiveCodeBlock() {
assertEquals(
listOf(
StreamingMarkdownBlock.Markdown("Intro"),
StreamingMarkdownBlock.Code(
language = "kotlin",
code = "val first = 1\n\nval second = 2",
),
),
parseStreamingMarkdownBlocks(
"Intro\n\n```kotlin\nval first = 1\n\nval second = 2",
),
)
}
@Test
fun closedFence_staysOnTheStreamingCodeSurfaceUntilFinal() {
val blocks = parseStreamingMarkdownBlocks(
"```kotlin\nval answer = 42\n```\n\nNext paragraph",
)
assertEquals(2, blocks.size)
assertEquals(
StreamingMarkdownBlock.Code(
language = "kotlin",
code = "val answer = 42",
),
blocks[0],
)
assertEquals(StreamingMarkdownBlock.Text("Next paragraph"), blocks[1])
}
@Test
fun longerFence_isNotClosedByShorterFenceInsideCode() {
assertEquals(
listOf(
StreamingMarkdownBlock.Code(
language = "markdown",
code = "```\ninside\n```",
),
),
parseStreamingMarkdownBlocks(
"````markdown\n```\ninside\n```\n````",
),
)
}
@Test
fun table_staysRawUntilTheFinalCommonMarkParse() {
val content = "| Name | Value |\n| --- | --- |\n| Alpha | 1 |\n| Beta |"
assertEquals(
listOf(StreamingMarkdownBlock.Text(content)),
parseStreamingMarkdownBlocks(content),
)
}
@Test
fun incompleteTableDelimiter_doesNotPrematurelyPromoteTheTable() {
val content = "| Name | Value |\n| --- | --"
assertEquals(
listOf(StreamingMarkdownBlock.Text(content)),
parseStreamingMarkdownBlocks(content),
)
}
@Test
fun escapedHeaderPipe_doesNotMakeTheTableLookSettled() {
val content = "| Name \\| alias | Value |\n| --- | --- |\n| Alpha | 1 |\n| Beta |"
assertEquals(
listOf(StreamingMarkdownBlock.Text(content)),
parseStreamingMarkdownBlocks(content),
)
}
@Test
fun listAndContinuation_stayTogetherUntilFinal() {
val content = "- first paragraph\n\n continuation\n- second"
assertEquals(
listOf(StreamingMarkdownBlock.Text(content)),
parseStreamingMarkdownBlocks(content),
)
}
@Test
fun lazyBlockQuoteContinuation_isNeverSplitIntoASettledPrefix() {
val content = "> quoted line\n\nlazy continuation"
assertEquals(
listOf(StreamingMarkdownBlock.Text(content)),
parseStreamingMarkdownBlocks(content),
)
}
@Test
fun crlfInput_isNormalizedWithoutLeakingCarriageReturns() {
val blocks = parseStreamingMarkdownBlocks("First\r\n\r\nSecond")
assertEquals(
listOf(
StreamingMarkdownBlock.Markdown("First"),
StreamingMarkdownBlock.Text("Second"),
),
blocks,
)
assertTrue(blocks.none { it.toString().contains('\r') })
}
}
@@ -0,0 +1,18 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Test
class StreamingMarkdownContentTest {
@Test
fun liveText_discardsOnlyLeadingBlankLines() {
assertEquals(
"First line\n\nSecond line",
"\n\r\n \t\nFirst line\n\nSecond line".withoutLeadingBlankLines(),
)
assertEquals(
" indented code",
"\n\n indented code".withoutLeadingBlankLines(),
)
}
}
@@ -0,0 +1,71 @@
package com.hermesandroid.relay.ui.screens
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatScrollSnapshotTest {
@Test
fun `same-tail stream completion requests an atomic bottom anchor`() {
val streaming = snapshot(isStreaming = true)
val complete = snapshot(isStreaming = false)
assertTrue(complete.isCompletionAfter(streaming))
}
@Test
fun `tail replacement is not mistaken for stream completion`() {
val streaming = snapshot(isStreaming = true)
val replaced = snapshot(isStreaming = false, lastMessageUiKey = "replacement-tail")
assertFalse(replaced.isCompletionAfter(streaming))
}
@Test
fun `message list rebuild is not mistaken for stream completion`() {
val streaming = snapshot(isStreaming = true)
val rebuilt = snapshot(isStreaming = false, messageCount = 10)
assertFalse(rebuilt.isCompletionAfter(streaming))
}
@Test
fun `ordinary streaming growth is not a completion`() {
val before = snapshot(contentLength = 4_000, isStreaming = true)
val after = snapshot(contentLength = 4_500, isStreaming = true)
assertFalse(after.isCompletionAfter(before))
}
@Test
fun `starting a stream is not a completion`() {
val idle = snapshot(isStreaming = false)
val streaming = snapshot(isStreaming = true)
assertFalse(streaming.isCompletionAfter(idle))
}
@Test
fun `server id adoption remains an observable tail change`() {
val local = snapshot(isStreaming = false)
val reconciled = local.copy(lastMessageId = "assistant-server-id")
assertNotEquals(local, reconciled)
}
private fun snapshot(
contentLength: Int = 12_000,
isStreaming: Boolean,
messageCount: Int = 8,
lastMessageUiKey: String = "assistant-ui-key",
) = ChatScrollSnapshot(
messageCount = messageCount,
lastMessageId = "assistant-live-id",
lastMessageUiKey = lastMessageUiKey,
lastContentLength = contentLength,
lastThinkingLength = 1_200,
lastToolCallCount = 2,
isStreaming = isStreaming,
)
}
@@ -0,0 +1,28 @@
package com.hermesandroid.relay.viewmodel
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatTurnCompletionPolicyTest {
@Test
fun `successful Sessions turns reload persisted message boundaries`() {
assertTrue(shouldReloadHistoryAfterSuccessfulTurn("sessions", gatewayReconcileRequired = false))
}
@Test
fun `healthy uninterrupted Gateway turns keep their live transcript`() {
assertFalse(shouldReloadHistoryAfterSuccessfulTurn("gateway", gatewayReconcileRequired = false))
}
@Test
fun `Gateway turns with a socket gap reload potentially missed events`() {
assertTrue(shouldReloadHistoryAfterSuccessfulTurn("gateway", gatewayReconcileRequired = true))
}
@Test
fun `stateless structured transports do not reload session history`() {
assertFalse(shouldReloadHistoryAfterSuccessfulTurn("runs", gatewayReconcileRequired = false))
assertFalse(shouldReloadHistoryAfterSuccessfulTurn("completions", gatewayReconcileRequired = false))
}
}
@@ -0,0 +1,111 @@
package com.hermesandroid.relay.viewmodel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class StreamDeltaCoalescerTest {
@Test
fun `token burst drains across display paced frames`() = runTest {
val textBatches = mutableListOf<String>()
val coalescer = StreamDeltaCoalescer(
scope = this,
onTextDelta = textBatches::add,
onThinkingDelta = {},
)
repeat(100) { coalescer.appendText("x") }
runCurrent()
assertTrue(textBatches.isEmpty())
advanceTimeBy(STREAM_DELTA_FRAME_MS - 1)
runCurrent()
assertTrue(textBatches.isEmpty())
advanceTimeBy(1)
runCurrent()
assertEquals(listOf("x".repeat(streamDeltaFrameBudget(100))), textBatches)
assertTrue(textBatches.joinToString("").length < 100)
advanceUntilIdle()
assertEquals("x".repeat(100), textBatches.joinToString(""))
assertTrue(textBatches.size > 1)
}
@Test
fun `text and thinking transitions preserve stream order`() = runTest {
val events = mutableListOf<String>()
val coalescer = StreamDeltaCoalescer(
scope = this,
onTextDelta = { events += "text:$it" },
onThinkingDelta = { events += "thinking:$it" },
)
coalescer.appendThinking("plan ")
coalescer.appendThinking("first")
coalescer.appendText("answer ")
coalescer.appendText("next")
coalescer.appendThinking("tail")
coalescer.flushNow()
assertEquals(
listOf("thinking:plan first", "text:answer next", "thinking:tail"),
events,
)
}
@Test
fun `terminal flush cancels the scheduled duplicate`() = runTest {
val textBatches = mutableListOf<String>()
val coalescer = StreamDeltaCoalescer(
scope = this,
onTextDelta = textBatches::add,
onThinkingDelta = {},
)
coalescer.appendText("complete before timer")
coalescer.flushNow()
advanceUntilIdle()
assertEquals(listOf("complete before timer"), textBatches)
}
@Test
fun `frame pacing never separates a surrogate pair`() = runTest {
val textBatches = mutableListOf<String>()
val coalescer = StreamDeltaCoalescer(
scope = this,
onTextDelta = textBatches::add,
onThinkingDelta = {},
)
coalescer.appendText("1234567🚀tail")
advanceUntilIdle()
assertEquals("1234567🚀tail", textBatches.joinToString(""))
assertTrue(textBatches.none { it.endsWith('\uD83D') })
assertTrue(textBatches.none { it.startsWith('\uDE80') })
}
@Test
fun `discard drops pending content`() = runTest {
val textBatches = mutableListOf<String>()
val coalescer = StreamDeltaCoalescer(
scope = this,
onTextDelta = textBatches::add,
onThinkingDelta = {},
)
coalescer.appendText("old connection")
coalescer.discard()
advanceUntilIdle()
assertTrue(textBatches.isEmpty())
}
}
+5 -4
View File
@@ -369,7 +369,7 @@ Key data classes: `MessageEvent` (inbound), `SendResult` (outbound), `SessionSou
- **Upstream doesn't solve this on the HTTP API surface.** Verification against `~/AppData/Local/Temp/hermes-agent/gateway/platforms/api_server.py` shows `APIServerAdapter.send()` is an explicit no-op with the comment `"API server uses HTTP request/response, not send()"`. `_write_sse_chat_completion` (api_server.py:651-757) streams raw `stream_q` deltas straight into SSE `content` chunks — it never invokes `extract_media()` and never routes deltas through `GatewayStreamConsumer` (which would at least strip `MEDIA:` tags via `_MEDIA_RE` at `stream_consumer.py:188`). The upstream `extract_media()` / `send_document()` calls at `gateway/run.py:4570`, `4747`, `4349` are only reachable from **non-streaming** paths (background tasks, cron, batch) and push-style platform adapters (Telegram, Feishu, WeChat, Slack), all of which override `send_document` with real platform APIs. The pull-based HTTP adapter inherits the base class default, which falls back to `self.send(chat_id, f"📎 File: {file_path}")` — which is the no-op. So `MEDIA:/tmp/...` has always passed through our chat stream as literal text.
- **Inline base64 in tool output was the obvious alternative but blows up LLM context.** A 1280×720 JPEG is ~135 KB base64, and every subsequent turn's context window has to re-ingest the bytes. Scales badly for video or multiple attachments per turn. Opaque tokens are ~25 chars and add essentially zero context cost.
- **No upstream PR in scope.** Fixing this properly upstream would mean implementing `send_document` on `APIServerAdapter` (likely via a side-channel SSE event or a new attachment field on the chat-completion chunk shape). That's a community-scoped API change, and user explicitly wanted an in-plugin workaround, not a fork.
- **Our relay is already the right place.** The plugin's relay server (`plugin/relay/server.py`) is a service we already own, already has HTTP routes (`/health`, `/pairing`, `/pairing/register`), already uses `SessionManager` for bearer-auth'd channels, and already lives on the phone's trust boundary (paired via the same QR). Adding file-serving doesn't create a new security surface or a new credential store — it reuses both.
- **Our relay is already the right place.** The plugin's relay server (`plugin/relay/server.py`) is a service we already own, already has HTTP routes (`/health`, `/pairing/register`), already uses `SessionManager` for bearer-auth'd channels, and already lives on the phone's trust boundary (paired via the same QR). Adding file-serving doesn't create a new security surface or a new credential store — it reuses both.
**How it works:**
@@ -465,7 +465,8 @@ The bare-path fetch is therefore safe as long as operators treat the allowed-roo
- **Grants on a single token (not multiple tokens)** — one WSS connection, one auth envelope, one session lookup. Per-channel expiry is checked at channel message dispatch time via `Session.channel_is_expired(name)`. Simpler to reason about than multiple parallel tokens, and the phone only needs one storage slot.
- **`math.inf` for never-expire** — represents "truly unbounded" in code, serializes to `null` on the wire (JSON doesn't have an infinity literal, and null maps cleanly to Kotlin's nullable `Long?`). `canonicalize()` uses `allow_nan=False` so accidentally trying to sign a payload with a raw `math.inf` crashes loudly — callers must explicitly emit `None`/`0`. Prevents silent serialization bugs.
- **Metadata on pairing entries, host wins over phone** — when the host operator runs `hermes pair --ttl 7d` and the phone sends `ttl_seconds=30d` in the auth envelope (because the user picked a different value on the TTL dialog), the host value wins. Operator policy is authoritative. If the host didn't specify anything, the phone's value applies.
- **Metadata on pairing entries is host-authoritative** — when the host operator runs `hermes pair --ttl 7d` and the phone sends `ttl_seconds=30d` in the auth envelope (because the user picked a different value on the TTL dialog), the host value wins. If host metadata is absent, the relay uses bounded server defaults; network clients never author session lifetime or grants. The legacy anonymous `POST /pairing` code-mint route is intentionally not registered, so every accepted code originates from a loopback-only operator flow.
- **Bearer session-policy changes are monotonic and self-only** — a Relay bearer may use `PATCH /sessions/{token_prefix}` only for its own token and only to shorten its session or grants. It cannot add grant names, lengthen a grant, switch to never-expire, or modify another session. Those authority-increasing changes require a fresh operator-approved pairing flow.
- **Token prefix (not full token) in `/sessions` responses** — a caller already holds their own full token; they should never see another session's full token. First 8 chars are enough to identify devices in a practical deployment (one operator, 1-3 phones) and enough entropy to avoid collisions. Collisions return 409 with the match count.
- **Always open the TTL picker (no skip)** — even when the QR carries an operator-chosen TTL, the dialog opens with that value preselected. The user is always in the loop for the trust decision. A future "don't ask again if QR specifies a TTL" toggle is a plausible refinement but not in this cut.
@@ -846,9 +847,9 @@ First attempt parsed a top-level `profiles:` / `agents:` list from one YAML. Tha
The hermes-agent dashboard exposes `/api/config` and `/api/skills` via `hermes_cli/web_server.py` — a separate loopback-only web server from the chat API at `:8642`. Two problems if we proxied through the dashboard:
1. **No profile scoping.** The dashboard's `/api/config` operates on the active profile only; there's no way to read another profile's config without switching first. Our relay already has the layout knowledge (`_load_profiles` scans the tree); duplicating that as "switch profile, read, switch back" is fragile and racy.
2. **Secrets leakage risk.** `config.yaml` never holds credentials (those live in `~/.hermes/.env` + `~/.hermes/auth.json`), but proxying a general-purpose config endpoint invites future callers to pick up sensitive fields. A purpose-built read route keeps the attack surface small and the shape explicit — the response is `{profile, path, config, readonly: true}`, with the `readonly` flag part of the contract so clients can't silently assume write support.
2. **Secrets leakage risk.** Credentials normally live in `~/.hermes/.env` or `~/.hermes/auth.json`, but Hermes also supports some credentials in `config.yaml` and extensions may add their own sensitive fields. A purpose-built read route therefore keeps the remote shape explicit: paired remote clients receive only `description` and `model.default`, while loopback operator callers can inspect the complete parsed file. The response remains `{profile, path, config, readonly: true}`, with `path: "config.yaml"` remotely so host layout is not disclosed.
Both endpoints trust the same boundary as every other phone-facing relay route: bearer-auth for remote callers, loopback for in-process dashboard proxy calls. `.env` and `auth.json` are **never** read or returned by these routes.
Both endpoints trust the same boundary as every other phone-facing relay route: bearer-auth for remote callers, loopback for in-process dashboard proxy calls. The config endpoint additionally enforces an explicit remote response schema rather than key-name redaction, so new or nested extension sections cannot silently become public. `.env` and `auth.json` are **never** read or returned by these routes.
**Why read-only in v0.7:**
+101 -8
View File
@@ -1,5 +1,5 @@
{
"schema_version": 2,
"schema_version": 3,
"canonical_locale": "en",
"verification_definitions": {
"canonical": "English source text that defines product meaning.",
@@ -8,6 +8,30 @@
"verified": "Comprehensively reviewed in context by a fluent contributor across the shipped surface."
},
"locales": {
"de": {
"native_name": "Deutsch",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
"android": "complete",
"readme": "english-fallback",
"user_docs": "core-pages",
"website": "complete"
},
"docs_locale": "de",
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
},
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
},
"en": {
"native_name": "English",
"verification": "canonical",
@@ -15,7 +39,8 @@
"surfaces": {
"android": "canonical",
"readme": "canonical",
"user_docs": "canonical"
"user_docs": "canonical",
"website": "canonical"
}
},
"es": {
@@ -23,28 +48,96 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "fe2f099227cc377e298aac67ae402099e252e5474eeab230ecde119e117149ae",
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
"android": "complete",
"readme": "english-fallback",
"user_docs": "english-fallback"
}
"user_docs": "core-pages",
"website": "complete"
},
"docs_locale": "es",
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
},
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
},
"ja": {
"native_name": "日本語",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
"android": "complete",
"readme": "english-fallback",
"user_docs": "core-pages",
"website": "complete"
},
"docs_locale": "ja",
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
},
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
},
"pt-BR": {
"native_name": "Português (Brasil)",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
"android": "complete",
"readme": "english-fallback",
"user_docs": "core-pages",
"website": "complete"
},
"docs_locale": "pt-BR",
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
},
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
},
"zh-Hans": {
"native_name": "简体中文",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "fe2f099227cc377e298aac67ae402099e252e5474eeab230ecde119e117149ae",
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
"android": "complete",
"readme": "maintained-summary",
"user_docs": "core-pages"
}
"user_docs": "core-pages",
"website": "complete"
},
"docs_locale": "zh-CN",
"docs_source_sha256": {
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
},
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
}
}
}
+63 -7
View File
@@ -1,8 +1,8 @@
# Localization
English is the canonical product language. Android ships Simplified Chinese and
Spanish catalogs; additional languages can be added without changing the runtime
architecture.
English is the canonical product language. Android also ships Brazilian
Portuguese, German, Japanese, Simplified Chinese, and Spanish catalogs;
additional languages can be added without changing the runtime architecture.
Translation coverage and linguistic verification are separate. Shipped locale
status is recorded in `docs/localization-status.json` as `ai-translated`,
@@ -11,10 +11,10 @@ technical gates pass; the status must not imply human review that did not occur.
See `docs/translation-playbook.md` for the required translation and critique
workflow.
Users can switch between System default, English, Spanish, and Simplified Chinese from
Settings → Appearance → Language. The picker stays synchronized with Android's
per-app language setting; Android 12 and lower use AppCompat's automatic locale
storage.
Users can switch between System default, English, Brazilian Portuguese, German,
Japanese, Spanish, and Simplified Chinese from Settings → Appearance → Language.
The picker stays synchronized with Android's per-app language setting; Android
12 and lower use AppCompat's automatic locale storage.
## Android resource contract
@@ -92,6 +92,62 @@ should link to the canonical English page rather than copying stale content.
status. README and user-documentation translations may follow app translation;
maintainer `docs/` and ADRs remain canonical English.
The public documentation currently localizes a deliberately bounded first-run
set for every Android locale:
- documentation home;
- Quick Start;
- condensed Installation & Setup;
- release-track choice;
- symptom-first Troubleshooting.
Fast-moving API, architecture, security, CLI, and operator references remain
canonical English and are linked from localized pages instead of copied. Each
localized page declares `translation_status` and `canonical_source` in its
frontmatter. `docs_source_sha256` in the status registry records the exact
English page revision used for every locale.
Validate localized documentation and links with:
```bash
python scripts/check-user-docs-locales.py
```
After intentionally refreshing all five locale versions of a changed English
core page, record the new canonical hashes with:
```bash
python scripts/check-user-docs-locales.py --refresh
```
The validator rejects stale source hashes, missing pages, broken internal
links, unbalanced code fences, and translated or invented executable lines.
VitePress runs this gate automatically before development and production builds.
## Marketing website
The product site ships the same locale set under `/de/`, `/es/`, `/ja/`,
`/pt-BR/`, and `/zh-CN/`. Marketing copy, navigation, accessibility labels, and
page metadata are localized. Product screenshots, command examples, and live UI
recreations remain unchanged so they continue to represent the shipped product.
Validate the typed copy dictionaries and their English-source freshness with:
```bash
python scripts/check-website-locales.py
```
After reviewing every marketing translation against an intentional English copy
change, record the new source hash with:
```bash
python scripts/check-website-locales.py --refresh
```
The Astro development, check, and production-build commands run this gate
automatically. Locale routes publish their own canonical URL, language metadata,
alternate-language links, and sitemap entry.
## Translation corrections and pull requests
Keep translation PRs scoped to one locale or one clearly described catalog
+2 -2
View File
@@ -127,13 +127,13 @@ cell "identical").
### Editor validation (JSON Schema)
A JSON Schema for this manifest is published at
`https://codename-11.github.io/hermes-relay/pet.schema.json` (source of truth:
`https://hermes-relay.dev/docs/pet.schema.json` (source of truth:
`user-docs/public/pet.schema.json`). Add it as the first key of a `pet.json` for
editor autocomplete and inline validation — and for an AI agent to lint its own
output against:
```json
{ "$schema": "https://codename-11.github.io/hermes-relay/pet.schema.json", "id": "blob", "states": { "idle": { "frames": ["idle.png"], "fps": 6 } } }
{ "$schema": "https://hermes-relay.dev/docs/pet.schema.json", "id": "blob", "states": { "idle": { "frames": ["idle.png"], "fps": 6 } } }
```
The `$schema` key is an unknown field to the loader and is silently ignored
+3 -3
View File
@@ -291,13 +291,12 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|-------|--------|---------|
| `/ws`, `/` | GET (upgrade) | Main WebSocket endpoint. Phone connects, sends `system/auth`, then multiplexes `chat`/`terminal`/`bridge` envelopes. |
| `/health` | GET | Returns `{status, version, clients, sessions}` JSON. |
| `/pairing` | POST | Generate a new relay-side pairing code. Returns `{"code": "ABC123"}`. Unrestricted (intended for host-local callers). |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the phone's chosen values (or the SessionManager defaults) are used. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim. |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the SessionManager's bounded defaults are used. Client-supplied policy in the WebSocket auth envelope is never authoritative. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and desktop pair/repair flows. Reads `API_SERVER_KEY` from the host-local config chain when the dashboard does not pass `api_key` explicitly. Optional request field `dashboard_url` is mirrored into the QR payload and response. |
| `/pairing/approve` | POST | **Loopback only, Phase 3 stub.** Same wire shape and loopback gate as `/pairing/register` — present so the Android client can target the route today. The semantic difference (operator reviewing a phone-initiated pending code before approval) still needs the pending-codes store + approval UX, marked `# TODO(Phase 3)` in the handler. |
| `/sessions` | GET | Bearer-auth'd. Returns `{"sessions": [ {token_prefix, device_name, device_id, created_at, last_seen, expires_at, grants, transport_hint, is_current}, ... ]}` for all currently-active paired devices. `token_prefix` is the first 8 characters of the session token — full tokens are NEVER included, so a caller holding one session token can't extract another. `expires_at` and grant values that are `math.inf` serialize as `null` (never expire). `is_current` is true for the session matching the caller's bearer. 401 on missing/invalid bearer. Used by the Android Paired Devices screen. **Loopback branch (2026-04-18):** callers on `127.0.0.1` / `::1` may skip the bearer and receive the same `{sessions: [...]}` payload without the `is_current` flag (no caller context). Added so the dashboard plugin proxy can list paired devices without needing to mint its own bearer. Non-loopback callers still require the bearer and retain `is_current`. |
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by first-N-char token prefix (N ≥ 4). Returns 200 `{"ok": true, "revoked_self": bool}` on exact match; 404 on zero matches; 409 on ambiguous (2+) matches with the count in the body. Self-revoke is allowed and flagged via `revoked_self: true` so the caller knows to wipe local state. Any paired device can revoke any other — see ADR 15 for the trade-off rationale. |
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd. Update a paired device's session TTL and/or per-channel grants in place. Body: `{"ttl_seconds": 2592000}` (extend only) or `{"grants": {"terminal": 604800}}` (grants only) or both. `ttl_seconds = 0` means never-expire. **Semantics: TTL restarts the clock from now** — "extend by 30 days" = "30 days from now", not "old expiry + 30 days". If `grants` is omitted but `ttl_seconds` is provided and shorter than the existing expiry, existing grants are automatically clamped to the new session lifetime (no grant outlives its session). Returns 200 with the updated `{expires_at, grants}`; 400 on missing/invalid body; 404 on prefix miss; 409 on ambiguous prefix. Backs the Android Paired Devices "Extend" button. |
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd, self-targeted, and reduction-only. Body `{"ttl_seconds": 3600}`, `{"grants": {"terminal": 600}}`, or both may shorten the caller's current session policy. A bearer cannot target another session, extend its lifetime, add or lengthen grants, or change a finite expiry to never-expire; authority-increasing changes require a fresh operator-approved pairing flow. Omitted grants retain their existing absolute ceilings and are clamped if the parent session is shortened. Returns 200 with the reduced `{expires_at, grants}`; 400 on missing/invalid or unknown grants; 403 on cross-session targets or policy expansion; 404 on prefix miss; 409 on ambiguous prefix. |
| `/clipboard/inbox` | POST | Bearer-auth'd clipboard rendezvous used by remote clients before native platform clipboard fallback. |
| `/media/register` | POST | **Loopback only.** Register a file path with the in-memory `MediaRegistry` and receive an opaque token. Used by host-local tools (`android_screenshot` etc.) to make a file fetchable by the paired phone without leaking the filesystem path. Request body: `{"path": "/abs/path", "content_type": "image/jpeg", "file_name": "screenshot.jpg"}`. Response: `{"ok": true, "token": "<url-safe-16>", "expires_at": <unix>}`. Returns 403 for non-loopback callers, 400 on validation failure (relative path, missing file, oversized, outside allowed roots, etc). Path sandboxing is enforced server-side — see ADR 14. |
| `/media/upload` | POST | Bearer-auth'd small upload endpoint for phone-originated media. Accepts JSON `{file_name, content_type, content}` where `content` is base64 and registers the decoded bytes with the media registry. |
@@ -322,6 +321,7 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
| `/voice/realtime-agent/session` | POST | Creates a brokered Realtime Agent session bound to the active profile, optional Hermes chat session id, provider/model/voice/sample-rate, auth principal, and event log path. |
| `/voice/realtime-agent/{session_id}` | GET websocket | Experimental broker websocket. For provider-native xAI or OpenAI sessions, Android sends `session.start`, `input_audio.append`, `input_audio.commit` without transcript text, `playback.drained`, `response.cancel`, `hermes.confirm`, and `session.close`. The relay streams PCM to the provider, normalizes transcript/audio/function-call events, brokers only the approved Hermes functions, and sends input transcript events, Hermes session/tool/confirmation state, provider PCM as `voice.output_audio.delta`, and final `voice.response.done`. Hermes remains the owner of tools, memory, transcript persistence, Android bridge safety, confirmations, and cancellation. |
| `/bridge/activity` | GET | **Loopback only.** Returns the `BridgeHandler.recent_commands` ring buffer (max 100 entries) as `{"activity": [ {request_id, method, path, params, sent_at, response_status, result_summary, error, decision}, ... ]}` — newest first. Query param: `?limit=N` (1–500, default 100) caps the response size. `params` is redacted for any key in `{password, token, secret, otp, bearer}`; `decision` is one of `pending` / `executed` / `blocked` / `confirmed` / `timeout` / `error`. 403 for non-loopback callers. Consumed by the dashboard plugin's Bridge Activity tab. |
| Device Control routes (`/screen`, `/tap`, `/type`, and peers) | GET/POST | Require `Authorization: Bearer <session_token>` and an active `bridge` grant before any request data is forwarded to a connected Android client. Host tools supply the token through `ANDROID_BRIDGE_TOKEN`; loopback callers do not bypass this gate. |
| `/media/inspect` | GET | **Loopback only.** Returns `{"media": [ {token, file_name, content_type, size, created_at, expires_at, last_accessed, is_expired}, ... ]}` — `MediaRegistry.list_all()` snapshot, newest first. Absolute file paths are **never** included — only `file_name` (basename). Query param: `?include_expired=true` includes evicted entries (default false, hides them). 403 for non-loopback callers. Consumed by the dashboard plugin's Media Inspector tab. |
| `/relay/info` | GET | Aggregate status and capability contract. Loopback dashboard calls may omit auth; remote callers require a paired-device bearer. Returns backward-compatible `version` plus `plugin_version`, `protocol_version`, stable `capabilities`, per-profile `relay_state`, counters, and `health`. |
| `/relay/security` | GET/PATCH | **Loopback only.** Runtime security toggles for local operators, `hermes relay insecure-api-key`, and `hermes-relay insecure-api-key`. `GET` returns `{"allow_insecure_api_bearer": false, "trust_proxy_headers": false, "scope": "runtime"}`. `PATCH {"allow_insecure_api_bearer": true}` enables plain-LAN API-key voice auth immediately for the running relay; `false` disables it. This is not persisted across restarts. |
+5 -5
View File
@@ -88,7 +88,7 @@ Connection lifecycle, auth, keepalive.
| Type | Direction | Payload |
|------|-----------|---------|
| `auth` (pairing mode) | App → Server | `{ pairing_code, ttl_seconds?, grants?, device_name, device_id }` — `ttl_seconds` / `grants` come from the phone's TTL picker dialog; host metadata wins over phone metadata when both are present |
| `auth` (pairing mode) | App → Server | `{ pairing_code, ttl_seconds?, grants?, device_name, device_id }` — `ttl_seconds` / `grants` remain in the wire shape for client compatibility, but only policy attached by a loopback-only host flow is authoritative; missing host metadata uses bounded server defaults |
| `auth` (session mode) | App → Server | `{ session_token, device_name, device_id }` — ttl/grants are not re-sent; server keeps the grant table keyed on the original pair |
| `auth.ok` | Server → App | `{ session_token, server_version, profiles[], expires_at, grants, transport_hint }` — see below |
| `auth.fail` | Server → App | `{ reason }` |
@@ -300,6 +300,7 @@ Implementation references:
| Tailscale helper (first-class) | `plugin/relay/tailscale.py` + `hermes-relay-tailscale` CLI (ADR 25). Publishes the loopback relay over the tailnet via `tailscale serve --bg --https=<port>`; managed TLS + tailnet ACL identity. Optional, graceful-absent when the binary isn't installed. Auto-retires when upstream PR #9295 lands. See [`docs/remote-access.md`](remote-access.md). |
| Multi-endpoint pairing | Single QR carries an ordered list of `role: lan/tailscale/public/...` candidates with strict-priority selection (ADR 24). Phone re-probes reachability on every network change. Per-candidate `transport_hint` drives the plaintext-`ws://` consent dialog. |
| Device revocation | Paired Devices screen → `GET /sessions` (tokens masked to 8-char prefix) / `DELETE /sessions/{token_prefix}` (self-revoke allowed, wipes local state + redirects to pair flow). Any paired device can revoke any other — trade-off documented in ADR 15. |
| Session policy updates | `PATCH /sessions/{token_prefix}` is self-targeted and reduction-only for normal Relay bearers. Extending a lifetime, adding or lengthening grants, or changing another session requires a fresh operator-approved pairing flow. |
| Terminal gate | Biometric/PIN required before terminal access (planned). |
---
@@ -432,10 +433,9 @@ HTTP routes registered by `create_app()` in `plugin/relay/server.py`:
|-------|--------|---------|
| `/ws`, `/` | GET (upgrade) | WebSocket handler — main multiplexed channel |
| `/health` | GET | Health check — returns `{status, version, clients, sessions}` |
| `/pairing` | POST | Generate a new relay-side pairing code |
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code. Used by the pair command (`hermes pair`, `/hermes-relay-pair`, or compatibility `hermes-pair`) to inject codes that will appear in QR payloads. Request: `{"code": "ABCD12"}`. Rejects non-loopback peers with HTTP 403. |
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) for dashboard and CLI/tray pair/repair flows. Optional request field `dashboard_url` is copied into the QR payload for custom dashboard routes. |
| `/api/profiles/{name}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}` — `config` is the parsed `config.yaml` for `~/.hermes/` (when `name == "default"`) or `~/.hermes/profiles/<name>/`. Loopback callers skip bearer; remote callers require the relay session bearer. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
| `/api/profiles/{name}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}`. Loopback callers receive the parsed `config.yaml` and absolute path. Remote callers require a relay session bearer and receive only the explicitly public `description` and `model.default` fields with `path: "config.yaml"`; arbitrary provider, platform, integration, and extension sections never cross the remote boundary. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
| `/api/profiles/{name}/avatar` | GET | Profile-scoped avatar discovery and image delivery. Searches direct children of the profile home for conventional names, preferring `avatar.*` then `profile.*` (`png`, `jpg`, `jpeg`, `webp`, `gif`; additional `profile-image`, `agent`, and `icon` stems are accepted). Synthetic `default` follows a valid sticky `active_profile` marker, matching its advertised identity. The resolved file must remain inside the profile home and satisfy the Relay media-size policy. Same loopback-or-session-bearer auth as the other profile reads. 404 when the profile or an image is absent. Android copies returned bytes into its existing device-local per-profile icon store. |
| `/api/profiles/{name}/skills` | GET | Profile-scoped skill enumeration. Walks `<profile>/skills/<category>/<skill>/SKILL.md` recursively; returns `{profile, skills: [{name, category, description, path, enabled: true}], total}`. Same auth model as `/config`. `name`/`description` come from YAML frontmatter when present, else directory basename. All skills report `enabled: true` today — see §22 for the toggle stub. |
| `/api/profiles/{name}/soul` | GET | Profile-scoped raw `SOUL.md` read. Returns `{profile, path, content, exists, size_bytes}` with optional `truncated: true` when content exceeds the 200KB inline cap. Absent SOUL.md returns 200 with `exists: false` and an empty content string so the Inspector can distinguish "no soul" from transport failure. Same auth model as `/config`. 404 on unknown profile; 500 `{error: "soul_read_failed"}` on decode error. See §22 in decisions.md. |
@@ -629,7 +629,7 @@ Wraps the existing relay protocol. When the agent calls `android_*` tools, the t
#### 6.4.1 `android_*` tool surface
Tools register against the Hermes plugin API in `plugin/tools/android_tool.py` (plus `plugin/tools/android_notifications.py`, `plugin/tools/android_navigate.py`). The Python-side Device Control tools issue HTTP requests to the relay on loopback; the relay forwards them to the phone over WSS; the sideload phone executes them via the accessibility service and returns structured responses. Google Play phones report `bridge.device_control_supported=false` from `/bridge/status`, so these tools are hidden from the agent and direct command probes fail closed with `error_code: device_control_sideload_only`.
Tools register against the Hermes plugin API in `plugin/tools/android_tool.py` (plus `plugin/tools/android_notifications.py`, `plugin/tools/android_navigate.py`). The Python-side Device Control tools issue bearer-authenticated HTTP requests to the relay on loopback using `ANDROID_BRIDGE_TOKEN`; the relay requires that session's active `bridge` grant before forwarding to the phone over WSS. The sideload phone executes commands via the accessibility service and returns structured responses. Google Play phones report `bridge.device_control_supported=false` from `/bridge/status`, so these tools are hidden from the agent and direct command probes fail closed with `error_code: device_control_sideload_only`.
**Baseline (pre-v0.4 — shipped in Phase 3 Wave 1):**
@@ -773,7 +773,7 @@ The `ActionResult.data` field indicates which tier succeeded (`"direct"` / `"par
- [x] Android Keystore session token storage (`SessionTokenStore` — `KeystoreTokenStore` with StrongBox-preferred via `setRequestStrongBoxBacked`, `LegacyEncryptedPrefsTokenStore` TEE-backed fallback, one-shot lossless migration on first launch)
- [x] User-chosen session TTL at pair time (`SessionTtlPickerDialog` — 1d / 7d / 30d / 90d / 1y / Never)
- [x] Per-channel grants on one session token (`Session.grants` — chat / terminal / bridge / TUI / split voice grants (`voice:config`, `voice:stt`, `voice:tts`), clamped to session lifetime)
- [x] Paired Devices screen (`PairedDevicesScreen` + `GET /sessions` + `DELETE /sessions/{prefix}` + `PATCH /sessions/{prefix}` for extend)
- [x] Paired Devices screen (`PairedDevicesScreen` + `GET /sessions` + `DELETE /sessions/{prefix}`; bearer-authenticated `PATCH /sessions/{prefix}` is self-targeted and reduction-only)
- [x] Transport security badge (`TransportSecurityBadge` — three states: secure / insecure-with-reason / insecure-unknown)
- [x] First-time insecure-mode ack dialog with reason picker (`InsecureConnectionAckDialog`)
- [x] Tailscale detection (`TailscaleDetector` — informational only)
+14
View File
@@ -53,6 +53,20 @@ After review:
- add recurring terminology corrections to this glossary;
- preserve translator credit and stale-PR lineage under `CONTRIBUTING.md`.
## Public documentation
Translate the first-run documentation as complete pages, preserving frontmatter,
internal links, component tags, command names, route paths, configuration keys,
and code samples exactly. Fast-moving API, relay-route, configuration, and
architecture reference remains canonical English until a locale has a durable
maintenance owner. Localized core pages should link to that reference rather
than copying it.
Run `python scripts/check-user-docs-locales.py` before previewing or building the
documentation. When a canonical English core page changes intentionally, review
every localized counterpart, then refresh its recorded source hash with
`python scripts/check-user-docs-locales.py --refresh`.
## Technical release gate
Run:
+16
View File
@@ -0,0 +1,16 @@
# Legacy documentation redirect
This directory is a temporary compatibility shim for Android releases that
hardcoded `https://codename-11.github.io/hermes-relay/` before PR #210.
Documentation is hosted only at `https://hermes-relay.dev/docs/`; GitHub Pages
serves redirect HTML and no documentation content.
The deployment workflow copies `redirect.html` to the project root, the Pages
404 fallback, and the exact deep-link paths embedded in released clients. The
JavaScript preserves the path, query string, and fragment while moving the
request under `/docs/`. The meta refresh and visible link provide a no-script
fallback to the guide root.
Removal is tracked in the repository root `TODO.md`. Do not delete this shim
solely because the first fixed release has shipped; honor the documented
compatibility window for older Play and sideload installations.
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<meta http-equiv="refresh" content="2;url=https://hermes-relay.dev/docs/" />
<link rel="canonical" href="https://hermes-relay.dev/docs/" />
<title>Hermes Relay documentation moved</title>
</head>
<body>
<main>
<h1>Hermes Relay documentation moved</h1>
<p>
Redirecting to
<a id="destination" href="https://hermes-relay.dev/docs/">hermes-relay.dev/docs</a>.
</p>
</main>
<script>
(() => {
const legacyBase = "/hermes-relay";
const pathname = window.location.pathname;
const suffix = pathname === legacyBase || pathname === `${legacyBase}/`
? "/"
: pathname.startsWith(`${legacyBase}/`)
? pathname.slice(legacyBase.length)
: "/";
const destination = new URL("https://hermes-relay.dev/docs/");
destination.pathname = `/docs/${suffix.replace(/^\/+/, "")}`;
destination.search = window.location.search;
destination.hash = window.location.hash;
document.getElementById("destination").href = destination.href;
window.location.replace(destination.href);
})();
</script>
</body>
</html>
+65
View File
@@ -1204,6 +1204,71 @@ class SessionManager:
self._save_to_disk()
return session
def reduce_session_policy(
self,
token: str,
ttl_seconds: int | None = None,
grants: dict[str, float] | None = None,
) -> Session | None:
"""Atomically reduce a live session's lifetime and grant ceilings.
Unlike :meth:`update_session`, this operation never rebuilds omitted
grants from defaults and never permits a policy expansion. It is the
safe self-service primitive for an ordinary session bearer; broader
updates require a separately authorized operator flow.
Raises :class:`ValueError` for unknown or malformed grants and
:class:`PermissionError` for any requested policy expansion.
"""
self._cleanup()
session = self._sessions.get(token)
if session is None or session.is_expired:
return None
now = time.time()
requested_session_expiry = session.expires_at
if ttl_seconds is not None:
requested_session_expiry = (
math.inf if ttl_seconds == 0 else now + float(ttl_seconds)
)
if requested_session_expiry > session.expires_at:
raise PermissionError(
"operator approval required to extend session"
)
requested_grant_expiries: dict[str, float] = {}
for channel, seconds in (grants or {}).items():
if channel not in session.grants:
raise ValueError(f"unknown grant {channel!r}")
if not isinstance(seconds, (int, float)) or not math.isfinite(
seconds
):
raise ValueError(f"invalid grant duration for {channel!r}")
if seconds < 0:
raise ValueError(f"invalid grant duration for {channel!r}")
requested_expiry = (
math.inf if seconds == 0 else now + float(seconds)
)
if requested_expiry > session.grants[channel]:
raise PermissionError(
f"operator approval required to expand grant {channel!r}"
)
requested_grant_expiries[channel] = requested_expiry
session.expires_at = requested_session_expiry
for channel, current_expiry in tuple(session.grants.items()):
requested_expiry = requested_grant_expiries.get(
channel,
current_expiry,
)
session.grants[channel] = _clamp_grant_to_session(
requested_expiry,
session.expires_at,
)
session.last_seen = now
self._save_to_disk()
return session
def active_count(self) -> int:
"""Return the number of non-expired sessions."""
self._cleanup()
+93 -68
View File
@@ -27,6 +27,7 @@ import logging
import math
import mimetypes
import os
import secrets
import signal
import ssl
import stat
@@ -186,16 +187,6 @@ async def handle_health(request: web.Request) -> web.Response:
)
async def handle_pairing(request: web.Request) -> web.Response:
"""Generate a new pairing code (for use by the Hermes agent/CLI).
POST /pairing → {"code": "ABC123"}
"""
server: RelayServer = request.app["server"]
code = server.pairing.generate_code()
return web.json_response({"code": code})
async def handle_pairing_register(request: web.Request) -> web.Response:
"""Pre-register an externally-provided pairing code.
@@ -810,22 +801,22 @@ async def handle_sessions_revoke(request: web.Request) -> web.Response:
async def handle_sessions_extend(request: web.Request) -> web.Response:
"""Update a paired device's session TTL and/or per-channel grants.
"""Reduce the calling device's session TTL and/or channel grants.
This is the "extend" action exposed on the phone's Paired Devices
screen, but also handles arbitrary TTL updates — passing
``ttl_seconds`` shorter than the current expiry clips the session
(equivalent to shortening, though the button is labeled "Extend"
for the common case). ``ttl_seconds == 0`` maps to never-expire.
A normal Relay bearer is session identity, not session-management
authority. It may reduce only its own live policy. Extending a
lifetime, adding or lengthening a grant, or changing another session
requires a fresh operator-approved pairing flow.
PATCH /sessions/{token_prefix}
Body: {"ttl_seconds": 2592000} # extend only
| {"grants": {"terminal": 604800}} # grants only
| {"ttl_seconds": 0, "grants": {...}} # both
Body: {"ttl_seconds": 300} # shorten only
| {"grants": {"terminal": 60}} # reduce grants
| {"ttl_seconds": 300, "grants": {...}} # both
→ 200 {"ok": true, "expires_at": ..., "grants": {...}}
→ 400 missing/invalid body or no fields provided
→ 401 missing/invalid bearer
→ 404 prefix doesn't match any active session
→ 403 cross-session target or policy expansion
→ 409 prefix matches multiple sessions
"""
server, current_session = _require_bearer_session(request)
@@ -873,7 +864,12 @@ async def handle_sessions_extend(request: web.Request) -> web.Response:
{"ok": False, "error": "'grants' must be an object"}, status=400
)
for k, v in grants.items():
if not isinstance(k, str) or not isinstance(v, (int, float)) or v < 0:
if (
not isinstance(k, str)
or not isinstance(v, (int, float))
or not math.isfinite(v)
or v < 0
):
return web.json_response(
{
"ok": False,
@@ -901,20 +897,27 @@ async def handle_sessions_extend(request: web.Request) -> web.Response:
)
target = matches[0]
updated = server.sessions.update_session(
target.token,
ttl_seconds=ttl_seconds,
grants=grants,
)
if not secrets.compare_digest(target.token, current_session.token):
raise web.HTTPForbidden(text="cannot modify another session")
try:
updated = server.sessions.reduce_session_policy(
target.token,
ttl_seconds=ttl_seconds,
grants=grants,
)
except ValueError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
except PermissionError as exc:
raise web.HTTPForbidden(text=str(exc)) from exc
if updated is None:
# Raced with expiry or revocation between find_by_prefix and update.
raise web.HTTPNotFound(text="session vanished mid-update, retry")
logger.info(
"Extended session %s... (%s)%s",
"Reduced session policy %s... (%s) [self]",
target.token[:8],
target.device_name,
" [self]" if target.token == current_session.token else "",
)
return web.json_response(
{
@@ -1704,17 +1707,10 @@ async def handle_media_by_path(request: web.Request) -> web.StreamResponse:
# channel to the connected phone.
#
# Auth model:
# * These routes are **unauthenticated** at the HTTP layer on purpose —
# the legacy relay was unauthenticated too, and the trust boundary
# is the same: only tools running on the same host as the relay can
# reach localhost:8767. The relay's default bind is 0.0.0.0 for the
# WebSocket side, but tools should always point at ``localhost``, so
# an attacker reaching port 8767 from the LAN would need the phone
# to also have auth'd with a valid pairing code — without a paired
# phone, every bridge HTTP call just returns 503.
# * If tightening is needed later, wrap these handlers with the same
# ``_require_bearer_session`` pattern used by ``/media/*`` — the
# bridge grant is already tracked per-session in ``Session.grants``.
# * Every route requires a live Relay bearer with an active ``bridge``
# grant. The same listener accepts external WebSocket connections, so
# callers are never trusted merely because host tools normally use
# ``localhost``.
#
# A paired but disconnected phone still drops bridge calls with 503 —
# the tool caller should retry or tell the user to reconnect the app.
@@ -1746,7 +1742,9 @@ async def _bridge_dispatch(
path: str,
) -> web.Response:
"""Forward an HTTP request to an Android bridge device."""
server: RelayServer = request.app["server"]
server, session = _require_bearer_session(request)
if session.channel_is_expired("bridge"):
raise web.HTTPForbidden(text="active bridge grant required")
method = request.method # GET or POST
params: dict[str, Any] = dict(request.query)
@@ -2455,8 +2453,33 @@ def _parse_skill_frontmatter(text: str) -> dict[str, Any]:
return data if isinstance(data, dict) else {}
def _public_profile_config(parsed: object) -> dict[str, Any]:
"""Build the explicitly public subset of a Hermes profile config.
Remote profile inspection must not serialize arbitrary configuration
sections: provider and extension fields may contain reusable credentials.
Keep this schema deliberately small and add fields only after classifying
them as safe for every paired Relay client.
"""
if not isinstance(parsed, dict):
return {}
public: dict[str, Any] = {}
description = parsed.get("description")
if isinstance(description, str):
public["description"] = description
model = parsed.get("model")
if isinstance(model, dict):
default_model = model.get("default")
if isinstance(default_model, str):
public["model"] = {"default": default_model}
return public
async def handle_profile_config(request: web.Request) -> web.Response:
"""Return the parsed ``config.yaml`` for a named profile.
"""Return a safe view of ``config.yaml`` for a named profile.
GET /api/profiles/{name}/config
→ 200 {"profile", "path", "config": {...}, "readonly": true}
@@ -2464,9 +2487,10 @@ async def handle_profile_config(request: web.Request) -> web.Response:
→ 404 profile dir missing or no config.yaml
→ 500 yaml parse error
Loopback callers may skip bearer auth (matches
``/notifications/recent``); remote callers must present a valid
relay session token.
Loopback callers may skip bearer auth and receive the complete parsed file.
Remote callers must present a valid relay session token and receive only
the explicitly public profile schema, never arbitrary config sections or
the host filesystem path.
"""
is_loopback = request.remote in ("127.0.0.1", "::1")
if is_loopback:
@@ -2521,11 +2545,14 @@ async def handle_profile_config(request: web.Request) -> web.Response:
if parsed is None:
parsed = {}
response_config = parsed if is_loopback else _public_profile_config(parsed)
response_path = str(config_path) if is_loopback else config_path.name
return web.json_response(
{
"profile": name,
"path": str(config_path),
"config": parsed,
"path": response_path,
"config": response_config,
"readonly": True,
}
)
@@ -3764,14 +3791,10 @@ async def _authenticate(
client_surface = str(payload.get("client_surface", "unknown") or "unknown")
device_form_factor = str(payload.get("device_form_factor", "unknown") or "unknown")
# The phone MAY send ttl_seconds / grants in its auth envelope, but
# if the operator pre-registered the code with metadata on the host
# side, those host-provided values win — the host has the authority
# to decide "how long and for which channels". Only fall back to the
# phone-sent fields when the code had no attached metadata (e.g. old
# phones predating the v2 auth envelope).
phone_ttl = payload.get("ttl_seconds")
phone_grants = payload.get("grants")
# Pairing policy is attached by a loopback-only operator flow. Clients
# may still send ttl_seconds / grants for wire compatibility, but those
# fields are not an authority boundary and must not influence sessions.
# Missing host metadata therefore resolves to SessionManager defaults.
detected_transport = _detect_transport_hint(request)
# Try session token first (reconnection)
@@ -3815,24 +3838,13 @@ async def _authenticate(
if pairing_code:
metadata = server.pairing.consume_code(pairing_code)
if metadata is not None:
# Thread host-side metadata (from /pairing/register) through
# to the session. Fall back to phone-sent values, then to
# library defaults.
# Thread host-side metadata (from a loopback-only pairing flow)
# through to the session. Missing metadata uses bounded library
# defaults; the network client cannot author session policy.
ttl_seconds: float | None = metadata.ttl_seconds
grants: dict[str, float] | None = metadata.grants
transport_hint = metadata.transport_hint or detected_transport
if ttl_seconds is None and isinstance(phone_ttl, (int, float)) and not isinstance(phone_ttl, bool):
if phone_ttl >= 0:
ttl_seconds = float(phone_ttl)
if grants is None and isinstance(phone_grants, dict):
cleaned: dict[str, float] = {}
for channel, value in phone_grants.items():
if isinstance(channel, str) and isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0:
cleaned[channel] = float(value)
if cleaned:
grants = cleaned
session = server.sessions.create_session(
device_name,
device_id,
@@ -3924,6 +3936,20 @@ async def _on_message(
)
_track_task(server, ws, task)
elif channel == "terminal":
token = server._clients.get(ws)
session = server.sessions.get_session(token) if token else None
if session is None or session.channel_is_expired("terminal"):
logger.warning(
"Rejected terminal message from device=%s: terminal grant expired",
session.device_id if session is not None else "unknown",
)
await _send_system(
ws,
"error",
{"message": "Terminal grant expired for this device"},
msg_id=msg_id,
)
return
task = asyncio.create_task(server.terminal.handle(ws, envelope))
_track_task(server, ws, task)
elif channel == "bridge":
@@ -4110,7 +4136,6 @@ def create_app(config: RelayConfig) -> web.Application:
app.router.add_get("/ws", handle_ws)
app.router.add_get("/", handle_ws)
app.router.add_get("/health", handle_health)
app.router.add_post("/pairing", handle_pairing)
app.router.add_post("/pairing/register", handle_pairing_register)
app.router.add_post("/pairing/mint", handle_pairing_mint)
app.router.add_post("/pairing/approve", handle_pairing_approve)
+3 -1
View File
@@ -121,7 +121,9 @@ def _synthesize_gemini(
raw = config.get("gemini")
gemini = dict(raw) if isinstance(raw, dict) else {}
for key in ("model", "voice", "base_url"):
# Never merge a request-supplied endpoint into credential-bearing host
# configuration. ``base_url`` is deliberately operator-controlled.
for key in ("model", "voice"):
value = overrides.get(key)
if isinstance(value, str) and value.strip():
gemini[key] = value.strip()
+3 -1
View File
@@ -506,7 +506,9 @@ def _extract_voice_overrides(payload: dict[str, Any]) -> dict[str, Any]:
nested = payload.get("enhanced") or payload.get("gemini")
source = nested if isinstance(nested, dict) else payload
overrides: dict[str, Any] = {}
for key in ("provider", "voice", "model", "base_url", "language"):
# Network destinations remain operator-controlled because provider
# adapters attach host-owned credentials to their configured endpoints.
for key in ("provider", "voice", "model", "language"):
value = source.get(key)
if isinstance(value, str) and value.strip():
overrides[key] = value.strip()
+101
View File
@@ -0,0 +1,101 @@
"""Security regression tests for Relay HTTP-to-Android bridge authorization."""
from __future__ import annotations
import os
import tempfile
import time
from unittest import mock
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
from plugin.relay.config import RelayConfig
from plugin.relay.server import create_app
class BridgeHttpAuthorizationTests(AioHTTPTestCase):
async def asyncSetUp(self) -> None:
self._hermes_home = tempfile.TemporaryDirectory()
self._env_patch = mock.patch.dict(
os.environ,
{"HERMES_HOME": self._hermes_home.name},
)
self._env_patch.start()
await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await super().asyncTearDown()
self._env_patch.stop()
self._hermes_home.cleanup()
async def get_application(self) -> web.Application:
return create_app(RelayConfig(profile_discovery_enabled=False))
def _session(self) -> object:
return self.app["server"].sessions.create_session(
"bridge-security-test",
"bridge-security-test",
)
@staticmethod
def _bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
async def test_anonymous_and_invalid_callers_are_rejected_before_dispatch(self) -> None:
dispatch = mock.AsyncMock(return_value={"status": 200, "result": {"ok": True}})
self.app["server"].bridge.handle_command = dispatch
anonymous = await self.client.get("/screen?include_bounds=true")
invalid = await self.client.post(
"/tap",
json={"x": 1, "y": 2},
headers=self._bearer("invalid-token"),
)
self.assertEqual(anonymous.status, 401)
self.assertEqual(invalid.status, 401)
dispatch.assert_not_awaited()
async def test_missing_and_expired_bridge_grants_are_rejected(self) -> None:
dispatch = mock.AsyncMock(return_value={"status": 200, "result": {"ok": True}})
self.app["server"].bridge.handle_command = dispatch
missing = self._session()
missing.grants.pop("bridge")
expired = self._session()
expired.grants["bridge"] = time.time() - 1
missing_response = await self.client.get(
"/screen", headers=self._bearer(missing.token)
)
expired_response = await self.client.post(
"/tap", json={"x": 1, "y": 2}, headers=self._bearer(expired.token)
)
self.assertEqual(missing_response.status, 403)
self.assertEqual(expired_response.status, 403)
dispatch.assert_not_awaited()
async def test_active_bridge_grant_preserves_request_and_device_selector(self) -> None:
dispatch = mock.AsyncMock(
return_value={"status": 200, "result": {"ok": True}}
)
self.app["server"].bridge.handle_command = dispatch
session = self._session()
response = await self.client.post(
"/tap?device=phone",
json={"x": 10, "y": 20},
headers=self._bearer(session.token),
)
self.assertEqual(response.status, 200)
self.assertEqual(await response.json(), {"ok": True})
dispatch.assert_awaited_once_with(
method="POST",
path="/tap",
params={},
body={"x": 10, "y": 20},
device="phone",
)
+7
View File
@@ -53,6 +53,12 @@ class _FakeSession:
class _FakeServer:
def __init__(self, bridge: BridgeHandler) -> None:
self.bridge = bridge
session = _FakeSession("bridge-test-token", "test", "test")
self.sessions = type(
"FakeSessions",
(),
{"get_session": lambda _self, token: session if token == session.token else None},
)()
class _FakeRequest:
@@ -70,6 +76,7 @@ class _FakeRequest:
self.query = query or {}
self._body = body
self.remote = remote
self.headers = {"Authorization": "Bearer bridge-test-token"}
@property
def body_exists(self) -> bool:
+113
View File
@@ -0,0 +1,113 @@
"""Security regression tests for operator-authorized Relay pairing."""
from __future__ import annotations
import math
import os
import tempfile
from unittest import mock
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
from plugin.relay.auth import Session
from plugin.relay.config import RelayConfig
from plugin.relay.server import create_app
class PairingAuthorityTests(AioHTTPTestCase):
async def asyncSetUp(self) -> None:
self._hermes_home = tempfile.TemporaryDirectory()
self._env_patch = mock.patch.dict(
os.environ,
{"HERMES_HOME": self._hermes_home.name},
)
self._env_patch.start()
await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await super().asyncTearDown()
self._env_patch.stop()
self._hermes_home.cleanup()
async def get_application(self) -> web.Application:
return create_app(RelayConfig(profile_discovery_enabled=False))
async def _authenticate(
self,
code: str,
*,
ttl_seconds: int,
grants: dict[str, int],
) -> Session:
ws = await self.client.ws_connect("/ws")
await ws.send_json(
{
"channel": "system",
"type": "auth",
"payload": {
"pairing_code": code,
"device_name": "security-regression",
"device_id": f"security-regression-{code}",
"ttl_seconds": ttl_seconds,
"grants": grants,
},
}
)
message = await ws.receive_json()
await ws.close()
self.assertEqual(message["type"], "auth.ok")
token = message["payload"]["session_token"]
session = self.app["server"].sessions.get_session(token)
self.assertIsNotNone(session)
assert session is not None
return session
async def test_anonymous_pairing_code_mint_is_not_exposed(self) -> None:
server = self.app["server"]
response = await self.client.post("/pairing")
self.assertEqual(response.status, 404)
self.assertEqual(server.pairing._codes, {})
self.assertEqual(server.sessions.active_count(), 0)
self.assertEqual(server.sessions._trusted_devices, {})
async def test_client_policy_is_ignored_when_host_metadata_is_absent(
self,
) -> None:
response = await self.client.post(
"/pairing/register",
json={"code": "SAFE01"},
)
self.assertEqual(response.status, 200, await response.text())
session = await self._authenticate(
"SAFE01",
ttl_seconds=0,
grants={"terminal": 0, "bridge": 0},
)
self.assertFalse(math.isinf(session.expires_at))
self.assertFalse(math.isinf(session.grants["terminal"]))
self.assertFalse(math.isinf(session.grants["bridge"]))
async def test_explicit_host_policy_remains_authoritative(self) -> None:
response = await self.client.post(
"/pairing/register",
json={
"code": "SAFE02",
"ttl_seconds": 0,
"grants": {"terminal": 0},
},
)
self.assertEqual(response.status, 200, await response.text())
session = await self._authenticate(
"SAFE02",
ttl_seconds=60,
grants={"terminal": 60},
)
self.assertTrue(math.isinf(session.expires_at))
self.assertTrue(math.isinf(session.grants["terminal"]))
@@ -0,0 +1,112 @@
"""Security tests for ``GET /api/profiles/{name}/config``."""
from __future__ import annotations
import tempfile
import time
import unittest
from pathlib import Path
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
from plugin.relay.config import RelayConfig
from plugin.relay.server import create_app
class ProfileConfigEndpointTests(AioHTTPTestCase):
async def get_application(self) -> web.Application:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.hermes_dir = Path(self._tmp.name)
config_path = self.hermes_dir / "config.yaml"
config_path.write_text(
"\n".join(
[
"description: Safe profile description",
"model:",
" default: safe-model",
"platforms:",
" api_server:",
" api_key: POC_API_SERVER_KEY_DO_NOT_USE",
"providers:",
" synthetic:",
" api_key: POC_PROVIDER_KEY_DO_NOT_USE",
"extensions:",
" future:",
" nested:",
" credential: POC_UNKNOWN_SECRET_DO_NOT_USE",
"",
]
),
encoding="utf-8",
)
app = create_app(RelayConfig(hermes_config_path=str(config_path)))
@web.middleware
async def force_remote_client(
request: web.Request,
handler: web.RequestHandler,
) -> web.StreamResponse:
return await handler(request.clone(remote="198.51.100.27"))
app.middlewares.append(force_remote_client)
self.session = app["server"].sessions.create_session(
"profile-reader", "test-device", ttl_seconds=3600
)
self.session.grants = {"chat": time.time() + 3600}
return app
async def test_remote_profile_config_returns_only_public_schema(self) -> None:
response = await self.client.get(
"/api/profiles/default/config",
headers={"Authorization": f"Bearer {self.session.token}"},
)
self.assertEqual(response.status, 200)
body = await response.json()
self.assertEqual(body["profile"], "default")
self.assertEqual(body["path"], "config.yaml")
self.assertEqual(
body["config"],
{
"description": "Safe profile description",
"model": {"default": "safe-model"},
},
)
serialized = await response.text()
self.assertNotIn("POC_API_SERVER_KEY_DO_NOT_USE", serialized)
self.assertNotIn("POC_PROVIDER_KEY_DO_NOT_USE", serialized)
self.assertNotIn("POC_UNKNOWN_SECRET_DO_NOT_USE", serialized)
self.assertNotIn(str(self.hermes_dir), serialized)
async def test_remote_profile_config_still_requires_bearer(self) -> None:
response = await self.client.get("/api/profiles/default/config")
self.assertEqual(response.status, 401)
class ProfileConfigLoopbackEndpointTests(AioHTTPTestCase):
async def get_application(self) -> web.Application:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.config_path = Path(self._tmp.name) / "config.yaml"
self.config_path.write_text(
"model:\n default: local-model\ncustom:\n enabled: true\n",
encoding="utf-8",
)
return create_app(
RelayConfig(hermes_config_path=str(self.config_path))
)
async def test_loopback_operator_retains_full_config_view(self) -> None:
response = await self.client.get("/api/profiles/default/config")
self.assertEqual(response.status, 200)
body = await response.json()
self.assertEqual(body["path"], str(self.config_path))
self.assertEqual(body["config"]["custom"], {"enabled": True})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,141 @@
"""Security regressions for Relay session-policy authorization."""
from __future__ import annotations
import math
import time
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase
from plugin.relay.config import RelayConfig
from plugin.relay.server import create_app
class SessionPolicyAuthorizationTests(AioHTTPTestCase):
async def get_application(self) -> web.Application:
return create_app(RelayConfig(profile_discovery_enabled=False))
def _create_session(self, name: str, *, ttl_seconds: int = 600):
return self.app["server"].sessions.create_session(
name,
f"{name}-id",
ttl_seconds=ttl_seconds,
client_surface="phone",
)
@staticmethod
def _headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
async def test_bounded_bearer_cannot_upgrade_its_policy(self) -> None:
session = self._create_session("bounded")
original_expiry = session.expires_at
original_grants = dict(session.grants)
response = await self.client.patch(
f"/sessions/{session.token[:8]}",
headers=self._headers(session.token),
json={
"ttl_seconds": 0,
"grants": {
"terminal": 0,
"bridge": 0,
"tui": 0,
"voice:tts": 0,
"attacker:custom": 0,
},
},
)
self.assertEqual(response.status, 403, await response.text())
self.assertEqual(session.expires_at, original_expiry)
self.assertEqual(session.grants, original_grants)
self.assertFalse(math.isinf(session.expires_at))
async def test_bearer_cannot_modify_another_session(self) -> None:
caller = self._create_session("caller")
target = self._create_session("target")
original_expiry = target.expires_at
response = await self.client.patch(
f"/sessions/{target.token[:8]}",
headers=self._headers(caller.token),
json={"ttl_seconds": 60},
)
self.assertEqual(response.status, 403, await response.text())
self.assertEqual(target.expires_at, original_expiry)
async def test_bearer_cannot_add_or_lengthen_grants(self) -> None:
session = self._create_session("bounded")
add_response = await self.client.patch(
f"/sessions/{session.token[:8]}",
headers=self._headers(session.token),
json={"grants": {"attacker:custom": 30}},
)
extend_response = await self.client.patch(
f"/sessions/{session.token[:8]}",
headers=self._headers(session.token),
json={"grants": {"terminal": 601}},
)
self.assertEqual(add_response.status, 400, await add_response.text())
self.assertEqual(extend_response.status, 403, await extend_response.text())
self.assertNotIn("attacker:custom", session.grants)
async def test_nan_grant_is_rejected(self) -> None:
session = self._create_session("bounded")
response = await self.client.patch(
f"/sessions/{session.token[:8]}",
headers=self._headers(session.token),
json={"grants": {"terminal": float("nan")}},
)
self.assertEqual(response.status, 400, await response.text())
self.assertFalse(math.isnan(session.grants["terminal"]))
async def test_self_service_can_only_reduce_policy(self) -> None:
session = self._create_session("bounded")
original_expiry = session.expires_at
original_bridge_expiry = session.grants["bridge"]
response = await self.client.patch(
f"/sessions/{session.token[:8]}",
headers=self._headers(session.token),
json={"ttl_seconds": 300, "grants": {"terminal": 60}},
)
body = await response.json()
self.assertEqual(response.status, 200, body)
self.assertLess(session.expires_at, original_expiry)
self.assertLessEqual(session.expires_at, time.time() + 301)
self.assertLessEqual(session.grants["terminal"], time.time() + 61)
self.assertLessEqual(session.grants["bridge"], original_bridge_expiry)
self.assertLessEqual(session.grants["bridge"], session.expires_at)
async def test_grant_only_reduction_preserves_omitted_ceilings(self) -> None:
session = self._create_session("bounded")
original_expiry = session.expires_at
original_grants = dict(session.grants)
response = await self.client.patch(
f"/sessions/{session.token[:8]}",
headers=self._headers(session.token),
json={"grants": {"terminal": 60}},
)
self.assertEqual(response.status, 200, await response.text())
self.assertEqual(session.expires_at, original_expiry)
self.assertLess(session.grants["terminal"], original_grants["terminal"])
for channel, expiry in original_grants.items():
if channel != "terminal":
self.assertEqual(session.grants[channel], expiry)
if __name__ == "__main__":
import unittest
unittest.main()
+111
View File
@@ -0,0 +1,111 @@
"""Authorization tests for terminal WebSocket dispatch."""
from __future__ import annotations
import asyncio
import json
import time
import unittest
from unittest.mock import AsyncMock
from plugin.relay.config import RelayConfig
from plugin.relay.server import RelayServer, _on_message
class _FakeWebSocket:
def __init__(self) -> None:
self.closed = False
self.sent: list[dict[str, object]] = []
async def send_str(self, raw: str) -> None:
self.sent.append(json.loads(raw))
async def close(self, **_kwargs: object) -> None:
self.closed = True
class TerminalAuthorizationTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.server = RelayServer(RelayConfig())
self.server.terminal.handle = AsyncMock()
self.ws = _FakeWebSocket()
self.server._client_tasks[self.ws] = set()
self.session = self.server.sessions.create_session(
"terminal-test",
"terminal-test-id",
grants={"terminal": 3600},
)
self.server._clients[self.ws] = self.session.token
async def asyncTearDown(self) -> None:
for task in self.server._client_tasks.get(self.ws, set()):
task.cancel()
await asyncio.gather(
*self.server._client_tasks.get(self.ws, set()),
return_exceptions=True,
)
await self.server.close()
async def _dispatch(self, msg_type: str = "terminal.attach") -> None:
await _on_message(
self.ws,
self.server,
json.dumps(
{
"channel": "terminal",
"type": msg_type,
"id": "terminal-request",
"payload": {},
}
),
)
await asyncio.sleep(0)
def _assert_authorization_error(self) -> None:
self.server.terminal.handle.assert_not_awaited()
self.assertEqual(len(self.ws.sent), 1)
self.assertEqual(self.ws.sent[0]["channel"], "system")
self.assertEqual(self.ws.sent[0]["type"], "error")
self.assertEqual(self.ws.sent[0]["id"], "terminal-request")
self.assertIn("Terminal grant", self.ws.sent[0]["payload"]["message"])
async def test_missing_terminal_grant_rejects_every_terminal_action(self) -> None:
self.session.grants.pop("terminal")
for msg_type in (
"terminal.attach",
"terminal.input",
"terminal.resize",
"terminal.list",
"terminal.detach",
"terminal.kill",
):
with self.subTest(msg_type=msg_type):
self.ws.sent.clear()
self.server.terminal.handle.reset_mock()
await self._dispatch(msg_type)
self._assert_authorization_error()
async def test_expired_terminal_grant_is_rejected(self) -> None:
self.session.grants["terminal"] = time.time() - 1
await self._dispatch()
self._assert_authorization_error()
async def test_revoked_session_on_connected_socket_is_rejected(self) -> None:
self.server.sessions.revoke_session(self.session.token)
await self._dispatch()
self._assert_authorization_error()
async def test_current_terminal_grant_dispatches_to_handler(self) -> None:
await self._dispatch()
self.server.terminal.handle.assert_awaited_once()
self.assertEqual(self.ws.sent, [])
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -810,6 +810,25 @@ class EnhancedVoiceHelpersTests(unittest.TestCase):
self.assertEqual(out["audio_tags"], False)
self.assertEqual(out["language"], "en")
def test_extract_overrides_drops_untrusted_base_urls(self) -> None:
from plugin.relay.voice import _extract_voice_overrides
top_level = _extract_voice_overrides(
{"text": "hi", "voice": "Puck", "base_url": "https://attacker.invalid"}
)
nested = _extract_voice_overrides(
{
"text": "hi",
"gemini": {
"voice": "Puck",
"base_url": "https://attacker.invalid",
},
}
)
self.assertEqual(top_level, {"voice": "Puck"})
self.assertEqual(nested, {"voice": "Puck"})
def test_extract_overrides_empty_for_plain_body(self) -> None:
from plugin.relay.voice import _extract_voice_overrides
@@ -855,6 +874,42 @@ class EnhancedVoiceHelpersTests(unittest.TestCase):
self.assertEqual(block["voices"], []) # free-text on the client
self.assertIn("language", block["overrides"])
def test_gemini_adapter_keeps_operator_configured_base_url(self) -> None:
from plugin.relay import upstream_voice
captured: dict[str, object] = {}
tts_mod = sys.modules["tools.tts_tool"]
original = getattr(tts_mod, "_generate_gemini_tts", None)
def _capture(text: str, output_path: str, config: dict[str, object]) -> str:
captured["config"] = config
return output_path
try:
tts_mod._generate_gemini_tts = _capture
result = upstream_voice._synthesize_gemini(
{
"provider": "gemini",
"gemini": {"base_url": "https://operator.example", "voice": "Kore"},
},
"hello",
"voice.mp3",
{"base_url": "https://attacker.invalid", "voice": "Puck"},
)
finally:
if original is None:
delattr(tts_mod, "_generate_gemini_tts")
else:
tts_mod._generate_gemini_tts = original
self.assertEqual(result, {"success": True, "file_path": "voice.mp3"})
config = captured["config"]
assert isinstance(config, dict)
gemini = config["gemini"]
assert isinstance(gemini, dict)
self.assertEqual(gemini["base_url"], "https://operator.example")
self.assertEqual(gemini["voice"], "Puck")
def test_apply_xai_speech_tags_calls_through_and_fails_soft(self) -> None:
# Used by the streaming /voice/output renderer to match the synthesize
# path's xAI tone behavior; must call upstream when present, fail soft
+6 -1
View File
@@ -25,7 +25,8 @@ def qualifier(tag: str) -> str:
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
normalized = path.read_text(encoding="utf-8").replace("\r\n", "\n")
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def placeholders(value: str) -> list[tuple[int, str]]:
@@ -115,6 +116,10 @@ def install(args: argparse.Namespace) -> None:
"native_name": manifest["native_name"],
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
source_set: digest(APP_SRC / source_set / "res" / "values" / "strings.xml")
for source_set in SOURCE_SETS
},
"surfaces": {
"android": "complete",
"readme": "english-fallback",
+2 -2
View File
@@ -189,8 +189,8 @@ def validate_status_registry(errors: list[str]) -> None:
fail(f"{STATUS_PATH.relative_to(REPO_ROOT)}: cannot read status registry: {exc}", errors)
return
if data.get("schema_version") != 2:
fail("localization status schema_version must be 2", errors)
if data.get("schema_version") != 3:
fail("localization status schema_version must be 3", errors)
if data.get("canonical_locale") != "en":
fail("localization status canonical_locale must be 'en'", errors)
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Validate localized VitePress entry pages and their canonical source hashes."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
from urllib.parse import unquote
ROOT = Path(__file__).resolve().parents[1]
DOCS_ROOT = ROOT / "user-docs"
STATUS_PATH = ROOT / "docs" / "localization-status.json"
CANONICAL_PAGES = {
"index.md": "/",
"guide/quick-start.md": "/guide/quick-start",
"guide/getting-started.md": "/guide/getting-started",
"guide/release-tracks.md": "/guide/release-tracks",
"guide/troubleshooting.md": "/guide/troubleshooting",
}
# Status registry key -> VitePress locale directory.
LOCALES = {
"de": "de",
"es": "es",
"ja": "ja",
"pt-BR": "pt-BR",
"zh-Hans": "zh-CN",
}
LINK_RE = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
FENCE_RE = re.compile(r"```[^\n]*\n(.*?)\n```", re.DOTALL)
def normalized_text(path: Path) -> str:
return path.read_text(encoding="utf-8").replace("\r\n", "\n")
def digest(path: Path) -> str:
return hashlib.sha256(normalized_text(path).encode("utf-8")).hexdigest()
def resolve_doc_link(page: Path, raw_target: str) -> Path | None:
target = unquote(raw_target.strip().split(maxsplit=1)[0])
target = target.split("#", 1)[0].split("?", 1)[0]
if not target or target.startswith(("#", "http://", "https://", "mailto:", "tel:")):
return None
if target.startswith("/docs/"):
target = target[len("/docs/") :]
candidate = DOCS_ROOT / target
elif target.startswith("/"):
candidate = DOCS_ROOT / target.lstrip("/")
else:
candidate = page.parent / target
if candidate.suffix in {".png", ".jpg", ".jpeg", ".svg", ".webp", ".json", ".txt"}:
return None
if candidate.suffix == ".html":
candidate = candidate.with_suffix("")
if candidate.suffix == ".md":
return candidate.resolve()
direct = candidate.with_suffix(".md")
if direct.exists():
return direct.resolve()
return (candidate / "index.md").resolve()
def validate_page(locale_dir: str, relative: str, canonical_route: str) -> list[str]:
errors: list[str] = []
source = DOCS_ROOT / relative
page = DOCS_ROOT / locale_dir / relative
label = f"{locale_dir}/{relative}"
if not page.exists():
return [f"{label}: missing localized page"]
text = normalized_text(page)
source_text = normalized_text(source)
if text == source_text:
errors.append(f"{label}: localized page is identical to English")
if "translation_status: ai-translated" not in text[:500]:
errors.append(f"{label}: missing translation_status: ai-translated frontmatter")
if f"canonical_source: {canonical_route}" not in text[:500]:
errors.append(f"{label}: canonical_source must be {canonical_route}")
if text.count("```") % 2:
errors.append(f"{label}: unbalanced fenced code block")
# Commands and configuration lines inside localized code blocks must remain
# byte-for-byte present in the canonical English source. Localized prose and
# headings are free to differ; executable material is not.
for block in FENCE_RE.findall(text):
for line in block.splitlines():
command = line.strip()
if command and not command.startswith("#") and command not in source_text:
errors.append(f"{label}: translated or invented code line: {command}")
for raw_target in LINK_RE.findall(text):
resolved = resolve_doc_link(page, raw_target)
if resolved is not None and not resolved.exists():
errors.append(f"{label}: broken internal link {raw_target} -> {resolved.relative_to(ROOT)}")
return errors
def refresh(status: dict) -> None:
source_hashes = {relative: digest(DOCS_ROOT / relative) for relative in CANONICAL_PAGES}
for status_key, locale_dir in LOCALES.items():
entry = status["locales"][status_key]
entry["docs_locale"] = locale_dir
entry["docs_source_sha256"] = source_hashes
entry["surfaces"]["user_docs"] = "core-pages"
STATUS_PATH.write_text(json.dumps(status, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--refresh",
action="store_true",
help="Record current English page hashes after translations have been refreshed.",
)
args = parser.parse_args()
status = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
if args.refresh:
refresh(status)
status = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
errors: list[str] = []
expected_hashes = {relative: digest(DOCS_ROOT / relative) for relative in CANONICAL_PAGES}
for status_key, locale_dir in LOCALES.items():
entry = status.get("locales", {}).get(status_key)
if entry is None:
errors.append(f"{status_key}: missing localization status entry")
continue
if entry.get("docs_locale") != locale_dir:
errors.append(f"{status_key}: docs_locale must be {locale_dir}")
if entry.get("surfaces", {}).get("user_docs") != "core-pages":
errors.append(f"{status_key}: user_docs surface must be core-pages")
if entry.get("docs_source_sha256") != expected_hashes:
errors.append(f"{status_key}: localized docs source hashes are stale; refresh translations, then run --refresh")
for relative, canonical_route in CANONICAL_PAGES.items():
errors.extend(validate_page(locale_dir, relative, canonical_route))
if errors:
print("Localized docs validation failed:")
for error in errors:
print(f"- {error}")
return 1
print(
f"Localized docs validation passed "
f"({len(LOCALES)} locales × {len(CANONICAL_PAGES)} core pages)"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Validate marketing-site locale coverage and English-source freshness."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TRANSLATIONS = ROOT / "website" / "src" / "i18n.ts"
STATUS = ROOT / "docs" / "localization-status.json"
LOCALES = {
"de": "de",
"es": "es",
"ja": "ja",
"pt-BR": "pt-BR",
"zh-Hans": "zh-CN",
}
def english_source_hash(source: str) -> str:
translations_start = source.index("export const translations")
start = source.index(" en: {", translations_start)
end = source.index("\n de: {", start)
return hashlib.sha256(source[start:end].encode("utf-8")).hexdigest()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--refresh", action="store_true", help="Record the current canonical English copy hash")
args = parser.parse_args()
source = TRANSLATIONS.read_text(encoding="utf-8")
status = json.loads(STATUS.read_text(encoding="utf-8"))
source_hash = english_source_hash(source)
failures: list[str] = []
for status_key, route_locale in LOCALES.items():
entry = status.get("locales", {}).get(status_key)
if not entry:
failures.append(f"missing localization status entry: {status_key}")
continue
if f" {route_locale!r}: {{" not in source and f" {route_locale}: {{" not in source:
failures.append(f"missing marketing translation dictionary: {route_locale}")
if args.refresh:
entry["website_source_sha256"] = source_hash
entry.setdefault("surfaces", {})["website"] = "complete"
else:
if entry.get("website_source_sha256") != source_hash:
failures.append(
f"{status_key}: marketing translation is stale; review it and run "
"scripts/check-website-locales.py --refresh"
)
if entry.get("surfaces", {}).get("website") != "complete":
failures.append(f"{status_key}: surfaces.website must be complete")
english = status.get("locales", {}).get("en")
if english:
english.setdefault("surfaces", {})["website"] = "canonical"
if args.refresh:
STATUS.write_text(json.dumps(status, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if failures:
print("Marketing locale validation failed:")
for failure in failures:
print(f"- {failure}")
return 1
print(f"Marketing locale validation passed ({len(LOCALES)} translations; source {source_hash[:12]})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+128 -49
View File
@@ -18,33 +18,145 @@ function resolveAppVersion(): string {
const appVersion = resolveAppVersion()
type LocalizedThemeCopy = {
label: string
selectText: string
quickStart: string
installation: string
releaseTracks: string
troubleshooting: string
englishReference: string
overview: string
coreGuides: string
canonicalReference: string
fullGuide: string
remoteAccess: string
apiReference: string
outlineTitle: string
previous: string
next: string
returnToTop: string
menu: string
appearance: string
lastUpdated: string
}
function localizedTheme(prefix: string, copy: LocalizedThemeCopy) {
return {
label: copy.label,
selectText: copy.selectText,
nav: [
{ text: copy.quickStart, link: `/${prefix}/guide/quick-start` },
{ text: copy.installation, link: `/${prefix}/guide/getting-started` },
{ text: copy.troubleshooting, link: `/${prefix}/guide/troubleshooting` },
{ text: copy.englishReference, link: '/reference/api' },
{ text: 'GitHub', link: 'https://github.com/Codename-11/hermes-relay' },
],
sidebar: {
[`/${prefix}/`]: [
{
text: copy.coreGuides,
items: [
{ text: copy.overview, link: `/${prefix}/` },
{ text: copy.quickStart, link: `/${prefix}/guide/quick-start` },
{ text: copy.installation, link: `/${prefix}/guide/getting-started` },
{ text: copy.releaseTracks, link: `/${prefix}/guide/release-tracks` },
{ text: copy.troubleshooting, link: `/${prefix}/guide/troubleshooting` },
],
},
{
text: copy.canonicalReference,
items: [
{ text: copy.fullGuide, link: '/guide/' },
{ text: copy.remoteAccess, link: '/guide/remote-access' },
{ text: copy.apiReference, link: '/reference/api' },
],
},
],
},
outlineTitle: copy.outlineTitle,
docFooter: { prev: copy.previous, next: copy.next },
returnToTopLabel: copy.returnToTop,
sidebarMenuLabel: copy.menu,
darkModeSwitchLabel: copy.appearance,
lastUpdatedText: copy.lastUpdated,
}
}
const localizedThemes = {
de: localizedTheme('de', {
label: 'Deutsch', selectText: 'Sprachen', quickStart: 'Schnellstart',
installation: 'Installation', releaseTracks: 'App-Versionen', troubleshooting: 'Fehlerbehebung',
englishReference: 'Englische Referenz', overview: 'Übersicht', coreGuides: 'Erste Schritte',
canonicalReference: 'Englische Referenz', fullGuide: 'Vollständige Anleitung', remoteAccess: 'Fernzugriff',
apiReference: 'API-Referenz', outlineTitle: 'Auf dieser Seite', previous: 'Zurück', next: 'Weiter',
returnToTop: 'Nach oben', menu: 'Menü', appearance: 'Darstellung', lastUpdated: 'Zuletzt aktualisiert',
}),
es: localizedTheme('es', {
label: 'Español', selectText: 'Idiomas', quickStart: 'Inicio rápido',
installation: 'Instalación', releaseTracks: 'Versiones de la app', troubleshooting: 'Solución de problemas',
englishReference: 'Referencia en inglés', overview: 'Descripción general', coreGuides: 'Primeros pasos',
canonicalReference: 'Referencia en inglés', fullGuide: 'Guía completa', remoteAccess: 'Acceso remoto',
apiReference: 'Referencia de API', outlineTitle: 'En esta página', previous: 'Anterior', next: 'Siguiente',
returnToTop: 'Volver arriba', menu: 'Menú', appearance: 'Apariencia', lastUpdated: 'Última actualización',
}),
ja: localizedTheme('ja', {
label: '日本語', selectText: '言語', quickStart: 'クイックスタート',
installation: 'インストール', releaseTracks: 'アプリの種類', troubleshooting: 'トラブルシューティング',
englishReference: '英語リファレンス', overview: '概要', coreGuides: 'はじめに',
canonicalReference: '英語リファレンス', fullGuide: '完全なガイド', remoteAccess: 'リモートアクセス',
apiReference: 'API リファレンス', outlineTitle: 'このページの内容', previous: '前へ', next: '次へ',
returnToTop: 'ページ上部へ', menu: 'メニュー', appearance: '外観', lastUpdated: '最終更新',
}),
'pt-BR': localizedTheme('pt-BR', {
label: 'Português (Brasil)', selectText: 'Idiomas', quickStart: 'Início rápido',
installation: 'Instalação', releaseTracks: 'Versões do app', troubleshooting: 'Solução de problemas',
englishReference: 'Referência em inglês', overview: 'Visão geral', coreGuides: 'Primeiros passos',
canonicalReference: 'Referência em inglês', fullGuide: 'Guia completo', remoteAccess: 'Acesso remoto',
apiReference: 'Referência da API', outlineTitle: 'Nesta página', previous: 'Anterior', next: 'Próxima',
returnToTop: 'Voltar ao topo', menu: 'Menu', appearance: 'Aparência', lastUpdated: 'Última atualização',
}),
'zh-CN': localizedTheme('zh-CN', {
label: '简体中文', selectText: '语言', quickStart: '快速开始',
installation: '安装与设置', releaseTracks: '应用版本', troubleshooting: '故障排除',
englishReference: '英文参考', overview: '概览', coreGuides: '入门指南',
canonicalReference: '英文参考', fullGuide: '完整用户指南', remoteAccess: '远程访问',
apiReference: 'API 参考', outlineTitle: '本页内容', previous: '上一页', next: '下一页',
returnToTop: '返回顶部', menu: '菜单', appearance: '外观', lastUpdated: '最后更新',
}),
}
export default defineConfig({
base: '/hermes-relay/',
base: '/docs/',
title: 'Hermes-Relay',
description: 'Hermes-Relay — the Android companion and remote-hands CLI for your Hermes agent. Runs on your machine; lives on your devices.',
locales: {
root: { label: 'English', lang: 'en' },
'zh-CN': { label: '简体中文', lang: 'zh-CN', link: '/zh-CN/' },
de: { label: 'Deutsch', lang: 'de', link: '/de/', themeConfig: localizedThemes.de },
es: { label: 'Español', lang: 'es', link: '/es/', themeConfig: localizedThemes.es },
ja: { label: '日本語', lang: 'ja', link: '/ja/', themeConfig: localizedThemes.ja },
'pt-BR': { label: 'Português (Brasil)', lang: 'pt-BR', link: '/pt-BR/', themeConfig: localizedThemes['pt-BR'] },
'zh-CN': { label: '简体中文', lang: 'zh-CN', link: '/zh-CN/', themeConfig: localizedThemes['zh-CN'] },
},
head: [
// Favicon — base path is NOT auto-applied to head entries in VitePress,
// so hard-prefix with /hermes-relay/ to match the GitHub Pages deploy.
['link', { rel: 'icon', type: 'image/svg+xml', href: '/hermes-relay/logo.svg' }],
['link', { rel: 'apple-touch-icon', href: '/hermes-relay/logo.svg' }],
// so hard-prefix with /docs/ to match the production deployment.
['link', { rel: 'icon', type: 'image/svg+xml', href: '/docs/logo.svg' }],
['link', { rel: 'apple-touch-icon', href: '/docs/logo.svg' }],
// Canonical
['link', { rel: 'canonical', href: 'https://codename-11.github.io/hermes-relay/' }],
['link', { rel: 'canonical', href: 'https://hermes-relay.dev/docs/' }],
// Open Graph — crawlers (Facebook, Messenger, Slack, Discord, LinkedIn) need absolute URLs
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:site_name', content: 'Hermes-Relay' }],
['meta', { property: 'og:title', content: 'Hermes-Relay — give your Hermes agent hands' }],
['meta', { property: 'og:description', content: 'Runs on your machine. Lives on your devices. A native Android companion for chat, voice, and phone control — plus a single-binary CLI the agent uses to work on any machine you pair.' }],
['meta', { property: 'og:url', content: 'https://codename-11.github.io/hermes-relay/' }],
['meta', { property: 'og:image', content: 'https://codename-11.github.io/hermes-relay/og-image.png' }],
['meta', { property: 'og:image:secure_url', content: 'https://codename-11.github.io/hermes-relay/og-image.png' }],
['meta', { property: 'og:url', content: 'https://hermes-relay.dev/docs/' }],
['meta', { property: 'og:image', content: 'https://hermes-relay.dev/docs/og-image.png' }],
['meta', { property: 'og:image:secure_url', content: 'https://hermes-relay.dev/docs/og-image.png' }],
['meta', { property: 'og:image:type', content: 'image/png' }],
['meta', { property: 'og:image:width', content: '1024' }],
['meta', { property: 'og:image:height', content: '500' }],
@@ -54,7 +166,7 @@ export default defineConfig({
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
['meta', { name: 'twitter:title', content: 'Hermes-Relay — give your Hermes agent hands' }],
['meta', { name: 'twitter:description', content: 'Android companion for chat, voice, and phone control + a single-binary CLI that gives your Hermes agent hands on any machine. One pair, every device.' }],
['meta', { name: 'twitter:image', content: 'https://codename-11.github.io/hermes-relay/og-image.png' }],
['meta', { name: 'twitter:image', content: 'https://hermes-relay.dev/docs/og-image.png' }],
['meta', { name: 'twitter:image:alt', content: 'Hermes-Relay — give your Hermes agent hands.' }],
// Theme — RelayRefresh.Background
@@ -74,43 +186,7 @@ export default defineConfig({
label: 'English',
selectText: 'Languages',
},
'zh-CN': {
label: '简体中文',
selectText: '语言',
nav: [
{ text: '快速开始', link: '/zh-CN/guide/quick-start' },
{ text: '功能', link: '/zh-CN/features/' },
{ text: '英文参考', link: '/reference/api' },
{ text: 'GitHub', link: 'https://github.com/Codename-11/hermes-relay' },
],
sidebar: {
'/zh-CN/': [
{
text: 'Hermes-Relay',
items: [
{ text: '概览', link: '/zh-CN/' },
{ text: '快速开始', link: '/zh-CN/guide/quick-start' },
{ text: '功能概览', link: '/zh-CN/features/' },
],
},
{
text: '英文参考',
items: [
{ text: '完整用户指南', link: '/guide/' },
{ text: '远程访问', link: '/guide/remote-access' },
{ text: '故障排除', link: '/guide/troubleshooting' },
{ text: 'API 参考', link: '/reference/api' },
],
},
],
},
outlineTitle: '本页内容',
docFooter: { prev: '上一页', next: '下一页' },
returnToTopLabel: '返回顶部',
sidebarMenuLabel: '菜单',
darkModeSwitchLabel: '外观',
lastUpdatedText: '最后更新',
},
...localizedThemes,
},
nav: [
@@ -195,9 +271,12 @@ export default defineConfig({
{
text: 'Reference',
items: [
{ text: 'Hermes API', link: '/reference/api' },
{ text: 'API & Route Contract', link: '/reference/api' },
{ text: 'Upstream Hermes', link: '/reference/upstream-hermes' },
{ text: 'Relay API', link: '/reference/relay-api' },
{ text: 'Compatibility', link: '/reference/compatibility' },
{ text: 'Configuration', link: '/reference/configuration' },
{ text: 'Relay Server', link: '/reference/relay-server' },
{ text: 'Relay Server Operations', link: '/reference/relay-server' },
{ text: 'Agent Cleanup Prompt', link: '/reference/agent-cleanup-prompt' },
],
},
@@ -0,0 +1,428 @@
<script setup lang="ts">
import { withBase } from 'vitepress'
const paths = [
{
label: 'Android companion',
state: 'Stable',
title: 'Put Hermes in your pocket',
description:
'Set up streaming chat, voice, Manage, notifications, and the optional sideload Device Control track.',
links: [
{ text: 'Quick start', href: withBase('/guide/quick-start.html') },
{ text: 'Installation & setup', href: withBase('/guide/getting-started.html') },
{ text: 'Release tracks', href: withBase('/guide/release-tracks.html') },
],
},
{
label: 'CLI · remote hands',
state: 'Experimental',
title: 'Pair another machine',
description:
'Install the single binary, pair it with Hermes, and expose consent-gated filesystem, terminal, and capture tools.',
links: [
{ text: 'Install the CLI', href: withBase('/desktop/installation.html') },
{ text: 'Pair a machine', href: withBase('/desktop/pairing.html') },
{ text: 'Commands', href: withBase('/desktop/subcommands.html') },
],
},
]
const references = [
{
label: 'FEATURES',
title: 'Understand a capability',
description: 'Chat, voice, profiles, connections, tools, themes, and phone control.',
href: withBase('/features/'),
},
{
label: 'ARCHITECTURE',
title: 'Trace the connection',
description: 'See which Hermes, dashboard, and Relay surfaces handle each feature.',
href: withBase('/architecture/'),
},
{
label: 'REFERENCE',
title: 'Look up the contract',
description: 'API routes, configuration, Relay server deployment, and operational details.',
href: withBase('/reference/api.html'),
},
]
const commonTasks = [
{ text: 'Connect from outside your LAN', href: withBase('/guide/remote-access.html') },
{ text: 'Troubleshoot a connection', href: withBase('/guide/troubleshooting.html') },
{ text: 'Choose Google Play or sideload', href: withBase('/guide/release-tracks.html') },
{ text: 'Add the optional Relay plugin', href: withBase('/guide/getting-started.html#relay-server-optional') },
{ text: 'Check whether a connection is secure', href: withBase('/architecture/connection-security.html') },
{ text: 'Troubleshoot the CLI', href: withBase('/desktop/troubleshooting.html') },
]
</script>
<template>
<main class="docs-hub" aria-label="Documentation paths">
<section class="docs-hub__section" aria-labelledby="choose-surface">
<div class="docs-hub__heading">
<p class="docs-hub__eyebrow">CHOOSE A SURFACE</p>
<h2 id="choose-surface">Two clients. One Hermes.</h2>
<p>Start with the device you want to connect. Relay stays optional until you need power tools.</p>
</div>
<div class="surface-grid">
<article v-for="path in paths" :key="path.label" class="surface-card">
<header>
<span>{{ path.label }}</span>
<span class="surface-card__state">{{ path.state }}</span>
</header>
<h3>{{ path.title }}</h3>
<p>{{ path.description }}</p>
<nav :aria-label="`${path.label} documentation`">
<a v-for="link in path.links" :key="link.href" :href="link.href">{{ link.text }} <span aria-hidden="true">→</span></a>
</nav>
</article>
</div>
</section>
<section class="relay-path" aria-labelledby="relay-path-title">
<div>
<p class="docs-hub__eyebrow"><span class="status-dot" aria-hidden="true"></span> OPTIONAL POWER PATH</p>
<h2 id="relay-path-title">Add Relay when you need hands.</h2>
<p>
Phone control, terminal sessions, remote desktop tools, notification forwarding, and paired CLI machines all begin here.
</p>
</div>
<div class="relay-path__actions">
<a class="primary-link" :href="withBase('/guide/getting-started.html#relay-server-optional')">Install the Relay plugin <span aria-hidden="true">→</span></a>
<a :href="withBase('/reference/relay-server.html')">Relay server reference</a>
</div>
</section>
<section class="docs-hub__section" aria-labelledby="find-answer">
<div class="docs-hub__heading">
<p class="docs-hub__eyebrow">GO DEEPER</p>
<h2 id="find-answer">Find the level of detail you need.</h2>
</div>
<div class="reference-grid">
<a v-for="item in references" :key="item.href" class="reference-card" :href="item.href">
<span>{{ item.label }}</span>
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
<strong aria-hidden="true">→</strong>
</a>
</div>
</section>
<section class="common-tasks" aria-labelledby="common-tasks-title">
<div>
<p class="docs-hub__eyebrow">COMMON TASKS</p>
<h2 id="common-tasks-title">Jump straight to the answer.</h2>
</div>
<nav aria-label="Common documentation tasks">
<a v-for="task in commonTasks" :key="task.href" :href="task.href">
<span>{{ task.text }}</span>
<span aria-hidden="true">→</span>
</a>
</nav>
</section>
</main>
</template>
<style scoped>
.docs-hub {
width: min(1120px, calc(100% - 48px));
margin: 8px auto 0;
padding-bottom: 80px;
}
.docs-hub__section {
padding: 56px 0;
border-top: 1px solid var(--hr-line);
}
.docs-hub__heading {
max-width: 660px;
margin-bottom: 28px;
}
.docs-hub__eyebrow {
margin: 0 0 10px;
color: var(--vp-c-brand-1);
font-family: var(--vp-font-family-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
}
.docs-hub h2,
.docs-hub h3,
.docs-hub p {
margin: 0;
}
.docs-hub h2 {
color: var(--vp-c-text-1);
font-size: clamp(26px, 3vw, 38px);
line-height: 1.15;
letter-spacing: -0.025em;
}
.docs-hub__heading > p:last-child,
.relay-path p,
.surface-card > p,
.reference-card p {
color: var(--vp-c-text-2);
line-height: 1.65;
}
.docs-hub__heading > p:last-child {
margin-top: 12px;
font-size: 16px;
}
.surface-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.surface-card,
.reference-card,
.relay-path {
border: 1px solid var(--hr-line);
background: var(--vp-c-bg-alt);
border-radius: 8px;
}
.surface-card {
display: flex;
min-height: 330px;
padding: 26px;
flex-direction: column;
}
.surface-card header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--vp-c-brand-1);
font-family: var(--vp-font-family-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.surface-card__state {
padding: 4px 7px;
border: 1px solid var(--hr-line);
border-radius: 3px;
color: var(--vp-c-text-3);
letter-spacing: 0.04em;
}
.surface-card h3 {
margin-top: 38px;
color: var(--vp-c-text-1);
font-size: 25px;
letter-spacing: -0.02em;
}
.surface-card > p {
margin-top: 12px;
}
.surface-card nav {
display: grid;
margin-top: auto;
padding-top: 26px;
gap: 0;
}
.surface-card nav a,
.common-tasks nav a {
display: flex;
min-height: 44px;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--hr-line);
color: var(--vp-c-text-1);
font-family: var(--vp-font-family-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
text-decoration: none;
}
.surface-card nav a:hover,
.common-tasks nav a:hover {
color: var(--vp-c-brand-1);
}
.relay-path {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr);
gap: 40px;
padding: 32px;
align-items: center;
}
.relay-path p:not(.docs-hub__eyebrow) {
max-width: 650px;
margin-top: 12px;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 7px;
border-radius: 50%;
background: var(--hr-green);
}
.relay-path__actions {
display: grid;
gap: 8px;
}
.relay-path__actions a {
padding: 12px 14px;
border: 1px solid var(--hr-line);
border-radius: 4px;
color: var(--vp-c-text-1);
font-family: var(--vp-font-family-mono);
font-size: 12px;
font-weight: 700;
text-align: center;
text-decoration: none;
}
.relay-path__actions .primary-link {
border-color: var(--vp-c-brand-2);
background: var(--vp-c-brand-2);
color: white;
}
.reference-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.reference-card {
position: relative;
min-height: 220px;
padding: 22px;
color: inherit;
text-decoration: none;
transition: border-color 160ms ease, transform 160ms ease;
}
.reference-card:hover {
border-color: var(--hr-line-strong);
transform: translateY(-2px);
}
.reference-card > span {
color: var(--vp-c-brand-1);
font-family: var(--vp-font-family-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
}
.reference-card h3 {
margin-top: 28px;
color: var(--vp-c-text-1);
font-size: 19px;
}
.reference-card p {
margin-top: 10px;
font-size: 14px;
}
.reference-card strong {
position: absolute;
right: 22px;
bottom: 18px;
color: var(--vp-c-brand-1);
}
.common-tasks {
display: grid;
grid-template-columns: minmax(240px, 0.8fr) minmax(0, 1.2fr);
gap: 64px;
padding: 56px 0 0;
border-top: 1px solid var(--hr-line);
}
.common-tasks h2 {
font-size: clamp(24px, 2.5vw, 32px);
}
.common-tasks nav {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 28px;
}
.common-tasks nav a {
min-height: 52px;
font-family: var(--vp-font-family-base);
font-size: 14px;
font-weight: 500;
letter-spacing: 0;
}
@media (max-width: 767px) {
.docs-hub {
width: min(100% - 32px, 640px);
margin-top: 0;
padding-bottom: 56px;
}
.docs-hub__section {
padding: 42px 0;
}
.surface-grid,
.reference-grid,
.relay-path,
.common-tasks,
.common-tasks nav {
grid-template-columns: 1fr;
}
.surface-card {
min-height: 0;
padding: 22px;
}
.surface-card h3 {
margin-top: 30px;
}
.relay-path {
gap: 26px;
padding: 24px;
}
.reference-card {
min-height: 190px;
}
.common-tasks {
gap: 24px;
padding-top: 42px;
}
}
@media (prefers-reduced-motion: reduce) {
.reference-card {
transition: none;
}
}
</style>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ref } from 'vue'
import { withBase } from 'vitepress'
const props = defineProps<{
src: string
alt: string
caption?: string
maxWidth?: string
}>()
const dialog = ref<HTMLDialogElement | null>(null)
function openImage() {
dialog.value?.showModal()
}
function closeImage() {
dialog.value?.close()
}
function closeOnBackdrop(event: MouseEvent) {
if (event.target === dialog.value) closeImage()
}
</script>
<template>
<figure class="expandable-image" :style="maxWidth ? { maxWidth } : undefined">
<button type="button" class="expandable-image__trigger" :aria-label="`Expand image: ${alt}`" @click="openImage">
<img :src="withBase(src)" :alt="alt" />
<span class="expandable-image__hint">Expand</span>
</button>
<figcaption v-if="caption">{{ caption }}</figcaption>
<dialog ref="dialog" class="expandable-image__dialog" :aria-label="alt" @click="closeOnBackdrop">
<div class="expandable-image__stage">
<button type="button" class="expandable-image__close" @click="closeImage">Close</button>
<img :src="withBase(src)" :alt="alt" />
</div>
</dialog>
</figure>
</template>
<style scoped>
.expandable-image {
width: 100%;
margin: 0.75rem 0 1.75rem;
}
.expandable-image[style] {
margin-right: auto;
margin-left: auto;
}
.expandable-image__trigger {
position: relative;
display: block;
width: 100%;
padding: 0;
overflow: hidden;
border: 1px solid var(--hr-line);
border-radius: 8px;
background: var(--vp-c-bg-alt);
cursor: zoom-in;
}
.expandable-image__trigger img {
display: block;
width: 100%;
margin: 0;
transition: transform 180ms ease;
}
.expandable-image__trigger:hover img {
transform: scale(1.01);
}
.expandable-image__trigger:focus-visible,
.expandable-image__close:focus-visible {
outline: 2px solid var(--vp-c-brand-1);
outline-offset: 3px;
}
.expandable-image__hint,
.expandable-image__close {
font-family: var(--vp-font-family-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.expandable-image__hint {
position: absolute;
right: 10px;
bottom: 10px;
padding: 6px 9px;
border: 1px solid rgba(247, 246, 240, 0.24);
border-radius: 4px;
background: rgba(8, 9, 13, 0.88);
color: #f7f6f0;
}
.expandable-image figcaption {
margin-top: 8px;
color: var(--vp-c-text-3);
font-size: 13px;
}
.expandable-image__dialog {
width: min(96vw, 1500px);
max-width: none;
height: min(92vh, 1000px);
max-height: none;
padding: 0;
border: 1px solid rgba(247, 246, 240, 0.2);
border-radius: 8px;
background: #08090d;
color: #f7f6f0;
}
.expandable-image__dialog::backdrop {
background: rgba(2, 3, 7, 0.88);
}
.expandable-image__stage {
position: relative;
display: grid;
width: 100%;
height: 100%;
padding: 52px 20px 20px;
place-items: center;
}
.expandable-image__stage img {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
margin: 0;
object-fit: contain;
}
.expandable-image__close {
position: absolute;
top: 12px;
right: 12px;
padding: 7px 10px;
border: 1px solid rgba(247, 246, 240, 0.24);
border-radius: 4px;
background: #191b31;
color: #f7f6f0;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.expandable-image__trigger img {
transition: none;
}
}
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { withBase } from 'vitepress'
</script>
<template>
<section class="first-run" aria-labelledby="first-run-title">
<header class="first-run__header">
<div>
<span>What you’ll see</span>
<h3 id="first-run-title">A successful connection, then your first chat.</h3>
</div>
<ol aria-label="First-run progress">
<li><strong>01</strong> Save Hermes</li>
<li><strong>02</strong> Check capabilities</li>
<li><strong>03</strong> Send a message</li>
</ol>
</header>
<div class="first-run__frames">
<figure>
<img
:src="withBase('/connections-demo.png')"
alt="Hermes-Relay Connections screen with API, Dashboard, Voice, and Relay readiness rows."
loading="lazy"
/>
<figcaption>
<strong>Connection ready.</strong>
Green rows show the surfaces your phone can reach. Relay may remain optional.
</figcaption>
</figure>
<figure>
<img
:src="withBase('/chat-demo.png')"
alt="Hermes-Relay Chat screen showing a streamed response and message composer."
loading="lazy"
/>
<figcaption>
<strong>Chat ready.</strong>
The status strip names the active route, model, and profile.
</figcaption>
</figure>
</div>
</section>
</template>
<style scoped>
.first-run {
margin: 1.5rem 0 2rem;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
background: var(--vp-c-bg-alt);
}
.first-run__header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.9fr);
gap: 24px;
align-items: end;
padding: 20px 22px;
border-bottom: 1px solid var(--vp-c-divider);
}
.first-run__header > div > span {
font-family: var(--vp-font-family-mono);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--vp-c-brand-1);
}
.first-run__header h3 {
margin: 6px 0 0;
border: 0;
font-size: 1.15rem;
}
.first-run__header ol {
display: grid;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.first-run__header li {
display: grid;
grid-template-columns: 28px 1fr;
gap: 8px;
line-height: 1.4;
}
.first-run__header li strong {
font-family: var(--vp-font-family-mono);
font-size: 0.66rem;
color: var(--vp-c-brand-1);
}
.first-run__frames {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
background: var(--vp-c-divider);
}
.first-run figure {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(140px, 0.8fr);
gap: 18px;
align-items: center;
margin: 0;
padding: 18px;
background: var(--vp-c-bg-alt);
}
.first-run img {
display: block;
width: 100%;
max-height: 330px;
object-fit: contain;
}
.first-run figcaption {
color: var(--vp-c-text-2);
font-size: 0.82rem;
line-height: 1.55;
}
.first-run figcaption strong {
display: block;
margin-bottom: 5px;
color: var(--vp-c-text-1);
}
@media (max-width: 900px) {
.first-run__frames,
.first-run__header {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.first-run figure {
grid-template-columns: minmax(0, 145px) 1fr;
padding: 14px;
}
.first-run img {
max-height: 260px;
}
}
</style>
@@ -63,22 +63,22 @@ const diagrams: Record<string, DiagramDef> = {
// ── Chat Flow: message lifecycle through the app ──────────────
'chat-flow': {
nodes: [
{ id: '1', type: 'hermes', position: { x: 0, y: 50 }, data: { label: 'User Message' }, sourcePosition: Position.Right },
{ id: '2', type: 'hermes', position: { x: 190, y: 50 }, data: { label: 'ChatViewModel' }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '3', type: 'hermes', position: { x: 190, y: 140 }, data: { label: 'Create Session' }, sourcePosition: Position.Right, targetPosition: Position.Top },
{ id: '4', type: 'hermes', position: { x: 420, y: 50 }, data: { label: 'ApiClient', accent: true }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '5', type: 'hermes', position: { x: 620, y: 50 }, data: { label: 'SSE Stream' }, sourcePosition: Position.Bottom, targetPosition: Position.Left },
{ id: '6', type: 'hermes', position: { x: 620, y: 140 }, data: { label: 'ChatHandler' }, sourcePosition: Position.Left, targetPosition: Position.Top },
{ id: '7', type: 'hermes', position: { x: 420, y: 140 }, data: { label: 'StateFlow' }, sourcePosition: Position.Left, targetPosition: Position.Right },
{ id: '8', type: 'hermes', position: { x: 190, y: 230 }, data: { label: 'Compose UI', accent: true }, targetPosition: Position.Top },
{ id: '1', type: 'hermes', position: { x: 0, y: 20 }, data: { label: 'User Message' }, sourcePosition: Position.Right },
{ id: '2', type: 'hermes', position: { x: 180, y: 20 }, data: { label: 'ChatViewModel' }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '3', type: 'hermes', position: { x: 300, y: 105 }, data: { label: 'Create Session' }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '4', type: 'hermes', position: { x: 430, y: 20 }, data: { label: 'ApiClient', accent: true }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '5', type: 'hermes', position: { x: 630, y: 20 }, data: { label: 'SSE Stream' }, sourcePosition: Position.Bottom, targetPosition: Position.Left },
{ id: '6', type: 'hermes', position: { x: 630, y: 185 }, data: { label: 'ChatHandler' }, sourcePosition: Position.Left, targetPosition: Position.Top },
{ id: '7', type: 'hermes', position: { x: 430, y: 185 }, data: { label: 'StateFlow' }, sourcePosition: Position.Left, targetPosition: Position.Right },
{ id: '8', type: 'hermes', position: { x: 180, y: 185 }, data: { label: 'Compose UI', accent: true }, targetPosition: Position.Right },
],
edges: [
{ id: 'e1', source: '1', target: '2', animated: edgeAnimated, style: edgeStyle },
{ id: 'e2', source: '2', target: '3', style: edgeDim, label: 'if new' },
{ id: 'e2', source: '2', target: '3', style: edgeDim, label: 'if new', type: 'smoothstep' },
{ id: 'e3', source: '2', target: '4', animated: edgeAnimated, style: edgeStyle },
{ id: 'e4', source: '3', target: '4', animated: edgeAnimated, style: edgeDim },
{ id: 'e4', source: '3', target: '4', animated: edgeAnimated, style: edgeDim, type: 'smoothstep' },
{ id: 'e5', source: '4', target: '5', animated: edgeAnimated, style: edgeStyle, label: '/chat/stream' },
{ id: 'e6', source: '5', target: '6', animated: edgeAnimated, style: edgeStyle },
{ id: 'e6', source: '5', target: '6', animated: edgeAnimated, style: edgeStyle, type: 'smoothstep' },
{ id: 'e7', source: '6', target: '7', animated: edgeAnimated, style: edgeStyle },
{ id: 'e8', source: '7', target: '8', animated: edgeAnimated, style: edgeStyle },
],
@@ -130,20 +130,20 @@ const diagrams: Record<string, DiagramDef> = {
nodes: [
{ id: '1', type: 'hermes', position: { x: 0, y: 50 }, data: { label: 'App Start' }, sourcePosition: Position.Right },
{ id: '2', type: 'hermes', position: { x: 160, y: 50 }, data: { label: 'Check Token' }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '3', type: 'hermes', position: { x: 340, y: 0 }, data: { label: 'Connect (WSS)', accent: true }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '4', type: 'hermes', position: { x: 340, y: 100 }, data: { label: 'Show Pairing Code' }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '5', type: 'hermes', position: { x: 540, y: 100 }, data: { label: 'Validate Code' }, sourcePosition: Position.Top, targetPosition: Position.Left },
{ id: '6', type: 'hermes', position: { x: 540, y: 0 }, data: { label: 'Store Token' }, sourcePosition: Position.Left, targetPosition: Position.Bottom },
{ id: '7', type: 'hermes', position: { x: 700, y: 0 }, data: { label: 'Connected' }, targetPosition: Position.Left },
{ id: '3', type: 'hermes', position: { x: 350, y: 0 }, data: { label: 'Connect (WSS)', accent: true }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '4', type: 'hermes', position: { x: 350, y: 110 }, data: { label: 'Show Pairing Code' }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '5', type: 'hermes', position: { x: 535, y: 110 }, data: { label: 'Validate Code' }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '6', type: 'hermes', position: { x: 710, y: 110 }, data: { label: 'Store Token' }, sourcePosition: Position.Right, targetPosition: Position.Left },
{ id: '7', type: 'hermes', position: { x: 880, y: 50 }, data: { label: 'Connected' }, targetPosition: Position.Left },
],
edges: [
{ id: 'e1', source: '1', target: '2', animated: edgeAnimated, style: edgeStyle },
{ id: 'e2', source: '2', target: '3', animated: edgeAnimated, style: edgeStyle, label: 'has token' },
{ id: 'e3', source: '2', target: '4', style: edgeDim, label: 'no token' },
{ id: 'e3', source: '2', target: '4', style: edgeDim, label: 'no token', type: 'smoothstep' },
{ id: 'e4', source: '4', target: '5', animated: edgeAnimated, style: edgeDim },
{ id: 'e5', source: '5', target: '6', animated: edgeAnimated, style: edgeStyle },
{ id: 'e6', source: '6', target: '3', animated: edgeAnimated, style: edgeStyle },
{ id: 'e7', source: '3', target: '7', animated: edgeAnimated, style: edgeStyle },
{ id: 'e6', source: '6', target: '7', animated: edgeAnimated, style: edgeStyle, type: 'smoothstep' },
{ id: 'e7', source: '3', target: '7', animated: edgeAnimated, style: edgeStyle, type: 'smoothstep' },
],
},
}
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { withBase } from 'vitepress'
defineProps<{ explainRelay?: boolean }>()
</script>
<template>
<section class="track-chooser" aria-label="Choose an Android release track">
<article class="track-card track-card--recommended">
<div class="track-card__label">Recommended</div>
<h3>Google Play</h3>
<p>Fastest setup, automatic updates, and the complete everyday Hermes experience.</p>
<ul>
<li>Chat, voice, profiles, and Manage</li>
<li>Terminal, media, and notifications with Relay</li>
<li>No AccessibilityService or unattended phone control</li>
</ul>
<StoreBadge />
</article>
<article class="track-card">
<div class="track-card__label">Power users</div>
<h3>Sideload</h3>
<p>The same app plus Device Control for people who want Hermes to operate the phone.</p>
<ul>
<li>Everything in the Google Play build</li>
<li>Screen reading, taps, typing, and navigation</li>
<li>Manual signed-APK installation and updates</li>
</ul>
<a class="track-card__action" href="https://github.com/Codename-11/hermes-relay/releases">
Download the signed APK <span aria-hidden="true">→</span>
</a>
</article>
<aside v-if="explainRelay" class="track-boundary">
<strong>Build choice ≠ Relay choice.</strong>
<span>
Google Play versus Sideload controls what Android can do. The optional
Relay plugin controls access to terminal, media, notifications, and
device channels on your Hermes host.
</span>
<a :href="withBase('/guide/getting-started.html#relay-server-optional')">How Relay fits in →</a>
</aside>
</section>
</template>
<style scoped>
.track-chooser {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 1.5rem 0 2rem;
}
.track-card {
display: flex;
min-width: 0;
flex-direction: column;
padding: 22px;
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
background: var(--vp-c-bg-alt);
}
.track-card--recommended {
border-color: color-mix(in srgb, var(--vp-c-brand-1) 55%, var(--vp-c-divider));
background: color-mix(in srgb, var(--vp-c-brand-soft) 45%, var(--vp-c-bg-alt));
}
.track-card__label {
width: fit-content;
margin-bottom: 14px;
font-family: var(--vp-font-family-mono);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--vp-c-brand-1);
}
.track-card h3 {
margin: 0;
border: 0;
font-size: 1.25rem;
}
.track-card p {
margin: 8px 0 14px;
color: var(--vp-c-text-2);
line-height: 1.55;
}
.track-card ul {
flex: 1;
margin: 0 0 18px;
padding-left: 1.15rem;
color: var(--vp-c-text-2);
}
.track-card li {
margin: 0.4rem 0;
line-height: 1.5;
}
.track-card__action {
display: inline-flex;
width: fit-content;
align-items: center;
gap: 8px;
padding: 9px 14px;
border: 1px solid var(--hr-line-strong);
border-radius: 999px;
font-family: var(--vp-font-family-mono);
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--vp-c-text-1);
}
.track-card__action:hover,
.track-card__action:focus-visible {
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
}
.track-boundary {
grid-column: 1 / -1;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px 18px;
align-items: center;
padding: 14px 16px;
border-left: 2px solid var(--vp-c-brand-1);
background: var(--vp-c-brand-soft);
color: var(--vp-c-text-2);
font-size: 0.9rem;
line-height: 1.55;
}
.track-boundary strong {
color: var(--vp-c-text-1);
}
.track-boundary a {
white-space: nowrap;
font-family: var(--vp-font-family-mono);
font-size: 0.7rem;
text-transform: uppercase;
}
@media (max-width: 720px) {
.track-chooser {
grid-template-columns: 1fr;
}
.track-boundary {
grid-column: auto;
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,103 @@
<template>
<nav class="symptom-nav" aria-labelledby="symptom-nav-title">
<header>
<span>Start with the symptom</span>
<h2 id="symptom-nav-title">What do you see?</h2>
</header>
<div class="symptom-nav__grid">
<a href="#cannot-connect"><strong>Red dot or no connection</strong><span>Check routes, host binding, and firewall.</span></a>
<a href="#messages-not-streaming"><strong>Chat does not stream</strong><span>Check authentication and server logs.</span></a>
<a href="#no-internet"><strong>No internet banner</strong><span>Confirm Android network state.</span></a>
<a href="#session-history"><strong>Sessions are missing</strong><span>Reconnect before loading history.</span></a>
<a href="#app-crashes"><strong>App crashes on startup</strong><span>Reset local connection state safely.</span></a>
<a href="#tailscale-checklist"><strong>Works at home, not away</strong><span>Verify the saved Tailscale route.</span></a>
</div>
</nav>
</template>
<style scoped>
.symptom-nav {
margin: 1.5rem 0 2rem;
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
overflow: hidden;
background: var(--vp-c-bg-alt);
}
.symptom-nav header {
padding: 18px 20px;
border-bottom: 1px solid var(--vp-c-divider);
}
.symptom-nav header span {
font-family: var(--vp-font-family-mono);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--vp-c-brand-1);
}
.symptom-nav h2 {
margin: 4px 0 0;
padding: 0;
border: 0;
font-size: 1.2rem;
}
.symptom-nav__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.symptom-nav__grid a {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
padding: 15px 18px;
border-right: 1px solid var(--vp-c-divider);
border-bottom: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-1);
text-decoration: none;
}
.symptom-nav__grid a:nth-child(2n) {
border-right: 0;
}
.symptom-nav__grid a:nth-last-child(-n + 2) {
border-bottom: 0;
}
.symptom-nav__grid a:hover,
.symptom-nav__grid a:focus-visible {
background: var(--vp-c-brand-soft);
}
.symptom-nav__grid strong {
font-size: 0.9rem;
}
.symptom-nav__grid span {
color: var(--vp-c-text-2);
font-size: 0.78rem;
line-height: 1.45;
}
@media (max-width: 600px) {
.symptom-nav__grid {
grid-template-columns: 1fr;
}
.symptom-nav__grid a,
.symptom-nav__grid a:nth-child(2n),
.symptom-nav__grid a:nth-last-child(-n + 2) {
border-right: 0;
border-bottom: 1px solid var(--vp-c-divider);
}
.symptom-nav__grid a:last-child {
border-bottom: 0;
}
}
</style>
+11 -13
View File
@@ -3,16 +3,16 @@ import './custom.css';
import { h } from 'vue';
import { withBase } from 'vitepress';
import HermesFlow from './components/HermesFlow.vue';
import InstallSection from './components/InstallSection.vue';
import HeroDemo from './components/HeroDemo.vue';
import FeatureMatrix from './components/FeatureMatrix.vue';
import SphereMark from './components/SphereMark.vue';
import ExperimentalBadge from './components/ExperimentalBadge.vue';
import HowItWorks from './components/HowItWorks.vue';
import SurfaceCards from './components/SurfaceCards.vue';
import StoreBadge from './components/StoreBadge.vue';
import CombineModel from './components/CombineModel.vue';
import PetPackBuilder from './components/PetPackBuilder.vue';
import DocsHomeHub from './components/DocsHomeHub.vue';
import ExpandableImage from './components/ExpandableImage.vue';
import FirstRunPreview from './components/FirstRunPreview.vue';
import ReleaseTrackChooser from './components/ReleaseTrackChooser.vue';
import TroubleshootingNavigator from './components/TroubleshootingNavigator.vue';
export default {
extends: DefaultTheme,
@@ -24,22 +24,20 @@ export default {
app.component('StoreBadge', StoreBadge);
app.component('CombineModel', CombineModel);
app.component('PetPackBuilder', PetPackBuilder);
app.component('DocsHomeHub', DocsHomeHub);
app.component('ExpandableImage', ExpandableImage);
app.component('FirstRunPreview', FirstRunPreview);
app.component('ReleaseTrackChooser', ReleaseTrackChooser);
app.component('TroubleshootingNavigator', TroubleshootingNavigator);
},
Layout() {
return h(DefaultTheme.Layout, null, {
'home-hero-image': () => h(HeroDemo),
'home-hero-actions-after': () =>
h('div', { class: 'hero-store-row' }, [
h(StoreBadge),
h('p', { class: 'hero-platform-note' }, 'CLI: Windows today · macOS / Linux coming soon'),
]),
'home-hero-after': () => [h(SphereMark), h(HowItWorks), h(SurfaceCards), h(InstallSection)],
'doc-after': () =>
h('div', { class: 'doc-footer-cta' }, [
h('hr'),
h('p', {
innerHTML:
`<strong>[?]</strong> <a href="https://github.com/Codename-11/hermes-relay/discussions">Ask a Question</a> · <strong>[!]</strong> <a href="https://github.com/Codename-11/hermes-relay/issues/new">Found a Bug?</a> · <strong>[+]</strong> <a href="${withBase('/guide/getting-started')}">Get Started</a>`,
`<strong>[?]</strong> <a href="${withBase('/guide/troubleshooting.html')}">Get Help</a> · <strong>[!]</strong> <a href="https://github.com/Codename-11/hermes-relay/issues/new">Found a Bug?</a> · <strong>[+]</strong> <a href="${withBase('/guide/getting-started.html')}">Get Started</a>`,
}),
]),
});
+6 -6
View File
@@ -1,10 +1,10 @@
<script setup>
import { withBase } from 'vitepress'
</script>
# Architecture
<img :src="withBase('/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." style="width:100%;margin:0.5rem 0 1.75rem;" />
<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."
caption="Select the diagram to inspect it at full size."
/>
## Connection Model
@@ -77,7 +77,7 @@ The Hermes API Server streams events using Server-Sent Events. Each event type m
The relay connection (bridge/terminal) uses a pairing code for initial setup, then session tokens for persistence.
<HermesFlow diagram="auth-flow" height="200px" />
<HermesFlow diagram="auth-flow" height="220px" />
Pairing codes use the full `A-Z / 0-9` alphabet (36 chars). The pair command (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim) on the Hermes host mints the code and pre-registers it with the relay via a loopback-only `/pairing/register` endpoint before embedding it in the QR — so the phone never types a code by hand. Session tokens are stored in EncryptedSharedPreferences backed by Android Keystore.
+73
View File
@@ -0,0 +1,73 @@
---
translation_status: ai-translated
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.
::: tip Übersetzungsstatus
Diese kompakte Übersetzung beschreibt den üblichen Einstieg. Erweiterte
Server-, TLS- und Betreiberoptionen stehen in der
[vollständigen englischen Anleitung](/guide/getting-started).
:::
## 1. App auswählen
| | Google Play | Sideload |
|---|---|---|
| Empfohlen für | Die meisten Nutzer | Nutzer von Device Control |
| Updates | Automatisch | APK manuell aktualisieren |
| Chat, Voice, Manage | Enthalten | Enthalten |
| Terminal, Medien, Benachrichtigungen mit Relay | Enthalten | Enthalten |
| Bildschirm lesen, tippen, schreiben, navigieren | Nicht enthalten | Enthalten |
<StoreBadge />
Die signierte Sideload-Datei endet mit `-sideload-release.apk` und liegt unter
[GitHub Releases](https://github.com/Codename-11/hermes-relay/releases). Lade
nicht die `.aab`-Datei herunter; sie ist nur für Google Play bestimmt.
## 2. Hermes erreichbar machen
Android benötigt den Hermes-API-Server, normalerweise unter `:8642`:
- `API_SERVER_ENABLED=true` aktiviert den Server.
- `API_SERVER_HOST=0.0.0.0` macht ihn im Netzwerk erreichbar.
- `API_SERVER_KEY` schützt Chat-Anfragen mit einem Bearer-Schlüssel.
- `hermes gateway` startet Hermes und den aktivierten API-Server.
::: warning Netzwerkzugriff absichern
`0.0.0.0` erlaubt anderen Geräten im Netzwerk den Zugriff. Verwende einen
starken API-Schlüssel. Stelle einen unverschlüsselten Port niemals direkt ins
Internet; verwende für den Fernzugriff Tailscale, ein VPN oder HTTPS.
:::
Das Dashboard unter `:9119` ist optional. Es wird für Manage und Standard-Voice
benötigt und hat eine eigene Anmeldung; der API-Schlüssel ist kein Dashboard-Login.
## 3. Verbinden und chatten
1. Öffne **Connect** in der Android-App.
2. Suche Hermes im LAN, scanne einen Einrichtungs-QR-Code oder trage API-URL und Schlüssel ein.
3. Tippe auf **Connect**.
4. Prüfe, ob **Chat · Ready** angezeigt wird.
5. Öffne Chat und sende die erste Nachricht.
Manage und Voice dürfen noch eine Anmeldung verlangen. Ein ungepaartes Relay
ist ebenfalls normal.
## Optional: Relay-Werkzeuge hinzufügen
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`,
`hermes relay doctor`, `hermes relay start --no-ssl` und `hermes pair`.
Device Control benötigt **beides**: die Sideload-App und ein gepaartes Relay.
[App-Versionen vergleichen →](/de/guide/release-tracks) ·
[Fernzugriff auf Englisch →](/guide/remote-access) ·
[Fehlerbehebung →](/de/guide/troubleshooting)
+57
View File
@@ -0,0 +1,57 @@
---
translation_status: ai-translated
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.
::: tip Übersetzungsstatus
Diese Seite wurde KI-gestützt übersetzt und technisch geprüft. Englisch bleibt
die verbindliche Quelle für Produkt- und Sicherheitsbedeutung.
:::
## 1. App installieren
Für die meisten Nutzer ist **Google Play** der schnellste Weg: Installation mit
einem Tipp und automatische Updates.
<StoreBadge />
Wenn Hermes den Bildschirm lesen, tippen, Text eingeben oder Apps bedienen soll,
installiere stattdessen die signierte **Sideload-APK**. Beide Varianten können
gleichzeitig auf demselben Gerät installiert sein.
## 2. Hermes starten
Auf dem Host muss Hermes erreichbar sein und der API-Server laufen. Starte ihn
bei Bedarf mit `hermes gateway`. Die ausführliche Einrichtung steht unter
[Installation & Einrichtung](/de/guide/getting-started).
## 3. Verbinden
Ö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 Adresse wie `http://<host>:8642` und den konfigurierten API-Schlüssel ein.
3. Scanne einen Einrichtungs-QR-Code mit URL und Schlüssel.
Wenn der Host absichtlich ohne `API_SERVER_KEY` läuft, bleibt das Schlüsselfeld leer.
## 4. Status prüfen
- **Chat · Ready** bedeutet, dass du Nachrichten senden kannst.
- **Manage** kann noch eine Dashboard-Anmeldung verlangen.
- **Voice** wird mit derselben Dashboard-Anmeldung freigeschaltet.
- **Relay** darf ungepaart bleiben und blockiert den Standardweg nicht.
## 5. Erste Nachricht senden
Öffne Chat und sende eine Nachricht. Ein grüner Verbindungspunkt im Kopfbereich
bestätigt, dass die aktive Hermes-Verbindung erreichbar ist.
[Ausführliche Installation →](/de/guide/getting-started) ·
[Fehlerbehebung →](/de/guide/troubleshooting) ·
[Vollständige englische Anleitung →](/guide/quick-start)
+38
View File
@@ -0,0 +1,38 @@
---
translation_status: ai-translated
canonical_source: /guide/release-tracks
---
# App-Versionen: Google Play oder Sideload
Beginne mit Google Play, außer du brauchst ausdrücklich Device Control. Beide
Apps stammen aus demselben Quellcode und können gleichzeitig installiert werden.
## Entscheidungshilfe
| Frage | Google Play | Sideload |
|---|---|---|
| Einfache Installation und automatische Updates? | Ja | Nein |
| Chat, Profile, Manage und Voice? | Ja | Ja |
| Terminal, Medien und Benachrichtigungen mit Relay? | Ja | Ja |
| Bildschirm lesen oder aufnehmen? | Nein | Ja |
| Tippen, schreiben, wischen und Apps bedienen? | Nein | Ja |
## App-Version und Relay sind getrennte Entscheidungen
Die **App-Version** bestimmt, ob Android Device Control enthält. Das optionale
**Relay-Plugin** verbindet Terminal, Medien, Benachrichtigungen und
Gerätekanäle mit dem Hermes-Host.
Device Control funktioniert nur mit **Sideload + gepaartem Relay**. Chat,
Manage und Standard-Voice benötigen keines von beiden.
## Später wechseln
Google Play und Sideload verwenden unterschiedliche Anwendungs-IDs. Du kannst
beide testen und eine Variante später entfernen. Einstellungen und Paarungen
werden pro App gespeichert.
[Google Play öffnen](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay) ·
[Sideload-APK herunterladen](https://github.com/Codename-11/hermes-relay/releases) ·
[Vollständiger englischer Vergleich →](/guide/release-tracks)
+57
View File
@@ -0,0 +1,57 @@
---
translation_status: ai-translated
canonical_source: /guide/troubleshooting
---
# Fehlerbehebung
Beginne mit dem sichtbaren Symptom. So lässt sich schnell trennen, ob Android,
das Netzwerk oder der Hermes-Host die Ursache ist.
- [Roter Punkt oder keine Verbindung](#keine-verbindung)
- [„No reachable endpoint“](#kein-erreichbarer-endpunkt)
- [Chat streamt nicht](#chat-streamt-nicht)
- [Manage oder Voice verlangt eine Anmeldung](#manage-und-voice)
- [Sitzungen fehlen](#sitzungen-fehlen)
- [App stürzt beim Start ab](#startabsturz)
## Keine Verbindung
1. Prüfe auf dem Host, ob `hermes gateway` läuft.
2. Öffne auf dem **Telefon** `http://<host>:8642/health`.
3. Prüfe `API_SERVER_ENABLED=true` und die Firewall.
4. Verwende vom Telefon niemals `localhost` oder `127.0.0.1`; diese Adressen zeigen auf das Telefon selbst.
## Kein erreichbarer Endpunkt
Die App hat alle gespeicherten LAN-, Tailscale- und öffentlichen Routen geprüft,
aber keine Antwort erhalten. Eine LAN-Adresse funktioniert nur im selben WLAN.
Für Tailscale müssen Telefon und Server verbunden sein.
## Chat streamt nicht
- Prüfe API-URL und API-Schlüssel.
- Tippe bei einem Fehlerbanner einmal auf **Retry**.
- Prüfe die Hermes-Serverprotokolle.
- Bei langen lokalen Modellläufen kann Android die Verbindung im Hintergrund trennen; die fertige Antwort wird nach der Wiederverbindung geladen.
## Manage und Voice
Manage und Standard-Voice verwenden die Dashboard-Anmeldung, nicht den
`API_SERVER_KEY`. Melde dich einmal im Manage-Bereich an und prüfe, ob das
Dashboard vom Telefon erreichbar ist.
## Sitzungen fehlen
Der Server muss beim Wechsel der Sitzung erreichbar sein. Große Sitzungen
können kurz laden; warte auf die Ladeanzeige, bevor du erneut wechselst.
## Startabsturz
Öffne Android **Einstellungen → Apps → Hermes-Relay → Speicher** und lösche die
App-Daten. Richte danach API-URL und Schlüssel erneut ein.
Für Relay-Probleme liefert `hermes relay doctor` eine schreibgeschützte Diagnose.
[Vollständige englische Fehlerbehebung →](/guide/troubleshooting) ·
[Installation →](/de/guide/getting-started)
+40
View File
@@ -0,0 +1,40 @@
---
layout: home
translation_status: ai-translated
canonical_source: /
title: Hermes-Relay
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.
actions:
- theme: brand
text: Android-Schnellstart
link: /de/guide/quick-start
- theme: alt
text: Installation
link: /de/guide/getting-started
- theme: alt
text: Englische Referenz
link: /reference/api
features:
- title: Standard-Hermes genügt
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.
---
## Umfang dieser Übersetzung
Diese KI-gestützte Übersetzung deckt den Einstieg, die Installation, die
App-Auswahl und die häufigsten Fehler ab. Schnell veränderliche API-,
Architektur-, Sicherheits- und CLI-Referenzen bleiben auf Englisch verbindlich.
[Schnellstart →](/de/guide/quick-start) ·
[Fehlerbehebung →](/de/guide/troubleshooting) ·
[Englische Referenz →](/reference/api)
+72
View File
@@ -0,0 +1,72 @@
---
translation_status: ai-translated
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.
::: tip Estado de la traducción
Esta guía resumida cubre la ruta habitual. Las opciones avanzadas de servidor,
TLS y operación están en la [guía completa en inglés](/guide/getting-started).
:::
## 1. Elige la aplicación
| | Google Play | Sideload |
|---|---|---|
| Recomendado para | La mayoría de usuarios | Usuarios de Device Control |
| Actualizaciones | Automáticas | Actualización manual del APK |
| Chat, Voice y Manage | Incluidos | Incluidos |
| Terminal, multimedia y notificaciones con Relay | Incluidos | Incluidos |
| Leer la pantalla, tocar, escribir y navegar | No incluido | Incluido |
<StoreBadge />
El archivo firmado de Sideload termina en `-sideload-release.apk` y se publica
en [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases). No
descargues el archivo `.aab`; está destinado a Google Play.
## 2. Haz que Hermes sea accesible
Android necesita el servidor de API de Hermes, normalmente en `:8642`:
- `API_SERVER_ENABLED=true` activa el servidor.
- `API_SERVER_HOST=0.0.0.0` permite el acceso desde la red.
- `API_SERVER_KEY` protege las solicitudes de Chat con una clave bearer.
- `hermes gateway` inicia Hermes y el servidor de API activado.
::: warning Protege el acceso de red
`0.0.0.0` permite que otros dispositivos de la red lleguen al servicio. Usa una
clave segura. No expongas directamente un puerto sin cifrar a Internet; para el
acceso remoto utiliza Tailscale, una VPN o HTTPS.
:::
El dashboard en `:9119` es opcional. Se utiliza para Manage y la voz estándar,
y tiene su propio inicio de sesión; la clave de API no inicia sesión en el dashboard.
## 3. Conecta y conversa
1. Abre **Connect** en Android.
2. Busca Hermes en la LAN, escanea un QR de configuración o introduce la URL y la clave.
3. Pulsa **Connect**.
4. Comprueba que aparezca **Chat · Ready**.
5. Abre Chat y envía el primer mensaje.
Manage y Voice todavía pueden pedir una sesión. También es normal que Relay
aparezca sin emparejar.
## Opcional: añade las herramientas de Relay
Instala el complemento solo para terminal, Device Control, multimedia,
notificaciones o herramientas remotas avanzadas. 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`.
Device Control necesita **las dos cosas**: la aplicación Sideload y un Relay emparejado.
[Comparar versiones →](/es/guide/release-tracks) ·
[Acceso remoto en inglés →](/guide/remote-access) ·
[Solución de problemas →](/es/guide/troubleshooting)
+57
View File
@@ -0,0 +1,57 @@
---
translation_status: ai-translated
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.
::: tip Estado de la traducción
Esta página se tradujo con asistencia de IA y pasó las comprobaciones técnicas.
El inglés sigue siendo la fuente canónica del significado del producto y la seguridad.
:::
## 1. Instala la aplicación
Para la mayoría de las personas, **Google Play** es el camino más rápido:
instalación con un toque y actualizaciones automáticas.
<StoreBadge />
Si quieres que Hermes lea la pantalla, toque, escriba o navegue por el teléfono,
instala en su lugar el APK firmado de **Sideload**. Las dos versiones pueden
estar instaladas a la vez.
## 2. Inicia Hermes
El servidor de API de Hermes debe estar activo y accesible desde el teléfono.
Si es necesario, inicia el host con `hermes gateway`. Consulta
[Instalación y configuración](/es/guide/getting-started) para preparar el servidor.
## 3. Conecta
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 una dirección como `http://<host>:8642` y la clave de API configurada.
3. Escanear un código QR de configuración que contenga la URL y la clave.
Si el host se ejecuta intencionadamente sin `API_SERVER_KEY`, deja la clave vacía.
## 4. Comprueba el estado
- **Chat · Ready** significa que ya puedes enviar mensajes.
- **Manage** puede pedir que inicies sesión en el dashboard.
- **Voice** se habilita con esa misma sesión del dashboard.
- **Relay** puede seguir sin emparejar y no bloquea el funcionamiento estándar.
## 5. Envía el primer mensaje
Abre Chat y envía un mensaje. El indicador verde del encabezado confirma que la
conexión activa con Hermes está disponible.
[Instalación detallada →](/es/guide/getting-started) ·
[Solución de problemas →](/es/guide/troubleshooting) ·
[Guía canónica en inglés →](/guide/quick-start)
+38
View File
@@ -0,0 +1,38 @@
---
translation_status: ai-translated
canonical_source: /guide/release-tracks
---
# Versiones de la aplicación: Google Play o Sideload
Empieza con Google Play salvo que necesites específicamente Device Control. Las
dos versiones proceden del mismo código y pueden convivir en el teléfono.
## Ayuda para elegir
| Pregunta | Google Play | Sideload |
|---|---|---|
| ¿Instalación sencilla y actualizaciones automáticas? | Sí | No |
| ¿Chat, perfiles, Manage y Voice? | Sí | Sí |
| ¿Terminal, multimedia y notificaciones con Relay? | Sí | Sí |
| ¿Leer o capturar la pantalla? | No | Sí |
| ¿Tocar, escribir, deslizar y manejar aplicaciones? | No | Sí |
## La versión y Relay son decisiones independientes
La **versión de la aplicación** decide si Android incluye Device Control. El
**complemento Relay** opcional conecta el terminal, el contenido multimedia,
las notificaciones y los canales de dispositivos con el host de Hermes.
Device Control solo funciona con **Sideload + Relay emparejado**. Chat, Manage y
la voz estándar no necesitan ninguno de los dos.
## Cambiar más adelante
Google Play y Sideload utilizan identificadores de aplicación distintos. Puedes
probar ambas y eliminar una después. Cada aplicación conserva sus propios ajustes
y emparejamientos.
[Abrir Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay) ·
[Descargar el APK Sideload](https://github.com/Codename-11/hermes-relay/releases) ·
[Comparación completa en inglés →](/guide/release-tracks)
+57
View File
@@ -0,0 +1,57 @@
---
translation_status: ai-translated
canonical_source: /guide/troubleshooting
---
# Solución de problemas
Empieza por el síntoma que puedes ver. Así podrás separar rápidamente un
problema de Android, de red o del host de Hermes.
- [Punto rojo o sin conexión](#sin-conexión)
- [“No reachable endpoint”](#ningún-endpoint-accesible)
- [Chat no transmite la respuesta](#chat-no-transmite)
- [Manage o Voice pide iniciar sesión](#manage-y-voice)
- [Faltan sesiones](#faltan-sesiones)
- [La aplicación falla al iniciarse](#fallo-al-iniciar)
## Sin conexión
1. Comprueba en el host que `hermes gateway` está en ejecución.
2. Abre `http://<host>:8642/health` desde el **teléfono**.
3. Comprueba `API_SERVER_ENABLED=true` y las reglas del firewall.
4. No uses `localhost` ni `127.0.0.1` en el teléfono; apuntan al propio teléfono.
## Ningún endpoint accesible
La aplicación probó todas las rutas guardadas —LAN, Tailscale y públicas— sin
obtener respuesta. Una dirección LAN solo funciona en la misma red Wi-Fi. Para
Tailscale, el teléfono y el servidor deben estar conectados.
## Chat no transmite
- Comprueba la URL y la clave de API.
- Si aparece un error, pulsa **Retry** una sola vez.
- Revisa los registros del servidor Hermes.
- Los modelos locales pueden tardar varios minutos; si Android corta la conexión en segundo plano, la respuesta terminada se recuperará al reconectar.
## Manage y Voice
Manage y la voz estándar usan la sesión del dashboard, no `API_SERVER_KEY`.
Inicia sesión una vez desde Manage y confirma que el dashboard sea accesible
desde el teléfono.
## Faltan sesiones
El servidor debe estar disponible al cambiar de sesión. Las sesiones grandes
pueden tardar unos instantes; espera al indicador de carga.
## Fallo al iniciar
Abre **Ajustes de Android → Aplicaciones → Hermes-Relay → Almacenamiento** y
borra los datos de la aplicación. Después vuelve a configurar la URL y la clave.
Para problemas de Relay, `hermes relay doctor` ofrece un diagnóstico de solo lectura.
[Solución de problemas completa en inglés →](/guide/troubleshooting) ·
[Instalación →](/es/guide/getting-started)
+41
View File
@@ -0,0 +1,41 @@
---
layout: home
translation_status: ai-translated
canonical_source: /
title: Hermes-Relay
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.
actions:
- theme: brand
text: Inicio rápido de Android
link: /es/guide/quick-start
- theme: alt
text: Instalación
link: /es/guide/getting-started
- theme: alt
text: Referencia en inglés
link: /reference/api
features:
- title: Hermes estándar es suficiente
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.
---
## Alcance de esta traducción
Esta traducción asistida por IA cubre los primeros pasos, la instalación, la
elección de la aplicación y los problemas más frecuentes. Las referencias de
API, arquitectura, seguridad y CLI que cambian rápidamente siguen teniendo su
fuente canónica en inglés.
[Inicio rápido →](/es/guide/quick-start) ·
[Solución de problemas →](/es/guide/troubleshooting) ·
[Referencia en inglés →](/reference/api)
+3 -3
View File
@@ -191,7 +191,7 @@ For a still pack, each generated PNG becomes a one-frame clip. This manifest wir
```json
{
"$schema": "https://codename-11.github.io/hermes-relay/pet.schema.json",
"$schema": "https://hermes-relay.dev/docs/pet.schema.json",
"schemaVersion": 1,
"id": "my-pet",
"label": "My Pet",
@@ -216,7 +216,7 @@ For an animated pack, each generated sheet becomes one clip. For a **4×4 grid o
```json
{
"$schema": "https://codename-11.github.io/hermes-relay/pet.schema.json",
"$schema": "https://hermes-relay.dev/docs/pet.schema.json",
"schemaVersion": 1,
"id": "my-pet",
"label": "My Pet",
@@ -261,7 +261,7 @@ The helper validates `pet.json`, checks that every referenced image exists, excl
<PetPackBuilder />
::: tip Validate as you author
The example above starts with a `$schema` line pointing at the published [pet schema](https://codename-11.github.io/hermes-relay/pet.schema.json). Keep it and editors like VS Code will autocomplete the fields and flag mistakes — a missing `idle`, a bad frame count, a typo'd state key — before you ever push. The app ignores the `$schema` key, and an AI agent can lint its output against the same file.
The example above starts with a `$schema` line pointing at the published [pet schema](https://hermes-relay.dev/docs/pet.schema.json). Keep it and editors like VS Code will autocomplete the fields and flag mistakes — a missing `idle`, a bad frame count, a typo'd state key — before you ever push. The app ignores the `$schema` key, and an AI agent can lint its output against the same file.
:::
::: tip Safe box: leave room inside every cell
+47 -76
View File
@@ -1,50 +1,26 @@
<script setup>
import { withBase } from 'vitepress'
</script>
# 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.
<div class="gs-steps">
<span>1 · Install the app</span>
<span>2 · Point it at Hermes</span>
<span>3 · Connect &amp; chat</span>
</div>
<ol class="gs-steps" aria-label="Setup progress">
<li><strong>01</strong><span>Install the app</span></li>
<li><strong>02</strong><span>Point it at Hermes</span></li>
<li><strong>03</strong><span>Connect &amp; chat</span></li>
</ol>
## 1. Install the app
The easiest way — install from Google Play and get automatic updates:
Choose the build by one question: do you want Hermes to operate the phone, or
only be available from it? Both builds come from the same codebase and can live
side-by-side.
<div class="gs-install-cta">
<StoreBadge />
</div>
<ReleaseTrackChooser />
That's the **Google Play** build: chat, profiles, voice, terminal/TUI relay,
media, the notification companion, relay sessions, and diagnostics. It's what
most people want.
Prefer to install the APK by hand, or want the full phone-control feature set?
That's the **Sideload** build — same app, plus the agent can read your screen,
tap, type, and navigate apps for you.
::: details Google Play vs. Sideload — which build is right for me?
Both flavors are built from the same codebase and install with **different
application IDs**, so you can run them side-by-side and try both.
| | Google Play | Sideload |
|---|---|---|
| Install | One tap, auto-updates | Manual APK from GitHub Releases |
| Chat, voice, Manage | ✅ | ✅ |
| Terminal / TUI relay, media, notifications | ✅ | ✅ |
| Device Control (screen reading, taps, typing, vision navigation) | — | ✅ |
Most users want **Google Play**. Pick **Sideload** if you want the agent to
operate your phone for you. The [Release tracks](/guide/release-tracks) page has
the full feature comparison and a decision guide.
:::
The [Release tracks](/guide/release-tracks) page explains the full capability
and safety differences. Building from source is an advanced alternative under
the Sideload instructions below.
### Sideload APK {#sideload-apk}
@@ -297,22 +273,13 @@ using the highest-priority reachable one. Chat and Manage move together — LAN
home, Tailscale when you leave.
:::
### See it working
### What you’ll see
Once you're connected, chat streams like this — responses, tool cards, markdown,
and the personality picker, all live:
The documentation uses deterministic renders from the real Android components,
so these screens update with the canonical screenshot set instead of drifting
like a hand-recorded demo.
<div class="demo-video-wrap">
<video
:src="withBase('/chat_demo.mp4')"
:poster="withBase('/chat_demo_poster.jpg')"
controls
muted
loop
playsinline
preload="metadata"
/>
</div>
<FirstRunPreview />
The chat header shows the agent name with a green pulse on the avatar when the
API server is reachable. If the dot is red:
@@ -494,37 +461,41 @@ shared with other Hermes tools. Flags: `--dry-run`, `--keep-clone`,
<style scoped>
.gs-steps {
display: flex;
flex-wrap: wrap;
gap: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin: 1.25rem 0 2rem;
padding: 0;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-divider);
list-style: none;
}
.gs-steps li {
display: grid;
grid-template-columns: 30px 1fr;
gap: 8px;
align-items: center;
margin: 0;
padding: 11px 12px;
background: var(--vp-c-bg-alt);
color: var(--vp-c-text-2);
}
.gs-steps strong {
font-family: var(--vp-font-family-mono);
font-size: 0.66rem;
color: var(--vp-c-brand-1);
}
.gs-steps span {
font-family: var(--vp-font-family-mono);
font-size: 0.72rem;
letter-spacing: 0.04em;
font-size: 0.68rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--vp-c-text-2);
background: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-radius: 999px;
padding: 0.35rem 0.85rem;
}
.gs-install-cta {
margin: 1.25rem 0 1.5rem;
}
.demo-video-wrap {
display: flex;
justify-content: center;
margin: 1.5rem 0 2rem;
}
.demo-video-wrap video {
max-width: 320px;
width: 100%;
height: auto;
border-radius: 16px;
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.5),
0 0 0 1px var(--vp-c-divider);
background: #000;
@media (max-width: 640px) {
.gs-steps {
grid-template-columns: 1fr;
}
}
</style>
+1 -1
View File
@@ -52,7 +52,7 @@ the Hermes API Server's SSE routes. The relay server handles Bridge Core,
terminal, TUI, media, notification companion, relay sessions, and relay-backed
voice routes. Sideload builds additionally expose Android Device Control routes.
## Current Status — v1.0.0
## Feature status
| Feature | Status |
|---------|--------|
+19 -6
View File
@@ -6,10 +6,14 @@ nothing here requires the Relay plugin — a vanilla Hermes install is enough.
## 1. Install the app
Grab the latest APK from the
[releases page](https://github.com/Codename-11/hermes-relay/releases) and
install it. Play-store, integrity-verification, and build-from-source options
live in [Installation & Setup](./getting-started).
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.
<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)
## 2. Have Hermes running
@@ -21,8 +25,9 @@ host machine.
## 3. Connect
Open the app and swipe through to **Connect**. Enter your server's address —
`http://<host>:8642` — and your API key, and the wizard probes everything for
you, finishing with a capability card:
`http://<host>:8642` — and the API key configured on that Hermes host. If the
server intentionally runs without `API_SERVER_KEY`, leave the key field blank.
The wizard probes everything for you, finishing with a capability card:
> Don't want to type it? Tap **Scan for Hermes on LAN** to auto-find the server,
> or use **Scan setup QR** — you can even ask your Hermes agent to generate a QR
@@ -44,6 +49,14 @@ That same session also unlocks voice for the connection.
Type a message, or tap the mic and speak. That's the whole Vanilla Hermes setup.
::: 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.
:::
[See the exact first-run screens and detailed setup →](./getting-started#_3-connect-chat)
::: 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
+6 -4
View File
@@ -2,11 +2,13 @@
Hermes-Relay ships in **two flavors** built from the same codebase. Most users want the Google Play one. Power users who want the agent to drive their phone hands-free want the sideload one. This page explains what's different, why, and how to choose.
## TL;DR
## Choose your build
- **Google Play** — easy install, automatic updates, every chat/profile/voice feature, terminal/TUI relay, media handoff, notification companion, relay sessions, and diagnostics. The relay-backed ones (terminal, media, notifications) need the [Relay plugin](#how-the-pieces-combine) paired to your Hermes host. It has **no AccessibilityService** and cannot read the screen, tap, type, swipe, capture screenshots, send SMS, make calls, access contacts/location, or perform unattended phone control.
- **Sideload** — manual install from GitHub Releases, all of the above plus AccessibilityService-backed Device Control: screen reading, taps, typing, gestures, screenshots, voice-routed bridge intents ("text Sam I'll be late", "open Chrome"), direct SMS/call dispatch, file sharing/MMS attachment handoff, vision-driven navigation, and the full `android_*` bridge toolset. The toggle is labeled "Agent Control."
- They coexist on a device — you can install both side-by-side and try them both.
Start with Google Play unless you specifically want Device Control. The two
builds use different application IDs, so they can coexist on one phone while
you decide.
<ReleaseTrackChooser explain-relay />
## How the pieces combine
+11 -6
View File
@@ -1,6 +1,11 @@
# Troubleshooting
## Can't connect to API server
Start with the symptom you can see. Each path begins with the smallest check
that separates an Android problem from a server or network problem.
<TroubleshootingNavigator />
## Can't connect to API server {#cannot-connect}
- **Red dot in chat header**: API server is unreachable
- Check that Hermes is running: `hermes gateway`
@@ -33,7 +38,7 @@ server machine. Use the server's LAN IP (e.g. `http://192.168.1.100:8642`) or
its Tailscale address instead.
:::
### Tailscale checklist
### Tailscale checklist {#tailscale-checklist}
If a Tailscale route fails its probe:
@@ -67,7 +72,7 @@ If `adb connect` is refused, the pairing succeeded but the wrong port was used,
or Wireless debugging rotated ports. Reopen **Developer options -> Wireless
debugging** on the phone and copy the current main port.
## Messages not streaming
## Messages not streaming {#messages-not-streaming}
- Check your API key is correct
- Look for error banners in the chat — tap **Retry** to resend
@@ -88,18 +93,18 @@ To reduce drops in the first place:
- Enable the gateway keep-alive option in Settings if you background the app
during long turns.
## "No internet connection" banner
## "No internet connection" banner {#no-internet}
- The app detected network loss via Android's ConnectivityManager
- Check your WiFi/mobile data connection
- The banner disappears automatically when connectivity returns
## Session history not loading
## Session history not loading {#session-history}
- The server must be reachable when switching sessions
- Large sessions may take a moment to load — watch for the loading indicator
## App crashes on startup
## App crashes on startup {#app-crashes}
- Clear app data: Settings > Apps > Hermes-Relay > Clear Data
- Re-enter your API server URL and key during onboarding
+11 -63
View File
@@ -2,71 +2,19 @@
layout: home
hero:
name: Hermes-Relay
text: Runs on your machine. Lives on your devices.
tagline: Pair your Hermes agent with the devices around you — a native Android companion for chat, voice, and full phone control, plus a single-binary CLI that gives the agent hands on any machine you put it on.
name: Documentation
text: Start with the surface you have.
tagline: Connect the Android companion, pair the CLI, or add the optional Relay power path. Each route starts with the shortest working setup.
actions:
- theme: brand
text: Get the Android app
link: /guide/getting-started
text: Android quick start
link: /guide/quick-start
- theme: alt
text: Give it hands — CLI
link: /desktop/
features:
- icon:
src: /icons/chat.svg
width: 40
height: 40
title: Chat that streams, not spins
details: Rides your dashboard's live gateway WebSocket when signed in — live thinking included — and falls back to direct HTTP/SSE against the API server. Markdown, syntax-highlighted code, reasoning blocks, tool-progress cards. No cloud relay in the path.
- icon:
src: /icons/personalities.svg
width: 40
height: 40
title: Hands-free voice
details: Talk to your agent using your server's own TTS/STT — or opt into the provider-native Realtime Agent for low-latency, barge-in conversation.
- icon:
src: /icons/sessions.svg
width: 40
height: 40
title: Manage from your pocket
details: Skills, cron jobs, profiles, models, and keys — your server's dashboard controls, rebuilt native. One sign-in unlocks Manage and voice.
- icon:
src: /icons/tools.svg
width: 40
height: 40
title: Your phone, on the agent's toolbelt
details: With the Relay plugin paired and the Sideload build, the agent can read the screen, tap, type, and navigate apps — fenced by a per-app blocklist, destructive-verb confirmation, and auto-disable.
link: /guide/release-tracks#how-the-pieces-combine
linkText: How the pieces combine
- icon:
src: /icons/tokens.svg
width: 40
height: 40
title: Hands on any machine
details: Install the single-binary CLI — desktop, laptop, or headless box — and the agent can read, write, search, run commands, and capture screens there. Consent-gated per device.
- icon:
src: /icons/markdown.svg
width: 40
height: 40
title: Works at home and away
details: Connections carry LAN, Tailscale, and public routes, and the app picks the best one on every connect and network change — home, train, or VPN, it just works.
- icon:
src: /icons/reasoning.svg
width: 40
height: 40
title: Notifications and media in the loop
details: Forward phone notifications to your agent, and let it hand files, images, and rich cards back into chat — shareable through native Android flows.
- icon:
src: /icons/security.svg
width: 40
height: 40
title: Private by architecture
details: No cloud in the path — QR pairing, Keystore-held tokens, TOFU cert pinning, per-channel grants with TTLs you choose, revocable from any client.
text: Install the CLI
link: /desktop/installation
- theme: alt
text: Product overview ↗
link: https://hermes-relay.dev/
---
<!-- Home body intentionally empty — the sphere, How-it-works strip, surface
cards, and Get-started section are slotted via .vitepress/theme/index.ts
(markdown body always renders below the VPFeatures grid, which is the
wrong place for all of them). -->
<DocsHomeHub />
+72
View File
@@ -0,0 +1,72 @@
---
translation_status: ai-translated
canonical_source: /guide/getting-started
---
# インストールと設定
手順は 3 つです。アプリをインストールし、Hermes に接続し、最初の
メッセージを送信します。Hermes がすでに動作している場合、サーバーへの追加
インストールは不要です。
::: tip 翻訳ステータス
この要約ガイドは一般的な導入手順を扱います。高度なサーバー、TLS、運用設定は
[完全な英語ガイド](/guide/getting-started)を参照してください。
:::
## 1. アプリを選ぶ
| | Google Play | Sideload |
|---|---|---|
| 推奨対象 | ほとんどのユーザー | Device Control を使うユーザー |
| 更新 | 自動 | APK を手動更新 |
| Chat、Voice、Manage | 含まれる | 含まれる |
| Relay のターミナル、メディア、通知 | 含まれる | 含まれる |
| 画面読み取り、タップ、入力、ナビゲーション | 含まれない | 含まれる |
<StoreBadge />
署名済み Sideload ファイルは `-sideload-release.apk` で終わり、
[GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) から
取得できます。`.aab` は Google Play 用なのでインストールしないでください。
## 2. Hermes を到達可能にする
Android は通常 `:8642` の Hermes API サーバーを使用します。
- `API_SERVER_ENABLED=true` で API サーバーを有効にします。
- `API_SERVER_HOST=0.0.0.0` でネットワークから到達可能にします。
- `API_SERVER_KEY` は Chat リクエストを bearer キーで保護します。
- `hermes gateway` で Hermes と有効化された API サーバーを起動します。
::: warning ネットワークアクセスを保護する
`0.0.0.0` はネットワーク上の他のデバイスからの接続を許可します。強力な
API キーを使用してください。暗号化されていないポートをインターネットへ
直接公開せず、リモートアクセスには Tailscale、VPN、HTTPS を使用します。
:::
`:9119` のダッシュボードはオプションです。Manage と標準 Voice に使用され、
独自のログインがあります。API キーはダッシュボードのログイン情報ではありません。
## 3. 接続して会話する
1. Android アプリで **Connect** を開きます。
2. LAN 検索、セットアップ QR、または API URL とキーの入力を選びます。
3. **Connect** をタップします。
4. **Chat · Ready** が表示されることを確認します。
5. Chat を開いて最初のメッセージを送ります。
Manage と Voice はログインを求める場合があります。Relay が未ペアリングでも正常です。
## オプション: Relay ツールを追加する
ターミナル、Device Control、メディア、通知、高度なリモートツールが必要な
場合だけプラグインを追加します。正規コマンドは
`hermes plugins install Codename-11/hermes-relay/plugin --enable`、
`hermes relay doctor`、`hermes relay start --no-ssl`、`hermes pair` です。
Device Control には **Sideload アプリとペアリング済み Relay の両方**が必要です。
[アプリの種類を比較 →](/ja/guide/release-tracks) ·
[英語のリモートアクセス →](/guide/remote-access) ·
[トラブルシューティング →](/ja/guide/troubleshooting)
+56
View File
@@ -0,0 +1,56 @@
---
translation_status: ai-translated
canonical_source: /guide/quick-start
---
# クイックスタート
インストール → 接続 → 会話を約 2 分で行えます。この標準手順では通常の
Hermes Agent だけを使用し、Relay プラグインは必要ありません。
::: tip 翻訳ステータス
このページは AI 支援で翻訳され、技術検証を通過しています。製品と
セキュリティの意味については英語版が正規情報です。
:::
## 1. アプリをインストールする
ほとんどのユーザーには **Google Play** 版が最短です。1 回の操作で
インストールでき、更新も自動で届きます。
<StoreBadge />
Hermes に画面の読み取り、タップ、文字入力、アプリ操作を許可したい場合は、
署名済みの **Sideload APK** を使用します。2 つの版は同時にインストールできます。
## 2. Hermes を起動する
Hermes API サーバーが起動し、スマートフォンから到達できる必要があります。
必要に応じて `hermes gateway` でホストを起動します。サーバーの準備は
[インストールと設定](/ja/guide/getting-started)を参照してください。
## 3. 接続する
アプリを開いて **Connect** へ進み、次のいずれかを使用します。
1. **Scan for Hermes on LAN** でローカルネットワークを検索する。
2. `http://<host>:8642` のようなアドレスと設定済み API キーを入力する。
3. URL とキーを含むセットアップ QR コードを読み取る。
ホストが意図的に `API_SERVER_KEY` なしで動作している場合、キーは空欄にします。
## 4. 状態を確認する
- **Chat · Ready** ならメッセージを送信できます。
- **Manage** ではダッシュボードへのログインを求められる場合があります。
- **Voice** も同じダッシュボードセッションで有効になります。
- **Relay** は未ペアリングのままでも標準機能を妨げません。
## 5. 最初のメッセージを送る
Chat を開いてメッセージを送信します。ヘッダーの緑色の接続表示は、現在の
Hermes 接続が利用可能であることを示します。
[詳細なインストール →](/ja/guide/getting-started) ·
[トラブルシューティング →](/ja/guide/troubleshooting) ·
[英語の正規ガイド →](/guide/quick-start)
+37
View File
@@ -0,0 +1,37 @@
---
translation_status: ai-translated
canonical_source: /guide/release-tracks
---
# アプリの種類: Google Play または Sideload
Device Control が明確に必要でない限り、Google Play 版から始めてください。
2 つの版は同じコードから作られ、同じ端末に共存できます。
## 選択ガイド
| 質問 | Google Play | Sideload |
|---|---|---|
| 簡単なインストールと自動更新 | はい | いいえ |
| Chat、プロファイル、Manage、Voice | はい | はい |
| Relay のターミナル、メディア、通知 | はい | はい |
| 画面の読み取りやキャプチャ | いいえ | はい |
| タップ、入力、スワイプ、アプリ操作 | いいえ | はい |
## アプリの種類と Relay は別の選択
**アプリの種類**は Android に Device Control を含めるかを決めます。
オプションの **Relay プラグイン**は、ターミナル、メディア、通知、
デバイスチャンネルを Hermes ホストへ接続します。
Device Control は **Sideload + ペアリング済み Relay** でのみ動作します。
Chat、Manage、標準 Voice はどちらも必要としません。
## 後から切り替える
Google Play と Sideload は異なるアプリ ID を使用します。両方を試してから
片方を削除できます。設定とペアリングはアプリごとに保存されます。
[Google Play を開く](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay) ·
[Sideload APK を取得](https://github.com/Codename-11/hermes-relay/releases) ·
[英語の完全な比較 →](/guide/release-tracks)
+57
View File
@@ -0,0 +1,57 @@
---
translation_status: ai-translated
canonical_source: /guide/troubleshooting
---
# トラブルシューティング
画面に見えている症状から始めます。Android、ネットワーク、Hermes ホストの
どこに問題があるかをすばやく切り分けられます。
- [赤い表示または接続なし](#接続できない)
- [「No reachable endpoint」](#到達可能なエンドポイントがない)
- [Chat がストリーミングしない](#chat-がストリーミングしない)
- [Manage または Voice がログインを求める](#manage-と-voice)
- [セッションが表示されない](#セッションが表示されない)
- [起動時にアプリがクラッシュする](#起動時のクラッシュ)
## 接続できない
1. ホストで `hermes gateway` が動作していることを確認します。
2. **スマートフォン**から `http://<host>:8642/health` を開きます。
3. `API_SERVER_ENABLED=true` とファイアウォール設定を確認します。
4. スマートフォンで `localhost` や `127.0.0.1` を使用しないでください。これらはスマートフォン自身を示します。
## 到達可能なエンドポイントがない
保存済みの LAN、Tailscale、公開ルートをすべて確認しましたが、応答がありません。
LAN アドレスは同じ Wi-Fi 内でのみ動作します。Tailscale ではスマートフォンと
サーバーの両方が接続済みである必要があります。
## Chat がストリーミングしない
- API URL と API キーを確認します。
- エラーが表示された場合は **Retry** を 1 回タップします。
- Hermes サーバーのログを確認します。
- ローカルモデルは数分かかる場合があります。Android がバックグラウンド接続を切断しても、再接続時に完了済みの応答を取得します。
## Manage と Voice
Manage と標準 Voice は `API_SERVER_KEY` ではなくダッシュボードセッションを
使用します。Manage から一度ログインし、スマートフォンからダッシュボードへ
到達できることを確認してください。
## セッションが表示されない
セッション切り替え時にはサーバーへ接続できる必要があります。大きな
セッションでは読み込みに時間がかかるため、進行表示を待ってください。
## 起動時のクラッシュ
Android の **設定 → アプリ → Hermes-Relay → ストレージ** でアプリデータを
削除し、API URL とキーを再設定します。
Relay の問題には、読み取り専用診断の `hermes relay doctor` を使用できます。
[英語の完全なトラブルシューティング →](/guide/troubleshooting) ·
[インストール →](/ja/guide/getting-started)

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