Compare commits

...
Author SHA1 Message Date
Bailey Dixonandlayerflex c8cd13e5bc fix(android): render bot chat history after binding
Preserve the route-owned ChatHandler as the rendered state source from first composition and cover fast/delayed history plus handler replacement and on-device lifecycle recovery.

Supersedes PR #453 while preserving contributor credit.

Co-authored-by: layerflex <254160994+layerflex@users.noreply.github.com>
2026-08-31 10:08:20 -04:00
Bailey Dixon ad4175bb6d test(android): remove obsolete clean chat instrumentation 2026-08-31 10:08:20 -04:00
dependabot[bot] 6b7cb706e0 chore(deps): bump coil from 3.5.0 to 3.6.0 (#502)
Bumps `coil` from 3.5.0 to 3.6.0.

Updates `io.coil-kt.coil3:coil-compose` from 3.5.0 to 3.6.0
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.5.0...3.6.0)

Updates `io.coil-kt.coil3:coil-gif` from 3.5.0 to 3.6.0
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.5.0...3.6.0)

Updates `io.coil-kt.coil3:coil-network-okhttp` from 3.5.0 to 3.6.0
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.5.0...3.6.0)

---
updated-dependencies:
- dependency-name: io.coil-kt.coil3:coil-compose
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.coil-kt.coil3:coil-gif
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.coil-kt.coil3:coil-network-okhttp
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 12:00:18 +00:00
dependabot[bot] 6031427ffb chore(deps): bump haze from 1.7.2 to 1.7.3 (#504)
Bumps `haze` from 1.7.2 to 1.7.3.

Updates `dev.chrisbanes.haze:haze` from 1.7.2 to 1.7.3
- [Release notes](https://github.com/chrisbanes/haze/releases)
- [Changelog](https://github.com/chrisbanes/haze/blob/1.7.3/CHANGELOG.md)
- [Commits](https://github.com/chrisbanes/haze/compare/1.7.2...1.7.3)

Updates `dev.chrisbanes.haze:haze-materials` from 1.7.2 to 1.7.3
- [Release notes](https://github.com/chrisbanes/haze/releases)
- [Changelog](https://github.com/chrisbanes/haze/blob/1.7.3/CHANGELOG.md)
- [Commits](https://github.com/chrisbanes/haze/compare/1.7.2...1.7.3)

---
updated-dependencies:
- dependency-name: dev.chrisbanes.haze:haze
  dependency-version: 1.7.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: dev.chrisbanes.haze:haze-materials
  dependency-version: 1.7.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:59:10 +00:00
dependabot[bot] 53dd21400a chore(deps): bump org.jetbrains.compose from 1.11.1 to 1.12.0 (#503)
Bumps [org.jetbrains.compose](https://github.com/JetBrains/compose-multiplatform) from 1.11.1 to 1.12.0.
- [Release notes](https://github.com/JetBrains/compose-multiplatform/releases)
- [Changelog](https://github.com/JetBrains/compose-multiplatform/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/compose-multiplatform/compare/v1.11.1...v1.12.0)

---
updated-dependencies:
- dependency-name: org.jetbrains.compose
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:58:09 +00:00
dependabot[bot] 802a0b0844 chore(deps): bump androidx.navigation:navigation-compose (#501)
Bumps androidx.navigation:navigation-compose from 2.9.8 to 2.10.0.

---
updated-dependencies:
- dependency-name: androidx.navigation:navigation-compose
  dependency-version: 2.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:57:10 +00:00
dependabot[bot] 7658329ca7 chore(deps): bump markdown-renderer from 0.44.0 to 0.45.0 (#500)
Bumps `markdown-renderer` from 0.44.0 to 0.45.0.

Updates `com.mikepenz:multiplatform-markdown-renderer-m3` from 0.44.0 to 0.45.0
- [Release notes](https://github.com/mikepenz/multiplatform-markdown-renderer/releases)
- [Changelog](https://github.com/mikepenz/multiplatform-markdown-renderer/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mikepenz/multiplatform-markdown-renderer/compare/v0.44.0...v0.45.0)

Updates `com.mikepenz:multiplatform-markdown-renderer-code` from 0.44.0 to 0.45.0
- [Release notes](https://github.com/mikepenz/multiplatform-markdown-renderer/releases)
- [Changelog](https://github.com/mikepenz/multiplatform-markdown-renderer/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/mikepenz/multiplatform-markdown-renderer/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: com.mikepenz:multiplatform-markdown-renderer-m3
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.mikepenz:multiplatform-markdown-renderer-code
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:56:41 +00:00
dependabot[bot] 1622db0b23 chore(deps): bump the testing group with 2 updates (#498)
Bumps the testing group with 2 updates: [io.github.takahirom.roborazzi:roborazzi](https://github.com/takahirom/roborazzi) and [io.github.takahirom.roborazzi:roborazzi-compose](https://github.com/takahirom/roborazzi).


Updates `io.github.takahirom.roborazzi:roborazzi` from 1.72.0 to 1.73.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.72.0...1.73.0)

Updates `io.github.takahirom.roborazzi:roborazzi-compose` from 1.72.0 to 1.73.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.72.0...1.73.0)

---
updated-dependencies:
- dependency-name: io.github.takahirom.roborazzi:roborazzi
  dependency-version: 1.73.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
- dependency-name: io.github.takahirom.roborazzi:roborazzi-compose
  dependency-version: 1.73.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 11:56:30 +00:00
Bailey Dixon bca3cd0e48 fix(plugin): harden Git workspace path containment (#492) 2026-08-30 23:30:51 -04:00
Bailey Dixon bb2f1e6c0c fix(plugin): harden Git workspace path containment 2026-08-30 23:28:51 -04:00
Bailey Dixon ff23d54332 Merge pull request #491 from Codename-11/fix/release-desktop-ui-assets
chore(website): refresh desktop UI source fingerprint
2026-08-30 22:28:31 -04:00
Bailey Dixon a4a0575688 chore(website): refresh desktop UI source fingerprint 2026-08-30 22:26:55 -04:00
Bailey Dixon 0509ac8377 Merge pull request #489 from Codename-11/release/android-plugin-cli-2026-08-30
release: prepare Android 1.14.0, Plugin 1.11.0, and CLI+UI beta.6
2026-08-30 22:01:55 -04:00
Bailey Dixon 3fdc2260dd release: prepare Android 1.14.0, Plugin 1.11.0, and CLI+UI beta.6 2026-08-30 21:50:10 -04:00
Bailey Dixon 1800bee7b1 Merge pull request #488 from Codename-11/integration/android-live-fixes
feat: integrate Android live fixes and Dashboard routing
2026-08-30 21:41:19 -04:00
40 changed files with 1203 additions and 181 deletions
+45 -13
View File
@@ -6,37 +6,69 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Fixed
- **Android Bot Chats render loaded history immediately.** Route-owned chat screens observe their own handler state from first composition, including fast history loads that settle before another frame. (Supersedes #453.)
## [Android 1.14.0] - 2026-08-30
### Added
- **Android can preview delegated agent work without leaving the parent chat.** The current-chat activity sheet shows bounded lifecycle, progress, and tool previews for concurrent children, opens vanilla Hermes child history read-only when the Gateway exposes it, and stays explicit when reconnect gaps or older routes leave details unavailable.
- **Android presents Relay Git as a first-class native workspace.** A compact optional Chat rail opens repository status, line totals, filters, diffs, branches, staging, commits, and remotes; the full workspace remains available from Settings when Chat controls are hidden.
- **Hermes-Relay Plugin provides a bounded Git workspace API for authenticated Dashboard clients.** Configured repository roots, path validation, tracked line totals, scoped write grants, and explicit confirmation protect repository reads and mutations.
- **Android can preview delegated agent work without leaving the parent chat.** The current-chat activity sheet shows bounded lifecycle, progress, and tool previews for concurrent children, opens vanilla Hermes child history read-only when the Gateway exposes it, and stays explicit when reconnect gaps or older routes leave details unavailable. (#447)
- **Android presents Relay Git as a first-class native workspace.** A compact optional Chat rail opens repository status, line totals, filters, diffs, branches, staging, commits, and remotes; the full workspace remains available from Settings when Chat controls are hidden. An updated optional Hermes-Relay Plugin is required for Git operations.
### Changed
- **Hermes-Relay Dashboard management is organized around operator tasks.** Overview, Devices, Activity, Remote Access, Git, and Settings now have separate native Dashboard surfaces; pairing is QR-first, paired clients use responsive cards, and token-backed media is labeled as a bounded diagnostic instead of a health counter.
- **New Relay pairing uses each Dashboard origin as the network ingress.** Dashboard, CLI, and TUI QR flows advertise the same-origin Relay transport for LAN, Tailscale, and public HTTPS routes. Recommended Tailscale uses dedicated HTTPS `:10443` for local Dashboard `:9119`, avoiding conflicts with an existing Traefik, Caddy, or nginx listener on `:443`; old `:443`/`:9119` routes and direct `:8767` remain explicit migration compatibility while `:8642` stays an optional API fallback.
- **Dashboard pairing now explains and verifies route security before exposing an invite.** Endpoint receipts show Dashboard, Relay, and API URLs with priority and per-surface probe status, distinguish tailnet encryption from application TLS, and block malformed or plaintext public candidates.
- **Connections now explain and recover each Dashboard, Relay, and optional API route independently.** LAN, Tailscale, and public HTTPS can fail over without allowing an unauthenticated or different-origin Relay route to borrow Dashboard credentials. Protected same-origin Relay health challenges are recognized as authentication boundaries instead of outages. An updated optional Hermes-Relay Plugin is required for same-origin Relay ingress. (Related: #399)
- **Android What's New leads with one curated release highlight without interrupting startup.** A timed post-update toast can be swiped or closed, previews additional feature/fix counts when a release has meaningful secondary items, expands into the centered highlight view on request, and keeps the full technical history available. Each release can present one plain-language summary, up to three primary benefits, and up to two quieter improvements, while release checks keep the structured entry, fallback, Play copy, and public release records aligned.
### Fixed
- **Android wake-word detection now loads a compatible native ONNX Runtime.** Packaged sherpa and Java JNI consumers are checked against the shared runtime for every supported ABI before release.
- **Android wake-word detection now loads a compatible native ONNX Runtime.** Packaged sherpa and Java JNI consumers are checked against the shared runtime for every supported ABI before release. (#444)
- **Android Continuous voice waits for barge-in microphone teardown before listening again.** Multi-turn hands-free conversations no longer lose the microphone after a response finishes with barge-in enabled. (#464)
- **Opening Android no longer claims or interrupts a turn already running in Hermes Desktop/TUI.** Passive foreground and session browsing now use read-only Gateway status plus profile-scoped history; live-session resume remains reserved for explicit Android actions and exact Android-owned recovery.
- **Android provisional Threads can be removed without touching server history.** The drawer now offers a local-only removal action, reconciles promoted phone sessions without duplicate rows, and keeps Thread routing isolated to the active saved connection.
- **Android Clarify cards make custom answers explicit and keyboard-friendly.** Choice prompts label their Other answer field, submit trimmed text from the keyboard, and do not restore an authoritatively expired prompt after session navigation.
- **Opening Android no longer claims or interrupts a turn already running in Hermes Desktop/TUI.** Passive foreground and session browsing now use read-only Gateway status plus profile-scoped history; live-session resume remains reserved for explicit Android actions and exact Android-owned recovery. (Related: #365)
- **Android provisional Threads can be removed without touching server history.** The drawer now offers a local-only removal action, reconciles promoted phone sessions without duplicate rows, and keeps Thread routing isolated to the active saved connection. (#461)
- **Android Clarify cards make custom answers explicit and keyboard-friendly.** Choice prompts label their Other answer field, submit trimmed text from the keyboard, and do not restore an authoritatively expired prompt after session navigation. (#446)
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
- **Android Voice Focus keeps Stop and immediate spoken steering available across every interaction mode.** Hold-to-talk now interrupts Thinking and Transcribing turns before capturing the replacement direction, remains operable through TalkBack, Switch Access, and keyboard controls, preserves pointer press-and-release behavior across floating controls, and Google Play no longer offers the sideload-only system overlay action.
- **Android Assistant sessions explain when no speech was captured instead of appearing stuck at Ready.** Retry feedback survives the separate system overlay process, recreated session UI requests the current turn state, and locked sessions keep transcript, response, and technical error text private.
- **Android New Chat keeps the current profile and stays fresh across profile switches.** Starting from All Profiles no longer forces the literal default profile, choosing another profile from an empty draft no longer reopens that profile's previous session after route settlement or restart, and leaving a provisional phone Thread cannot route the next turn to its old chat under the new profile.
- **Android self-hosted OIDC keeps sign-in on one trusted Dashboard origin.** Android follows upstream `native_pkce` capability for every interactive provider and falls back to exact-host cookies only when needed. Private and Tailscale routes can discover a provider-declared callback, verify the same installation, and ask before retaining a different authenticated Dashboard/Gateway origin; public origins require HTTPS while reviewed local or overlay HTTP remains compatible. Routes presents that origin separately from optional network paths and no longer exposes internal roles as a VPN. (#399)
- **Android Assistant sessions explain when no speech was captured instead of appearing stuck at Ready.** Retry feedback survives the separate system overlay process, recreated session UI requests the current turn state, and locked sessions keep transcript, response, and technical error text private. (Related: #424)
- **Android New Chat keeps the current profile and stays fresh across profile switches.** Starting from All Profiles no longer forces the literal default profile, choosing another profile from an empty draft no longer reopens that profile's previous session after route settlement or restart, and leaving a provisional phone Thread cannot route the next turn to its old chat under the new profile. (#436)
- **Android Dashboard connections and profile drawers no longer wait on unavailable optional routes.** Dashboard, API fallback, and Relay probes run independently; API/Relay never gate a normal Dashboard connection, Gateway auth/ticket failures are not blindly retried, and authenticated session history remains available without a live Gateway socket. Concurrent route probes are shared and generation-safe, healthy same-priority routes win immediately, superseded session reads cancel their HTTP calls, and optional PR decoration stays outside the session-list critical path.
### Removed
- **Android Chat no longer includes the hidden clean-focus presentation.** The long-press gesture, overlapping instructional pill, reduced composer, and alternate fading transcript were removed so Chat keeps one complete interaction model. Voice Focus remains available.
## [Plugin 1.11.0] - 2026-08-30
### Added
- **Hermes-Relay Plugin provides a bounded Git workspace API for authenticated Dashboard clients.** Configured repository roots, path validation, tracked line totals, scoped write grants, and explicit confirmation protect repository reads and mutations.
- **Relay extensions can use the authenticated Dashboard origin as one network ingress.** Fixed allowlisted HTTP and WebSocket paths proxy to the local Relay while Dashboard admission and Relay session authentication remain separate. (Related: #399)
### Changed
- **Hermes-Relay Dashboard management is organized around operator tasks.** Overview, Devices, Activity, Remote Access, Git, and Settings now have separate native Dashboard surfaces; pairing is QR-first, paired clients use responsive cards, and token-backed media is labeled as a bounded diagnostic instead of a health counter. (#486)
- **Dashboard, CLI, and TUI pairing advertise the same explicit route set.** Recommended Tailscale uses dedicated HTTPS `:10443` for local Dashboard `:9119`, public HTTPS and LAN stay visible fallbacks, and old `:443`/`:9119` plus direct `:8767` remain migration compatibility.
- **Pairing receipts explain transport protection before exposing an invite.** Per-surface probes distinguish application TLS, tailnet encryption, optional API fallback, and authenticated Relay ingress.
### Fixed
- **Public and roaming pairing no longer invent closed direct Relay or Dashboard ports.** Exact Dashboard origins own their same-origin Relay paths, ambiguous or plaintext public candidates fail closed, and inactive optional API routes are omitted.
- **Dense pairing QRs scan reliably.** Dashboard, CLI, and TUI render integer-sized modules with a full quiet zone.
- **Remote-access migration keeps existing listeners safe.** Recommended setup avoids taking over `:443`, explicit legacy cleanup remains available, and default disable actions remove only the listeners they own.
## [0.4.0-beta.6] - 2026-08-30
### Changed
- **Hermes-Relay CLI+UI preserves the complete multi-route pairing topology.** Dashboard, Relay, optional API, priorities, and transport protection remain attached to one saved host across LAN, Tailscale, and public routes. (Related: #399)
### Fixed
- **Desktop rejects Dashboard-ingress Relay dials until it can mint Dashboard WebSocket tickets.** The daemon and host selector choose a compatible direct Relay fallback instead of attempting an unauthenticated same-origin ingress.
- **API-less pairing remains valid.** Dashboard and direct Relay routes can pair without inventing an optional API server, while secure-first ranking keeps plain LAN as the final fallback.
## [Android 1.13.2] - 2026-08-25
### Added
+6 -22
View File
@@ -1,33 +1,22 @@
# Hermes-Relay CLI+UI v__VERSION__
**Release Date:** 2026-08-25
**Release Date:** 2026-08-30
This beta makes the Desktop connector resilient through Relay interruptions,
aligns Windows computer control with current CUA Driver releases, adds a native
Linux ARM64 build, and hardens installation and update discovery.
This beta preserves complete multi-route pairing while preventing Desktop from dialing Dashboard-ingress Relay routes before Dashboard WebSocket ticket support is available. (Related: #399)
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64; the management UI is Windows-only.
## What's changed
### Added
- **Linux ARM64 is a first-class release target.** The one-line installer,
updater, checksums, and release artifacts now cover both Linux x64 and arm64.
- **The public site shows the real Windows CLI UI.** Deterministic screenshots
cover connections, host access, activity, computer control, and updates.
### Changed
- **Public naming is aligned.** Releases use `Hermes-Relay CLI+UI` while the
beta keeps its existing `desktop-v*` tag and updater contract.
- **Saved hosts retain the full route topology.** Dashboard, Relay, optional API, route priority, transport protection, certificate pins, and the selected host survive LAN, Tailscale, and public-route changes without creating duplicate hosts.
- **API-less pairing is first-class.** Dashboard plus direct Relay can pair without inventing an optional API server, while secure-first ranking retains plain LAN as the final fallback.
### Fixed
- **The daemon reconnects instead of exiting after an interrupted Relay socket.** Relay restarts and repeated transient replacement failures stay on bounded automatic backoff, and terminal failures persist an accurate stopped reason for the UI.
- **Oversized desktop-tool output no longer closes the shared connection.** PowerShell output and every serialized desktop response stay inside the Relay WebSocket budget.
- **Current CUA Driver releases remain compatible by contract.** Driver 0.20 and newer are accepted when their manifest and required tools match Hermes, and Windows uses the manifest-declared direct standard-mode runtime instead of a stale machine-wide daemon.
- **Install and update discovery paginates the multi-surface release history.** Desktop releases remain discoverable after more Android and Server releases, Windows cooperative updates clean their released backup, and unsigned installers retain the normal SmartScreen warning.
- **Dashboard-ingress Relay routes fail closed on Desktop.** The daemon, host selector, and Relay transport reject ingress that requires a Dashboard WebSocket ticket and choose a compatible direct Relay fallback instead of attempting an unauthenticated dial.
- **Pairing accepts the current v3 candidate shape.** Optional API records, same-origin Dashboard/Relay routes, and legacy top-level payloads remain compatible without collapsing route ownership.
## Install
@@ -58,9 +47,4 @@ hermes-relay --version
hermes-relay hosts list --json
hermes-relay daemon start
hermes-relay daemon status --json
hermes-relay computer-use status --json
```
On Windows, click the Hermes-Relay CLI UI notification-area icon to open the management popup directly above it.
See the [CLI and tray guide](https://hermes-relay.dev/docs/desktop/) for installation, access modes, grants, and troubleshooting.
+14 -9
View File
@@ -1,26 +1,29 @@
# Hermes-Relay Plugin v__VERSION__
**Release Date:** August 25, 2026
**Release Date:** August 30, 2026
## Summary
This release adds a provider-neutral account-usage surface for Android and Dashboard clients. Relay resolves Codex credential pools, structured Nous balances, and OpenCode Go windows on the Hermes host without returning provider credentials.
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
This release lets one authenticated Hermes Dashboard origin carry Gateway plus optional Relay extensions, adds a bounded Git workspace, and reorganizes the Dashboard plugin around operator tasks. Standard chat, session history, profiles, Manage, and standard voice remain upstream-owned and do not require this plugin.
## Added
- **Provider-neutral usage snapshots.** Authenticated Dashboard clients can resolve the exact active Codex pool entry, Nous balances, and OpenCode Go account windows through one normalized schema.
- **Bounded paired-client fallback.** Operators may explicitly enable the Relay usage route for paired standalone clients while credentials remain host-side.
- **Dashboard same-origin Relay ingress.** Fixed allowlisted HTTP and WebSocket routes proxy to the local Relay while Dashboard admission and Relay session authentication remain independent. (Related: #399)
- **Bounded Git workspace.** Configured roots, path containment, line totals, diffs, branches, staging, commits, remotes, grants, and explicit confirmations protect repository operations.
## Changed
- **Usage capabilities are explicit.** Responses identify Relay-enhanced credential pools, structured balances, and provider adapters instead of implying unsupported upstream data.
- **Public product naming is aligned.** Releases use `Hermes-Relay Plugin` while retaining the `server-v*` tag and installation contract.
- **Task-oriented Dashboard UI.** Overview, Devices, Activity, Remote Access, Git, and Settings now have dedicated surfaces with QR-first pairing, responsive device cards, and honest media diagnostics. (#486)
- **One explicit route topology.** Dashboard, CLI, and TUI pairing advertise Dashboard, Relay, and optional API surfaces with stable priorities across Tailscale, public HTTPS, and LAN.
- **Dedicated Tailscale listener.** Recommended setup uses tailnet HTTPS `:10443` to local Dashboard `:9119`, avoiding ownership of a reverse proxy's `:443`. Existing `:443`, `:9119`, and direct `:8767` routes remain migration compatibility.
## Fixed
- **Custom Hermes homes resolve correctly.** Relay profile discovery and session persistence follow `HERMES_HOME` by default while preserving the explicit `RELAY_HERMES_CONFIG` override.
- Public and roaming invites no longer synthesize closed direct Relay `:8767` or wrong Dashboard `:9119` routes.
- Ambiguous, credential-bearing, or plaintext public candidates fail closed before an invite is exposed.
- Dense pairing QRs use integer-sized modules and a full quiet zone.
- Inactive optional API routes are omitted; protected Dashboard-ingress `401/403` responses display as authentication-required while direct Relay and API failures remain failures.
- Default Tailscale disable actions remove only owned listeners, and explicit migration cleanup accepts only the bounded supported ports.
## Install / update
@@ -32,6 +35,8 @@ Standard chat, session history, and Vanilla Hermes voice remain upstream-owned a
# or, if already installed:
hermes-relay-update
Restart or reload the Hermes Dashboard and Relay after updating so the new manifest, routes, and committed Dashboard bundle are active.
## Verify
hermes relay doctor
+24 -11
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.13.2
# Hermes-Relay Android v1.14.0
**Release Date:** August 25, 2026
**Release Date:** August 30, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.13.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.14.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,20 +12,33 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This release adds a parent-configured Supervised Mode and improves its return from full settings. It also keeps session rows neutral until live activity is confirmed.
This release makes saved Hermes connections reliable across LAN, Tailscale, and public HTTPS while keeping Dashboard authentication bound to its exact trusted origin. It also adds delegated-agent previews and an optional native Git workspace, and improves Voice, Assistant, Threads, profile drafts, and Clarify interactions.
## Added
- Use a profile-pinned Supervised Mode with parent-controlled attachments, Standard voice, generated media, history, actions, and technical details. Device authentication protects full settings; this remains a client-side restricted view rather than a server-enforced account boundary.
- **Delegated-agent previews.** Follow bounded lifecycle, progress, tool previews, and available read-only child history without leaving the parent chat. Partial history and reconnect gaps remain explicit. (#447)
- **Native Git workspace.** Review repository state, diffs, branches, staging, commits, and remotes from Chat or Settings. Git operations require Hermes-Relay Plugin v1.11.0 and retain confirmation and grant boundaries.
## Changed
- **Route-aware connections.** Dashboard, Relay, and optional API health are evaluated independently across LAN, Tailscale, and public HTTPS. Same-origin Relay ingress stays on the Dashboard origin that owns authentication, while direct compatibility routes keep separate credentials. (Related: #399)
- **Voice Focus controls.** Stop and immediate spoken steering remain accessible while Hermes is Thinking, Transcribing, or Speaking, including TalkBack, Switch Access, keyboard, and sideload overlay surfaces.
## Fixed
- Keep session rows neutral while optional live activity is unavailable or still loading, and reserve full-row activity borders for actual Starting or Working turns.
- Keep Supervised Chat rendered when parent access relocks after visiting full settings.
- Wake-word detection packages one compatible ONNX Runtime for sherpa and Java JNI on every supported ABI. (#444)
- Continuous voice waits for barge-in microphone teardown before listening again. (#464)
- Fresh chats retain their selected profile without reopening a previous session or carrying a proactive Thread route across profiles. (#436)
- Provisional Threads can be removed locally and reconcile with promoted sessions without deleting server history. (#461)
- Clarify cards expose a reachable Other answer, keyboard Send, and authoritative expiry behavior. (#446)
- Passive Android browsing no longer claims or interrupts a turn owned by another client. (Related: #365)
- Assistant sessions show retryable no-speech feedback, recover their active state after recreation, and redact conversation details behind the keyguard. (Related: #424)
- Protected Relay ingress `401/403` responses are recognized as authentication boundaries rather than outages; malformed, different-origin, and direct unauthorized routes still fail closed.
## Install / Verify
- App version: **1.13.2** (versionCode **51**).
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
- App version: **1.14.0** (versionCode **52**).
- Standard Chat, sessions, profiles, Manage, and standard voice continue to work against unmodified upstream Hermes without the optional Relay plugin.
- Install Hermes-Relay Plugin v1.11.0 for same-origin Relay extensions, Git workspace actions, Bridge, media, proactive features, and enhanced voice.
- Granular Device Control and the system Voice Focus overlay remain sideload-only; the Google Play build does not declare their restricted permissions.
- Existing connections, drafts, sessions, profile ownership, and legacy direct Relay routes remain data-preserving compatibility paths.
+2 -2
View File
@@ -399,8 +399,8 @@ dependencies {
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
// Renders real composables on the JVM at an exact canvas — no device, no
// status bar, no clipping. See StoreScreenshotTest.
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.72.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.72.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.73.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.73.0")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
@@ -7,7 +7,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.test.platform.app.InstrumentationRegistry
@@ -28,50 +27,6 @@ class AmbientVisualizationVisibilityTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun cleanMode_backgroundOff_hidesSphereAndKeepsComposer() {
composeTestRule.setContent {
AmbientTestProviders(enabled = false) {
CleanChatMode(
messages = emptyList(),
isStreaming = false,
sphereState = SphereState.Idle,
streamingIntensity = 0f,
toolCallBurst = 0f,
animationEnabled = true,
enabled = true,
onSend = {},
onExit = {},
)
}
}
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertDoesNotExist()
composeTestRule.onNodeWithContentDescription(targetString(R.string.agent_text_send_cd))
.assertExists()
}
@Test
fun cleanMode_backgroundOn_rendersSphere() {
composeTestRule.setContent {
AmbientTestProviders(enabled = true) {
CleanChatMode(
messages = emptyList(),
isStreaming = false,
sphereState = SphereState.Idle,
streamingIntensity = 0f,
toolCallBurst = 0f,
animationEnabled = false,
enabled = true,
onSend = {},
onExit = {},
)
}
}
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertExists()
}
@Test
fun voiceMode_backgroundOff_hidesSphereAndKeepsVoiceUi() {
composeTestRule.setContent {
@@ -0,0 +1,160 @@
package com.hermesandroid.relay.ui.screens
import android.os.Handler
import android.os.Looper
import android.view.accessibility.AccessibilityNodeInfo
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.viewmodel.AndroidGatewayContractFixture
import com.hermesandroid.relay.viewmodel.ChatViewModel
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/** On-device proof for the route-owned first-composition collection boundary. */
class BotChatScreenBindingInstrumentedTest {
private lateinit var fixture: AndroidGatewayContractFixture
private lateinit var gatewayScope: CoroutineScope
private lateinit var dashboardClient: DashboardApiClient
private lateinit var gatewayClient: GatewayChatClient
private lateinit var viewModel: ChatViewModel
private lateinit var handler: ChatHandler
private var activityScenario: ActivityScenario<BotChatBindingTestActivity>? = null
@Before
fun setUp() {
fixture = AndroidGatewayContractFixture()
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
dashboardClient = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
)
gatewayClient = GatewayChatClient(
initialDashboardClient = dashboardClient,
okHttpClient = OkHttpClient(),
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
viewModel = ChatViewModel()
handler = ChatHandler()
}
@After
fun tearDown() {
activityScenario?.close()
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
dashboardClient.shutdown()
fixture.shutdown()
}
@Test
fun fastInitialHistoryRendersBeforeNavigationAndSurvivesLifecycleResume() {
val route = BotGatewayRoute(
key = BotGatewayRouteKey("fixture-gateway", PROFILE_NAME),
connectionLabel = "Fixture gateway",
)
val bot = BotRosterEntry(
profile = Profile(
name = PROFILE_NAME,
model = "fixture-model",
description = "Fixture profile",
),
displayName = "Research",
route = route,
)
val scenario = ActivityScenario.launch(BotChatBindingTestActivity::class.java)
.also { activityScenario = it }
scenario.onActivity { activity ->
activity.setContent {
MaterialTheme {
BotChatScreen(
route = route,
bot = bot,
sessionId = STORED_SESSION_ID,
gatewayClient = gatewayClient,
dashboardClient = dashboardClient,
chatViewModel = viewModel,
onBack = {},
handlerFactory = { handler },
historyLoader = { _, _, _ ->
Result.success(
listOf(
MessageItem(
id = HISTORY_ID,
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive(HISTORY_TEXT),
timestamp = 1.0,
finishReason = "stop",
),
),
)
},
profileIconFlow = { _, _ -> MutableStateFlow(null) },
)
}
}
}
waitUntil { handler.messages.value.singleOrNull()?.content == HISTORY_TEXT }
waitUntil { renderedTextExists(HISTORY_TEXT) }
scenario.moveToState(Lifecycle.State.STARTED)
scenario.moveToState(Lifecycle.State.RESUMED)
waitUntil { renderedTextExists(HISTORY_TEXT) }
assertEquals(0, fixture.rpcCount("prompt.submit"))
}
private fun renderedTextExists(expected: String): Boolean {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.waitForIdleSync()
val root = instrumentation.uiAutomation.rootInActiveWindow ?: return false
return root.containsText(expected)
}
private fun AccessibilityNodeInfo.containsText(expected: String): Boolean {
if (text?.toString() == expected || contentDescription?.toString() == expected) return true
return (0 until childCount).any { index -> getChild(index)?.containsText(expected) == true }
}
private fun waitUntil(condition: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (System.nanoTime() < deadline) {
if (condition()) return
Thread.sleep(25)
}
assertTrue("Condition was not satisfied within 5 seconds", condition())
}
private companion object {
const val PROFILE_NAME = "research"
const val STORED_SESSION_ID = "20260829_120000_bot_chat"
const val HISTORY_ID = "persisted-bot-history"
const val HISTORY_TEXT = "Durable Bot Chat history is ready."
}
}
+4
View File
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.hermesandroid.relay.ui.screens.BotChatBindingTestActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
android:exported="true"
@@ -0,0 +1,6 @@
package com.hermesandroid.relay.ui.screens
import androidx.activity.ComponentActivity
/** Empty debug-only host populated by the Bot Chat lifecycle instrumentation. */
class BotChatBindingTestActivity : ComponentActivity()
@@ -1,3 +1,3 @@
v1.13.2 - Supervised Mode and clearer activity
v1.14.0 - Connections that follow you
Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
+50
View File
@@ -1,6 +1,56 @@
{
"schema": 2,
"versions": [
{
"version": "1.14.0",
"title": "Connections that follow you",
"date": "2026-08-30",
"highlight": {
"title": "Reliable routes",
"summary": "Move between LAN, Tailscale, and public HTTPS without mixing Dashboard or Relay authentication.",
"bullets": [
"Keep Chat and sessions on the trusted Dashboard while optional Relay routes recover independently.",
"Preview delegated agent work and use the optional native Git workspace.",
"Use more reliable Continuous voice, Voice Focus, Assistant, Threads, profiles, and Clarify controls."
]
},
"improvements": [
"Wake-word detection now packages a compatible native runtime for every supported ABI.",
"Protected Relay health checks no longer appear as broken routes."
],
"toastDigest": {
"additionalFeatureCount": 2,
"fixCount": 10,
"preview": [
"delegated-agent previews",
"safer voice and sessions"
]
},
"playNotes": "Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.",
"sections": [
{
"header": "Use the best available route",
"bullets": [
"Resolve Dashboard, Relay, and optional API health independently across LAN, Tailscale, and public HTTPS.",
"Keep same-origin Relay ingress on the exact Dashboard origin that owns authentication."
]
},
{
"header": "Follow active work",
"bullets": [
"Preview delegated-agent lifecycle, progress, tools, and available read-only child history from the parent chat.",
"Review Git status, diffs, branches, staging, commits, and remotes through the optional Relay plugin."
]
},
{
"header": "Keep voice and conversations owned correctly",
"bullets": [
"Serialize microphone handoff and keep Voice Focus Stop and steering accessible across active phases.",
"Preserve fresh profile drafts, provisional Thread ownership, Clarify answers, passive observation, and Assistant privacy through reconnects."
]
}
]
},
{
"version": "1.13.2",
"title": "Supervised Mode and clearer activity",
+7 -6
View File
@@ -1,9 +1,10 @@
v1.13.2 - Supervised Mode and clearer activity
v1.14.0 - Connections that follow you
Supervised Mode
* Protect parent settings with your phone's device authentication.
* Choose access to attachments, Standard voice, generated media, history, actions, and technical details.
* Keep Supervised Chat open when returning from parent settings.
Reliable routes
* Keep Chat and sessions on the trusted Dashboard while optional Relay routes recover independently.
* Preview delegated agent work and use the optional native Git workspace.
* Use more reliable Continuous voice, Voice Focus, Assistant, Threads, profiles, and Clarify controls.
Also improved
* Activity indicators now appear only while Hermes is genuinely working.
* Wake-word detection now packages a compatible native runtime for every supported ABI.
* Protected Relay health checks no longer appear as broken routes.
@@ -56,11 +56,21 @@ import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.SessionMessageLoadMode
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.ui.components.MessageBubble
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import java.io.File
import kotlinx.coroutines.flow.Flow
internal typealias BotChatHistoryLoader = suspend (
profileName: String,
sessionId: String,
mode: SessionMessageLoadMode,
) -> Result<List<MessageItem>>
internal typealias BotChatProfileIconFlow = (connectionId: String, profileName: String) -> Flow<String?>
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -74,14 +84,52 @@ fun BotChatScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
) {
val handler = remember(route.key) { ChatHandler() }
BotChatScreen(
route = route,
bot = bot,
sessionId = sessionId,
gatewayClient = gatewayClient,
dashboardClient = dashboardClient,
chatViewModel = chatViewModel,
onBack = onBack,
handlerFactory = ::ChatHandler,
historyLoader = { profileName, storedSessionId, mode ->
dashboardClient.getSessionMessages(
sessionId = storedSessionId,
profile = profileName,
mode = mode,
)
},
profileIconFlow = connectionViewModel::profileIconFlow,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun BotChatScreen(
route: BotGatewayRoute,
bot: BotRosterEntry,
sessionId: String,
gatewayClient: GatewayChatClient,
dashboardClient: DashboardApiClient,
chatViewModel: ChatViewModel,
onBack: () -> Unit,
handlerFactory: () -> ChatHandler,
historyLoader: BotChatHistoryLoader,
profileIconFlow: BotChatProfileIconFlow,
) {
val handler = remember(route.key) { handlerFactory() }
val context = LocalContext.current
val messages by chatViewModel.messages.collectAsState()
val isStreaming by chatViewModel.isStreaming.collectAsState()
// This route owns the handler but binds it to the ViewModel only after the
// first composition. Collecting delegated ViewModel getters here can pin
// Compose to their empty pre-bind fallback when history settles before the
// next frame. Observe the route-owned source directly so StateFlow replay
// covers fast history, live streaming, completion, and errors.
val messages by handler.messages.collectAsState()
val isStreaming by handler.isStreaming.collectAsState()
val isLoading by chatViewModel.isLoadingHistory.collectAsState()
val error by chatViewModel.error.collectAsState()
val iconPath by connectionViewModel
.profileIconFlow(route.connectionId, route.profileName)
val error by handler.error.collectAsState()
val iconPath by profileIconFlow(route.connectionId, route.profileName)
.collectAsState(initial = null)
val listState = rememberLazyListState()
var composer by remember(route.key, sessionId) { mutableStateOf("") }
@@ -101,11 +149,7 @@ fun BotChatScreen(
selected?.name == route.profileName
}
chatViewModel.setProfileMessageLoaderWithMode { _, storedSessionId, mode ->
dashboardClient.getSessionMessages(
sessionId = storedSessionId,
profile = route.profileName,
mode = mode,
)
historyLoader(route.profileName, storedSessionId, mode)
}
chatViewModel.updateApiClient(null)
chatViewModel.updateGatewayClient(gatewayClient)
@@ -0,0 +1,233 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.viewmodel.ChatViewModel
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.JsonPrimitive
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35], qualifiers = "w390dp-h844dp-432dpi")
class BotChatScreenBindingTest {
@get:Rule
val compose = createComposeRule()
private val resources = CopyOnWriteArrayList<ScreenResources>()
@After
fun tearDown() {
resources.forEach(ScreenResources::close)
}
@Test
fun fastHistoryPublishedDuringInitialBindRendersWithoutNavigation() {
val screen = resources("research")
val handler = ChatHandler()
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
screen.content(
handler = handler,
historyLoader = { _, _, _ ->
Result.success(history(screen.sessionId, FAST_HISTORY))
},
)
}
}
compose.mainClock.advanceTimeByFrame()
compose.waitUntil(5_000) { handler.messages.value.singleOrNull()?.content == FAST_HISTORY }
compose.mainClock.autoAdvance = true
compose.waitForIdle()
compose.onNodeWithText(FAST_HISTORY).assertIsDisplayed()
compose.runOnIdle { handler.onTextDelta("live-tail", LIVE_TAIL) }
compose.onNodeWithText(LIVE_TAIL).assertIsDisplayed()
compose.runOnIdle { handler.onStreamError(HANDLER_ERROR) }
compose.onNodeWithText(HANDLER_ERROR).assertIsDisplayed()
}
@Test
fun delayedHistoryAfterCompositionRendersFromTheSameHandler() {
val screen = resources("builder")
val handler = ChatHandler()
val releaseHistory = CompletableDeferred<Unit>()
compose.setContent {
MaterialTheme {
screen.content(
handler = handler,
historyLoader = { _, _, _ ->
releaseHistory.await()
Result.success(history(screen.sessionId, DELAYED_HISTORY))
},
)
}
}
compose.waitUntil(5_000) { screen.viewModel.isLoadingHistory.value }
compose.onNodeWithText(DELAYED_HISTORY).assertDoesNotExist()
releaseHistory.complete(Unit)
compose.waitUntil(5_000) { handler.messages.value.isNotEmpty() }
compose.onNodeWithText(DELAYED_HISTORY).assertIsDisplayed()
}
@Test
fun replacementHandlerRejectsLateHistoryAndOldHandlerPublications() {
val screen = resources("operator")
val firstHandler = ChatHandler()
val secondHandler = ChatHandler()
val releaseFirstHistory = CompletableDeferred<Unit>()
val target = mutableStateOf(
Target(
revision = 0,
handler = firstHandler,
loader = { _, _, _ ->
releaseFirstHistory.await()
Result.success(history(screen.sessionId, OLD_HISTORY))
},
),
)
compose.setContent {
val current = target.value
key(current.revision) {
MaterialTheme {
screen.content(current.handler, current.loader)
}
}
}
compose.waitUntil(5_000) { screen.viewModel.isLoadingHistory.value }
compose.runOnIdle {
target.value = Target(
revision = 1,
handler = secondHandler,
loader = { _, _, _ -> Result.success(history(screen.sessionId, NEW_HISTORY)) },
)
}
compose.waitForIdle()
compose.runOnIdle { assertSame(secondHandler, screen.viewModel.boundHandler) }
compose.waitUntil(5_000) { secondHandler.messages.value.singleOrNull()?.content == NEW_HISTORY }
compose.onNodeWithText(NEW_HISTORY).assertIsDisplayed()
releaseFirstHistory.complete(Unit)
compose.waitForIdle()
assertEquals(emptyList<String>(), firstHandler.messages.value.map { it.content })
compose.runOnIdle { firstHandler.onTextDelta("old-tail", OLD_TAIL) }
compose.waitForIdle()
compose.onNodeWithText(OLD_HISTORY).assertDoesNotExist()
compose.onNodeWithText(OLD_TAIL).assertDoesNotExist()
assertEquals(listOf(OLD_TAIL), firstHandler.messages.value.map { it.content })
assertEquals(listOf(NEW_HISTORY), secondHandler.messages.value.map { it.content })
}
private fun resources(profileName: String): ScreenResources = ScreenResources(profileName).also {
resources += it
}
private fun history(sessionId: String, text: String) = listOf(
MessageItem(
id = "history-$sessionId",
sessionId = sessionId,
role = "assistant",
content = JsonPrimitive(text),
timestamp = 1.0,
finishReason = "stop",
),
)
private inner class ScreenResources(profileName: String) {
val route = BotGatewayRoute(
key = BotGatewayRouteKey("gateway-$profileName", profileName),
connectionLabel = "Fixture gateway",
)
val bot = BotRosterEntry(
profile = Profile(
name = profileName,
model = "fixture-model",
description = "Fixture profile",
),
displayName = profileName.replaceFirstChar(Char::uppercase),
route = route,
)
val sessionId = "fixture-$profileName-session"
val viewModel = ChatViewModel()
private val gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val dashboardClient = DashboardApiClient("http://127.0.0.1:1")
private val gatewayClient = GatewayChatClient(
initialDashboardClient = dashboardClient,
fixedSessionProfile = profileName,
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
@androidx.compose.runtime.Composable
fun content(handler: ChatHandler, historyLoader: BotChatHistoryLoader) {
BotChatScreen(
route = route,
bot = bot,
sessionId = sessionId,
gatewayClient = gatewayClient,
dashboardClient = dashboardClient,
chatViewModel = viewModel,
onBack = {},
handlerFactory = { handler },
historyLoader = historyLoader,
profileIconFlow = { _, _ -> MutableStateFlow(null) },
)
}
fun close() {
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
dashboardClient.shutdown()
}
}
private data class Target(
val revision: Int,
val handler: ChatHandler,
val loader: BotChatHistoryLoader,
)
private companion object {
const val FAST_HISTORY = "History loaded before the next frame."
const val DELAYED_HISTORY = "History loaded after composition."
const val LIVE_TAIL = "Live tail from the owned handler."
const val HANDLER_ERROR = "Owned handler error"
const val OLD_HISTORY = "Late history from the old route."
const val OLD_TAIL = "Old handler live tail."
const val NEW_HISTORY = "History from the replacement route."
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@hermes-relay/cli",
"version": "0.4.0-beta.5",
"version": "0.4.0-beta.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hermes-relay/cli",
"version": "0.4.0-beta.5",
"version": "0.4.0-beta.6",
"license": "MIT",
"bin": {
"hermes-relay": "bin/hermes-relay.js"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hermes-relay/cli",
"version": "0.4.0-beta.5",
"version": "0.4.0-beta.6",
"description": "Thin-client CLI for Hermes-Relay — talk to a remote Hermes agent over WSS with pairing auth, stream-renders tool calls and responses to plain stdout.",
"type": "module",
"bin": {
+1 -1
View File
@@ -1,2 +1,2 @@
// Regenerated from package.json by gen:version script. Do not edit by hand.
export const VERSION = "0.4.0-beta.5" as const
export const VERSION = "0.4.0-beta.6" as const
+1 -1
View File
@@ -1232,7 +1232,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermes-relay-tray"
version = "0.4.0-beta.5"
version = "0.4.0-beta.6"
dependencies = [
"base64 0.22.1",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hermes-relay-tray"
version = "0.4.0-beta.5"
version = "0.4.0-beta.6"
description = "Compact Windows management UI for Hermes-Relay CLI"
authors = ["Axiom Labs"]
edition = "2021"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@hermes-relay/tray-ui",
"version": "0.4.0-beta.5",
"version": "0.4.0-beta.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hermes-relay/tray-ui",
"version": "0.4.0-beta.5",
"version": "0.4.0-beta.6",
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"lucide-react": "^0.468.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hermes-relay/tray-ui",
"private": true,
"version": "0.4.0-beta.5",
"version": "0.4.0-beta.6",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hermes-Relay CLI UI",
"version": "0.4.0-beta.5",
"version": "0.4.0-beta.6",
"identifier": "com.axiomlabs.hermes-relay-tray",
"build": {
"beforeDevCommand": "npm run dev",
+1
View File
@@ -66,6 +66,7 @@ the upstream contract identifiers it depends on.
| Scenario | Contract exercised |
|---|---|
| `initial_history_bind` | Durable, profile-scoped history is already available when the client resumes and first binds its rendered transcript |
| `ordinary_turn` | Normal message start, deltas, completion, and persisted history |
| `rapid_tools_interims` | Rapid chunks, reasoning, tool activity, and interim assistant boundaries |
| `queued_follow_up` | Two explicitly owned turns and ordered queue drainage |
+1 -1
View File
@@ -51,7 +51,7 @@
"sourceFingerprint": {
"algorithm": "sha256",
"normalization": "text-lf-v1",
"digest": "8cf00c04da9e80621b439563145be49ede9539ac95ab903b1903763004e370bc",
"digest": "38253cb9fb1124a629625ea0bc5be09a4f61af91e3fca0103cbb12a5279aae54",
"files": [
"desktop/tray/ui/App.tsx",
"desktop/tray/ui/main.tsx",
+2 -2
View File
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.13.2 - Supervised Mode and clearer activity
v1.14.0 - Connections that follow you
Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
```
## Category
+6 -6
View File
@@ -1,10 +1,10 @@
[versions]
appVersionName = "1.13.2"
appVersionCode = "51"
appVersionName = "1.14.0"
appVersionCode = "52"
agp = "9.3.2"
kotlin = "2.4.10"
compose-bom = "2026.08.00"
navigation-compose = "2.9.8"
navigation-compose = "2.10.0"
okhttp = "5.5.0"
kotlinx-serialization = "1.11.0"
kotlinx-coroutines = "1.11.0"
@@ -21,9 +21,9 @@ core-ktx = "1.19.0"
exifinterface = "1.4.2"
datastore = "1.2.1"
splashscreen = "1.2.0"
markdown-renderer = "0.44.0"
coil = "3.5.0"
haze = "1.7.2"
markdown-renderer = "0.45.0"
coil = "3.6.0"
haze = "1.7.3"
mlkit-barcode = "17.3.0"
zxing-core = "3.5.4"
camera = "1.6.1"
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Hermes-Relay",
"description": "Paired devices, Bridge activity, media tokens, and remote access for Hermes-Relay",
"icon": "Activity",
"version": "1.10.0",
"version": "1.11.0",
"tab": {
"path": "/relay",
"position": "after:skills"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hermes-relay-dashboard",
"version": "1.10.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hermes-relay-dashboard",
"version": "1.10.0",
"version": "1.11.0",
"devDependencies": {
"esbuild": "^0.25.12",
"qrcode": "^1.5.4"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-relay-dashboard",
"version": "1.10.0",
"version": "1.11.0",
"private": true,
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
"scripts": {
+181 -31
View File
@@ -20,9 +20,12 @@ from __future__ import annotations
import logging
import os
import re
import stat
import subprocess
import tempfile
from functools import wraps
from pathlib import Path
from threading import Lock, RLock
from typing import Any
from .config import raw_config_value
@@ -48,6 +51,12 @@ _SSH_USERINFO_RE = re.compile(r"^([^/@:]+)@([^:]+):")
_ERROR_USERINFO_RE = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://)([^\s/@]+)@")
_ERROR_SSH_USERINFO_RE = re.compile(r"(?<![\w@])([^\s/@:]+)@([^\s:]+):")
# FastAPI executes synchronous routes concurrently. These locks serialize Git
# operations per repository without creating worker threads, so one API call
# cannot replace a validated working-tree path underneath another.
_REPO_LOCKS_GUARD = Lock()
_REPO_LOCKS: dict[str, RLock] = {}
class GitStateError(ValueError):
"""A caller supplied an invalid repo id, path, or diff kind."""
@@ -91,13 +100,18 @@ def _run_git_bounded(
args: list[str],
*,
mutation: bool,
literal_pathspecs: bool = False,
) -> tuple[int, str, str]:
"""Run Git without materializing unbounded stdout or stderr in memory."""
error_type = GitError if mutation else GitStateError
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
try:
command = ["git", "-C", str(repo)]
if literal_pathspecs:
command.append("--literal-pathspecs")
command.extend(args)
result = subprocess.run(
["git", "-C", str(repo), *args],
command,
stdout=stdout_file,
stderr=stderr_file,
timeout=GIT_TIMEOUT_SECONDS,
@@ -139,9 +153,14 @@ def _safe_git_error(text: str) -> str:
return scrubbed[:MAX_GIT_ERROR_BYTES].strip()
def _git(repo: Path, *args: str) -> str:
def _git(repo: Path, *args: str, literal_pathspecs: bool = False) -> str:
"""Run ``git -C <repo> <args>`` and return bounded stdout."""
returncode, stdout, stderr = _run_git_bounded(repo, list(args), mutation=False)
returncode, stdout, stderr = _run_git_bounded(
repo,
list(args),
mutation=False,
literal_pathspecs=literal_pathspecs,
)
if returncode != 0:
raise GitStateError(
f"git {args[0] if args else 'command'} failed for {repo.name}: "
@@ -157,13 +176,79 @@ def _is_git_repo(path: Path) -> bool:
def _is_link_or_junction(path: Path) -> bool:
is_junction = getattr(path, "is_junction", None)
return path.is_symlink() or bool(is_junction and is_junction())
if path.is_symlink() or bool(is_junction and is_junction()):
return True
if os.name != "nt":
return False
try:
attributes = os.lstat(path).st_file_attributes
except (AttributeError, FileNotFoundError, OSError):
return False
reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
return bool(attributes & reparse_point)
def _is_within(root: Path, candidate: Path) -> bool:
return candidate == root or root in candidate.parents
def _canonical_repo_root(repo: Path) -> Path:
"""Return an unlinked absolute repository root or fail closed."""
lexical = Path(os.path.abspath(repo))
try:
if _is_link_or_junction(lexical):
raise GitStateError("repository root changed during operation")
canonical = lexical.resolve(strict=True)
except GitStateError:
raise
except (OSError, RuntimeError) as exc:
raise GitStateError("repository root changed during operation") from exc
if os.path.normcase(str(canonical)) != os.path.normcase(str(lexical)):
raise GitStateError("repository root changed during operation")
return canonical
def _serialized_repo_operation(function: Any) -> Any:
"""Serialize a Git operation against other operations on the same repo."""
@wraps(function)
def wrapped(repo: Path, *args: Any, **kwargs: Any) -> Any:
key = os.path.normcase(os.path.abspath(repo))
with _REPO_LOCKS_GUARD:
lock = _REPO_LOCKS.setdefault(key, RLock())
with lock:
return function(repo, *args, **kwargs)
return wrapped
def _resolve_repo_disk_path(
repo: Path,
path: str,
*,
strict: bool,
) -> Path:
"""Resolve a validated relative path and prove canonical repo containment.
``Path.resolve`` follows symlinks and junctions before ``commonpath``
compares the canonical filesystem paths. Different-drive paths fail
closed on Windows. Callers that open the result must revalidate the opened
handle before using it as defense in depth against an unexpected path
replacement. Concurrent external same-user filesystem mutation is outside
the plugin trust boundary.
"""
safe_path = resolve_repo_path(repo, path)
root = _canonical_repo_root(repo)
candidate = (root / safe_path).resolve(strict=strict)
try:
common = Path(os.path.commonpath((str(root), str(candidate))))
except ValueError as exc:
raise GitStateError(f"path escapes repository: {safe_path}") from exc
if os.path.normcase(str(common)) != os.path.normcase(str(root)):
raise GitStateError(f"path escapes repository: {safe_path}")
return candidate
def _has_link_component(base: Path, path: Path) -> bool:
current = base
try:
@@ -177,6 +262,19 @@ def _has_link_component(base: Path, path: Path) -> bool:
return False
def _validate_untracked_delete_path(repo: Path, path: str) -> str:
"""Validate one exact untracked file before delegating deletion to Git."""
safe_path = resolve_repo_path(repo, path)
root = _canonical_repo_root(repo)
lexical = root / safe_path
if _has_link_component(root, lexical):
raise GitStateError(f"path contains a link or junction: {safe_path}")
candidate = _resolve_repo_disk_path(repo, safe_path, strict=False)
if candidate.exists() and candidate.is_dir():
raise GitStateError(f"path is not a file: {safe_path}")
return safe_path
def repo_id(repo: Path, base: Path | None = None) -> str:
"""Stable collision-free id relative to the configured canonical base."""
if base is None:
@@ -433,19 +531,27 @@ def repo_diff(repo: Path, path: str, kind: str) -> dict[str, Any]:
args = ["diff", "--no-color", "--"]
if kind == "staged":
args = ["diff", "--cached", "--no-color", "--"]
output = _git(repo, *args, safe_path)
output = _git(repo, *args, safe_path, literal_pathspecs=True)
truncated = len(output) > MAX_DIFF_BYTES
if truncated:
output = output[:MAX_DIFF_BYTES]
return {"path": safe_path, "kind": kind, "diff": output, "truncated": truncated}
@_serialized_repo_operation
def read_file(repo: Path, path: str) -> dict[str, Any]:
"""Read a tracked file's working-tree content. Untracked/binary/missing → error."""
safe_path = resolve_repo_path(repo, path)
# Confirm the file is tracked before reading.
try:
_git(repo, "ls-files", "--error-unmatch", "--", safe_path)
_git(
repo,
"ls-files",
"--error-unmatch",
"--",
safe_path,
literal_pathspecs=True,
)
except GitStateError as exc:
raise GitStateError(f"file is not tracked: {safe_path}") from exc
@@ -453,12 +559,16 @@ def read_file(repo: Path, path: str) -> dict[str, Any]:
# modified-but-uncommitted file returns what is on disk. Read bytes first:
# binary content dies on the NUL check (before any decode), and non-UTF-8
# text raises a clear GitStateError instead of an unhandled 500.
root = repo.resolve()
try:
disk_path = (repo / safe_path).resolve(strict=True)
if not _is_within(root, disk_path):
raise GitStateError(f"path escapes repository: {safe_path}")
disk_path = _resolve_repo_disk_path(repo, safe_path, strict=True)
with disk_path.open("rb") as handle:
# Re-resolve after opening, then prove the open handle still names
# that in-repo file. This fails closed if a parent, junction, repo
# root, or leaf is replaced between validation and open; reads use
# the already-verified handle after this point.
revalidated = _resolve_repo_disk_path(repo, safe_path, strict=True)
if not os.path.samestat(os.fstat(handle.fileno()), revalidated.stat()):
raise GitStateError(f"path changed during read: {safe_path}")
raw = handle.read(MAX_FILE_BYTES + 1)
except OSError as exc:
raise GitStateError(f"could not read file: {safe_path}") from exc
@@ -573,12 +683,22 @@ def _is_git_repo(path: Path) -> bool:
return (path / ".git").exists()
def _run_mutation(repo: Path, args: list[str]) -> str:
def _run_mutation(
repo: Path,
args: list[str],
*,
literal_pathspecs: bool = False,
) -> str:
"""Run a mutating ``git -C <repo> <args>``; raise a classified GitError.
Arg lists only (never shell interpolation); bounded by a timeout.
"""
returncode, stdout, stderr_output = _run_git_bounded(repo, args, mutation=True)
returncode, stdout, stderr_output = _run_git_bounded(
repo,
args,
mutation=True,
literal_pathspecs=literal_pathspecs,
)
if returncode != 0:
stderr = _safe_git_error(stderr_output or stdout)
raise GitError(
@@ -588,11 +708,33 @@ def _run_mutation(repo: Path, args: list[str]) -> str:
return stdout
def _mutate(repo: Path, args: list[str]) -> str:
def _mutate(
repo: Path,
args: list[str],
*,
literal_pathspecs: bool = False,
) -> str:
"""Validate ``repo`` is a real git work tree, then run the mutation."""
if not _is_git_repo(repo):
raise GitError(f"not a git repository: {repo.name}", code="non-repo")
return _run_mutation(repo, args)
return _run_mutation(repo, args, literal_pathspecs=literal_pathspecs)
def _is_tracked_path(repo: Path, path: str) -> bool:
"""Classify one literal path without treating Git failures as untracked."""
returncode, stdout, stderr = _run_git_bounded(
repo,
["ls-files", "--error-unmatch", "--", path],
mutation=False,
literal_pathspecs=True,
)
if returncode == 0:
return True
if returncode == 1:
return False
raise GitStateError(
f"git ls-files failed for {repo.name}: {_safe_git_error(stderr or stdout)}"
)
def _is_dirty(repo: Path) -> bool:
@@ -712,20 +854,23 @@ def _fresh_mutation_result(
return result
@_serialized_repo_operation
def stage(repo: Path, paths: list[str]) -> dict[str, Any]:
"""Stage ``paths`` (repo-relative) and return fresh status."""
safe = _validate_paths(paths)
_mutate(repo, ["add", "--"] + safe)
_mutate(repo, ["add", "--"] + safe, literal_pathspecs=True)
return _fresh_mutation_result(repo)
@_serialized_repo_operation
def unstage(repo: Path, paths: list[str]) -> dict[str, Any]:
"""Unstage ``paths`` and return fresh status."""
safe = _validate_paths(paths)
_mutate(repo, ["restore", "--staged", "--"] + safe)
_mutate(repo, ["restore", "--staged", "--"] + safe, literal_pathspecs=True)
return _fresh_mutation_result(repo)
@_serialized_repo_operation
def discard(
repo: Path,
paths: list[str],
@@ -740,26 +885,25 @@ def discard(
_require_confirmation(confirmation, CONFIRM_DISCARD)
safe = _validate_paths(paths)
tracked: list[str] = []
untracked: list[str] = []
for path in safe:
try:
_git(repo, "ls-files", "--error-unmatch", "--", path)
if _is_tracked_path(repo, path):
tracked.append(path)
except GitStateError:
# Untracked path — only touched when delete_untracked is set.
if not delete_untracked:
continue
root = repo.resolve()
candidate = (repo / path).resolve()
if candidate != root and root not in candidate.parents:
raise GitStateError(f"path escapes repository: {path}")
if candidate.is_file():
candidate.unlink()
elif delete_untracked:
untracked.append(path)
# Reject links and directories before asking Git to remove only the exact
# literal file names. Concurrent same-user filesystem mutation is outside
# the plugin trust boundary; stationary redirections fail closed here.
deletable = [_validate_untracked_delete_path(repo, path) for path in untracked]
if deletable:
_mutate(repo, ["clean", "-f", "--"] + deletable, literal_pathspecs=True)
# Revert tracked modifications for the given paths.
if tracked:
_mutate(repo, ["checkout", "--"] + tracked)
_mutate(repo, ["checkout", "--"] + tracked, literal_pathspecs=True)
return _fresh_mutation_result(repo)
@_serialized_repo_operation
def commit(repo: Path, message: str) -> dict[str, Any]:
"""Create a commit from the staged index. Empty message is rejected."""
message = _validate_commit_message(message)
@@ -767,14 +911,16 @@ def commit(repo: Path, message: str) -> dict[str, Any]:
return _fresh_mutation_result(repo)
@_serialized_repo_operation
def commit_selected(repo: Path, message: str, paths: list[str]) -> dict[str, Any]:
"""Commit only the given ``paths`` (staged + modified) under ``message``."""
message = _validate_commit_message(message)
safe = _validate_paths(paths)
_mutate(repo, ["commit", "-m", message, "--"] + safe)
_mutate(repo, ["commit", "-m", message, "--"] + safe, literal_pathspecs=True)
return _fresh_mutation_result(repo)
@_serialized_repo_operation
def fetch(repo: Path, remote: str = "origin") -> dict[str, Any]:
"""Fetch from ``remote`` (default origin) and return fresh status/branches."""
remote = _validate_remote(repo, remote)
@@ -782,6 +928,7 @@ def fetch(repo: Path, remote: str = "origin") -> dict[str, Any]:
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
@_serialized_repo_operation
def pull(repo: Path, remote: str = "origin", branch: str = "") -> dict[str, Any]:
"""Pull from ``remote``/``branch`` (defaults: origin + current branch).
@@ -805,6 +952,7 @@ def pull(repo: Path, remote: str = "origin", branch: str = "") -> dict[str, Any]
return _fresh_mutation_result(repo)
@_serialized_repo_operation
def push(
repo: Path,
remote: str = "origin",
@@ -826,6 +974,7 @@ def push(
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
@_serialized_repo_operation
def checkout(
repo: Path,
ref: str,
@@ -857,7 +1006,7 @@ def _staged_diff(repo: Path, paths: list[str] | None) -> tuple[str, bool]:
if paths:
args.append("--")
args.extend(paths)
output = _git(repo, *args)
output = _git(repo, *args, literal_pathspecs=bool(paths))
truncated = len(output) > MAX_DIFF_BYTES
if truncated:
output = output[:MAX_DIFF_BYTES]
@@ -945,6 +1094,7 @@ async def commit_message_selected(
return await _generate_message(diff)
@_serialized_repo_operation
def stash_checkout(
repo: Path,
ref: str,
+1 -1
View File
@@ -1,7 +1,7 @@
name: hermes-relay
manifest_version: 2
api_version: 1
version: 1.10.0
version: 1.11.0
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
author: Axiom Labs
license: MIT
+1 -1
View File
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
# CLI+UI releases use desktop/package.json and desktop-v* tags. The /health endpoint
# reports this plugin version, and stale values make live diagnosis harder than
# it should be.
__version__ = "1.10.0"
__version__ = "1.11.0"
from .server import create_app, main # noqa: E402 — must come after __version__
+60
View File
@@ -313,6 +313,15 @@ class GitStateFileTests(unittest.TestCase):
with self.assertRaises(ValueError):
git_state.read_file(self.repo, "untracked.txt")
def test_read_tracking_check_treats_pathspec_magic_as_literal(self) -> None:
(self.repo / "name1.txt").write_text("tracked", encoding="utf-8")
_git(self.repo, "add", "name1.txt")
_git(self.repo, "commit", "-q", "-m", "add tracked pathspec sibling")
(self.repo / "name[1].txt").write_text("untracked", encoding="utf-8")
with self.assertRaisesRegex(git_state.GitStateError, "file is not tracked"):
git_state.read_file(self.repo, "name[1].txt")
def test_read_missing_file_raises(self) -> None:
with self.assertRaises(ValueError):
git_state.read_file(self.repo, "nope.txt")
@@ -358,6 +367,57 @@ class GitStateFileTests(unittest.TestCase):
with self.assertRaisesRegex(git_state.GitStateError, "escapes repository"):
git_state.read_file(self.repo, tracked_path)
def test_read_rejects_parent_swapped_after_validation(self) -> None:
nested = self.repo / "nested"
nested.mkdir()
tracked = nested / "tracked.txt"
tracked.write_text("safe", encoding="utf-8")
_git(self.repo, "add", "nested/tracked.txt")
_git(self.repo, "commit", "-q", "-m", "add nested file")
parked = self.repo / "nested-original"
outside = self.base / "outside"
outside.mkdir()
(outside / "tracked.txt").write_text("secret", encoding="utf-8")
original_open = Path.open
swapped = False
def swap_before_open(path: Path, *args: object, **kwargs: object):
nonlocal swapped
if not swapped and path == tracked:
swapped = True
nested.rename(parked)
_link_directory(nested, outside)
return original_open(path, *args, **kwargs)
with patch.object(Path, "open", new=swap_before_open):
with self.assertRaisesRegex(git_state.GitStateError, "escapes repository"):
git_state.read_file(self.repo, "nested/tracked.txt")
self.assertTrue((parked / "tracked.txt").exists())
self.assertEqual("secret", (outside / "tracked.txt").read_text(encoding="utf-8"))
def test_read_rejects_repo_root_swapped_after_validation(self) -> None:
parked = self.base / "file-repo-original"
outside = _init_repo(self.base, "outside-repo")
(outside / "README.md").write_text("# Secret\n", encoding="utf-8")
original_open = Path.open
swapped = False
def swap_before_open(path: Path, *args: object, **kwargs: object):
nonlocal swapped
if not swapped and path == self.repo / "README.md":
swapped = True
self.repo.rename(parked)
_link_directory(self.repo, outside)
return original_open(path, *args, **kwargs)
with patch.object(Path, "open", new=swap_before_open):
with self.assertRaisesRegex(git_state.GitStateError, "repository root changed"):
git_state.read_file(self.repo, "README.md")
self.assertEqual("# Secret\n", (outside / "README.md").read_text(encoding="utf-8"))
def test_read_tracked_file_is_bounded_during_read(self) -> None:
(self.repo / "large.txt").write_text("x" * (git_state.MAX_FILE_BYTES + 100), encoding="utf-8")
_git(self.repo, "add", "large.txt")
+278
View File
@@ -12,6 +12,8 @@ import os
import subprocess
import unittest
from pathlib import Path
from threading import Event, Thread
from unittest.mock import patch
from plugin import git_state
@@ -50,6 +52,15 @@ def _init_bare_remote(root: Path, name: str) -> Path:
return remote
def _link_directory(link: Path, target: Path) -> None:
try:
link.symlink_to(target, target_is_directory=True)
except OSError:
if os.name != "nt":
raise
_run(["cmd", "/c", "mklink", "/J", str(link), str(target)], link.parent)
class _MutationBase(unittest.TestCase):
def setUp(self) -> None:
import tempfile
@@ -99,6 +110,46 @@ class StageUnstageTests(_MutationBase):
with self.assertRaises(git_state.GitStateError):
git_state.stage(self.repo, ["../outside"])
def test_stage_treats_wildcard_as_literal_path(self) -> None:
(self.repo / "first.txt").write_text("first", encoding="utf-8")
(self.repo / "second.txt").write_text("second", encoding="utf-8")
with self.assertRaises(git_state.GitError):
git_state.stage(self.repo, ["*.txt"])
status = git_state.repo_status(self.repo)
self.assertEqual([], status["staged"])
self.assertEqual(
{"first.txt", "second.txt"},
{entry["path"] for entry in status["untracked"]},
)
def test_stage_treats_pathspec_magic_as_literal_path(self) -> None:
literal = "name[1].txt"
expanded = "name1.txt"
(self.repo / literal).write_text("literal", encoding="utf-8")
(self.repo / expanded).write_text("expanded", encoding="utf-8")
git_state.stage(self.repo, [literal])
status = git_state.repo_status(self.repo)
self.assertEqual({literal}, {entry["path"] for entry in status["staged"]})
self.assertEqual({expanded}, {entry["path"] for entry in status["untracked"]})
def test_unstage_treats_wildcard_as_literal_path(self) -> None:
for name in ("first.txt", "second.txt"):
(self.repo / name).write_text(name, encoding="utf-8")
_git(self.repo, "add", name)
with self.assertRaises(git_state.GitError):
git_state.unstage(self.repo, ["*.txt"])
status = git_state.repo_status(self.repo)
self.assertEqual(
{"first.txt", "second.txt"},
{entry["path"] for entry in status["staged"]},
)
class CommitTests(_MutationBase):
def test_commit_creates_a_real_commit(self) -> None:
@@ -147,6 +198,17 @@ class CommitTests(_MutationBase):
with self.assertRaises(git_state.GitError):
git_state.commit_selected(self.repo, "", ["a.txt"])
def test_commit_selected_treats_wildcard_as_literal_path(self) -> None:
for name in ("first.txt", "second.txt"):
(self.repo / name).write_text(name, encoding="utf-8")
_git(self.repo, "add", name)
with self.assertRaises(git_state.GitError):
git_state.commit_selected(self.repo, "must stay scoped", ["*.txt"])
self.assertNotIn("first.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
self.assertNotIn("second.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
class DiscardConfirmationTests(_MutationBase):
def test_discard_requires_confirmation_string(self) -> None:
@@ -179,6 +241,222 @@ class DiscardConfirmationTests(_MutationBase):
)
self.assertFalse((self.repo / "untracked.txt").exists())
def test_discard_delete_untracked_rejects_link_outside_repo(self) -> None:
outside = self.base / "outside.txt"
outside.write_text("keep", encoding="utf-8")
link = self.repo / "untracked-link.txt"
try:
link.symlink_to(outside)
except OSError as exc:
self.skipTest(f"symlink creation unavailable: {exc}")
with self.assertRaisesRegex(git_state.GitStateError, "link or junction"):
git_state.discard(
self.repo,
["untracked-link.txt"],
confirmation=git_state.CONFIRM_DISCARD,
delete_untracked=True,
)
self.assertEqual("keep", outside.read_text(encoding="utf-8"))
self.assertTrue(link.is_symlink())
@unittest.skipUnless(os.name == "nt", "Windows junction fallback")
def test_discard_rejects_junction_on_python_311_fallback(self) -> None:
target = self.repo / "target"
target.mkdir()
(target / "keep.txt").write_text("keep", encoding="utf-8")
junction = self.repo / "junction"
_run(["cmd", "/c", "mklink", "/J", str(junction), str(target)], self.repo)
with patch.object(Path, "is_junction", return_value=False, create=True):
with self.assertRaisesRegex(git_state.GitStateError, "link or junction"):
git_state.discard(
self.repo,
["junction/keep.txt"],
confirmation=git_state.CONFIRM_DISCARD,
delete_untracked=True,
)
self.assertEqual("keep", (target / "keep.txt").read_text(encoding="utf-8"))
def test_discard_delete_untracked_treats_wildcard_as_literal_path(self) -> None:
for name in ("first.txt", "second.txt"):
(self.repo / name).write_text(name, encoding="utf-8")
git_state.discard(
self.repo,
["*.txt"],
confirmation=git_state.CONFIRM_DISCARD,
delete_untracked=True,
)
self.assertTrue((self.repo / "first.txt").exists())
self.assertTrue((self.repo / "second.txt").exists())
def test_discard_does_not_delete_when_tracking_check_fails(self) -> None:
target = self.repo / "keep.txt"
target.write_text("keep", encoding="utf-8")
with patch.object(
git_state,
"_run_git_bounded",
return_value=(128, "", "fatal: repository unavailable"),
):
with self.assertRaisesRegex(git_state.GitStateError, "ls-files failed"):
git_state.discard(
self.repo,
["keep.txt"],
confirmation=git_state.CONFIRM_DISCARD,
delete_untracked=True,
)
self.assertEqual("keep", target.read_text(encoding="utf-8"))
def test_discard_delete_untracked_does_not_follow_swapped_parent(self) -> None:
nested = self.repo / "nested"
nested.mkdir()
(nested / "delete.txt").write_text("repo", encoding="utf-8")
parked = self.repo / "nested-original"
outside = self.base / "outside"
outside.mkdir()
outside_file = outside / "delete.txt"
outside_file.write_text("keep", encoding="utf-8")
original_validate = git_state._validate_untracked_delete_path
def swap_before_delete(repo: Path, path: str) -> str:
if nested.exists() and not nested.is_symlink():
nested.rename(parked)
_link_directory(nested, outside)
return original_validate(repo, path)
with patch.object(
git_state,
"_validate_untracked_delete_path",
side_effect=swap_before_delete,
):
with self.assertRaisesRegex(git_state.GitStateError, "link or junction"):
git_state.discard(
self.repo,
["nested/delete.txt"],
confirmation=git_state.CONFIRM_DISCARD,
delete_untracked=True,
)
self.assertEqual("keep", outside_file.read_text(encoding="utf-8"))
self.assertTrue((parked / "delete.txt").exists())
def test_discard_delete_untracked_rejects_swapped_repo_root(self) -> None:
(self.repo / "delete.txt").write_text("repo", encoding="utf-8")
parked = self.base / "write-repo-original"
outside = self.base / "outside-repo"
outside.mkdir()
outside_file = outside / "delete.txt"
outside_file.write_text("keep", encoding="utf-8")
original_validate = git_state._validate_untracked_delete_path
def swap_before_delete(repo: Path, path: str) -> str:
self.repo.rename(parked)
_link_directory(self.repo, outside)
return original_validate(repo, path)
with patch.object(
git_state,
"_validate_untracked_delete_path",
side_effect=swap_before_delete,
):
with self.assertRaisesRegex(git_state.GitStateError, "repository root changed"):
git_state.discard(
self.repo,
["delete.txt"],
confirmation=git_state.CONFIRM_DISCARD,
delete_untracked=True,
)
self.assertEqual("keep", outside_file.read_text(encoding="utf-8"))
self.assertTrue((parked / "delete.txt").exists())
def test_discard_tracked_treats_wildcard_as_literal_path(self) -> None:
for name in ("first.txt", "second.txt"):
(self.repo / name).write_text("v1", encoding="utf-8")
_git(self.repo, "add", name)
_git(self.repo, "commit", "-q", "-m", "add tracked files")
for name in ("first.txt", "second.txt"):
(self.repo / name).write_text("v2", encoding="utf-8")
git_state.discard(
self.repo,
["*.txt"],
confirmation=git_state.CONFIRM_DISCARD,
)
self.assertEqual("v2", (self.repo / "first.txt").read_text(encoding="utf-8"))
self.assertEqual("v2", (self.repo / "second.txt").read_text(encoding="utf-8"))
def test_discard_serializes_against_checkout_on_same_repo(self) -> None:
target = self.repo / "delete.txt"
target.write_text("delete", encoding="utf-8")
_git(self.repo, "branch", "other")
validation_reached = Event()
allow_discard = Event()
checkout_started = Event()
checkout_completed = Event()
errors: list[BaseException] = []
original_validate = git_state._validate_untracked_delete_path
def blocked_validate(repo: Path, path: str) -> str:
result = original_validate(repo, path)
validation_reached.set()
if not allow_discard.wait(5):
raise AssertionError("test timed out waiting to continue discard")
return result
def run_discard() -> None:
try:
git_state.discard(
self.repo,
["delete.txt"],
confirmation=git_state.CONFIRM_DISCARD,
delete_untracked=True,
)
except BaseException as exc: # pragma: no cover - reported below
errors.append(exc)
def run_checkout() -> None:
checkout_started.set()
try:
git_state.checkout(
self.repo,
"other",
confirmation=git_state.CONFIRM_DIRTY_CHECKOUT,
)
except BaseException as exc: # pragma: no cover - reported below
errors.append(exc)
finally:
checkout_completed.set()
with patch.object(
git_state,
"_validate_untracked_delete_path",
side_effect=blocked_validate,
):
discard_thread = Thread(target=run_discard)
checkout_thread = Thread(target=run_checkout)
discard_thread.start()
self.assertTrue(validation_reached.wait(5))
checkout_thread.start()
self.assertTrue(checkout_started.wait(5))
self.assertFalse(checkout_completed.wait(0.2))
allow_discard.set()
discard_thread.join(5)
checkout_thread.join(5)
self.assertFalse(discard_thread.is_alive())
self.assertFalse(checkout_thread.is_alive())
self.assertEqual([], errors)
self.assertFalse(target.exists())
self.assertEqual("other", _git(self.repo, "branch", "--show-current"))
def test_discard_returns_fresh_status(self) -> None:
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
_git(self.repo, "add", "tracked.txt")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-relay"
version = "1.10.0"
version = "1.11.0"
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
requires-python = ">=3.11"
dependencies = [
+4
View File
@@ -88,6 +88,10 @@ The initial catalog covers ordinary streaming, rapid chunks/reasoning/tool
events, queued turns, scoped and foreign/unscoped inputs, persisted history,
and both issue #365 terminal-gap forms:
- `initial_history_bind`: a durable, profile-scoped transcript exists before
the client resumes, so rendered clients can exercise first-composition
binding without relying on a new turn to trigger recomposition.
- `subagent_child_preview`: interleaved concurrent child lifecycle events carry
stable child/session identity, thinking/progress/tool previews, and distinct
completed/interrupted terminal states. Its upstream requirement also proves
@@ -77,6 +77,29 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
await rejected.release()
await ws.close()
async def test_initial_history_is_available_before_session_resume(self) -> None:
fixture, base_url = await self.start("initial_history_bind")
async with self.session.get(
f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages",
params={"profile": "research", "limit": 500, "offset": 0, "order": "asc"},
) as response:
history = await response.json()
self.assertEqual(
["Open the durable Bot Chat.", "Durable Bot Chat history is ready."],
[row["content"] for row in history["messages"]],
)
ws, _ = await self.connect(base_url)
await self.rpc(
ws,
1,
"session.resume",
{"session_id": fixture.scenario.stored_session_id, "profile": "research"},
)
resumed = (await ws.receive_json())["result"]
self.assertEqual(fixture.scenario.live_session_id, resumed["session_id"])
self.assertEqual(fixture.scenario.stored_session_id, resumed["stored_session_id"])
async def test_ordinary_turn_persists_authoritative_history(self) -> None:
fixture, base_url = await self.start("ordinary_turn")
ws, _ = await self.connect(base_url)
@@ -307,6 +330,7 @@ class ScenarioTestCase(unittest.TestCase):
"active_status_profile_scope",
"active_status_unsupported",
"cross_client_observation",
"initial_history_bind",
"ordinary_turn",
"rapid_tools_interims",
"subagent_child_preview",
@@ -334,7 +358,11 @@ class ScenarioTestCase(unittest.TestCase):
from vanilla_gateway.scenario import Scenario
Scenario.from_dict(scenario)
def test_terminal_gap_manifests_select_upstream_contracts(self) -> None:
def test_contract_manifests_select_upstream_contracts(self) -> None:
self.assertEqual(
("gateway.session_resume_durable",),
load_scenario("initial_history_bind").contract_requirements,
)
self.assertEqual(
(
"gateway.message_complete",
@@ -0,0 +1,14 @@
{
"name": "initial_history_bind",
"live_session_id": "fixture-live-history",
"stored_session_id": "20260829_120000_bot_chat",
"profile": "research",
"contract_requirements": [
"gateway.session_resume_durable"
],
"initial_history": [
{"id": 1, "role": "user", "content": "Open the durable Bot Chat.", "timestamp": 1.0},
{"id": 2, "role": "assistant", "content": "Durable Bot Chat history is ready.", "timestamp": 2.0, "finish_reason": "stop"}
],
"turns": []
}
+1 -1
View File
@@ -17,7 +17,7 @@ plugins {
kotlin("jvm")
// Compose compiler — version inherited from the root plugins {} block.
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose") version "1.11.1"
id("org.jetbrains.compose") version "1.12.0"
}
kotlin {
Generated
+1 -1
View File
@@ -387,7 +387,7 @@ wheels = [
[[package]]
name = "hermes-relay"
version = "1.10.0"
version = "1.11.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },