Compare commits

...
213 Commits
Author SHA1 Message Date
Bailey Dixon 94a95162af Merge pull request #79 from Codename-11/dev
Release: Hermes-Relay 1.1.0 (Android + plugin)
2026-06-16 20:58:11 -04:00
Bailey DixonandClaude Opus 4.8 3016eb1a0b chore(release): Hermes-Relay 1.1.0 (Android + plugin)
Android appVersionName 1.1.0 / appVersionCode 13; plugin 1.1.0 (already in
sync across pyproject/manifest/package.json). CHANGELOG [1.1.0] cut; Android
and plugin GitHub-Release bodies written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:44:48 -04:00
Bailey Dixon 742486bc84 Merge pull request #78 from Codename-11/feat/plugin-enhancements
feat(plugin): env prompts, native install, /relay slash commands, session-start hook
2026-06-16 20:37:22 -04:00
Bailey Dixon 4a19a0e002 Merge pull request #76 from Codename-11/fix/dashboard-button-styling
fix(dashboard): Nous DS button/badge contract + relay-status slot widget
2026-06-16 20:37:19 -04:00
Bailey Dixon 2a13e0d1c1 Merge pull request #77 from Codename-11/docs/refresh
docs: refresh skill + user docs for gateway-first chat + accurate counts
2026-06-16 20:37:15 -04:00
Bailey Dixon 6bf94c562b Merge pull request #75 from Codename-11/fix/settings-ui-cleanup
feat(android): settings UI cleanup — exception-only pills, plugin badge, section reorg
2026-06-16 20:37:12 -04:00
Bailey DixonandClaude Opus 4.8 100d4a7b69 feat(dashboard): relay-status header-slot widget
Registers a compact "Relay · connected/offline/unpaired" Badge into the host
dashboard's `header-right` slot via window.__HERMES_PLUGINS__.registerSlot, so
relay state shows on every dashboard page. Polls the plugin's loopback overview
every 15s, derives state, and catches all fetch errors to "offline" — never
throws in the header. Uses the host Nous DS Badge `tone` (success/warning/
secondary) directly. Manifest declares slots:[header-right] for discovery.

Built on the button/badge adapter fix in this PR; bundle rebuilt with both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:32:37 -04:00
Bailey DixonandClaude Opus 4.8 8eda3699f9 feat(plugin): env-key prompts, native install path, /relay slash commands, session-start hook
Adopt four upstream plugin surfaces for easier setup/use:
- requires_env rich form: declare the optional voice-provider keys (XAI/OpenAI/
  ElevenLabs) so `hermes plugins install` prompts for them with a "get yours"
  link instead of hand-editing ~/.hermes/.env. Standard path needs none.
- Native install: document/support `hermes plugins install
  Codename-11/hermes-relay/plugin` for tools-only setups (additive; the full
  relay still uses the curl install.sh).
- /relay slash commands (status/devices/pair) usable mid-chat from any platform,
  reusing existing relay logic; every path guarded.
- A minimal on_session_start hook: one 0.5s-timeout guarded /health ping,
  returns None, can't slow or crash the gateway.

Verified against upstream/main plugin contract (register_command, register_hook
on_session_start, requires_env shape, plugins install subdir).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:30:43 -04:00
Bailey DixonandClaude Opus 4.8 d1e086a8ae docs: refresh skill + user docs for gateway-first chat + accurate tool/version counts
- Skill docs: fix the broken `hermes-relay-doctor` command (-> `hermes relay
  doctor`), dead ROADMAP anchor, stale 0.6.0/0.2.0 version samples, and the
  pre-gateway "chat -> API server" framing in the pair skill.
- user-docs: gateway-first chat framing across direct-api / relay-server /
  architecture pages + README; desktop tool count 9 -> 23 (computer-use marked
  experimental); fixed the unsourced "v0.8.0+" requirement.
- Dev docs (relay-protocol.md, relay-server.md, relay_server/SKILL.md): same
  gateway-first correction.
- plugin/dashboard/README.md: drop the leftover "Hackathon submission" section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:03:34 -04:00
Bailey DixonandClaude Opus 4.8 453d12c804 fix(dashboard): translate button/badge props to the Nous DS contract
The host dashboard's __HERMES_PLUGIN_SDK__.components.Button is the Nous DS
button (boolean flags outlined/ghost/invert/destructive + size, NO `variant`
prop); Badge uses `tone`. The plugin passed shadcn-style `variant=...`, which
was silently dropped, so every button collapsed to the solid default
(bg-midground, near-white on this theme) with its label hidden by a
`color: inherit` reset — the "blank white boxes". Added Button/Badge adapters in
ui-shims.jsx mapping our props to the DS contract (+ theme-token fallbacks),
dropped the label-hiding reset, switched tabs/PairDialog to the adapters, and
rebuilt dist/. Generalises the #71 fix (which targeted .bg-primary while the DS
button uses .bg-midground).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:01:06 -04:00
Bailey DixonandClaude Opus 4.8 567e4bf851 feat(android): settings UI cleanup — exception-only pills, plugin badge, section reorg
Status pills are now exception-only (quiet when healthy); Power tools shows a
single state-aware "Plugin active/required/offline" badge instead of a per-card
"Relay paired" chip; Connections moved to the top, Diagnostics + Developer
options to the App section; status chips restyled to the app's translucent
language and the brand blue deepened. Also fixes the Chat-settings streaming
picker wrapping and makes the system-prompt preview reflect enabled toggles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:00:05 -04:00
Bailey Dixon 21938a670e Merge pull request #74 from Codename-11/fix/installer-uv-venv
fix(installer): support uv-managed hermes-agent venvs
2026-06-16 18:39:21 -04:00
Bailey DixonandClaude Opus 4.8 c364bee003 fix(installer): support uv-managed hermes-agent venvs (no pip)
install.sh step 2 assumed `python -m pip` exists in the hermes-agent venv,
but venvs created by uv (the upstream default) ship no pip module, aborting
the editable install with "No module named pip". Detect a pip-less venv and
bootstrap pip via ensurepip, or fall back to `uv pip`, with a tolerant version
readback. Venvs that already have pip are unaffected. Verified against the
docker-server uv venv (Python 3.11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:20:55 -04:00
Bailey Dixon 01bd6b402d feat(android): polish chat session UX 2026-06-16 16:20:41 -04:00
Bailey DixonandClaude Opus 4.8 86fd744baa Merge origin/dev fixes (#70 force-close, #71 button contrast) into dev
Brings PR #73 (corrupt-keyset connect force-close fix + dashboard button
contrast fix) together with the dev branch work (per-surface release
notes, Play auto-publish, :ui-preview, dashboard rework, chat UX).

Conflict reconciliation:
- plugin/dashboard/src/styles.css: my #71 contrast rules auto-merged on
  top of the dashboard rework, but that rework switched the theme to the
  --color-* token convention. Updated .bg-primary / .bg-secondary /
  .bg-destructive to var(--color-*-foreground, ...) (chaining the old
  names + a hardcoded fallback) so they pick up the reworked theme
  instead of falling through to the fallback. dist/style.css regenerated
  from src via the package copyFileSync step.
- CHANGELOG.md: combined the dev Added/Changed entries with the #70/#71
  Fixed entries under [Unreleased].
- DEVLOG.md: kept all three 2026-06-16 entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:58:55 -04:00
Bailey Dixon b35dac8bd2 Merge pull request #73 from Codename-11/Codename-11/fix-dashboard-contrast-and-connect-force-close
fix: connect force-close from corrupt keyset (#70) + dashboard button contrast (#71)
2026-06-16 15:44:30 -04:00
Bailey DixonandClaude Opus 4.8 c054094b60 docs: changelog + devlog for connect force-close and dashboard contrast fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:43:11 -04:00
Bailey DixonandClaude Opus 4.8 b6ece0a1cf fix(dashboard): restore button label contrast on solid variants
The scoped reset ".hermes-relay-plugin button { color: inherit }" lands
at specificity (0,1,1), which outranks the host shadcn Button's
text-*-foreground utilities (0,1,0), so solid-variant buttons painted
their label in the inherited container foreground -- which on the
dashboard theme nearly matches the button background, leaving labels
unreadable. Re-assert the paired foreground colour on .bg-primary,
.bg-secondary and .bg-destructive at (0,2,0) so they win back over the
reset without !important; ghost/outline buttons and inputs keep
inheriting, which is what they want. dist/style.css re-synced via the
package's copyFileSync build step.

Fixes #71

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:43:11 -04:00
Bailey DixonandClaude Opus 4.8 48ddba5fb7 fix(auth): heal corrupt token-store keyset to stop connect force-close
EncryptedSharedPreferences decrypts its Tink keyset eagerly during
construction, so a corrupt legacy keyset (the classic post-upgrade /
post-restore case, where the encrypted blob outlives the hardware
master key it was sealed against) threw AEADBadTagException straight out
of LegacyEncryptedPrefsTokenStore's constructor and force-closed the app
right after a successful pair, on both standard and relay connections.
Every accessor already healed via resetPrefs(), and KeystoreTokenStore
hides construction behind tryCreate's try/return-null, but the
directly-constructed legacy store had no such guard (AuthManager.kt:340).

LegacyEncryptedPrefsTokenStore now builds via buildPrefsResilient(),
which deletes the corrupt file and rebuilds a fresh keyset on failure.
AuthManager.store() wraps the legacy fallback in runCatching and
degrades to a new non-persistent InMemoryTokenStore if even the rebuild
fails, so token-store construction can never force-close. Confirmed
against the android-v1.0.0 stack trace: the frames resolve exactly to
AuthManager.kt:340 and SessionTokenStore.kt:260/266.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:43:10 -04:00
Bailey DixonandClaude Opus 4.8 6bef10f89b docs(claude): document Gradle modules in repo layout + Key Files
CLAUDE.md's Repository Layout showed a flat single-app tree while
settings.gradle.kts has :app, :relay-core, :relay-ui, :ui-preview, and the
quest included build. Add all of them to the layout and Key Files. The
relay-core/relay-ui/quest Quest/XR port modules are flagged [EXPERIMENTAL]
/ in-development (not shipped); ui-preview is the dev-only desktop hot-reload
harness added this session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:07:04 -04:00
Bailey DixonandClaude Opus 4.8 46261ff9b3 ci(release): give plugin and CLI per-release notes files (Android parity)
Plugin and CLI GitHub Release bodies were static boilerplate baked into the
workflow YAML. Move them to hand-written PLUGIN_RELEASE_NOTES.md /
CLI_RELEASE_NOTES.md (Summary + Added/Changed/Fixed + Install/Verify), the same
format as Android's RELEASE_NOTES.md.

- release-plugin.yml / release-cli.yml: render the notes file (sed-substituting
  __VERSION__, plus __TAG__ for CLI) and pass it via body_path instead of inline
  body, so install/pin commands stay version-accurate without manual edits.
- release-cli.yml publish-release: add actions/checkout (it previously only
  downloaded build artifacts, so the notes file was absent).
- RELEASE.md: §2 cross-refs all three per-surface files; plugin recipe commits
  PLUGIN_RELEASE_NOTES.md; CLI CI section documents CLI_RELEASE_NOTES.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:58:32 -04:00
Bailey Dixon c40ab728cb fix(android): refine chat command and session UX 2026-06-16 13:43:14 -04:00
Bailey DixonandClaude Opus 4.8 fd343932e5 docs(release): correct Play service-account setup nav (Users and permissions)
Play Console reorganized its navigation — there is no longer a "Setup > API
access" group. Update §3 to the current path: create the service account +
JSON key in Google Cloud Console, then authorize it via Play Console >
Users and permissions > Invite new users with the granular Release
permissions. Verified against developers.google.com/android-publisher/getting_started.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:08:05 -04:00
Bailey DixonandClaude Opus 4.8 505eb51586 chore(dev): add Play auto-publish, worktree doc, and :ui-preview hot-reload module
- CI: release-android.yml uploads the googlePlay AAB to Production as a DRAFT
  when PLAY_SERVICE_ACCOUNT_JSON is set (stable tags only). sideload publishing
  is disabled structurally via playConfigs so only googlePlay can reach Play.
- docs/worktree-workflow.md: one-worktree-per-feature mental model, Orca-manages-
  worktrees note, raw git-worktree fallback, and mapping onto the main/dev contract.
- :ui-preview: JVM-only Compose for Desktop hot-reload harness (CMP 1.10.3), sharing
  the platform-agnostic MorphingSphereCore from :relay-ui via a srcDir include.
- RELEASE.md (secrets table + §5 note), CHANGELOG [Unreleased], DEVLOG, .gitignore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:52:51 -04:00
Bailey Dixon a28703b652 Merge branch 'wip/preserve-dev-dirty-settings-layout' into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/SettingsScreen.kt
2026-06-16 11:07:23 -04:00
Bailey Dixon 572c7a7fca feat(android): polish chat session UX 2026-06-16 11:05:32 -04:00
Bailey Dixon e7fb1dc1de chore(release): migrate plugin and cli tag tracks 2026-06-16 10:50:32 -04:00
Bailey Dixon 2376ee64b9 chore(release): rename release workflows by surface 2026-06-16 10:23:09 -04:00
Bailey Dixon 3325f33c9e docs(release): normalize surface release names 2026-06-16 10:16:13 -04:00
Bailey Dixon 429fda9f0c Merge branch 'Codename-11/relay-plugin-audit' into dev 2026-06-16 09:36:32 -04:00
Bailey Dixon c090169545 feat(plugin): bundle relay management surface
Align the relay plugin/server metadata to 1.1.0 and add a version-track checker for Android, server/plugin, and desktop release surfaces.
2026-06-16 09:36:07 -04:00
Bailey Dixon 57e94d8e92 Merge branch 'feature/settings-power-tools-layout' into dev 2026-06-15 22:00:06 -04:00
Bailey Dixon 0192de05dd feat(android): reorganize settings power tools 2026-06-15 21:58:54 -04:00
Bailey Dixon 2aaee0e9cb chore: sync main into dev
# Conflicts:
#	DEVLOG.md
2026-06-15 18:15:40 -04:00
Bailey Dixon bae409d02a chore(deps): batch Android dependency updates (#69) 2026-06-15 14:29:55 -04:00
dependabot[bot]andBailey Dixon 8109ed9cc2 chore(deps): bump actions/setup-node from 4 to 6 (#20)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bailey Dixon <10284999+Codename-11@users.noreply.github.com>
2026-06-15 13:44:59 -04:00
dependabot[bot]andBailey Dixon b52a5d1249 chore(deps): bump actions/configure-pages from 5 to 6 (#19)
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bailey Dixon <10284999+Codename-11@users.noreply.github.com>
2026-06-15 13:38:57 -04:00
dependabot[bot]andBailey Dixon 8433c9daae chore(deps): bump actions/upload-pages-artifact from 3 to 5 (#37)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bailey Dixon <10284999+Codename-11@users.noreply.github.com>
2026-06-15 13:38:20 -04:00
dependabot[bot]andBailey Dixon 1727d6e372 chore(deps): bump actions/deploy-pages from 4 to 5 (#38)
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bailey Dixon <10284999+Codename-11@users.noreply.github.com>
2026-06-15 13:37:23 -04:00
dependabot[bot]andBailey Dixon d0d7951fd2 chore(deps): bump gradle-wrapper from 9.4.1 to 9.5.1 (#50)
Bumps [gradle-wrapper](https://github.com/gradle/gradle) from 9.4.1 to 9.5.1.
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.4.1...v9.5.1)

---
updated-dependencies:
- dependency-name: gradle-wrapper
  dependency-version: 9.5.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>
Co-authored-by: Bailey Dixon <10284999+Codename-11@users.noreply.github.com>
2026-06-15 12:59:07 -04:00
Bailey Dixon 43d6cca601 Merge pull request #68 from Codename-11/fix/claude-review-main-hotfix
fix(ci): unblock Claude review for bot PRs
2026-06-15 12:43:23 -04:00
Bailey Dixon c7a6f03dc2 fix(ci): skip claude review for bot-authored PRs 2026-06-15 12:42:08 -04:00
Bailey Dixon ddccae7ec2 Merge pull request #67 from Codename-11/fix/claude-review-bot-skip
fix(ci): skip claude review for bot-authored PRs
2026-06-15 12:37:40 -04:00
Bailey Dixon ae82340b19 fix(ci): skip claude review for bot-authored PRs 2026-06-15 12:36:16 -04:00
Bailey DixonandClaude Opus 4.8 8142a399b9 docs(release): make the Play Console upload track-neutral (Production for GA)
§5 hardcoded "Release > Testing > Internal testing" as the upload step, which is
wrong for a stable GA on a live listing. Reframe: the AAB is track-agnostic, a GA
publishes straight to Production (the D-U-N-S org account is exempt from the
closed-testing gate), and Internal/Open/Closed are opt-in channels, not a mandatory
ladder. Also corrects the Play "What's new" source (docs/play-store-listing.md,
not RELEASE_NOTES.md) and the automated-upload track flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:48:50 -04:00
Bailey Dixon 4992d5e0ec Merge pull request #61 from Codename-11/dev
Release v1.0.0 (android-v1.0.0): dev → main
2026-06-14 22:11:57 -04:00
Bailey DixonandClaude Opus 4.8 d284d7a1e5 fix(ci): detect release PR by base+head, not a title prefix
The Claude Code Review job skips the aggregate dev -> main release PR (feature
work is reviewed before landing on dev; release PRs are gated by CI + release
metadata). Detection required the title to start with "release:", but the actual
release PR is titled "Release vX.Y.Z …", so IS_RELEASE_PR was false — the full
review ran on the entire release diff and hit the action timeout, failing a
required check and blocking the release merge. Per the branching model main only
receives release merges from dev, so base==main && head==dev is the release flow;
drop the fragile title check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:58:10 -04:00
Bailey Dixon c1ca2c1b97 Merge branch 'main' into dev
Reconcile main's 2026-06-12 "deploy refreshed site" snapshot (5c7d649) with dev's
continued docs rework. The 6 conflicting user-docs files (HeroDemo.vue, custom.css,
theme/index.ts, getting-started.md, guide/index.md, quick-start.md) are resolved in
favor of dev — the deliberate, newer, more-complete rechrome that supersedes the
earlier snapshot (e.g. dev's quick-start adds the API-key + QR-scan guidance;
getting-started is the reworked 492-line Google-Play-first funnel vs the 322-line
snapshot). Theme imports verified self-consistent (all 9 components present).

This unblocks the dev -> main release PR for android-v1.0.0.
2026-06-14 21:31:17 -04:00
Bailey DixonandClaude Opus 4.8 99b51c5611 fix(android): transport-aware session persistence + drawer refresh
Non-default agent chats forked a new session on every send. The api_server
(SSE) and gateway transports store sessions in different DBs with different id
namespaces, so a session created by one cannot be resumed by the other on a
non-default profile: api_server (api_* ids) persists to the launch state.db and
ignores ?profile=, while the gateway (YYYYMMDD_* ids) binds the profile's own
state.db. A stale api_ id resumed over the gateway 404s -> fork.

- ProfileSessionStore is now keyed by SessionTransport (GATEWAY/SSE) as well as
  connection+profile, so a gateway session and an SSE session never clobber one
  slot.
- saveLastSessionId buckets by the session id's namespace (the prefix is the
  server's ground truth about what can resume it).
- refreshLastSessionForProfile restores the active transport's slot and defers
  while the gateway probe is Unknown; a gatewayAvailability collector re-runs the
  restore once it settles. A null save clears only the active known transport
  slot, never mid-defer or right after a connection switch.

Also: a newly created session was missing from the drawer until a manual reload
(the only post-creation list refresh fired mid-stream, before the session was
persisted server-side). onCompleteCb now refreshes the session list after the
turn, and the drawer refreshes on open.

Verified on-device via ADB (no fork, clean resume; drawer shows new sessions
without reload). ProfileSessionStoreTest rewritten for the transport key with
slot-independence, forSessionId/forEndpoint, and clear-scope coverage; lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:22:54 -04:00
Bailey DixonandClaude Opus 4.8 088fbabe52 fix(android): profile-scope post-turn history reconciliation
The gateway/sessions post-turn reload (onCompleteCb) and its error-recovery path
reloaded the server-authoritative transcript via the bare api_server
`/api/sessions/{id}/messages` (no `profile=`). A gateway turn on a non-default
profile persists into THAT profile's own state.db, so that read 404s →
getMessages maps it to emptyList() → loadMessageHistory silently wiped the
just-finished turn (it then reappeared in the drawer, which is profile-scoped).
Route both reloads through loadSessionHistory(sid), which prefers the `?profile=`
dashboard loader on gateway connections. Default profile was unaffected.

Confirmed on-device via logcat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:58:18 -04:00
Bailey DixonandClaude Opus 4.8 c87fadea7e docs(devlog): depersonalize for public distribution
Rewrite DEVLOG.md as a factual, third-person engineering log: drop personal-name
attributions and AI/assistant process self-narration, and scrub real server LAN /
Tailscale IPs and the tailnet hostname to neutral placeholders. Technical content,
dates, commit refs, and the public signing-cert identity are preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:55:54 -04:00
Bailey DixonandClaude Opus 4.8 7e30156635 docs(release): polish v1.0.0 notes for public distribution
- CHANGELOG: condense the [1.0.0] block to crisp Keep-a-Changelog bullets
  (Added/Changed/Fixed), scrub personal names from historical blocks, add the
  ephemeral-vs-server-wide profile note, set the release date.
- whats_new.txt / RELEASE_NOTES.md / play-store-listing: add per-conversation
  profiles; refine the Play "What's new" around the standard-vs-advanced path,
  upstream no-plugin support, UI/UX, QoL, and polish (<=500 chars).
- RELEASE.md: add a "Scrub for public distribution" step to release-prep.
- CLAUDE.md / AGENTS.md (new) / CONTRIBUTING.md: codify public-repo writing
  hygiene (no personal names, no private infra, no AI process narration; crisp
  changelog at release-prep; depersonalized devlog).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:20:08 -04:00
Bailey DixonandClaude Opus 4.8 787982098c feat(android): confirm before the Manage tab's server-wide Activate Profile
The Manage tab's "Activate Profile" sets the server's persistent default agent
(POST /api/profiles/active) for every client — distinct from the ephemeral,
per-conversation profile switch in chat. Route it through the existing confirm
dialog with copy that spells out the server-wide effect, so it can't be mistaken
for the in-chat switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:19:49 -04:00
Bailey DixonandClaude Opus 4.8 ae5b93f9e5 docs: redesign README and clarify Hermes server setup guidance
README: feature-banner hero + screenshot gallery, Google Play marked live, lean renamed CLI section; drop the stale embedded demo video (GitHub CSP won't render external/Pages video) in favor of a link to the docs demo.

user-docs (getting-started, quick-start): defer first-time server setup to upstream Hermes docs, annotate the API/dashboard config, frame the API key as a user-chosen value, add 0.0.0.0 security notes, document the LAN-scan / manual / agent-generated-QR connect paths, and add non-technical skip-path + 'dashboard is optional' signposts.

Remove orphaned assets/chat_demo.mp4 + poster; the user-docs/public copies the docs site serves are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:12:01 -04:00
Bailey DixonandClaude Opus 4.8 ca2c626c10 fix(android): hydrate agent profiles at connect, not lazily on sheet-open
Cold start showed the default agent in the header even with a profile persisted;
opening the agent sheet then fetched the profile list, resolved the persisted name
(e.g. "Gary"), and visibly snapped the header + re-scoped the chat.

Root cause: a profile selection is persisted as a NAME and only resolves once the
connection's profile LIST arrives. On a dashboard/gateway connection the relay
auth.ok list is empty and _dashboardProfiles was fetched lazily — only by the agent
sheet's LaunchedEffect — so the pending name couldn't resolve until the picker
opened. Now ConnectionViewModel calls refreshDashboardProfiles() eagerly at the end
of activeConnectionId.collect, and clears _dashboardProfiles on a connection switch
so a pending name can't resolve against the previous connection's list. The
agentProfiles collector resolves the pending name as soon as the eager fetch lands.

(Chat profile selection stays ephemeral/per-session via session.create/resume
{profile} — this only changes WHEN the list is fetched, no new server writes.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:52:29 -04:00
Bailey DixonandClaude Opus 4.8 60f9b7a564 fix(android): per-profile sessions via dashboard REST, not gateway session.list
Re-verified against upstream NousResearch/hermes-agent (tui_gateway/server.py,
hermes_cli/web_server.py, apps/desktop). The gateway `session.list` RPC reads one
process-global SessionDB pinned to the launch profile — it can't scope per-profile
over a single socket — so the prior a1a758d approach showed the launch profile's
sessions regardless of the active profile.

Switch the drawer to the dashboard `GET /api/sessions?profile=<name>` surface (and
load each tapped session's transcript via `…/{id}/messages?profile=<name>`), which
opens that profile's own state.db directly — exactly how the official desktop
sidebar scopes, same id-space the gateway resume reads. Without the messages half,
opening a non-default profile's session would render empty.

Also fixes the switch UX + adds the picked QoL polish:
- activateGatewayProfile no longer calls createNewChat() — the profile-context
  switch already cancels the in-flight turn and resets the thread; the second reset
  raced it (the "reply typing, then a new chat appears" jank).
- A: empty chat reads "Chat with <Agent>" + the agent's description (desktop intro).
- B: leading delay(160) in the profile-context effect coalesces the lastSessionId
  null->value churn, skipping the intermediate empty paint on a switch with history.
- C: updateSessions preserves the active optimistic row past the min_messages=1
  refresh; sendMessageInternal stamps a new chat's drawer row with the first message.
- D: drawer shows a spinner instead of flashing "No sessions yet" while loading.

Removed the misleading gateway listSessions() + its test; added DashboardApiClient
listSessions/getSessionMessages request-shape tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:38:27 -04:00
Bailey DixonandClaude Opus 4.8 a1a758d011 feat(android): per-profile session drawer via gateway session.list
Sessions are profile-bound (each in its profile's state.db), but the drawer
listed via the api_server /api/sessions, which reads ONE shared DB with no
profile concept (verified upstream: _handle_list_sessions takes only
limit/offset/source). So the drawer couldn't scope to a profile.

Match the desktop: add GatewayChatClient.listSessions() → the `session.list`
RPC (the call the desktop session picker uses), which reads the active
profile's own DB and so returns only that profile's sessions. refreshSessions()
now routes through it on gateway connections (api_server /api/sessions stays the
SSE / fallback path), so the drawer re-scopes to the active profile's
conversations and switching a profile shows that agent's sessions.

Test: listSessions parses the gateway session list into SessionItems.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:12:34 -04:00
Bailey DixonandClaude Opus 4.8 b7e5c67714 fix(android): switch gateway agent profiles via profile-bound sessions (verified upstream)
The previous attempts (config.set {key:"profile"}, then setActiveProfile) were
wrong: the gateway rejected the config key, and the dashboard's active-profile
route doesn't touch a live gateway session — so the header read the new profile
while the running agent still answered as the old one.

Verified against upstream tui_gateway: a profile is a FULL agent (its own
HERMES_HOME/state.db, model, SOUL, personality, skills); sessions are
PROFILE-BOUND (the agent is built once at session.create from the session's
profile and a live session never adopts a new one); there is no profile-switch
RPC — the desktop passes `profile` on session.create / session.resume.

So:
- GatewayChatClient carries the selected profile on session.create AND
  session.resume via a live sessionProfileProvider (wired by ChatViewModel from
  the selected-profile provider), so a session is built as that agent.
- activateGatewayProfile drops the old session and starts a fresh chat — the
  next session.create binds the new profile, so the agent actually becomes it.
- Removed the wrong GatewayChatClient.setProfile (config.set / setActiveProfile).

Tests: session.create binds the selected profile; omits it when none selected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:00:45 -04:00
Bailey DixonandClaude Opus 4.8 331c3eb333 fix(android): agent-name slot shows the NAME, not the SOUL summary; drop avatar ring
Loading dashboard profiles into agentProfiles regressed the header: a dashboard
profile's description is a verbose SOUL summary ("Builds and maintains…"), and
two paths surfaced it in the agent-name slot.

- effectiveProfile no longer falls back to the advertised "default" profile, so
  with no explicit pick the main agent's name comes from the personality
  ("Victor") instead of the default profile's summary.
- profileDisplayName is now name-first: the profile NAME goes in the name slot;
  the description is only a blank-name last resort. A selected profile shows its
  name, not its summary.

Also drop the avatar's customized accent ring: the avatar letter already swaps
to the active agent, so the ring was a redundant overlay (and it read as
offset, drawn on a separate gapped box). The avatar is now a plain circle whose
letter swaps. Removed the now-unused `customized` flag + `border` import.

Tests updated: effectiveProfile returns null without an explicit pick; agentName
uses the profile name even when a verbose description exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:35:06 -04:00
Bailey DixonandClaude Opus 4.8 35d544b9cf fix(android): profile switch via /api/profiles/active + cleaner agent display
- Profile hot-swap key was wrong: the gateway's config.set has no `profile`
  key (it answered "unknown config key: profile"), unlike `model`. Switch
  GatewayChatClient.setProfile to the dashboard POST /api/profiles/active
  (setActiveProfile) — the route Manage and the official desktop use; the live
  gateway session adopts the new active profile on its next turn. Dropped the
  now-wrong config.set unit test (the route is covered by
  DashboardApiClientTest.profileActions_useActiveAndDeleteRoutes).

- Top-bar subtitle: show a NON-default personality BEFORE the model
  ("Catgirl · gpt-5.5"); the default personality is implied, so it's just the
  model. The primary line stays the agent name (unchanged).

- Profile cards cleaner: the profile NAME is the headline, the friendly
  description + model share one subtitle, and the verbose "profile: … ·
  compatibility overlay · active" caption is gone. Status stays visible — a
  prominent "Active" badge on the running profile (plus the green dot), and the
  relay-specific Overlay/API badge is dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:15:47 -04:00
Bailey DixonandClaude Opus 4.8 5a661fee42 feat(android): show dashboard agent profiles in the chat profile picker
The agent sheet's Profile section sourced only the relay's auth.ok profile list,
which is empty on a dashboard-only (non-relay) connection — so the host's actual
Hermes agent profiles (the ones set via Manage → Profiles, like the official
desktop) never appeared. Load them from the dashboard instead:

- DashboardApiClient.listProfiles() — GET /api/profiles, deserialized straight
  into the shared Profile type (the @SerialName fields already match the JSON).
  Tolerant of the array ({profiles:[…]}/{items:[…]}) and object-map
  ({profiles:{name:{…}}}) shapes; a sparse row gets name (map key) + empty model
  injected rather than failing the list.
- ConnectionViewModel: _dashboardProfiles, merged into agentProfiles as
  relay.ifEmpty { dashboard } (relay-paired connections unchanged), plus
  refreshDashboardProfiles(); the agent sheet refreshes it on open.

Because dashboard profiles map into the existing Profile type, the Profile
dropdown, selectProfile, the top bar, and the config.set {key:"profile"}
hot-swap all work unchanged — and the picked profile being in the list dodges
the resolvePendingProfileFrom reset.

Tests: listProfiles parses array + object-map shapes into Profiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:54:55 -04:00
Bailey DixonandClaude Opus 4.8 928e830044 feat(android): collapsible Profile / Personality / Model pickers in the agent sheet
The agent sheet rendered all three lists in full, so a server with many
personalities or models pushed Session/stats far down. Add CollapsiblePickerSection
— a tappable header (SectionLabel + current value + chevron) that collapses its
option rows by default and expands on tap — and wrap the Profile, Personality,
and Model sections in it. The rich rows (SOUL/skills badges, provider-grouped
models, runtime dots) are unchanged; they just live behind the header now, so
the header reads "Personality — Catgirl" until expanded.

Pure wrap, no row rewrite — zero behavior change beyond render-on-expand.
Compile + lint + assemble green; on-device layout pending review.

Also: CHANGELOG/DEVLOG entries for this and the profile hot-swap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:08:34 -04:00
Bailey DixonandClaude Opus 4.8 d8d9ce76cf feat(android): hot-swap gateway profiles from the chat picker
Selecting a gateway profile did nothing to the agent: selectProfile only set
client state + rebuilt the SSE client, and the gateway's bare prompt.submit
carries no profile, so the running agent kept the server's active profile.
(SSE turns were fine — they send the profile per-request as profileName.)

Mirror the verified model switch: GatewayChatClient.setProfile(name) dispatches
config.set {key:"profile", value, session_id} — the session-scoped path, so the
live session's agent (SOUL + model + skills) hot-swaps in place with no new
session and no lost context, matching the official desktop's clean profile
swap. ChatViewModel.activateGatewayProfile() wires it (mirrors selectModel):
prewarm → setProfile → "Switched to <profile>" notice (a failed/unknown key
surfaces as an error, not a silent no-op) → refresh model.options so the picker
reflects the profile's model. The agent-sheet profile rows call it alongside
the existing selectProfile state update.

Test: setProfile hot-swaps the live session via config set asserts the RPC
shape (key=profile, value, session_id=live-1). The exact upstream key mirrors
_apply_model_switch; live behavior to be confirmed on-device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:56:03 -04:00
Bailey DixonandClaude Opus 4.8 a6cb1e023e docs(whats-new): mention open/save images in the in-app release notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:09:27 -04:00
Bailey DixonandClaude Opus 4.8 b4a8c7cfef fix(android): render the in-app What's New cleanly
WhatsNewDialog pasted the raw whats_new.txt into one Text, so bullets showed as
literal "*" and the Chat/Manage/Voice/Polish section headers had no emphasis.
Parse the format instead — version line -> primary subtitle, blank-separated
sections -> bold headers, "* " bullets with indented continuations -> real "•"
bullets with hanging indent and spacing. Same source file (also the Play
"What's new" field); only the in-app rendering changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:41:54 -04:00
Bailey DixonandClaude Opus 4.8 3f0866e97f Merge feature/gateway-chat-transport into dev (v1.0.0)
Gateway chat transport (live thinking via dashboard /api/ws) and the
desktop-parity wave: attachments, steer, interactive ask cards, edit/resend,
subagent lanes, context meter, server slash commands, turn-complete + keep-alive
notifications, latency tracing, network-blip survival + route-following, the
in-chat model picker, generated-image rendering, open/save images & attachments,
and the cold-start connect-flash fix. Version 1.0.0 (appVersionCode 12).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:32:43 -04:00
Bailey DixonandClaude Opus 4.8 d433d09906 docs: v1.0.0 release prep
- CHANGELOG: fold the [Unreleased] open/save-attachments + cold-start-flash
  entries into [1.0.0] (the tag isn't cut yet; it's all release-day work).
- DEVLOG: add the open/save + cold-start session entry with on-device verify.
- CLAUDE.md: Key Files entries for MediaSaver / ChatImageViewer / ChatImageContent
  and the InboundAttachmentCard long-press menu.
- README / RELEASE_NOTES / whats_new / play-store listing / privacy / security /
  user-docs: 1.0.0 release-prep refresh (standard-first story, version pins,
  branding).
- Assets: regenerated play-store feature graphic (RelayRefresh indigo, Play-
  accurate trio) via new scripts/gen-feature-graphic.mjs; chat demo poster
  jpg -> png.
- Tooling: pnpm lockfile + workspace for the user-docs build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:22:05 -04:00
Bailey DixonandClaude Opus 4.8 37a24c00fa feat(android): open/save chat images & attachments + fix cold-start connect flash
Open/save: tapping an image in chat (generated/inline assistant image OR an
inbound attachment) opens a full-screen viewer — pinch-zoom/pan, double-tap
1x/2.5x, Share/Save/Close. Save lands in Pictures/Hermes-Relay with no storage
permission on API 29+ (MediaStore scoped storage); pre-Q and any failure path
fall back to the system share sheet. Non-image attachment cards gain a
long-press Open/Share/Save menu (files -> Download/Hermes-Relay); tap still
opens externally. Saves preserve original bytes (read back from the cached
content:// or base64, never a re-encode); a magic-byte sniff fixes the
extension for remote images that arrive without a usable content-type (also in
stageForShare, so a shared image is named .jpg not .bin).

New: util/MediaSaver.kt (save/share/open + remote fetch + sniff),
ui/components/ChatImageViewer.kt (viewer + ChatImageViewerSource decoupling
Coil-model/bitmap display from a suspend bytesProvider). Wired into
ChatImageContent (remote inline) and InboundAttachmentCard (attachment image +
file-card menu).

Cold-start flash: the chat empty-state briefly showed the loud "Connect to
Hermes" CTA during launch while ConnectionStore hydrated DataStore async (an
empty store and a not-yet-loaded store were indistinguishable). Added
ConnectionStore.isHydrated -> ConnectionViewModel.chatConnectState
(Connecting/Ready/NeedsConnection, seeded Connecting); the empty-state shows a
quiet "Connecting to Hermes..." spinner (with a "Manage connections" escape
hatch) until hydration confirms nothing is configured, only then the CTA.

Verified e2e on-device (gpt-5.5 echoed a picsum image -> rendered -> tap ->
viewer -> Save wrote sunset.jpg + toast; share sheet reads "1 image";
cold-start shows no connect flash). lint + assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:21:26 -04:00
Bailey DixonandClaude Opus 4.8 e4f2fdd70d feat(android): keep-alive FGS, latency tracer, slide-down handoff toast
Lands the gateway desktop-parity wave files that the prior integration
commits referenced but left untracked, so the tree builds consistently.

- Keep connected in background (opt-in, both flavors): GatewayKeepAliveService
  (specialUse FGS holding the process up so the gateway socket survives
  background/Doze) + GatewayKeepAlivePrefs (shared KEY_GATEWAY_KEEP_ALIVE +
  setter); declared in the main manifest so googlePlay ships it too. Driven by
  the Chat Settings toggle; MainActivity hands consent before startForeground.
- Turn latency tracing: TurnLatencyTracer emits one durations-only TurnLatency
  INFO line per turn (warm/cold connect/session/submit/ttfe/ttft/done) across
  the gateway + 3 SSE paths for desktop-comparable diagnosis.
- Slide-down status + update toasts: ConnectionHandoffBanner / UpdateBanner
  become floating overlays (swipe-to-dismiss, status-bar inset) instead of
  banners that pushed the UI down.
- Gateway carries no phone-context preamble: PhoneStatusPromptBuilder note +
  the gateway path keeps prompt.submit bare (preamble persisted into the
  transcript and was visible from desktop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:21:02 -04:00
Bailey DixonandClaude Opus 4.8 23a3f97caf fix(android): model picker reads the real upstream models + clean switch
The model picker showed only `hermes-agent` (the api_server /v1/models generic
alias) and a tap reported a spurious "/model failed: not a quick/plugin/skill
command" even though the switch applied. Both are now fixed to match the
upstream desktop/TUI picker:

- SOURCE: fetch the curated provider/model list from the gateway `model.options`
  RPC (the same source the desktop picker uses) — real models grouped by
  authenticated provider: x-ai/grok, openai/gpt-5.5, anthropic/claude-opus-4.8,
  google/gemini, etc. Falls back to /v1/models + profile models on SSE. Rides
  the live socket (after a gateway turn / when Ready / on picker open), never a
  cold /api/ws open for metadata.
- DISPATCH: switch via the gateway `config.set {key:"model", value:"<model>
  --provider <slug>"}` RPC (the `_apply_model_switch` path) instead of the
  `/model` SLASH path, whose `command.dispatch` fallback reported the spurious
  failure. Now shows a clean "Model switched to <model>." notice (+ any
  provider warning).
- UI: the Model section renders provider→model groups (provider name header +
  model rows) like the desktop two-stage picker, flattened into the agent sheet.

Verified on-device: picker lists grok / gpt-5.5 / claude / gemini by provider;
tapping openai/gpt-5.5 switched the session (session.info model=gpt-5.5
provider=openai-api) and showed "Model switched to openai/gpt-5.5." with no
failure card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:51:57 -04:00
Bailey DixonandClaude Opus 4.8 edbc3bfc14 docs(devlog): image render, model switcher, route-following verified on-device
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:08:26 -04:00
Bailey DixonandClaude Opus 4.8 154b48367f feat(android): in-chat model switcher + gateway route-following
Model switching (b):
- GET /v1/models -> in-chat Model picker in the agent sheet (alongside
  Profile/Personality), augmented with the configured profiles' models since
  /v1/models often collapses to a single generic alias.
- Picking a model dispatches `/model <name>` on the gateway (surfacing the
  model-info confirmation card) and sets a per-turn override for SSE; "Server
  default" clears it. Gateway is warmed first so a pick before the first turn
  of a session still has a live session for slash.exec.
- Verified on-device: picker renders, tap switches the model + shows the
  confirmation.

Gateway route-following (c):
- The gateway client's dashboard target is now mutable: on a SUSTAINED mid-turn
  route switch (LAN->Tailscale), activeGatewayChatClient RETARGETS the
  in-flight client (reconnect via the new route, keep the live session id) so
  the turn follows the route instead of being stranded on the dead one. The
  resolved API URL is a key on the gateway-client effect so the retarget
  actually fires on a route change.
- Verified on-device: forced sustained Wi-Fi drop -> 'gateway route changed
  mid-turn - retargeting active client to follow the route' -> reconnect via
  Tailscale keeping the session, turn NOT cancelled, UI not wedged.
- A fresh socket can't replay an in-flight turn's events (upstream
  session.resume doesn't reattach), so after a retarget the turn gets a short
  30s settle instead of the full 180s watchdog; the reconcile-on-error then
  recovers the server's answer. Full live-follow needs an upstream
  resume-reattach / per-socket subscription.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:07:40 -04:00
Bailey DixonandClaude Opus 4.8 850309431e docs(devlog): gateway turn survival + chat UI session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:06:32 -04:00
Bailey DixonandClaude Opus 4.8 71a6c60bb5 feat(android): render generated images in chat + Telegram-style scroll follow
Generated/inline images now render in chat instead of a blank element:
- Add Coil 3 (coil-compose + coil-network-okhttp) with an explicit singleton
  ImageLoader (OkHttp fetcher) so http(s) image URLs load reliably.
- Parse markdown image links (![alt](src)) out of assistant content and
  render them: remote http(s) URLs load via Coil with loading/error states;
  a server-local path (or a load failure) degrades to an inline notice that
  explains WHY it can't be shown (with the path / tap-to-open), rather than
  the empty space the markdown renderer produced for ![](...).
- The image-link token is stripped from the markdown body so it doesn't
  double-render; surrounding prose is preserved.

Scroll: add a small slop to the chat list's at-bottom check so a burst of
streaming content (or a sub-frame layout gap before the auto-follow re-pins)
doesn't read as "user scrolled away" and drop the Telegram-style follow.

Note: image rendering compiles + Coil resolves; on-device visual check is
pending (device was locked during the autonomous run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:59:35 -04:00
Bailey DixonandClaude Opus 4.8 1d10cae5f7 fix(android): keep gateway chat turns alive across network blips + chat UI polish
Mid-turn network handling was cancelling or losing gateway chat turns:

- session.resume mints a NEW live session id + fresh agent upstream, so the
  old "rejoin via resume" orphaned the running turn (its thread keeps
  emitting on the OLD id). Reconnect the socket only and KEEP the live
  session id; retry with backoff up to 20s instead of giving up in ~24ms.
- A transient Wi-Fi blip marked the active endpoint unreachable and switched
  routes (LAN->Tailscale) mid-blip, rebuilding the chat client and
  cancelling the turn. Defer the loss reaction behind a 6s grace, add
  endpoint hysteresis (don't switch DOWN in priority on a transient probe
  miss), and stop route-change rebuilds from cancelling an in-flight gateway
  turn: activeGatewayChatClient keeps an active-turn client, updateApiClient
  skips gateway turns, and the route-driven rebuild is deferred while a turn
  streams.
- Reconcile server history on error too, so a turn that fails on the client
  after the server finished it still surfaces the answer.

Chat UI:
- Suppress the empty timestamp-only assistant bubble (a message carrying
  only thinking/tool calls, both rendered outside the bubble).
- A transport failure no longer wedges the composer in "streaming" behind a
  dead Stop button; the cancellation flag is reset at each new turn and the
  streaming UI is finalized even on a swallowed cancel.

Test: rewrote the mid-turn rejoin test to assert the real no-resume
recovery (tail on the original session id) instead of the prior
resume-based assumption. Verified e2e on-device via forced Wi-Fi drop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:41:45 -04:00
Bailey DixonandClaude Fable 5 aadac40843 docs(assets): refresh 02_chat.png with the redesigned input bar
Re-shot the chat screenshot on-device. The old capture showed the
previous footer (separate "/" slash button + mic glyph). The new one
shows the redesigned input bar — pill field, one morphing trailing slot,
GraphicEq waveform voice glyph, no slash button — in the proven
uptime/memory demo, alongside the live "Thought process" thinking cards
and a terminal tool card. Same 1080x2244 framing (top 96px status bar
cropped) as the other assets/screenshots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:11:30 -04:00
Bailey DixonandClaude Fable 5 1da8adce99 docs: rework Android getting-started, add Google Play badge, refresh chat guide
- getting-started.md: replace the flat wall of setup commands with a
  three-step funnel (install -> point at Hermes -> connect). The
  Get-it-on-Google-Play badge is the primary install action; all server
  setup, sideload install + SHA256/cert verification, dashboard auth, and
  build-from-source detail is preserved behind collapsible details blocks
  and OS code-group tabs so new users aren't scared off.
- Add a self-hosted Google Play badge SVG and a reusable <StoreBadge>
  component (registered globally), also slotted into the home hero.
- HeroDemo: rebuild the phone-mockup input bar to the redesigned chatbar
  (no slash button, one morphing Send/Voice/Stop trailing slot, GraphicEq
  waveform voice glyph).
- chat.md: document the new input bar, steering, edit-and-resend, the
  context meter, subagent lanes, interactive ask cards, turn-complete
  notifications, and the gateway mobile-preamble behavior.
- Normalize "Hermes Relay" -> "Hermes-Relay" in phone-control-tools/voice.
- CHANGELOG + DEVLOG entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:41:33 -04:00
Bailey DixonandClaude Fable 5 5249b7c2ea fix(android): carry mobile app-context preamble on gateway turns
The phone-context block (PhoneStatusPromptBuilder.buildPromptBlock) was
forwarded only on the SSE/runs/sessions paths via system_message. The
gateway's prompt.submit is bare text (no system slot — verified upstream),
so when the gateway transport is auto-preferred (Manage signed in) the
agent stopped receiving any phone context.

Add buildGatewayPreamble(), which returns just the non-sensitive mobile
preamble gated by the app-context master toggle, and prepend it to the
gateway wire text as "[preamble]\n\n<message>" — guarded to skip slash
commands (a prepended "/cmd" no longer starts with "/" and would break
server-side slash routing). The local user bubble and session title keep
the clean message; only the persisted wire copy carries the marker. The
richer bridge/permission/safety block stays SSE-only and on the
android_phone_status tool, to avoid bloating every persisted user turn.

Also normalize the product name to "Hermes-Relay" (hyphenated) in
user-facing app strings; bare "Relay" now only ever means the relay
server/plugin component.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:40:17 -04:00
Bailey DixonandClaude Fable 5 62f8403c58 docs: changelog/devlog/key-files for the gateway desktop-parity wave
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:54 -04:00
Bailey DixonandClaude Fable 5 9d4c857e11 feat(android): gateway parity — integration (steer flow, asks, edit/resend, slash, notify)
ChatViewModel: mid-turn gateway sends steer (rejected → queue + honest
caption; steered text = local "steer-" bubble preserved across reloads);
pendingAsk flow → ask HermesCards, answerAsk dispatches respond RPCs
answer-before-collapse (failed RPC leaves the card retryable; double-tap
guarded); regenerateFromMessage (0-based USER ordinal excluding local
traces, local truncate, 500-message safety gate, returns Boolean so the
edit chip never eats text); contextUsage flow; server slash catalog
(fetch only on ready socket or post-turn — never cold-opens) + slash.exec
→ 4018 → command.dispatch routing (exec/plugin/skill → notice, send →
prompt, prefill → composer); turn-complete notification (settings-gated,
backgrounded-only, never on cancel); image attachments ride the gateway
(SSE fallback narrowed to non-image); cancelled preflight no longer
resurrects on SSE.

ChatHandler: generating-tool adoption, subagent lane mutations
(interrupted ≠ success), ask-card append/stamp, truncateMessagesFrom,
generating/lane sweeps on BOTH complete and error paths. ChatScreen:
ChatInputBar swap, 5-state trailing derivation, lanes, meter + ctx
subtitle, edit-mode chip, server-command merge, cards keep empty bubbles
alive. Manifest: POST_NOTIFICATIONS (main — googlePlay could never post
on 13+). MainActivity: cancel notification on resume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:53 -04:00
Bailey DixonandClaude Fable 5 89475439a0 feat(android): gateway parity — UI components (input bar, ask cards, lanes, meter, notifier)
- ChatInputBar (new): Telegram-clean bar — pill BasicTextField, no slash
  button (typing "/" keeps autocomplete; long-press "+" opens the full
  palette), ONE trailing slot morphing Send/Voice/Stop/Steer/Queue via
  AnimatedContent, caption row above the bar during streaming-with-text,
  waveform voice glyph with one-shot hint pill + amber needs-setup badge.
- Ask cards: HermesCard gains an input slot (choice chips + free text,
  masked secret with reveal toggle + "Not stored in chat history",
  sudo hold-to-confirm 650ms press-fill + countdown, approval reuses
  plain actions); new ask.* built-in types; SUBMIT_ASK dispatch mode
  excluded from session sync so secret values never leave the card.
- SubagentLane (new): per-taskIndex lane — guide rail, compact tool rows,
  auto-collapse to a one-line summary; interrupted ≠ success.
- ContextMeterBar (new): 2dp strip, silent <50%, Relay→Amber@75%→
  Danger@90%.
- ToolProgressCard/CompactToolCall: "preparing" state for tool.generating
  (MoreHoriz + alpha-breathe, faded mono args preview, no progress bar).
- TurnCompleteNotifier (new): channel chat_turn_complete, BigText,
  tool-count subtext, tap deep-links to chat, cancel on resume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:29 -04:00
Bailey DixonandClaude Fable 5 3cb97e8a63 feat(android): gateway parity — network layer (steer, asks, attachments, catalog, subagents)
Wire contracts verified against upstream tui_gateway source (spec workflow,
file:line evidence). GatewayChatClient gains: session.steer (Queued/
Rejected/Failed — only accepted mid-tool-batch); the four ask-response
RPCs (clarify/sudo/secret request_id-keyed, approval session-scoped;
secrets/passwords never logged); image.attach_bytes uploads between
session establish and prompt.submit (60s timeout, one legacy
image.attach.bytes fallback on -32601, per-socket name memory; upload
failure → preflight fallback, prompt never submitted); commands.catalog
(per-socket cache, connectIfNeeded gate so composition never cold-opens
sockets) + slash.exec/command.dispatch with JSON-RPC error codes
surfaced; truncate_before_user_ordinal on prompt.submit; ask-aware turn
watchdog (a blocked clarify produces 300s of legitimate event silence —
the flat 180s watchdog was killing the turn and force-denying the ask).

Mapper: tool.generating pre-mints synthetic preparing tools adopted by
the next tool.start (per-name FIFO); five subagent.* cases →
GatewaySubagentEvent; asks re-shaped into structured GatewayAsk
(requestId preserved; approval has none by contract); usage gains
context_used/max/percent. GatewayTurnCallbacks members are REQUIRED —
the compiler forces dispatchOn main-thread wrapping for every addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:05 -04:00
Bailey DixonandClaude Fable 5 40a2859a71 fix(android): reconcile gateway turns against server history on complete
Tool cards required an app restart to appear after a gateway turn: live
tool events are gated server-side by display.tool_progress (off on
Bailey''s host — the same key that silences tool-progress spam on chat
platforms; default installs emit, which is why upstream desktop shows
live cards), and the gateway branch skipped the post-turn history reload
the sessions path has always done.

Gateway turns now reload server-authoritative messages on
message.complete — tool cards + persisted reasoning appear immediately
after the reply regardless of the server''s live-event config, and events
lost in a mid-turn rejoin gap are recovered the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:13:59 -04:00
Bailey DixonandClaude Fable 5 798365959c feat(android): restore persisted reasoning on history load + card timestamps
Caching audit (Bailey): tool calls already persist server-side and
reconstruct on history load, but per-message reasoning — which the server
also persists — was dropped during rehydration, so Thought-process blocks
existed only for the live turn and vanished on returning to a chat.
MessageItem now parses reasoning/reasoning_content and loadMessageHistory
restores it into thinkingContent. Server session DB stays the single
source of truth (no client-side store) — the gap was a dropped field, not
a missing cache layer.

Timestamps: right-aligned h:mm a on the ThinkingBlock header (hidden
while streaming) and on ToolProgressCard merged with duration
("3.1s · 5:32 PM"), matching the time message bubbles already show.
History-restored tool calls fall back to the parent message timestamp
(the OpenAI wire format has no per-call clock).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:44:44 -04:00
Bailey DixonandClaude Fable 5 3900d23037 feat(android): mid-turn gateway rejoin — reconnect + session.resume on socket loss
Two mid-session "Software caused connection abort" drops on-device today
(Samsung Wi-Fi power-save/roam), one of which killed a turn 90s into its
reasoning phase. The server keeps generating through a disconnect (orphan
reaper holds the session), and tui_gateway rebinds emits to the new
transport on session.resume — the same recovery the desktop TUI uses.

Socket loss with a turn in flight now triggers a bounded rejoin (max 2
per turn): fresh ticket, reconnect, session.resume, stream continues on
the new socket. Reentrancy-guarded so a connect failure inside a rejoin
cannot spawn a second one; cooldown is bypassed for active turns. Rejoin
failure surfaces the stream error as before.

Tests: mid-turn close → rejoin → completion on the new socket (fresh
ticket asserted); unreachable rejoin → stream error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:44:24 -04:00
Bailey DixonandClaude Fable 5 c931206ca0 docs(devlog): gateway on-device round 1 — transport confirmed, UI fixes, tool-card investigation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:09:48 -04:00
Bailey DixonandClaude Fable 5 ef3e595421 feat(android): per-event gateway frame logging
Tool cards did not render on a gateway turn and the only way to localize
it was reading log absences. Log every gateway event SSE-style: delta
types log length only, everything else logs a 300-char payload excerpt —
one tool-calling turn now shows definitively whether tool.start arrives
(client issue) or never leaves the server (display.tool_progress config /
agent callback path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:09:48 -04:00
Bailey DixonandClaude Fable 5 fb65ffbaa9 fix(android): single typing indicator + instant bottom-follow during streaming
Two on-device regressions surfaced by gateway-speed deltas:

- Double typing dots: ChatScreen rendered a standalone StreamingDots
  item below the list on top of MessageBubble''s in-bubble dots. The
  bubble keeps its dots; the outer item is gone (Telegram-style single
  indicator).
- Bottom-pinned stutter during live thinking: the auto-follow ran
  animateScrollToItem per delta under collectLatest. At gateway token
  frequency (vs SSE''s ~190-char bursts) that is a cancel/restart storm —
  every cancellation strands the viewport mid-animation on earlier
  content before the next delta yanks it back. Same-turn growth now pins
  the bottom instantly (scrollToItem); the animation is reserved for
  discrete new-bubble appends. Trailing spacer no longer animateItem()s —
  its position shifts on every delta of the bubble above it and a
  constant 8dp gap gains nothing from placement animation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:09:32 -04:00
Bailey DixonandClaude Fable 5 019986a833 feat(android): INFO logs for gateway connect + per-turn submit
On-device verification had to infer the transport from the ABSENCE of
SSE logs — the gateway happy path was completely silent. One line on
/api/ws ready and one per submitted turn (with the stored session id)
makes logcat show positively which transport served a send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:37:18 -04:00
Bailey DixonandClaude Fable 5 5d96bb74d6 docs: gateway transport changelog/devlog + standard-path-upstream-only principle
- CLAUDE.md: new first Key Instruction — the Standard (no-plugin) path
  must work against unmodified upstream hermes-agent (Google Play users;
  server-side needs go through upstream PRs or the relay plugin). Noted
  the /api/ws event-richness gap (tui_gateway is the only surface with
  live reasoning.delta) and added Key Files entries for the three new
  gateway files.
- CHANGELOG: [Unreleased] entry for the gateway chat transport.
- DEVLOG: session entry — latency diagnosis (49–71s reasoning dead air),
  upstream surface verification, what shipped, bugs the tests caught,
  deferred follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:23:22 -04:00
Bailey DixonandClaude Fable 5 82f24d3c2d feat(android): wire gateway chat transport — auto-prefer + per-turn SSE fallback
Live thinking lands: with Manage signed in, "auto" now resolves chat to
the gateway transport and reasoning.delta streams into the existing
ThinkingBlock + sphere Thinking state during the previously-dead
reasoning window. Standard-path constraint holds — vanilla upstream only,
no server changes.

- ChatViewModel: activeStream retyped EventSource? → ActiveTurnHandle so
  all cancel/teardown sites are transport-agnostic; SSE dispatch
  extracted to dispatchSse() and the gateway branch falls back to it per
  turn (no client wired / attachments — prompt.submit is bare text /
  preflight failure). "sessions" fallback degrades to "completions" when
  no server session exists. Voice-intent/card synthetic traces stay
  unsynced on gateway turns. Interactive asks (clarify/approval/sudo/
  secret) render as a SYSTEM notice via ChatHandler.addSystemNotice —
  display-only (desktop CLI v0.1 precedent), never spoken by voice.
- ConnectionViewModel: GatewayAvailability piggybacks on the standard-
  voice dashboard probe (/api/status + /api/auth/me — no ticket-burn);
  sticky markGatewayUnsupported() on WS-upgrade rejection, reset on
  connection switch; gateway client cached per (connection, dashboard
  URL) sharing the Manage cookie store; resolution delegated to the pure
  resolveStreamingEndpointPreference().
- RelayApp: gatewayAvailability keys the endpoint-resolution effect so a
  mid-session Manage sign-in flips auto → gateway without a restart.
- ChatSettingsScreen: 5th endpoint option "Gateway" + sign-in hint row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:23:08 -04:00
Bailey DixonandClaude Fable 5 6467601464 feat(android): GatewayChatClient — JSON-RPC chat over dashboard /api/ws
Newline-delimited JSON-RPC 2.0 over OkHttp WebSocket against the upstream
tui_gateway surface, authenticated with a FRESH single-use ws-ticket per
connect attempt (DashboardApiClient.requestWsTicket — shares the Manage
tab cookie session).

- Connect: 2-attempt loop (stale pooled connections can poison the first
  try after a server restart), gateway.ready handshake gate, 5s failure /
  300s rate-limit cooldowns, sticky onGatewayUnsupported on 404/403
  upgrades.
- Turns: sendTurn() resumes the stored session id (session.create
  fallback rotates it via onSessionId), prompt.submit, 180s watchdog
  reset on every event, cancel → best-effort session.interrupt.
  onPreflightFailure fires only when nothing started server-side, so the
  caller can re-dispatch the turn on an SSE endpoint.
- Lifecycle: lazy connect on first send, 30s grace close after app
  background (server parks sessions in its orphan reaper; resume picks
  them back up), no background reconnect loops.
- onClosing acks peer-initiated close frames — OkHttp does NOT do this
  automatically, and without the ack the socket sits half-closed for the
  ~60s close timeout, stalling reconnects.
- Tests: MockWebServer WS harness — handshake order, fresh ticket per
  reconnect, resume→create fallback, foreign-session drop, cancel →
  interrupt, mid-turn socket loss → stream error, preflight fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:49 -04:00
Bailey DixonandClaude Fable 5 721c6890ca feat(android): gateway wire models + event mapper
Foundation for the Gateway chat transport (upstream tui_gateway JSON-RPC
over the dashboard /api/ws — the surface hermes-desktop speaks, and the
only vanilla-upstream surface streaming reasoning live).

- GatewayModels: GatewayAvailability, GatewayConnectionState,
  ActiveTurnHandle (transport-agnostic turn cancel), GatewayTurnCallbacks,
  and pure resolveStreamingEndpointPreference() — "auto" prefers gateway
  when the dashboard probe says Ready.
- GatewayEventMapper (pure JVM): per-turn event→callback mapping.
  reasoning.delta/thinking.delta stream into the existing thinking UI;
  message.complete backfills text/reasoning when nothing streamed and
  translates tui_gateway usage keys (input/output/total — NOT the SSE
  input_tokens scheme); unknown event types are silently ignored
  (forward compat); synthetic FIFO tool ids when tool_id is absent;
  interactive asks surface via onInteractionRequest.
- Tests: full mapping table as fixtures + resolution matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:31 -04:00
Bailey Dixon 5c7d6490f1 docs(user-docs): deploy refreshed site 2026-06-12 16:10:23 -04:00
Bailey DixonandClaude Fable 5 bd60f06916 feat(docs): code-driven hero demo replacing homepage video embed
HeroDemo.vue rewritten as a ~20s looping recreation of the app: DOM chat
chrome over a canvas running the real preview/web/sphere.js algorithm,
driven through the product state machine (boot gate -> typed prompt ->
execute_code card with toolCallBurst -> streamed answer -> idle).

- Sphere tween rig runs on a monotonic clock (looped scene time fed the
  tweens a negative elapsed at every wrap; smoothstep extrapolation
  slammed char indices to the ramp floor - rings of periods through the
  eye). shadowStrength 0 to match the app's pearl shading.
- Header/navbar 1:1 with the live app: hamburger, light avatar, filled
  LAN pill, separate share / code / tune buttons, navy active tab.
- ?demoT=<seconds> scrubber freezes any timeline point for review and
  headless capture; reduced-motion gets the completed scene statically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:15:46 -04:00
Bailey DixonandClaude Fable 5 0c7877919d docs(media): re-shoot screenshots + demo video, drop orphaned foreground-service clip
Programmatic re-capture on S25 Ultra (demo mode, 96px status-bar crop in
post): 8 fresh 1080x2244 stills and a new 47s chat demo video + poster,
replacing the outdated set in assets/ and user-docs/public/. Removes the
orphaned foreground_service_demo.mp4 (23.5MB, unreferenced).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:15:33 -04:00
Bailey DixonandClaude Fable 5 588151cd40 Merge fix/health-retry-burst-gate-diagnostic: health fast-retry burst + startup-gate timeout diagnostic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:32:58 -04:00
Bailey DixonandClaude Fable 5 06ba7f1b00 fix(android): fast-retry burst on unreachable health verdict + gate-timeout diagnostic
Camera bug #2: "loading conversation" varied ~6-28s against the same
LAN server. Structural cause: the API health loop is a flat 30s ticker,
so one transient checkHealth() miss (cold-start race with the route
resolver, Wi-Fi settling, mid-route-swap) parked apiServerReachable
false for a full tick -- the gate holds, the 12s backstop dumps to the
CTA, chat heals at the next tick (the ~28s tail; the rest of the
variance was the one-time keystore hint priming after the reinstall).

- Bounded fast-retry burst: on a transition INTO Unreachable, three
  quick re-probes (2.5s/5s/7.5s), re-armed only by a Reachable verdict.
  StateFlow dedup makes repeat failures un-retriggerable; a genuinely
  down server fails one burst and settles back to the 30s cadence. The
  2-consecutive-failures route-re-resolve escalation is untouched.
- Requested diagnostic: when the 12s backstop (not readiness, not a
  settled error) opens the startup gate, DiagnosticsLog records a
  Warning naming the unmet conditions (chatReady / historySettled /
  narration stage / health / route) so future variance is explainable
  from Settings -> Diagnostics instead of needing a camera.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:32:57 -04:00
Bailey DixonandClaude Fable 5 1d09c7bac4 Merge fix/startup-gate-chatready: reveal gate keys on the chat surface''s own readiness signal
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:54:47 -04:00
Bailey DixonandClaude Fable 5 6791f485a5 fix(android): startup gate releases on chatReady -- the signal the chat CTA renders from
Bailey still caught a blink of "Connect Standard Hermes" between sphere
exit and full chat. Root cause: the gate''s happy path keyed on
startupApiUp (which accepts the route resolver''s early HEAD /health
evidence), while ChatScreen renders its connect CTA from chatReady
(chat client built + client-based reachability verdict) -- a strictly
later signal. The gate could release with narration done and history
settled while chatReady was still false, exposing the CTA during the
fade until the health verdict landed.

The happy clause is now chatReady && initialChatSettled &&
narrationComplete, and the "conversation" check row''s Done is keyed on
chatReady too -- the narration cannot finish, and the gate cannot
release, until the exact signal the revealed surface renders from is
true. Resolver evidence still drives the route/hermes narration rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:54:47 -04:00
Bailey DixonandClaude Fable 5 bd6e3fc68c Merge fix/splash-choreography-blend: startup check choreography + OS-splash blend
Also carries the concurrent docs session''s user-docs commit (176fc7f),
which landed on this branch via the shared working tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:22:29 -04:00
Bailey DixonandClaude Fable 5 7bc853d83d feat(android): startup checks visibly verify; OS splash blends into the sphere
- Narration choreography: check rows resolve strictly top-to-bottom,
  each holding an ASCII-spinner beat (>=350ms) before its verdict lands
  -- with the key-less fast path every signal can be true before the
  sphere fades in, and an all-checkmarks-at-once reveal read as
  "nothing was actually checked". The gate''s happy path waits for the
  narration to finish (~1.5s); error and timeout releases don''t.
- System splash blend: dark_background was still the pre-cockpit
  #1A1A2E -- now #08090D (= RelayRefresh.Background) so the OS splash
  and the sphere screen read as one continuous surface; splash_blank
  was a pathless vector that OneUI treats as invalid (falls back to
  drawing the launcher mark -- confirmed in the adb capture) and now
  carries a real fully-transparent rect path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:21:36 -04:00
Bailey DixonandClaude Fable 5 176fc7f6bc docs(user-docs): cockpit rechrome, two-path reposition, CLI reframe, sphere gaze fix
- Rechrome VitePress theme to the RelayRefresh cockpit palette: navy-black
  base, warm-white ink + alpha hairlines, electric-indigo accent, grid/dot
  home texture, warm-paper light mode; swept hardcoded old-palette colors
  from HermesFlow/HermesFlowNode/HeroDemo/ExperimentalBadge/FeatureMatrix
- Reposition marketing: hero "Runs on your machine. Lives on your devices.",
  quick-path-first funnel ("Just connect" no-server-install card above the
  "Give it hands" relay-plugin power path), SurfaceCards + HowItWorks
  components slotted into the home layout, benefit-led feature cards
- Rename "Desktop CLI" -> "CLI" across copy (binary is host-agnostic; path/
  track rename deferred to code refactor); Windows-today / macOS-Linux-soon
  status on every availability claim incl. hero subtext; drop "self-hosted"
  qualifier in favor of plain "Hermes agent"
- desktop/index.md re-led with the remote-hands story; tray/chat copy
  rescoped (chat & management belong to hermes-desktop); modes table
  reordered Tools/Daemon first
- Sidebar: add voice, voice-intents, phone-control-tools, relay-server,
  flavor-differences (existing pages previously unreachable); bump stale
  version pins (app 0.8.1, desktop alpha.18)
- SphereMark: fix gaze drift/snap by pinning lightAngleBlend to exactly 1
  (partial blends leak the unbounded natural light angle), ambient life
  moved into proximity-eased fbm wander, mouse-only pointer tracking,
  occlusion halo over the home dot grid, larger + tighter mobile sizing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:20:43 -04:00
Bailey DixonandClaude Fable 5 ab92229feb Merge fix/cold-start-keystore-fastpath: key-less API client fast path + resolver-evidence startup gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:01:56 -04:00
Bailey DixonandClaude Fable 5 bbe435438d docs: changelog + devlog for cold-start keystore fast path
(DEVLOG also carries the concurrent docs session''s updated marketing
paragraph; its user-docs files land separately.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:01:55 -04:00
Bailey DixonandClaude Fable 5 2b886619ce fix(android): cold start no longer queues the API client behind StrongBox decrypt
Measured on-device (S25 Ultra, wireless adb, timestamped screencaps +
logcat): the resolver verified the LAN route at +0.5s, then the app sat
behind a continuous wall of serialized StrongBox keystore operations
(~550ms each, keystore2 watchdog firing every second) until +15.1s,
when AuthManager init finally decrypted the store -- whose only finding
was "there is no API key". rebuildApiClient() awaited getApiKey(), so
the API client, health probe, capabilities, and chat restore all queued
behind 15 seconds of crypto, and the startup gate's 12s backstop fired
first, revealing disconnected chat.

- New plain-SharedPreferences hint (api_key_present, boolean only --
  never key material) written by setApiKey/clearApiKey and converged in
  AuthManager init after the real decrypt. apiKeyForClientBuild() skips
  getApiKey() when the connection is known key-less; used by the
  cold-start DataStore collector, rebuildApiClient, and
  rebuildChatApiClient. Default is "assume present => wait", so a
  missing/stale hint can only reproduce the old slow path, never strip
  auth off a keyed connection.
- Startup gate: a published activeEndpoint now counts as hermes-online
  evidence -- the resolver only publishes a winner after a successful
  HEAD /health on that route, which lands ~1s in; the narration no
  longer sits on "contacting hermes..." waiting for the client-based
  probe to repeat the same check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:01:39 -04:00
Bailey DixonandClaude Fable 5 caf8ccac62 Merge feature/startup-gate-narration: startup sphere readiness gate + narration, Terminal/Settings back buttons, pill spacing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:36:46 -04:00
Bailey DixonandClaude Fable 5 787fe42b64 docs: changelog + devlog for startup gate narration and header polish
(DEVLOG also carries the user-docs marketing-reposition entry written by
the concurrent docs session; its files land separately.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:36:45 -04:00
Bailey DixonandClaude Fable 5 0020d95132 feat(android): startup sphere holds until ready + narrates; header back buttons; pill spacing
Startup gate rework. The splash gate released on the FIRST Unreachable
health verdict (often a probe against the persisted URL moments before
the route resolver landed) and the sphere force-hid itself at 5.5s
regardless of progress -- cold starts played out as a slideshow:
disconnected "connect" CTA, then connected, then the conversation. Now:

- Happy path: gate holds until the server answers AND the last
  conversation is restored (new one-way initialChatSettled latch in
  ChatViewModel, set on every conclusion path of switchProfileContext;
  history fetch wrapped in try/finally so a throw cannot strand
  isLoadingHistory or the gate)
- Error path: an Unreachable verdict must survive a 3s settle window
  before it releases; the normal UI then owns offline presentation
- Backstop: 12s timeout that RELEASES the gate instead of yanking the
  sphere out from under an unfinished startup
- Terminal-style check lines narrate progress at the sphere's bottom
  (state restored / route / hermes online / conversation), all rows
  always laid out so the column never reflows

Also: Terminal and Settings TopAppBars gain the standard back arrow
(both are pushed destinations with no back affordance), including
Terminal's PowerFeatureGate variant; RelayStatusStrip margins tightened
(top 2->3dp, bottom 8->4dp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:36:30 -04:00
Bailey DixonandClaude Fable 5 de07797853 Merge feature/route-override-split-manage-cache: Use-now/Prefer split, Manage waterfall fix, disk cache
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:37:22 -04:00
Bailey DixonandClaude Fable 5 06b4be358e docs: changelog + devlog for route-override split, Manage waterfall fix, disk cache
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:37:12 -04:00
Bailey DixonandClaude Fable 5 77abadfc2e feat(android): Manage disk cache + single-preamble concurrent section loads
Two halves of "fully loaded takes 5-10s":

1. Waterfall: every section fetch re-ran the dashboard auth preamble
   (status -> providers -> session -> ws-ticket) before its payload --
   8 sections x 5 sequential round trips ~= 40. DashboardPreamble is now
   fetched once per sweep and shared; prewarmDashboardManage aborts on an
   unreachable/unauthenticated preamble and fans section GETs out
   concurrently; the in-screen sibling prewarm reuses the visible
   section's verified status/session. Net ~40 sequential -> ~4 + 8
   concurrent. Foreground loads keep the full preamble (header needs
   fresh status).

2. Cold process: the payload cache was process-lifetime only. New
   DashboardManageDiskCache mirrors Loaded entries to plain JSON under
   cacheDir (schema-versioned, tmp+rename, mutex-serialized; corrupt or
   foreign versions decode to empty) -- deliberately NOT
   EncryptedSharedPrefs per the Tink global-lock lesson; the payload
   carries no credentials. Hydration at app start preserves
   fetchedAtMillis so entries render instantly AND count as stale; the
   SWR window and the prewarm (cold filter widened to stale-Loaded)
   refresh them quietly. Sign-in/out clear sites also wipe the file.

DashboardSummaryItem/DashboardItemAction/DashboardActionKind moved to
the new file (private -> internal @Serializable); DashboardStatus /
DashboardAuthProvider / DashboardAuthSession annotated @Serializable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:37:00 -04:00
Bailey DixonandClaude Fable 5 61ea0fc74f feat(android): separate "Use now" (transient) from "Prefer this route" (sticky)
"Use now" routed through setPreferredEndpointRole, so a one-time route
switch silently persisted preferredRouteRole. Split per act-now-vs-policy:

- useRouteNow(role): transient setManualRoleOverride + probeNow only;
  dies on disconnect; null restores the persisted preference
- "Prefer this route" (3-dot menu, now a toggle with Stop preferring)
  remains the only writer of preferredRouteRole
- ConnectionManager.manualRoleOverride exposed as a StateFlow so the
  Routes card labels Current as automatic / preferred / manual (until
  disconnect), plus Cancel-manual-switch and Stop-preferring actions

Tailscale is deliberately NOT auto-preferred: strict priority +
reachability already promotes it when LAN dies and keeps the faster
LAN path at home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:36:42 -04:00
Bailey DixonandClaude Fable 5 fbe9feea5f Merge fix/keystore-main-thread-contention: lazy keystore cookie store + shared per-connection instances
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:02:38 -04:00
Bailey DixonandClaude Fable 5 1ebf628304 fix(android): app-start UI freeze from Keystore/Tink global-lock contention
Cold starts froze the UI for up to ~11s (Skipped 1386 frames, Davey!
duration=11596ms). Logcat showed the main thread waiting 4s+ inside
AndroidKeysetManager$Builder.build() behind DefaultDispatcher workers:
EncryptedDashboardCookieStore built its Keystore-backed prefs EAGERLY
in its constructor - a 1-4s operation on StrongBox devices that
serializes through a process-global Tink lock - and several paths
(Manage per-fetch client factory, connection validation probe, session
clear, and the new Manage pre-warm at 8 instances per sweep) each
constructed their own copies, stacking seconds-long lock holds that
main-thread keystore users queued behind.

- EncryptedDashboardCookieStore: keystore-backed store is now built
  lazily on first cookie access (always an OkHttp/IO thread);
  construction is free on any thread.
- ConnectionViewModel.dashboardCookieStoreFor(connectionId): ONE cached
  store per connection, now used by Manage, the validation probe,
  session clear, standard voice, and the pre-warm - one keyset build
  per connection per process instead of one per consumer.
- prewarmDashboardManage: takes the shared store and builds ONE
  DashboardApiClient for the whole sweep (core extracted to
  fetchDashboardSectionStateWith); NonCancellable client shutdown.
- DashboardOAuthSignInDialog cookieStoreFactory widened to the
  DashboardCookieStore interface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:02:38 -04:00
Bailey DixonandClaude Fable 5 b945038a44 Merge feature/manage-loading-polish: Manage payload cache + pre-warm + overview polish
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:43:51 -04:00
Bailey DixonandClaude Fable 5 bd6f3b1aea feat(android): Manage payload cache + pre-warm, skeleton and overview polish
Every entry to Manage was a cold load: the payload cache lived in
remember{} and died with the screen, and the skeleton stacked four
progress bars with fake narrative labels that read like three different
failures. The KPI glyphs (ok/.../!) needed decoding and the status
banner crammed five facts into one line that two trailing buttons (one
a duplicate "Connection" link) kept truncating.

- DashboardPayloadCache: process-lifetime singleton keyed
  connection|dashboardUrl|section; Loaded.fetchedAtMillis drives a 30s
  stale-while-revalidate window (fresh -> no fetch; stale -> cached
  content stays up, thin refresh bar only). Sign-in/out clear as before.
- App-start pre-warm: fetch core extracted to
  fetchDashboardSectionState(); prewarmDashboardManage() fills cold
  keys only, aborts on first unreachable/auth failure, never marks
  Loading so it cannot fight the open screen. RelayApp fires it
  (1.5s debounce) when the persisted dashboard snapshot says reachable
  and signed-in/auth-free, and again after a route handoff.
- Skeleton: one LinearProgressIndicator + three pulsing content-shaped
  ghost cards; no per-card spinners, no fake labels.
- KPI strip: section count / tone-colored dashboard state word
  (ready / sign-in / offline / error) / server version. RelayMetricCard
  gains an optional valueColor.
- Status banner: two-line layout (state + identity + Sign out, then
  URL - route - checked time); duplicate "Connection" button removed -
  the Connections tile is rendered directly below it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:43:51 -04:00
Bailey DixonandClaude Fable 5 e8b007f0ec Merge feature/manage-route-visibility: Manage dashboard target line + per-route sign-in hint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:22:51 -04:00
Bailey DixonandClaude Fable 5 fcbbb85713 feat(android): Manage names its dashboard target + per-route sign-in hint
Manage over a roamed route failed opaquely: the dashboard (:9119) is a
separate server from the API (:8642), sessions are host-scoped cookies,
and an explicit dashboard URL override pins the surface - but the tab
never said which URL it was hitting or why a home sign-in did not carry
over to the Tailscale host.

- Persistent "Dashboard: <url> - <route> route" target line under the
  Manage mode strip (route suffix only when the resolver has moved the
  dashboard off the persisted URL).
- "Dashboard unavailable" card names the exact URL that failed.
- Sign-in card explains per-host cookies when the route has moved:
  sign in once here, the app keeps both sessions.
- Overview connection banner gains the route label.
- New ConnectionViewModel.dashboardRouteMovedHint; the existing
  standardVoiceSignInRouteHint refactored to reuse it (semantics
  unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:22:51 -04:00
Bailey DixonandClaude Fable 5 22d3ee3d6f Merge feature/standard-voice-dashboard-surface: standard voice dashboard surface, route switching + probe visibility
Standard (no-plugin) voice retargeted at the dashboard surface with
Manage parity (models/keys/profiles/skills hub/SOUL editor); standard-
route network auto-switch (LAN <-> Tailscale roaming without Relay) with
escalation + route-candidate preservation; Routes editor (add/edit/
remove fallback routes); remote-access discoverability across setup and
status; visible route-probe outcomes ("Probe now"/"Use now" no longer
fail silently) and bare-host URL forgiveness with port guidance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:02:02 -04:00
Bailey DixonandClaude Fable 5 e7b6f698e0 docs: changelog + devlog for route-probe visibility; URL and ADB-over-Tailscale guidance
- CHANGELOG [Unreleased]: per-route reachability verdicts, bare-host
  URL forgiveness + port copy, silent Re-check/Use-now fix.
- DEVLOG: field-report diagnosis (remote phone on tailnet, route never
  switched, "Resolving" over the internal relay URL) and the fix set.
- user-docs remote-access: new "Which URL Do I Enter?" section - API
  port 8642 vs dashboard 9119 vs relay 8767; raw 100.x Tailscale IP
  needs http:// (and an API server bound beyond loopback) while a
  *.ts.net hostname behind tailscale serve is https-only-by-name.
- user-docs troubleshooting: pairing Android Studio wireless debugging
  to a phone over its Tailscale IP (adb pair vs adb connect ports).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:01:28 -04:00
Bailey DixonandClaude Fable 5 6d180e4c67 fix(android): visible route-probe outcomes + bare-host URL forgiveness
"Probe now"/"Use now" failed silently when every saved route lost its
health probe: probeAndReconnect() early-returned without publishing on
the standard (no relay socket) path, leaving the Routes card on
"Current: Resolving" over the connection's relay URL with no feedback,
and probeNow()'s fixed 100ms delay always lost the race against a real
resolve (4s+ when LAN must time out), pointing the follow-up health
checks at the stale route.

- ConnectionManager: awaitable probeAndReconnectNow() that always
  publishes the resolve outcome (live-socket transient-miss guard
  preserved); probeAndReconnect() is now a launch wrapper.
- EndpointResolver: per-route RouteProbeOutcome map (reachable / human
  failure reason, survives clearCache) with the TLS case spelled out -
  an https route against the plain-HTTP API server fails every probe
  and was previously indistinguishable from "server down".
- ConnectionViewModel: RouteProbeStatus (Idle/Probing/Done(winner));
  probeNow() awaits the resolve, rebuilds the API client on route
  change, queues a re-run when tapped mid-probe; save/remove route end
  in a visible probe cycle.
- Routes UI: "Checking..." progress on Re-check, per-row full URL
  (scheme visible) + last verdict, explicit "No route reachable -
  using saved URL ..." instead of eternal "Resolving".
- URL forgiveness: Connection.normalizeApiUrlInput() defaults bare
  hosts to http:// + the surface's port (API 8642, dashboard 9119);
  explicitly-schemed URLs pass verbatim. Applied across the wizard,
  route editor, and updateApiServerUrl; field copy names the ports;
  route editor previews "Will save: ..." live.

Tests: resolver outcome verdicts, probeAndReconnectNow publish-on-
failure regression, 10 normalizeApiUrlInput cases incl. the bare
Tailscale IP end-to-end journey. Lint + unit suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:01:13 -04:00
Bailey DixonandClaude Fable 5 307f8389f4 docs: refresh README and Play listing around the standard-first story
README: one Quick Start mirroring the app's capability card (Chat /
Manage / Voice / Remote / Relay), voice no longer described as
relay-only, Manage + remote access promoted to headline features,
desktop CLI trimmed behind an explicit alpha banner stating the
planned refocus into a remote hands connector, stale CI badge /
broken anchors / version-pinned what's-new section removed.

Play listing: end-user-first short description (76/80), 3-step quick
start, Manage + Works Away From Home feature blocks, corrected
no-plugin voice story, v0.8.1 release notes (464/500). Compliance
sections kept verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:53:41 -04:00
Bailey DixonandClaude Fable 5 6eadec3d5c docs: changelog + devlog for remote-access discoverability
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:42:38 -04:00
Bailey DixonandClaude Fable 5 fc9ff2b249 feat(android): make remote access discoverable across setup and status
A user following the wizard's happy path (scan LAN, pick server, connect)
ended up with a LAN-only connection and learned remote access existed
only when stranded on "Hermes API unreachable" away from home. Four
nudges, each at a moment the user is actually paying attention:

- Standard setup: the Tailscale URL field moves out of the collapsed
  Advanced expander into the main form as "Remote access - Tailscale URL
  (optional)", with a "Tailscale detected on this phone" hint when the
  detector fires.
- Setup result card: new "Remote" readiness line - green when a fallback
  route exists, neutral "LAN only - add a Tailscale or public route"
  otherwise. StandardApiSetupResult gains remoteRouteConfigured.
- Status pill: "Hermes API unreachable" now diagnoses instead of just
  reporting - single-route connections get "Away from the server's
  network? Add a Tailscale or public route" (sharpened when the phone is
  on Tailscale); multi-route connections get "none of the N routes
  responded, fallbacks retried automatically".
- Connections card: when the phone is on Tailscale but the connection
  has no Tailscale route, an "Add Tailscale route" shortcut opens the
  route editor directly (editor state hoisted out of the routes expander
  so the nudge works while the list is collapsed).

user-docs: remote-access guide documents the on-phone route editor, the
LAN-only callouts, and the one-sign-in-per-route cookie behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:42:01 -04:00
Bailey DixonandClaude Fable 5 8b5698b4b3 docs: changelog + devlog for pre-release polish and routes editor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:30:10 -04:00
Bailey DixonandClaude Fable 5 f8755108a8 feat(android): add/edit/remove fallback routes from the Connections Routes card
The Relay path provisions multi-route candidates via the v3 pairing QR's
endpoints array, but the standard (no-Relay) path had only the wizard's
optional Tailscale field at setup time - no way to add a remote route
after the fact, and no way to edit or remove one. The Routes card was
read-only (prefer / probe / view pin).

- EndpointsCard: "Add route" action, Edit/Remove menu items on fallback
  rows (priority > 0; the primary row mirrors the connection's API URL
  and stays protected), remove confirmation, and a RouteEditorDialog
  with Tailscale/Public/Custom role chips + URL validation. Empty-state
  copy now offers manual add alongside the QR path.
- ConnectionViewModel.saveExtraRoute / removeExtraRoute: persist to
  Connection.routeCandidates, seed from legacy sources first (per-device
  PairingPreferences, or a primary synthesized from saved URLs) so an
  edit never hides routes the card was showing, guard host:port
  collisions, clear a stale preferred-route override on remove, and kick
  a cache-cleared re-resolve so the new route takes effect immediately.
- Wizard's Tailscale field now mentions routes are editable later under
  Settings -> Connections -> Routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:29:12 -04:00
Bailey DixonandClaude Fable 5 98f2e549cc fix(android): pre-release polish for route switching
- Gate network-change socket actions on shouldReconnect: a network event
  whose resolved winner differed from the last URL could resurrect a
  relay socket the user explicitly disconnected (pre-existing hole the
  switchover refactor preserved). Routes still publish for HTTP surfaces.
- refreshActiveEndpoint keeps the live route on a transient probe miss
  while the relay socket is Connected, mirroring the network-callback
  guard, instead of downgrading every HTTP surface to the saved URL.
- Sign-in route hint now uses the endpoint display label (Tailscale, not
  tailscale) and the chat mic toast is route-aware too.
- Reset the unreachable-escalation counter while no API client exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:19:15 -04:00
Bailey DixonandClaude Fable 5 237d38affd docs: changelog + devlog for standard-route network auto-switch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:55:12 -04:00
Bailey DixonandClaude Fable 5 b3402a976e fix(android): route-resolve escalation, per-route sign-in hint, route-candidate preservation
Follow-ups to the standard-route network switchover fix:

- Periodic API health loop now escalates two consecutive Unreachable
  probes into a cache-cleared route re-resolve - the safety net for
  network changes the NetworkCallback missed (e.g. always-on VPN keeping
  "internet available" true through a Wi-Fi -> cell handoff). Client
  rebuild stays reactive via the effectiveApiServerUrl collector.
- Voice Settings explains the per-host dashboard cookie gate when the
  resolver has moved the dashboard off the persisted route: new
  standardVoiceSignInRouteHint flow + route-aware sign-in copy, plus a
  Diagnostics entry from the availability probe.
- URL edits no longer wipe stored fallback routes: new
  Connection.mergeRouteCandidates preserves priority>0 extras (wizard
  Tailscale URL, pairing-payload endpoints) verbatim across
  updateApiServerUrl / updateRelayUrl / connectRelay /
  testRelayReachable / saveApiAndProbeVoice / saveStandardApiConnection.
- Drop the duplicate networkStatus -> revalidate() collector left
  behind by the rechrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:53:26 -04:00
Bailey Dixon efcab7875e Merge fix/standard-route-network-switchover: standard-route network auto-switch (items 1-3) 2026-06-11 08:41:35 -04:00
Bailey DixonandClaude Fable 5 75b51c75de fix(android): network-aware route switching for standard (no-Relay) connections
ConnectionManager's ADR 24 NetworkCallback only registered inside
connect(), and its onAvailable/onLost handlers bailed without a relay
socket URL - so standard connections never re-resolved LAN/Tailscale
routes on network change, and the only recovery was backgrounding the
app (ON_RESUME -> revalidate()).

- Register the NetworkCallback at construction; no-op without context.
- Unify onAvailable/onLost into a debounced re-resolve that publishes
  activeEndpoint even with no socket (HTTP surfaces follow via
  effectiveApiServerUrl / effectiveDashboardUrl); socket swap/reconnect
  behavior for the relay path is preserved.
- refreshActiveEndpoint(clearProbeCache) + revalidate() now clear the
  resolver's probe cache so a just-died route can't win the resolve for
  the rest of the 60s positive TTL.
- activeDashboardUrl() now delegates to effectiveDashboardUrl, so
  standard voice + its availability probe follow the resolved route
  instead of pinning to the persisted LAN dashboard URL.

Robolectric coverage: callback registration/unregistration lifecycle,
socketless onAvailable publishing activeEndpoint, and stale-cache vs
clearProbeCache resolve behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:41:18 -04:00
Bailey DixonandClaude Fable 5 7adb146763 docs: changelog + devlog for chat polish and quick-start docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:25:50 -04:00
Bailey DixonandClaude Fable 5 603f0e8f24 docs(user-docs): two-minute Quick Start + Manage and voice refresh
- New guide/quick-start.md leads the sidebar: install -> connect ->
  capability card -> talk, with power tools in a collapsed details
  block. Detail stays on Installation & Setup.
- features/dashboard.md Android Manage section now lists the real
  per-section capabilities (skills hub browse/preview/install, model
  picker with cost confirm, Keys set/reveal/clear, profile create/
  describe/SOUL editing) and fixes the stale claim that SOUL editing
  needs the paired inspector.
- features/voice.md Requirements split standard-route (dashboard audio,
  one Manage sign-in) from relay-route requirements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:25:50 -04:00
Bailey DixonandClaude Fable 5 1fa41dacee feat(android): quote-in-reply, share conversation, Manage overflow, ambient tip
- Message long-press now opens a Copy / "Quote in reply" menu when the
  quote handler is wired (quote drops the text into the input as a
  Markdown blockquote); copy-only call sites keep the direct copy.
- Chat top bar gains a Share icon (visible with messages) exporting the
  conversation as Markdown via the system share sheet.
- Manage cards with 5+ actions keep three inline and fold the rest
  behind a "More" dropdown - profile cards no longer wrap two rows.
- Settings -> Appearance documents the ambient long-press/tap gesture,
  keeping the hidden entry discoverable incl. via screen readers.

Audit note: scroll-to-bottom FAB, session drawer search, not-connected
empty state with Connect CTA, stop-during-streaming, and tappable
suggestion chips already existed - no changes needed there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:25:49 -04:00
Bailey DixonandClaude Fable 5 445fc98be8 docs: lead voice.md with the standard (no-Relay) route + changelog/devlog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:59:33 -04:00
Bailey DixonandClaude Fable 5 92277c7d07 feat(android): floating status pill, gesture ambient mode, media scope label
- RelayStatusStrip becomes an inset rounded capsule floating above the
  gesture area; the previous zero-radius bordered bar read as a hard
  rectangle against rounded display corners.
- Ambient (fullscreen sphere) mode drops its top-bar toggle: long-press
  the conversation background to enter (message bubbles keep their copy
  long-press and consume first), tap or long-press anywhere to return,
  with a transient "tap to return to chat" hint pill on each entry.
- Media settings now state on-screen that they apply only to
  Relay-delivered attachments, not standard connections or chat uploads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:59:33 -04:00
Bailey DixonandClaude Fable 5 1a21601f14 fix(android): unclosed nested KDoc comments + missing import broke compile
Kotlin block comments NEST: writing the glob `/api/audio/*` (or
/v1/audio/*) inside a KDoc opens a nested comment that the KDoc
terminator does not close, swallowing code until a later */ - producing
"Unclosed comment" at EOF and ~1080 cascade unresolved-reference errors
(ConnectionViewModel and StandardHermesVoiceClient never compiled).
Spell the routes without the trailing star in all three block comments;
line comments were unaffected. Also add the missing RoundedCornerShape
import used by the skills-hub and SOUL editor dialogs.

These slipped through because the local gate piped gradlew through
`tail`, which made the pipeline exit 0 regardless of build status.
Verified for real this time (pipefail): compileSideloadDebugKotlin,
compileGooglePlayDebugKotlin, :app:lint, and
testGooglePlayDebugUnitTest all pass with GRADLE_EXIT=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:40:00 -04:00
Bailey DixonandClaude Fable 5 e8661d3439 docs: changelog + devlog for capability card, quiet standard UX, hub featured
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:22:00 -04:00
Bailey DixonandClaude Fable 5 7f1b1ffcab feat(android): concrete feature rows on onboarding Chat/Manage/Power pages
The cockpit refresh reworked Welcome and the Connect wizard but left the
middle pages as icon + one sentence. Each now carries three feature rows
in the Welcome page's row style: Chat = streaming / profiles / voice
(no extra install); Manage = control / skills hub / one sign-in unlocks
voice; Power = terminal / bridge / realtime. Copy leads standard-first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:21:59 -04:00
Bailey DixonandClaude Fable 5 a1a55c2cef feat(android): voice readiness line on the connection wizard result card
StandardSetupResultCard already scored Chat / Manage / Relay; complete
the capability card with Voice. StandardApiSetupResult gains
voiceAvailability, settled in the same setup probe (dashboard status ->
auth -> audio-route HEAD) and mirrored into the live availability flow,
so the card and the mic gate are accurate the moment setup completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:21:59 -04:00
Bailey DixonandClaude Fable 5 ceab6a1184 fix(android): no relay warnings or mislabeled errors on standard-only voice
Voice Settings fetched three relay configs on open and snackbar-ed every
failure, so a standard-only user got "Relay unreachable" snackbars for a
route they do not use. Gate the fetches on relayVoiceReady and replace
the relay-backed sections (Fallback TTS / Voice Output editor / Realtime
config) with a quiet "Voice Providers" note: speech uses the server-
configured TTS/STT; pair Relay to pick providers from the phone. The
STT section and Test Current Engine stop showing permanent "loading...".

RelayErrorClassifier: preserve IllegalStateException messages (voice
routing throws actionable copy like "needs dashboard sign-in - open
Manage" that was being rewritten into relay advice), and neutralize
connect/timeout/unknown-host bodies to say "server" since those
exceptions also surface from API/dashboard routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:21:59 -04:00
Bailey DixonandClaude Fable 5 23ed1190b4 feat(android): skills hub featured view on dialog open
GET /api/skills/hub/sources populates the browse dialog before the first
search: a "Sources: Official (Nous), skills.sh, ..." line plus the
centralized index's featured skills, marked installed via the same lock
map. Best-effort - failures stay silent and search still works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:21:59 -04:00
Bailey DixonandClaude Fable 5 c7666d7b93 docs: changelog entries for voice/manage work + session log follow-up
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:26:25 -04:00
Bailey DixonandClaude Fable 5 333e369a77 style(android): re-scope blue softening to the active connection card
Feedback: the brand Electric blue was right everywhere except as a
full-card fill. Revert Electric to #111DFF (cockpit selected panels,
pills, light-theme primary keep the vivid blue) and add ElectricMuted
(#4F5BD5), applied only to the active connection card as a 0.42-alpha
wash in place of the full-opacity primaryContainer fill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:26:24 -04:00
Bailey DixonandClaude Fable 5 dcc7be5ed2 feat(android): skills hub browse/install and SOUL editing in Manage
- Skills tab gains "Browse hub" (multi-source search via
  /api/skills/hub/search with installed-state marking, SKILL.md preview
  before install, install/uninstall) and "Update installed". Hub
  mutations are async server-side spawns ({ok, pid}) - the UI reports
  "started" and keeps install rows disabled to prevent double-fires;
  dashboard client read timeout raised to 45s so the server's 30s
  search fan-out can't die client-side at the edge.
- Profiles gain "Edit SOUL": fetches the full SOUL.md (dashboard GET is
  untruncated, safe round-trip), monospace full-file editor dialog,
  PUT on save; creates the file when absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:26:24 -04:00
Bailey DixonandClaude Fable 5 45df538f29 docs: record dashboard voice/audio surface and session log
CLAUDE.md dashboard web-server paragraph now lists the audio, model,
env, and profile routes plus the standard-voice cookie-auth model and
the api_server audio_api:false status. DEVLOG entry for 2026-06-10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:03:57 -04:00
Bailey DixonandClaude Fable 5 703ef8a30b style(android): soften Electric brand blue to indigo
#111DFF (near-pure RGB blue) was too saturated against the muted
navy/periwinkle palette and too dark under Paper text on the selected
connections card. #4F5BD5 stays on the Relay/Purple hue axis, roughly
doubles luminance, and keeps Paper text above WCAG AA. Drives
relaySelectedPanel, dark primaryContainer, and light primary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:03:57 -04:00
Bailey DixonandClaude Fable 5 b91509a331 feat(android): add Manage model, keys, and profile parity actions
Close the phone-vs-hermes-desktop capability gap on the dashboard surface:

- Models tab: "Change main model" opens an /api/model/options picker
  (unauthenticated providers visible but unselectable, pointing at Keys);
  POST /api/model/set with the upstream expensive-model confirm_required
  round-trip surfaced as a confirmation dialog.
- New Keys tab over GET /api/env: Set (write-only, password-masked),
  Reveal (POST /api/env/reveal, server rate-limited), Clear (DELETE with
  JSON body). Channel-managed vars stay visible, tagged "channel", since
  the app has no Channels page to defer to.
- Profiles tab: New profile (POST /api/profiles, clone-from-default
  checkbox), Describe (PUT .../description, blank clears), per-profile
  Model via the shared picker (PUT .../model).
- Overview gains Models + Keys tiles; input-backed action kinds route to
  dialogs instead of firing immediately; successful dashboard sign-in now
  refreshes standard-voice availability so the mic unlocks without
  waiting for the next health tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:03:57 -04:00
Bailey DixonandClaude Fable 5 a3eebbc4b4 fix(android): route standard voice through dashboard surface with cookie auth
StandardHermesVoiceClient implemented the hermes-desktop /api/audio/*
contract but aimed it at the API server (:8642) with a bearer header.
Verified against upstream/main (d1383a6b1, 2026-06-10): api_server has no
audio routes (capabilities advertise audio_api: false; PR #8199 unmerged) -
the routes live on the dashboard web server behind cookie-session auth.
Standard-only users got an enabled mic and a 404 every turn; Auto-route
relay users uploaded full base64 audio to a 404 before each fallback.

- StandardHermesVoiceClient: dashboardUrlProvider + per-connection
  encrypted cookie jar shared with Manage sign-in (new
  DynamicDashboardCookieJar resolves the store per request so connection
  switches stay correct); bearer dropped; 401/404 copy points at Manage
  sign-in / server update.
- New StandardVoiceAvailability (Ready/SignInRequired/Unreachable/
  Unsupported/Unknown) fed by probeStandardVoice(): /api/status ->
  /api/auth/me when gated -> HEAD route-existence check (405 = present).
  Replaces HermesApiClient.probeAudioApi(); re-probes after dashboard
  sign-in/out via refreshStandardVoice().
- AutoVoiceAudioClient Auto order flipped to Relay-first: paired Relay is
  profile-aware and needs no dashboard sign-in; Standard is the
  zero-plugin path for vanilla installs.
- Voice Settings: per-route live status lines, "Sign in via Manage" CTA,
  unsupported-build hint; Realtime Agent labelled relay-required with an
  inline error + guidance when selected without one. Chat mic toast is
  availability-aware.
- DashboardApiClient grows the model/env/profile write methods consumed by
  the Manage parity commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:03:32 -04:00
Bailey Dixon 8a9e0327e1 fix(android): gate startup chrome behind sphere 2026-06-10 18:58:47 -04:00
Bailey Dixon ecefc8b908 fix(android): smooth bridge return and manage loading 2026-06-10 18:28:38 -04:00
Bailey Dixon c9fe4c40a8 fix(android): refine manage and bridge return chrome 2026-06-10 17:15:39 -04:00
Bailey Dixon bda0beec4f fix(android): streamline manage detail layout 2026-06-10 16:55:11 -04:00
Bailey Dixon 55a4227e4d feat(android): apply relay cockpit refresh 2026-06-09 22:29:25 -04:00
Bailey Dixon 22083d4f28 merge standard dashboard power tools split 2026-06-07 19:23:56 -04:00
Bailey Dixon 7d56289f7c feat(android): add standard dashboard power tools split 2026-06-07 19:23:28 -04:00
Bailey Dixon 7d667b9096 feat(android): prefer upstream skills endpoint (#63) 2026-06-04 21:03:59 -04:00
Bailey Dixon f7edffcd81 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
#	RELEASE_NOTES.md
2026-05-26 21:36:26 -04:00
Bailey Dixon ed9dafe571 Merge pull request #62 from Codename-11/fix/voice-barge-in-crash-0.8.1
release(android): android-v0.8.1 — voice barge-in crash hotfix
2026-05-26 21:33:23 -04:00
Bailey Dixon 851dfc7f25 Merge remote-tracking branch 'origin/main' into fix/voice-barge-in-crash-0.8.1 2026-05-26 21:23:32 -04:00
Bailey DixonandClaude Opus 4.7 b0df2c83f5 release(android): android-v0.8.1
Patch release: fixes the voice-mode barge-in crash on the legacy TTS
playback path (ExoPlayer audioSessionId read off-main). versionCode
10 -> 11. No new features — ADR 33 / persistent-session work stays on
dev for the next minor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:22:00 -04:00
Bailey DixonandClaude Opus 4.7 a8a4bc7514 fix(android): read ExoPlayer audio session id on main thread
Voice chat crashed the instant Hermes began replying when barge-in was
enabled and audio used the legacy /voice/synthesize (Media3) path:

  IllegalStateException: Player is accessed on the wrong thread.
  Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'

BargeInListener runs its mic reader on Dispatchers.IO and, to attach
AcousticEchoCanceler, polls an audioSessionIdProvider lambda. On the
legacy path that provider read exoPlayer.audioSessionId directly.
ExoPlayer is thread-confined — its getAudioSessionId() getter calls
verifyApplicationThread() and throws off-main. (The realtime PCM path
was immune: it provides an AudioTrack session id, which is thread-safe.)

VoicePlayer.audioSessionId now serves a @Volatile cache populated from
main-thread Media3 callbacks (AnalyticsListener.onAudioSessionIdChanged
plus a belt-and-braces read in onIsPlayingChanged), so it is safe to
read from any thread.

Adds VoicePlayerTest coverage: the getter reflects the cached id, never
re-invokes the thread-confined getter, and defaults to 0 before the
audio track is allocated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:20:43 -04:00
Bailey DixonandClaude Opus 4.7 b53df95bbf docs: correct stale VoicePlayer "MediaPlayer" references to Media3 ExoPlayer
VoicePlayer was migrated to a single Media3 ExoPlayer (gapless TTS queue)
in the V5 voice-quality pass, but two current-state descriptions still
called it a MediaPlayer — the CLAUDE.md Key Files row and the decisions.md
voice references. The CLAUDE.md drift actively misled a crash diagnosis.
Also note audioSessionId is now a thread-safe @Volatile cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:00:06 -04:00
Bailey Dixon f879fbbd51 Merge pull request #60 from Codename-11/fix/voice-barge-in-wrong-thread
fix(android): read ExoPlayer audio session id on main thread
2026-05-26 20:41:28 -04:00
Bailey DixonandClaude Opus 4.7 a586f3dd60 fix(android): read ExoPlayer audio session id on main thread
Voice chat crashed the instant Hermes began replying when barge-in was
enabled and audio used the legacy /voice/synthesize (Media3) path:

  IllegalStateException: Player is accessed on the wrong thread.
  Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'

BargeInListener runs its mic reader on Dispatchers.IO and, to attach
AcousticEchoCanceler, polls an audioSessionIdProvider lambda. On the
legacy path that provider read exoPlayer.audioSessionId directly.
ExoPlayer is thread-confined — its getAudioSessionId() getter calls
verifyApplicationThread() and throws off-main. (The realtime PCM path
was immune: it provides an AudioTrack session id, which is thread-safe.)

VoicePlayer.audioSessionId now serves a @Volatile cache populated from
main-thread Media3 callbacks (AnalyticsListener.onAudioSessionIdChanged
plus a belt-and-braces read in onIsPlayingChanged), so it is safe to
read from any thread.

Adds VoicePlayerTest coverage: the getter reflects the cached id, never
re-invokes the thread-confined getter, and defaults to 0 before the
audio track is allocated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:31:23 -04:00
Bailey Dixon 65db2e60d4 docs: add relay architecture spec page 2026-05-26 19:30:54 -04:00
Bailey Dixon d41e1b659b docs: add relay architecture spec page 2026-05-26 19:22:06 -04:00
Bailey Dixon de9988322b Merge pull request #59 from Codename-11/fix/realtime-voice-error-display
fix(voice): classify realtime voice.error for a clear, actionable message
2026-05-24 17:51:59 -04:00
Bailey DixonandClaude Opus 4.7 ff09c6116b fix(voice): classify realtime voice.error instead of showing raw provider string
The in-stream voice.error handler set uiState.error to the raw relay message
(e.g. 'xAI Realtime auth is not configured ...'). Route it through surfaceError
-> classifyError('voice_config') so provider-auth and other relay failures show
a clear, actionable banner ('Realtime provider auth unavailable ...') plus a
one-shot errorEvents snackbar with a Voice settings action, matching how the
result-failure path already surfaces errors. Raw detail is still recorded to the
Diagnostics log.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:42:26 -04:00
Bailey Dixon 7c5b7729c1 Merge pull request #58 from Codename-11/feature/realtime-persistent-session
feat(voice): persistent Realtime Agent conversation (one socket across turns)
2026-05-24 16:13:35 -04:00
Bailey DixonandClaude Opus 4.7 304d8e26c8 feat(voice): persistent realtime-agent session (VoiceViewModel wiring)
Wire the persistent session end to end. Realtime Agent voice now opens one
provider session/socket on the first turn (runRealtimeAgent persistent mode in
realtimeSessionJob) and feeds subsequent utterances on realtimeTurnChannel, so
the provider keeps the live conversation across turns.

- Per-turn event state hoisted to fields so the session-lived callback serves
  every turn; submitRealtimeTurn / the open path reset it per turn.
- onRealtimeTurnComplete finalizes each spoken turn (re-arms continuous listen);
  closeRealtimeSession tears down on exit / engine switch / onCleared / error.
- VoicePreferences.realtimePersistentSession (default true) + a Voice Settings ->
  Realtime Agent -> Persistent session toggle fall back to the one-shot path.

Compiles clean (compileSideloadDebugKotlin). Needs on-device validation
(multi-turn follow-ups, barge-in, background promotion mid-conversation,
exit/re-enter) — flag lets you fall back without a rebuild.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:03:02 -04:00
Bailey DixonandClaude Opus 4.7 742d899042 feat(voice): persistent realtime-agent session foundation (client)
Add opt-in persistent mode to RelayVoiceClient.runRealtimeAgent: when a
turnInputs ReceiveChannel is supplied, the WebSocket stays open across turns
(voice.response.done fires onTurnComplete instead of closing), subsequent
RealtimeTurnInputs are sent on the same socket with monotonic chunk ids, the
idle/turn guards scope to an active turn only, and the call ends when the channel
closes. One-shot path (turnInputs=null) is byte-for-byte unchanged.

Relay needs no change — _handle_provider_native_ws already loops over
input_audio/commit/response on one socket. Plan: docs/plans/2026-05-24-realtime-persistent-session.md.

VoiceViewModel wiring follows in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 15:27:48 -04:00
Bailey Dixon 783fc34e01 Merge pull request #57 from Codename-11/feature/realtime-voice-loop-and-logging
feat(voice): log realtime Hermes run lifecycle (ADR 33 observability)
2026-05-24 15:18:31 -04:00
Bailey DixonandClaude Opus 4.7 ac51a95842 feat(voice): log realtime Hermes run lifecycle (ADR 33 observability)
The hermes.run.* event handlers mutated UI state silently, so a promoted
background run was invisible in logcat even though it ran. Add Log.i for
run started / progress (tier/floor/status) / promoted / background_completed /
cancelled so the background-task lifecycle is traceable on-device.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 14:23:40 -04:00
Bailey Dixon 697610d92c Merge pull request #56 from Codename-11/feature/realtime-background-hermes-runs
feat(realtime): ADR 33 — background Hermes runs in Realtime Agent voice
2026-05-24 13:14:00 -04:00
Bailey DixonandClaude Opus 4.7 f300531054 docs(realtime): close ADR 33 Phase 0 — both verdicts hold-floor-ok
Record unconditional per-provider verdicts and mark Phase 0 done:
- OpenAI: hold-floor-ok (empirical 10/20/30s idle probe).
- xAI: hold-floor-ok — not conditional. The shipping Realtime Agent already
  holds xai_realtime sessions open across between-turn idle (turn_detection:None
  + resume TTL) with no idle-close reports; a relay-host probe is a regression
  check, not a precondition.

Also records that the spike's premise was superseded: Tier B closes the pending
provider call with an interim ack rather than holding an open response, so the
socket only sees the normal between-turns idle gap. No provider needs the
must-reopen fallback; default-on is unblocked.

Updates realtime-voice-poc.md findings, the plan's Phase 0 acceptance (Status:
DONE), and ADR 33's Phase 0 line (RESOLVED).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:21:30 -04:00
Bailey DixonandClaude Opus 4.7 0c7c21964d docs(realtime): ADR 33 Phase 0 verdict — OpenAI idle hold-floor-ok (empirical)
Ran scripts/realtime-provider-idle-probe.py against the live OpenAI realtime API
(VOICE_TOOLS_OPENAI_KEY): the session survived 10s/20s/30s quiescent idle windows
and returned clean audio on every post-idle turn -> verdict hold-floor-ok.
xAI recorded analytically as hold-floor-ok (no dev-box creds; same
turn_detection:None multi-turn model + the promotion path closes the pending
call rather than holding an open response) pending relay-host confirmation.

Fills the docs/realtime-voice-poc.md Idle tolerance findings table, satisfying
the Phase 0 acceptance (a documented per-provider verdict). Logs an incidental
OpenAI session.audio.output.format.rate schema-drift follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:19:22 -04:00
Bailey DixonandClaude Opus 4.7 ab1ffdd2aa docs(devlog): ADR 33 background Hermes runs session entry
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:14:03 -04:00
Bailey DixonandClaude Opus 4.7 ab10097f55 feat(realtime): ADR 33 Phase 3 (Android + docs) — promotion UI + event handling
Android:
- RealtimeVoiceEvent gains tier/floor; parse hermes.run.promoted +
  hermes.run.background_completed in VoiceViewModel, surfaced as a
  BackgroundRunState 'working on it' chip in VoiceModeOverlay (cleared on
  background_completed / cancel).
- RealtimeVoiceConfig gains a promotion block; new
  RelayVoiceClient.updateRealtimeAgentPromotion() PATCH.
- Voice Settings → Realtime Agent → Background tasks: promote toggle, spoken
  handoff toggle, and result-delivery segmented control, persisted to the relay.

Docs:
- CHANGELOG [Unreleased], relay-protocol.md (ADR 33 background-runs section),
  user-docs/features/voice.md (Background tasks).

Kotlin compiles clean under ./gradlew lint (the only 2 lint errors are in the
gitignored local.properties, absent in CI). Python realtime suite 58 tests green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:13:04 -04:00
Bailey DixonandClaude Opus 4.7 e294a571a4 feat(realtime): ADR 33 Phase 3 (relay) — default-on, Tier C durable, config surface
- Flip realtime_voice_promotion_enabled default to true. Safe because the
  promotion path closes the pending provider call with an interim ack rather
  than holding an open response, so the socket only sees the normal between-turns
  idle gap. Phase 0 probe still recommended to confirm per-provider survival.
- Tier C: hermes_run_task(mode='background') detaches immediately (tier=durable),
  even when grace-period promotion is off. Schema 'mode' enum gains 'background'.
- Expose promotion settings in /voice/realtime-agent config GET (promotion block)
  and accept them in PATCH (_validate_config_updates) so Android can read/write.

test_realtime_promotion gains the Tier C immediate-detach case (58 tests green).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:51:22 -04:00
Bailey DixonandClaude Opus 4.7 35893e239f feat(realtime): ADR 33 Phase 2 — Tier B grace-period promotion (default off)
Long Hermes runs in Realtime Agent no longer block the provider event pump.
_run_brokered_tool now shields the run task and waits promote_after_ms; if the
run is still in flight, it detaches to the background (tier=promoted) and returns
control to the pump. _deliver_background_result awaits the task, emits
hermes.run.background_completed, waits for the floor to clear, then speaks the
result once via the existing forced-summary path.

- New events: hermes.run.promoted, hermes.run.background_completed (models.py)
- New realtime_voice settings (config.py + profile_voice.py): promotion_enabled
  (default false), promote_after_ms (6000), background_default_mode, spoken_handoff,
  progress_spoken_after_ms, progress_repeat_ms, result_delivery, max_background_runs
- Provider-tool-call path closes the pending call with an interim background ack
  so the socket isn't left awaiting output; forced path speaks a handoff line
- Cancel (response.cancel / hermes_cancel) stops the background task; background
  delivery task cancelled on session close
- Completion replays through the event ring on resume (detach-safe)

test_realtime_promotion: promote+pump-responsive, short=no-promote, cancel,
detach-resume-replays. Full realtime suite (53 tests) green; pre-existing
unrelated xAI-OAuth-pool test failure noted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:48:14 -04:00
Bailey DixonandClaude Opus 4.7 06332f8f4e feat(realtime): ADR 33 Phase 1 — relay audio floor owner
Add plugin/relay/realtime_agent/floor.py: a pure, single-owner audio floor
(provider | relay_tts | android_filler mouths; idle/provider_speaking/
hermes_filler/result_pending labels) that makes explicit the serialization the
blocking await provided implicitly. Wire it into the broker behavior-
preservingly:

- acquire/release PROVIDER on AUDIO_DELTA/AUDIO_DONE (+ RESPONSE_DONE safety net)
- acquire/release RELAY_TTS around _render_provider_audio
- gate spoken filler by floor.can_speak(ANDROID_FILLER); stamp floor + tier on
  hermes.run.progress

Adds session fields hermes_run_tier + floor. No audible change (today's flow has
no contention); invariants proven in test_realtime_floor (background result never
barges, filler suppressed while provider speaks, relay-TTS only when owned).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:37:25 -04:00
Bailey DixonandClaude Opus 4.7 60623c1751 feat(realtime): ADR 33 Phase 0 provider idle-tolerance probe + docs
Add scripts/realtime-provider-idle-probe.py and the Idle tolerance section in
docs/realtime-voice-poc.md. The probe holds an xAI/OpenAI realtime socket
quiescent across idle windows and reports a per-provider verdict
(hold-floor-ok | needs-keepalive | must-reopen) that selects each provider's
Tier B strategy. Verdict gates ADR 33 default-on promotion.

Also lands ADR 33 and the companion implementation plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:33:12 -04:00
Bailey DixonandClaude Opus 4.7 024ee6c1db docs(release): scrub signing-cert identity from v0.8.0 release notes
The v0.8.0 notes' Verification section published the signing cert CN
(a personal name) in the live GitHub Release. Prior releases never
listed the cert identity — generalize to 'release-signed with the
production upload keystore' to match the house style. Live release body
already updated via gh release edit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:30:49 -04:00
Bailey Dixon 9951ff77c7 Merge pull request #55 from Codename-11/dev
release(android): android-v0.8.0
2026-05-23 21:37:41 -04:00
Bailey DixonandClaude Opus 4.7 d4e5927b60 Merge branch 'main' into dev
Sync the v0.7.0 release topology (main 9d297b5) into dev before the
v0.8.0 release PR. No content delta — 9d297b5's changes originated on
dev; this only brings main's tip into dev's ancestry so the strict-mode
dev->main release PR is up-to-date.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 21:23:17 -04:00
Bailey DixonandClaude Opus 4.7 437af29665 release(android): android-v0.8.0
Promote the accumulated [Unreleased] work to 0.8.0 (dated 2026-05-23) and
finalize release-facing docs. Version source is already at appVersionName
0.8.0 / appVersionCode 10 (monotonic from 0.7.0 / 9).

- CHANGELOG: date 0.8.0 2026-05-23; add the realtime playback fix, Voice Lab
  text/mic demos, and playback diagnostics bullets; keep an empty
  [Unreleased] above.
- RELEASE_NOTES / whats_new.txt: 0.8.0 user-facing notes — provider-native
  Realtime Agent, reliable low-latency playback, Voice Lab demos, diagnostics.
- user-docs (voice, feature matrix, getting-started, release-tracks,
  connections, index): document reliable realtime playback and the Voice Lab.
- play-store-listing + RELEASE.md: 0.8.0 listing copy and signing-path notes.
- CLAUDE.md: bump Current state line to v0.8.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:56:17 -04:00
Bailey DixonandClaude Opus 4.7 182f4b9944 fix(android): reliable low-latency realtime voice playback + Voice Lab demos
Follow-up fixes for the provider-native Realtime Agent voice feature,
verified on-device.

Playback:
- Fix silent / choppy first-turn realtime audio. The AudioTrack deep buffer
  cold-started with its playback head parked at zero, dropping or stuttering
  the first turn. Shrink STREAM_BUFFER_MS 4000 -> 700ms in RealtimePcmPlayer,
  retune the low-latency prebuffer, and remove the preroll force-start.
- Add playback diagnostics: time-to-first-audio metric, requested-vs-actual
  buffer logging, a timer-based first-frame watchdog
  (VoiceViewModel.startRealtimePlaybackWatchdog), and a drain cross-check
  (playbackDrainDrift), surfaced through the new DiagnosticsLog.

Voice Lab:
- Drive the lab waveform from RealtimePcmPlayer.playbackAmplitude() at the
  playback cursor instead of socket-arrival time.
- Rework the demos: Text demo = raw provider TTS (runRealtimeDemo); Mic demo
  = full agent path (runRealtimeAgent: real STT + Hermes + spoken reply) with
  tap-to-record/stop (RealtimePcmRecorder.captureUntilStopped).

Includes supporting connection diagnostics surfaces, realtime turn/context
sync, relay/bootstrap broker changes, and the accompanying Android + Python
tests and scripts/realtime-voice-lab-smoke.ps1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:56:17 -04:00
Bailey Dixon ab6b358598 Merge pull request #54 from Codename-11/fix/voice-test-suite
test(android): un-defer voice/audio test suite (#32) + fix barge-in resume regression
2026-05-23 14:15:28 -04:00
Bailey DixonandClaude Opus 4.7 3dfb744ee4 docs: log voice test-suite un-deferral + barge-in resume fix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:03:32 -04:00
Bailey DixonandClaude Opus 4.7 e3d7bdb917 test(android): un-defer voice/audio test suite + fix DataStore deadlock (#32)
Un-ignore all 8 voice/audio test classes deferred under issue #32. The
"full suite hangs indefinitely" symptom was NOT Robolectric's classloader
(the v0.5.1 hypothesis) — it was BargeInPreferencesTest building its
DataStore on a TestScope(StandardTestDispatcher()) whose scheduler is never
advanced, so dataStore.data never emits and repo.flow.first() suspends
forever. Back the DataStore with a real Dispatchers.IO scope.

With the real hang fixed, VoicePlayerTest runs cleanly in the normal test
source set under Robolectric (no separate source set needed):
- add robolectric 4.14.1 (testImplementation)
- unitTests.isIncludeAndroidResources = true
- @RunWith(RobolectricTestRunner) + @Config(sdk=[34]) so ExoPlayer's static
  init resolves android.os.Looper

Full :app:testGooglePlayDebugUnitTest: 525 completed, 0 failed, no hang.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:03:31 -04:00
Bailey DixonandClaude Opus 4.7 76fead710d fix(android): correct lint suppression for sideload bridge in googlePlay
Bridge code lives in src/main and compiles into the googlePlay flavor, but
the service + its FOREGROUND_SERVICE_*/POST_NOTIFICATIONS permissions are
declared only in the sideload manifest (googlePlay deliberately omits
device-control for Play-Store compliance). Lint statically analyzes the
merged googlePlay manifest and can't see that the code is unreachable there.

- AutoDisableWorker: the hasPostNotificationsPermission() early-return guard
  was already correct; the existing @SuppressLint used the generic
  "MissingPermission" ID, not the notify()-specific "NotificationPermission".
  Added the correct ID.
- BridgeForegroundService: suppress "ForegroundServiceType" on
  startForegroundNotification() with justification — sideload declares
  foregroundServiceType, googlePlay can't start the undeclared service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:03:14 -04:00
Bailey DixonandClaude Opus 4.7 4e43bc48b1 fix(android): sync trimmed-card dispatches as bare envelopes
buildSyntheticMessages short-circuited on `msg.cards.isEmpty()`, silently
dropping card-action dispatches whose card had been trimmed from the rolling
MAX_MESSAGES buffer. That contradicted the builder's own docstring and the
card==null fallback below it, which exist precisely to emit a bare-envelope
audit record (card_key + action_value) in that case.

Gate emission on `cardDispatches.isEmpty()` only. Fixes the failing
CardDispatchSyncBuilderTest.buildSyntheticMessages_unknownCardKey_stillEmitsBareEnvelope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:03:14 -04:00
Bailey DixonandClaude Opus 4.7 c93b5f905f fix(android): preserve barge-in resume tail across consumer restart
The barge-in "resume after interruption" feature was silently broken.
onBargeInDetected() captures the interrupt point, then interruptSpeaking()
restarts the TTS consumer; the fresh play worker immediately hits an empty
audioQueue and fires onQueueDrained -> clearSpokenChunksState() synchronously
(on Dispatchers.Main.immediate), wiping spokenChunks before the 600ms resume
watchdog reads it. The watchdog always saw an empty tail and dropped the
resume.

Snapshot the un-played tail (pendingResumeTail) synchronously in
onBargeInDetected() — the semantically correct moment, "what was unplayed
when the user barged in" — instead of re-reading live state in the watchdog.

Surfaced by un-deferring VoiceViewModelBargeInTest (issue #32).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:02:56 -04:00
Bailey Dixon 90a3c16772 feat: add provider-native realtime agent voice 2026-05-20 13:22:28 -04:00
Bailey Dixon 6179374831 docs(android): align user guide with bridge core split 2026-05-19 20:31:45 -04:00
Bailey Dixon 5278e5b0bb fix(android): ship Play bridge core without device control 2026-05-19 20:16:27 -04:00
Bailey Dixon 9d297b58b8 release: v0.7.0
Merge dev into main for Hermes-Relay v0.7.0 and relay-v0.7.0.
2026-05-19 20:05:19 -04:00
Bailey Dixon 3e61355191 fix(ci): skip Claude review on release PRs 2026-05-19 19:38:59 -04:00
Bailey Dixon 73553f414c Merge branch 'dev' into feature/realtime-hermes-voice-agent
# Conflicts:
#	TODO.md
2026-05-19 19:29:53 -04:00
Bailey Dixon 51acd73deb feat: add realtime Hermes voice agent mode 2026-05-19 19:27:22 -04:00
Bailey Dixon b341d9a2ab fix(android): opt in shared QR scanner image access 2026-05-19 19:08:31 -04:00
Bailey Dixon 0249fc0e7d release: v0.7.0 and relay-v0.7.0 2026-05-19 18:55:29 -04:00
Bailey Dixon 638974eb97 feat(android): polish realtime voice mode 2026-05-19 18:26:54 -04:00
Bailey Dixon d5b74ef746 feat: add realtime voice playground and profile support 2026-05-19 15:34:26 -04:00
Bailey Dixon 18673d0ae1 feat(relay): add streaming voice provider routes 2026-05-18 14:40:16 -04:00
Bailey Dixon 81596b1f14 feat(android): add experimental voice overlay 2026-05-18 14:27:47 -04:00
Bailey Dixon d2c169f2fd feat(desktop): add tray pairing and consent flow 2026-05-17 21:53:01 -04:00
Bailey DixonandClaude Opus 4.7 c3d34810da fix(android voice): prefer paired session token over API key for /voice/* bearer
Symptom: in Voice mode over plain-LAN ws://, tap mic -> red banner
"Voice access expired - extend or re-pair with voice grants" even though
the Connections card shows API Server / Relay / Session all green and
chat works fine.

Root cause: RelayVoiceClient.resolveBearerToken() preferred the saved
Hermes API key (apiBearerTokenProvider) over the paired Relay session
token. The relay's _request_is_secure_enough_for_api_bearer guard in
plugin/relay/voice_auth.py correctly rejects API-bearer auth on /voice/*
over plaintext outside loopback/Tailscale, returning a 403 with body
"Hermes API bearer token requires HTTPS outside loopback or Tailscale,
or RELAY_ALLOW_INSECURE_API_BEARER=1". The client flattened every 403
to "Voice access expired" so the actual reason was hidden.

Fix (RelayVoiceClient.kt):
- Invert bearer precedence: session token first, API key as fallback.
  Once paired, the session is the credential the QR/pair handshake
  already established - it has no transport guard, so it works on any
  WSS transport the relay already accepts. API-key path stays for
  chat+voice-only installs that never paired.
- describeHttpError now reads the server's text/plain response body
  and appends it to the fallback message, so future 403s show the
  relay's real reason instead of a one-size-fits-all string.

Live-verified on a Samsung S25 Ultra (sideload flavor) paired over
ws://172.16.24.250:8767: pre-fix tap-mic-then-stop produced the red
banner and "transcribe failed: Voice access expired" in logcat; from-PC
curl http://172.16.24.250:8767/voice/config with a fake bearer returned
403 + the "Hermes API bearer token requires HTTPS..." body confirming
the insecure-bearer branch fired. Post-fix the transcribe/synthesize
round-trips succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:46:34 -04:00
Bailey DixonandClaude Opus 4.7 53b53d4caf chore(relay): finalize relay-v0.6.2 prep - split-track infra, dashboard CI, upstream_voice adapter
Bundles the relay-track work accumulated under [Unreleased]:

- Adds relay-v* release workflow + dashboard plugin CI
- Adds scripts/check-relay-version-sync.py; bump-relay-version.sh now
  keeps pyproject, plugin.yaml, and dashboard metadata in lockstep
- Re-syncs plugin/dashboard/{manifest,package,package-lock}.json back to
  0.6.1 ahead of the next bump
- Isolates upstream STT/TTS imports behind plugin.relay.upstream_voice
  so upstream voice-API drift has one patch point (no behavior change)
- Adds docs/upstream-integration-sync.md tracking which surfaces use
  upstream extension points vs relay-owned compatibility layers
- Tightens Relay CI paths, timeouts, and release action versions
- Ignores .scratch/ for ad-hoc debugging artifacts

Voice POC files (plugin/voice_lab/, docs/realtime-voice-poc.md,
scripts/voice-lab.ps1, plugin/tests/test_voice_lab.py) remain untracked.

Android voice bearer fix (RelayVoiceClient.kt) is intentionally NOT in
this commit - it ships as a separate v0.6.2 Android patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:45:28 -04:00
Bailey Dixon d133adfc50 chore(release): split relay release track 2026-05-06 16:29:07 -04:00
Bailey Dixon 16e93d92f4 merge: sync main into dev after v0.6.1 release 2026-05-05 22:29:12 -04:00
539 changed files with 116678 additions and 7012 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+67
View File
@@ -0,0 +1,67 @@
name: CI dashboard plugin
on:
push:
branches: [main, dev]
paths:
- "plugin/dashboard/**"
- "scripts/check-plugin-version-sync.py"
- "scripts/check-server-version-sync.py"
- ".github/workflows/ci-dashboard.yml"
pull_request:
branches: [main, dev]
paths:
- "plugin/dashboard/**"
- "scripts/check-plugin-version-sync.py"
- "scripts/check-server-version-sync.py"
- ".github/workflows/ci-dashboard.yml"
permissions:
contents: read
concurrency:
group: ci-dashboard-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
jobs:
build-and-test:
name: Build and test dashboard plugin
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
cache-dependency-path: plugin/dashboard/package-lock.json
- name: Install dashboard deps
working-directory: plugin/dashboard
run: npm ci
- name: Build dashboard bundle
working-directory: plugin/dashboard
run: npm run build
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Verify plugin-owned version metadata
run: python scripts/check-plugin-version-sync.py
- name: Install dashboard API test deps
run: pip install -r relay_server/requirements.txt fastapi httpx pytest requests
- name: Run dashboard API tests
run: python -m unittest plugin.dashboard.test_plugin_api
- name: Verify dashboard bundle outputs
run: |
test -s plugin/dashboard/dist/index.js
test -s plugin/dashboard/dist/style.css
grep -q "hr-modal-card" plugin/dashboard/dist/style.css
+31 -3
View File
@@ -1,4 +1,4 @@
name: CI desktop CLI
name: CI desktop
on:
push:
@@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
@@ -69,7 +69,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
@@ -86,3 +86,31 @@ jobs:
- name: --help
run: node bin/hermes-relay.js --help
tray-shell:
name: Tray shell checks
runs-on: windows-latest
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: desktop/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install deps
run: npm ci
- name: Cargo check tray shell
run: npm run tray:check
- name: Test tray shell
run: npm run tray:test
@@ -1,43 +1,72 @@
# Hermes-Relay — Python Relay CI Pipeline
# Hermes-Relay — Plugin CI Pipeline
#
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
# Python-affecting paths so Android-only changes don't spin up the
# plugin-affecting paths so Android-only changes don't spin up the
# Python toolchain.
#
# Pipeline: syntax-check -> unit-tests
# Pipeline: syntax-check -> focused plugin tests
name: CI — Relay
name: CI — Plugin
on:
push:
branches: [main, dev]
paths:
- "plugin/**"
- "plugin/__init__.py"
- "plugin/android_tool.py"
- "plugin/cli.py"
- "plugin/pair.py"
- "plugin/plugin.yaml"
- "plugin/dashboard/manifest.json"
- "plugin/dashboard/package.json"
- "plugin/dashboard/package-lock.json"
- "plugin/relay/**"
- "plugin/tools/**"
- "plugin/tests/**"
- "relay_server/**"
- "hermes_relay_bootstrap/**"
- "pyproject.toml"
- ".github/workflows/ci-relay.yml"
- "scripts/check-plugin-version-sync.py"
- "scripts/check-server-version-sync.py"
- "scripts/bump-plugin-version.sh"
- "scripts/bump-server-version.sh"
- ".github/workflows/ci-plugin.yml"
pull_request:
branches: [main, dev]
paths:
- "plugin/**"
- "plugin/__init__.py"
- "plugin/android_tool.py"
- "plugin/cli.py"
- "plugin/pair.py"
- "plugin/plugin.yaml"
- "plugin/dashboard/manifest.json"
- "plugin/dashboard/package.json"
- "plugin/dashboard/package-lock.json"
- "plugin/relay/**"
- "plugin/tools/**"
- "plugin/tests/**"
- "relay_server/**"
- "hermes_relay_bootstrap/**"
- "pyproject.toml"
- ".github/workflows/ci-relay.yml"
- "scripts/check-plugin-version-sync.py"
- "scripts/check-server-version-sync.py"
- "scripts/bump-plugin-version.sh"
- "scripts/bump-server-version.sh"
- ".github/workflows/ci-plugin.yml"
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
concurrency:
group: ci-relay-${{ github.ref }}
group: ci-plugin-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
jobs:
# ──────────────────────────────────────────────
# Python Relay — py_compile syntax sanity
# Python Plugin — py_compile syntax sanity
# ──────────────────────────────────────────────
syntax-check:
name: Syntax check (Python)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -50,27 +79,33 @@ jobs:
- name: Install dependencies
run: pip install -r relay_server/requirements.txt
- name: Syntax check (plugin.relay — canonical location)
- name: Syntax check (plugin relay — canonical location)
run: |
python -m py_compile plugin/relay/server.py
python -m py_compile plugin/relay/channels/terminal.py
python -m py_compile plugin/relay/channels/chat.py
python -m py_compile plugin/relay/channels/bridge.py
python -m py_compile plugin/relay/voice.py
python -m py_compile plugin/relay/upstream_voice.py
- name: Syntax check (relay_server shim)
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
- name: Validate Plugin version metadata
run: python scripts/check-plugin-version-sync.py
# ──────────────────────────────────────────────
# Python Relay — unittest discover
# Python Plugin — focused route/auth/session tests
#
# Tests are ADVISORY on dev (push or PR) so WIP commits don't block the
# merge queue. Strict on main — the dev → main release-merge PR surfaces
# any real failures before release.
# ──────────────────────────────────────────────
unit-tests:
name: Unit tests (Python)
name: Focused Plugin tests (Python)
needs: syntax-check
runs-on: ubuntu-latest
timeout-minutes: 10
# Advisory on dev, strict on main. Evaluates to false (= strict) for
# pushes to main and PRs whose base branch is main; true (= advisory)
# for everything else (dev pushes, dev-targeted PRs, feature branches).
@@ -84,14 +119,14 @@ jobs:
with:
python-version: "3.11"
# conftest.py imports `pytest` and `responses` at collection time.
# `unittest discover` walks conftest.py like any other module, so both
# must be importable even though none of the tests themselves use
# pytest fixtures (they're all stdlib unittest.TestCase).
- name: Install dependencies
run: |
pip install -r relay_server/requirements.txt
pip install pytest responses
- name: Run unit tests
run: python -m unittest discover plugin/tests
- name: Run focused Plugin tests
run: |
python -m pytest \
plugin/tests/test_relay_security.py \
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py
+1 -1
View File
@@ -2,7 +2,7 @@
# branch protection on `main` has a check name it can rely on, regardless
# of which paths the PR touches.
#
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-relay.yml`,
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-plugin.yml`,
# `ci-desktop.yml`) are scoped via `paths:` filters so a docs-only or
# desktop-only PR doesn't spin up the Android toolchain. Branch protection's
# "required status checks" treat a check that doesn't run as failing — so
+48 -2
View File
@@ -17,21 +17,67 @@ jobs:
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
env:
# Any dev -> main PR is, by the branching model, the aggregate release PR
# (main only ever receives release merges from dev). Detect it by base+head
# alone — a title-format match (e.g. "release:") is fragile and silently
# let a "Release v1.0.0 …"-titled PR run the full review and time out.
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' }}
# Bot-authored PRs such as Dependabot do not receive the same secret
# surface as human-authored PRs, and Claude Code rejects bot actors unless
# explicitly allow-listed. Keep the required check green with a no-op and
# rely on the dependency CI/status checks for those PRs.
IS_BOT_PR: ${{ github.event.pull_request.user.type == 'Bot' }}
steps:
- name: Skip aggregate release PR review
if: env.IS_RELEASE_PR == 'true'
run: |
echo "Skipping Claude Code Review for aggregate dev -> main release PR."
echo "Feature work is reviewed before it lands on dev; release PRs are gated by CI and release metadata checks."
- name: Skip bot-authored PR review
if: env.IS_BOT_PR == 'true'
run: |
echo "Skipping Claude Code Review for bot-authored PR."
echo "Bot PRs are gated by Required checks plus their path-specific CI jobs."
- name: Checkout repository
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
uses: actions/checkout@v4
with:
fetch-depth: 1
# Depth 2 includes the pull_request merge commit's first parent, which
# lets the next step detect whether this PR changes the workflow file.
fetch-depth: 2
- name: Detect Claude review workflow changes
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
id: changed-workflow
shell: bash
run: |
if git rev-parse --verify HEAD^1 >/dev/null 2>&1 &&
git diff --name-only HEAD^1 HEAD | grep -Fxq ".github/workflows/claude-code-review.yml"; then
echo "claude_review_workflow=true" >> "$GITHUB_OUTPUT"
else
echo "claude_review_workflow=false" >> "$GITHUB_OUTPUT"
fi
- name: Skip Claude review workflow self-change
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow == 'true'
run: |
echo "Skipping Claude Code Review because this PR changes the review workflow itself."
echo "The Claude action requires this workflow file to match the default branch before it can exchange the app token."
- name: Run Claude Code Review
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow != 'true'
timeout-minutes: 15
id: claude-review
uses: anthropics/claude-code-action@v1
with:
+4 -4
View File
@@ -36,7 +36,7 @@ jobs:
fetch-depth: 0 # Full history for lastUpdated timestamps
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
@@ -51,10 +51,10 @@ jobs:
working-directory: user-docs
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v5
with:
path: user-docs/.vitepress/dist
@@ -68,4 +68,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
@@ -1,15 +1,16 @@
# Hermes-Relay — Release Pipeline
# Hermes-Relay-Android — Release Pipeline
#
# Triggered when a version tag (v*) is pushed.
# Triggered when an Android release tag (android-v*) is pushed.
# Validates the tag matches the app version in libs.versions.toml,
# runs CI checks, builds a release APK, and creates a GitHub Release.
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
# GitHub Release. Plugin/Python package releases use plugin-v* tags.
name: Release
name: Release Android
on:
push:
tags:
- "v*"
- "android-v*"
permissions:
contents: write
@@ -26,7 +27,7 @@ jobs:
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
run: echo "version=${GITHUB_REF#refs/tags/android-v}" >> $GITHUB_OUTPUT
- name: Verify version sync
run: |
@@ -77,6 +78,7 @@ jobs:
name: Build & Publish Release
needs: [validate, ci]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
@@ -131,9 +133,10 @@ jobs:
cat SHA256SUMS.txt
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
name: v${{ needs.validate.outputs.version }}
name: Hermes-Relay-Android v${{ needs.validate.outputs.version }}
tag_name: android-v${{ needs.validate.outputs.version }}
body_path: RELEASE_NOTES.md
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
# Attach all four flavored artifacts — users sideload the
@@ -148,11 +151,43 @@ jobs:
app/build/outputs/bundle/*Release/*.aab
app/build/outputs/SHA256SUMS.txt
- name: Upload to Play Console (production draft)
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
# Runs only when the Play service-account secret is configured AND this is
# a stable tag (prereleases — versions containing a dash — are skipped so
# an `-rc.N` build never lands on the production listing). HERMES_KEYSTORE_PATH
# was exported into $GITHUB_ENV by the "Decode release keystore" step above
# and persists across steps in this job, so the AAB is release-signed.
#
# `publishGooglePlayReleaseBundle` is the flavor-scoped task — only the
# googlePlay AAB is uploaded (sideload is disabled via playConfigs in
# app/build.gradle.kts). The play{} block pins releaseStatus = DRAFT, so the
# build lands on the Production track as a DRAFT: CI does the upload, a human
# clicks "Start rollout" in Play Console. A bad tag can never auto-go-live.
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON != '' && !contains(needs.validate.outputs.version, '-') }}
run: |
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
./gradlew publishGooglePlayReleaseBundle --track=production
rm -f play-service-account.json
- name: Play upload skipped (no secret)
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON == '' }}
run: |
echo "ℹ️ PLAY_SERVICE_ACCOUNT_JSON not set — skipped Play Console upload." \
"GitHub Release artifacts are still published; upload to Play manually" \
"(see RELEASE.md §5)." >> "$GITHUB_STEP_SUMMARY"
- name: Release summary
env:
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
run: |
echo "## Release v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
echo "## Hermes-Relay-Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
+222
View File
@@ -0,0 +1,222 @@
name: Release CLI
on:
push:
tags: ['cli-v*']
permissions:
contents: write
jobs:
build-cli-binaries:
name: Build cross-platform CLI binaries via Bun compile
runs-on: ubuntu-latest
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v4
- name: Setup Node.js (for npm ci + tsc)
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: desktop/package-lock.json
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.x'
- name: Install deps
run: npm ci
- name: Type-check
run: npm run type-check
- name: Build dist/ (tsc)
run: npm run build
- name: Print Bun version (diagnostics)
run: bun --version
- name: Prepare binary output dir
run: mkdir -p dist/bin
# Keep the package.json scripts as the single source of truth for Bun
# compile flags so release and local smoke builds cannot diverge.
- name: Build Windows x64
run: npm run build:bin:win
- name: Build Linux x64
run: npm run build:bin:linux
- name: Build macOS x64
run: npm run build:bin:mac-x64
- name: Build macOS arm64
run: npm run build:bin:mac-arm
- name: Size guard (<150 MB each)
run: |
set -e
for f in dist/bin/hermes-relay-*; do
sz=$(stat -c%s "$f")
mb=$(( sz / 1024 / 1024 ))
echo " $f - ${mb} MB"
if [ "$sz" -gt 157286400 ]; then
echo "FAIL: $f exceeds 150 MB - Bun likely shipped a debug build or we added a large dep."
exit 1
fi
done
- name: Smoke-test Linux binary
run: |
set -e
chmod +x dist/bin/hermes-relay-linux-x64
for cmd in --version --help doctor; do
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1 || true)
exit_code=$?
if [ -z "$out" ] || [ ${#out} -lt 10 ]; then
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' produced no output (exit=$exit_code)"
echo "Raw output was: [$out]"
exit 1
fi
echo " smoke OK: $cmd -> $(echo "$out" | head -1)"
done
- name: Upload CLI release assets
uses: actions/upload-artifact@v4
with:
name: cli-binaries
path: |
desktop/dist/bin/hermes-relay-win-x64.exe
desktop/dist/bin/hermes-relay-linux-x64
desktop/dist/bin/hermes-relay-darwin-x64
desktop/dist/bin/hermes-relay-darwin-arm64
retention-days: 7
build-windows-tray-installer:
name: Build Windows tray installer
runs-on: windows-latest
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: desktop/package-lock.json
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.x'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install deps
run: npm ci
- name: Type-check
run: npm run type-check
- name: Build dist/ (tsc)
run: npm run build
- name: Test tray shell
run: npm run tray:test
- name: Build tray installer
run: npm run tray:build
- name: Normalize installer asset name
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist/tray | Out-Null
$installer = Get-ChildItem -Path tray/src-tauri/target/release/bundle/nsis -Filter '*_x64-setup.exe' | Select-Object -First 1
if (-not $installer) { throw 'NSIS installer was not produced' }
Copy-Item -Force $installer.FullName dist/tray/hermes-relay-desktop-windows-x64-setup.exe
- name: Smoke-test tray exe launch
shell: pwsh
run: |
$home = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
New-Item -ItemType Directory -Force -Path $home | Out-Null
$env:USERPROFILE = $home
$env:HOME = $home
$proc = Start-Process -FilePath tray/src-tauri/target/release/hermes-relay-desktop.exe -WindowStyle Hidden -PassThru
Start-Sleep -Seconds 5
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
Stop-Process -Id $proc.Id -Force
Write-Host "tray launch smoke OK pid=$($proc.Id)"
- name: Upload Windows tray release asset
uses: actions/upload-artifact@v4
with:
name: cli-windows-tray-installer
path: desktop/dist/tray/hermes-relay-desktop-windows-x64-setup.exe
retention-days: 7
publish-release:
name: Publish GitHub Release
runs-on: ubuntu-latest
needs:
- build-cli-binaries
- build-windows-tray-installer
steps:
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
# (the other publish-release steps only consume downloaded build artifacts).
- uses: actions/checkout@v4
- name: Extract CLI version
id: version
run: echo "version=${GITHUB_REF_NAME#cli-v}" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v4
with:
path: release-assets
- name: Generate SHA256SUMS
run: |
set -e
find release-assets -type f ! -name SHA256SUMS.txt -print0 \
| sort -z \
| xargs -0 sha256sum \
| sed -E 's#release-assets/[^/]+/##' > release-assets/SHA256SUMS.txt
cat release-assets/SHA256SUMS.txt
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
# (cli-v0.3.0) so the install/pin commands stay accurate without manual edits.
- name: Render release notes
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ github.ref_name }}
run: |
sed -e "s/__VERSION__/${VERSION}/g" -e "s/__TAG__/${TAG}/g" \
CLI_RELEASE_NOTES.md > cli_release_notes_rendered.md
echo "=== rendered release body ===" && cat cli_release_notes_rendered.md
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay-CLI v${{ steps.version.outputs.version }}
tag_name: ${{ github.ref_name }}
draft: false
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
fail_on_unmatched_files: true
body_path: cli_release_notes_rendered.md
files: |
release-assets/cli-binaries/hermes-relay-win-x64.exe
release-assets/cli-binaries/hermes-relay-linux-x64
release-assets/cli-binaries/hermes-relay-darwin-x64
release-assets/cli-binaries/hermes-relay-darwin-arm64
release-assets/cli-windows-tray-installer/hermes-relay-desktop-windows-x64-setup.exe
release-assets/SHA256SUMS.txt
-146
View File
@@ -1,146 +0,0 @@
name: Release desktop CLI
on:
push:
tags: ['desktop-v*']
permissions:
contents: write
jobs:
build-binaries:
name: Build cross-platform binaries via Bun compile
runs-on: ubuntu-latest
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v4
- name: Setup Node.js (for npm ci + tsc)
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: desktop/package-lock.json
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.x'
- name: Install deps
run: npm ci
- name: Type-check
run: npm run type-check
- name: Build dist/ (tsc)
run: npm run build
- name: Print Bun version (diagnostics)
run: bun --version
- name: Prepare binary output dir
run: mkdir -p dist/bin
# NOTE: delegate to the package.json scripts so there's a single source
# of truth for Bun compile flags. Previously these steps inlined their
# own flag list, which silently diverged from `npm run build:bin:*` and
# made the "drop --bytecode" fix ineffective on desktop-v0.3.0-alpha.2.
- name: Build Windows x64
run: npm run build:bin:win
- name: Build Linux x64
run: npm run build:bin:linux
- name: Build macOS x64
run: npm run build:bin:mac-x64
- name: Build macOS arm64
run: npm run build:bin:mac-arm
- name: Size guard (<150 MB each)
run: |
set -e
for f in dist/bin/hermes-relay-*; do
sz=$(stat -c%s "$f")
mb=$(( sz / 1024 / 1024 ))
echo " $f — ${mb} MB"
if [ "$sz" -gt 157286400 ]; then
echo "FAIL: $f exceeds 150 MB — Bun likely shipped a debug build or we added a large dep."
exit 1
fi
done
# Smoke-test the Linux binary (runner platform) before publishing.
# Catches the two failure modes we've burned alpha tags on:
# - startup segfault (exit != 0, no output)
# - silent exit 0 with zero output (main() never invoked)
# We can only smoke the Linux target without a cross-platform matrix;
# Windows/macOS smoke would need their own runners — tracked as an
# alpha.5+ hardening item.
- name: Smoke-test Linux binary
run: |
set -e
chmod +x dist/bin/hermes-relay-linux-x64
for cmd in --version --help doctor; do
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1 || true)
exit_code=$?
if [ -z "$out" ] || [ ${#out} -lt 10 ]; then
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' produced no output (exit=$exit_code)"
echo "Raw output was: [$out]"
exit 1
fi
echo " smoke OK: $cmd → $(echo "$out" | head -1)"
done
- name: Generate SHA256SUMS
working-directory: desktop/dist/bin
run: |
sha256sum hermes-relay-* > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
draft: false
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
fail_on_unmatched_files: true
body: |
# Hermes-Relay Desktop CLI — ${{ github.ref_name }}
**Experimental phase.** Binaries are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. See the install scripts for the `Unblock-File` / `xattr -dr` escape hatches.
## Install
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
**macOS / Linux:**
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
Pin this specific release with `HERMES_RELAY_VERSION=${{ github.ref_name }}`.
## Verify
```
hermes-relay --version
hermes-relay pair --remote ws://<host>:8767
hermes-relay shell
```
See [Desktop CLI docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
files: |
desktop/dist/bin/hermes-relay-win-x64.exe
desktop/dist/bin/hermes-relay-linux-x64
desktop/dist/bin/hermes-relay-darwin-x64
desktop/dist/bin/hermes-relay-darwin-arm64
desktop/dist/bin/SHA256SUMS.txt
+111
View File
@@ -0,0 +1,111 @@
name: Release Plugin
on:
push:
tags:
- "plugin-v*"
permissions:
contents: write
jobs:
validate:
name: Validate Plugin release
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v6
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/plugin-v}" >> "$GITHUB_OUTPUT"
- name: Verify Plugin version sync
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
env:
TAG_VERSION: ${{ steps.version.outputs.version }}
test:
name: Test Plugin package
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install test dependencies
run: |
pip install -r relay_server/requirements.txt
pip install pytest responses
- name: Syntax check
run: |
python -m py_compile plugin/relay/server.py
python -m py_compile plugin/relay/voice.py
python -m py_compile plugin/relay/upstream_voice.py
python -m py_compile plugin/relay/voice_auth.py
python -m py_compile plugin/tools/android_tool.py
python -m py_compile plugin/tools/desktop_tool.py
python -m py_compile relay_server/__init__.py relay_server/__main__.py
- name: Run focused Plugin tests
run: |
python -m pytest \
plugin/tests/test_relay_security.py \
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py
package:
name: Build and publish Plugin package
needs: [validate, test]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Build wheel and sdist
run: |
pip install build
python -m build
- name: Generate checksums
run: |
cd dist
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
# Render PLUGIN_RELEASE_NOTES.md (hand-written per release) into the GitHub
# Release body, substituting the version token so the Install command stays
# accurate without a manual edit. The file is the single source of the notes;
# see RELEASE.md "Plugin / Python package release".
- name: Render release notes
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
sed "s/__VERSION__/${VERSION}/g" PLUGIN_RELEASE_NOTES.md > release_notes_rendered.md
echo "=== rendered release body ===" && cat release_notes_rendered.md
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay-Plugin v${{ needs.validate.outputs.version }}
tag_name: plugin-v${{ needs.validate.outputs.version }}
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
fail_on_unmatched_files: true
body_path: release_notes_rendered.md
files: |
dist/*.whl
dist/*.tar.gz
dist/SHA256SUMS.txt
+11
View File
@@ -26,6 +26,7 @@ local.properties
/app/build/
/relay-core/build/
/relay-ui/build/
/ui-preview/build/
/quest/build/
/app/release/
*.apk
@@ -46,6 +47,13 @@ certs/
# Local tools
.subframe/
voice-lab-runs/
realtime-voice-runs/
realtime-agent-runs/
voice_rec_*.wav
# Ad-hoc debugging artifacts (logcat dumps, screenshots, UI XMLs)
.scratch/
# VitePress
user-docs/.vitepress/cache/
@@ -75,3 +83,6 @@ keystore.properties
# Desktop TUI smoke harness runtime artifacts
.smoke-relay.pid
.smoke-relay.log
# Generated tray frontend vendor assets copied from desktop/node_modules
desktop/tray/ui/vendor/
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"mobile-mcp": {
"command": "npx",
"args": ["-y", "@mobilenext/mobile-mcp@latest"]
}
}
}
+44 -386
View File
@@ -1,386 +1,44 @@
<!-- @subframe-version 0.15.1-beta -->
<!-- @subframe-managed -->
# hermes-android - SubFrame Project
This project is managed with **SubFrame**. AI assistants should follow the rules below to keep documentation up to date.
> **Note:** This file is named `AGENTS.md` to be AI-tool agnostic. CLAUDE.md and GEMINI.md contain a reference to this file.
---
## Core Working Principle
**Only do what the user asks.** Do not go beyond the scope of the request.
- Implement exactly what the user requested — nothing more, nothing less.
- Do not change business logic, flow, or architecture unless the user explicitly asks for it.
- If a user asks for a design change, only change the design. Do not refactor, restructure, or modify functionality alongside it.
- If you have additional suggestions or improvements, **present them as suggestions** to the user. Never implement them without approval.
- The user's request must be completed first. Additional ideas come after, as proposals.
---
## Relationship to Native AI Tools
SubFrame **enhances** native AI coding tools — it does not replace them.
**Claude Code** works exactly as normal. Built-in features (`/init`, `/commit`, `/review-pr`, `/compact`, `/memory`, CLAUDE.md) are fully supported. CLAUDE.md is Claude Code's native instruction file — users can add their own tool-specific instructions freely. SubFrame adds a small backlink reference pointing to this AGENTS.md file using HTML comment markers (`<!-- SUBFRAME:BEGIN -->` / `<!-- SUBFRAME:END -->`). SubFrame will never overwrite user content in CLAUDE.md.
**Gemini CLI** works exactly as normal. Built-in features (`/init`, `/model`, `/memory`, `/compress`, `/settings`, GEMINI.md) are fully supported. GEMINI.md is Gemini CLI's native instruction file — same backlink approach as CLAUDE.md. Users can add their own instructions freely and SubFrame won't overwrite them.
**Codex CLI** gets SubFrame context via a wrapper script at `.subframe/bin/codex` that injects AGENTS.md as an initial prompt.
**This file (AGENTS.md)** contains SubFrame-specific rules that apply across all tools:
- Sub-Task management (`.subframe/tasks/*.md`, index at `.subframe/tasks.json`)
- Codebase mapping (`.subframe/STRUCTURE.json`)
- Context preservation (`.subframe/PROJECT_NOTES.md`)
- Internal docs and changelog (`.subframe/docs-internal/`)
- Session notes and decision tracking
---
## Session Start
**Read these files at the start of each session:**
1. **`.subframe/STRUCTURE.json`** — Module map, file locations, architecture notes
2. **`.subframe/PROJECT_NOTES.md`** — Project vision, past decisions, session notes
3. **`.subframe/tasks.json`** — Sub-task index (pending, in-progress, completed)
This gives you full project context before making any changes. The session-start hook (if configured) automatically injects pending/in-progress sub-tasks into your context, but you should still read these files for deeper understanding.
### Concurrent Work & Worktrees
Before making changes, check whether other AI sessions or agent teams are already working on this repository. Signs of concurrent work include:
- In-progress sub-tasks you didn't start (check `.subframe/tasks.json`)
- Recent uncommitted changes in `git status` that aren't yours
- Lock files or active worktrees (`git worktree list`)
**If concurrent work is detected**, ask the user: "Another session appears to be working on this project. Should I use a git worktree to avoid conflicts?"
**Git worktrees** create an isolated copy of the repo on a separate branch, allowing parallel work without merge conflicts:
- Each worktree has its own working directory and branch
- Changes in one worktree don't affect others until merged
- Use worktrees when multiple agents or sessions work on different features simultaneously
**When to suggest a worktree:**
- Agent teams spawning multiple workers on the same repo
- User asks to work on a feature while another is in progress
- The session-start hook flags concurrent sessions
**When worktrees are NOT needed:**
- Single-session work with no concurrent agents
- Read-only exploration or research tasks
- Quick fixes that won't conflict with in-progress work
---
## Hooks (Automatic Awareness)
SubFrame can configure project-level hooks that automate sub-task awareness. These hooks fire automatically — no manual intervention needed.
| Hook | When it fires | What it does |
|------|---------------|--------------|
| **SessionStart** | Startup, resume, after compaction | Injects pending/in-progress sub-tasks into context |
| **UserPromptSubmit** | Each user prompt | Fuzzy-matches prompt against pending sub-tasks, suggests starting a match |
| **Stop** | When AI finishes responding | Reminds about in-progress sub-tasks; flags untracked work if source files changed |
| **PreToolUse** | Before tool execution | Project-specific guardrails (if configured) |
| **PostToolUse** | After tool execution | Project-specific follow-ups (if configured) |
These hooks ensure sub-task awareness even after context compaction. Hook configuration lives in `.claude/settings.json`.
---
## Skills (Slash Commands)
SubFrame provides optional slash commands for AI coding tools that support them (e.g., Claude Code):
| Skill | Purpose |
|-------|---------|
| `/sub-tasks` | Interactive sub-task management — list, start, complete, add, archive |
| `/sub-docs` | Sync all SubFrame documentation after feature work (changelog, CLAUDE.md, PROJECT_NOTES, STRUCTURE) |
| `/sub-audit` | Code review + documentation audit on recent changes |
| `/onboard` | Bootstrap SubFrame files from existing codebase context |
Skills are deployed to `.claude/skills/` and enhance the workflow — but direct file editing always works as a fallback. If your AI tool doesn't support skills, follow the manual instructions in each section below.
---
## Sub-Task Management
> **Terminology:** "Sub-Tasks" are SubFrame's project task tracking system. The name plays on "Sub" from SubFrame and disambiguates from Claude Code's internal todo tools. When the user says "sub-task", they mean this system.
### Sub-Task File Format
Each sub-task lives in its own markdown file at `.subframe/tasks/<id>.md` with YAML frontmatter:
```yaml
---
id: task-abc12345
title: Short and clear title (max 60 characters)
status: pending | in_progress | completed
priority: high | medium | low
category: feature | fix | refactor | docs | test | chore
description: AI's detailed explanation — what, how, which files affected
userRequest: User's original prompt/request — copy exactly
acceptanceCriteria: When is this task done? Concrete testable criteria
blockedBy: [] # task IDs this depends on
blocks: [] # task IDs that depend on this
createdAt: ISO timestamp
updatedAt: ISO timestamp
completedAt: ISO timestamp | null
---
## Notes
[YYYY-MM-DD] Session notes, alternatives considered, dependencies.
## Steps
- [x] Completed step
- [ ] Pending step
```
A generated index is kept at `.subframe/tasks.json` for hooks and quick lookups. After creating or modifying task `.md` files, regenerate the index by reading all `.subframe/tasks/*.md` files (excluding `archive/`) and building the JSON with tasks grouped by status.
### Sub-Task Recognition Rules
**These ARE SUB-TASKS:**
- When the user requests a feature or change
- Decisions like "Let's do this", "Let's add this", "Improve this"
- Deferred work: "We'll do this later", "Let's leave it for now"
- Gaps or improvement opportunities discovered while coding
- Situations requiring bug fixes
**These are NOT SUB-TASKS:**
- Error messages and debugging sessions
- Questions, explanations, information exchange
- Temporary experiments and tests
- Work already completed and closed
- Instant fixes (like typo fixes)
### Sub-Task Creation Flow
1. Detect sub-task patterns during conversation
2. **Check existing sub-tasks first** — read `.subframe/tasks.json` to avoid duplicates
3. Ask the user: "I identified these sub-tasks from our conversation, should I add them?"
4. If approved, create `.subframe/tasks/<id>.md` with all required frontmatter fields
5. Regenerate the `.subframe/tasks.json` index
### Sub-Task Content Rules
**title:** Short, action-oriented
- OK: "Add tasks button to terminal toolbar"
- Bad: "Tasks"
**description:** AI's detailed technical explanation
- What will be done, how, which files affected
- Minimum 2-3 sentences
**userRequest:** User's original words — copy verbatim for context preservation
**acceptanceCriteria:** Concrete, testable completion criteria
### Sub-Task Status Updates
**Before starting any work**, check `.subframe/tasks.json` for an existing sub-task that matches. If found, set it to `in_progress` — do not create a duplicate.
- `pending` → `in_progress` — immediately when you begin working (update `updatedAt`)
- `in_progress` → `completed` — when done and verified (set `completedAt`, update `updatedAt`)
- `completed` → `pending` — when reopening, add a note explaining why
- After commit: check and update the status of all related sub-tasks
- **Incomplete work:** If partially done at session end, leave as `in_progress` and add a notes entry
### Sub-Task Lifecycle
- If a sub-task grows beyond its original scope, split it — create new sub-tasks and reference the parent ID in notes
- Cross-reference relevant commit hashes or PR numbers in notes
- Update the description if the approach changes significantly
### Priority Guidelines
- **high** — Blocking other work or explicitly flagged as urgent by the user
- **medium** — Normal feature work and standard bug fixes
- **low** — Nice-to-have improvements, deferred items, minor polish
---
## .subframe/PROJECT_NOTES.md Rules
### When to Update?
- When an important architectural decision is made
- When a technology choice is made
- When an important problem is solved and the solution method is noteworthy
- When an approach is determined together with the user
### Format
Free format. Date + title is sufficient:
```markdown
### [YYYY-MM-DD] Topic title
Conversation/decision as is, with its context...
```
### Update Flow
- Update immediately after a decision is made
- You can add without asking the user (for important decisions)
- You can accumulate small decisions and add them in bulk
### Organization Rules
- Keep **"Project Vision"** at the top, then **"Session Notes"** in chronological order
- Notes should capture the **why** (decisions, trade-offs, alternatives rejected), not the **what** (code structure belongs in STRUCTURE.json)
- When the same topic spans multiple sessions, consolidate related notes under the original heading rather than creating duplicates
- When notes grow beyond ~500 lines, consider archiving older session notes or grouping by month
---
## Context Preservation (Automatic Note Taking)
SubFrame's core purpose is to prevent context loss. Capture important moments and ask the user.
### When to Ask?
Ask the user: **"Should I add this to .subframe/PROJECT_NOTES.md?"** when:
- A sub-task is successfully completed
- An important architectural/technical decision is made
- A bug is fixed and the solution method is noteworthy
- "Let's do this later" is said (also add as a sub-task)
- A new pattern or best practice is discovered
### Importance Threshold
**Would it take more than 5 minutes to re-derive or re-explain in a future session?** If yes, capture it.
**Always capture:** Architecture decisions, technology choices, approach changes, user preferences discovered during work.
**Never capture:** Routine debugging steps, simple config changes, typo fixes.
**Note failed approaches too** — a brief "We tried X, it didn't work because Y" prevents future re-exploration of dead ends.
### Completion Detection
Pay attention to these signals:
- User approval: "okay", "done", "it worked", "nice", "fixed", "yes"
- Moving from one topic to another
- User continuing after build/run succeeds
### How to Add?
1. **DON'T write a summary** — Add the conversation as is, with its context
2. **Add date** — In `### [YYYY-MM-DD] Title` format
3. **Add to Session Notes section** — At the end of PROJECT_NOTES.md
### When NOT to Ask
- For every small change (it becomes spam)
- Typo fixes, simple corrections
- If the user already said "no" or "not needed", don't ask again for that topic
### If User Says "No"
No problem, continue. The user can also say what they consider important themselves: "add this to notes"
---
## .subframe/STRUCTURE.json Rules
**This file is the map of the codebase.**
### When to Update?
- When a new file/folder is created
- When a file/folder is deleted or moved
- When module dependencies change
- When an IPC channel is added or changed
- When an important architectural pattern is discovered (architectureNotes)
### Full Schema
```json
{
"modules": {
"main/moduleName": {
"file": "src/main/moduleName.ts",
"description": "What this module does",
"exports": ["init", "loadData"],
"depends": ["fs", "path", "shared/ipcChannels"],
"functions": {
"init": { "line": 15 },
"loadData": { "line": 42 }
}
}
},
"ipcChannels": {
"CHANNEL_NAME": {
"direction": "renderer → main",
"handler": "main/moduleName"
}
},
"architectureNotes": {
"topicName": {
"issue": "Description of the pattern or concern",
"solution": "How it was resolved"
}
}
}
```
### Update Rules
- The pre-commit hook (if configured) auto-updates STRUCTURE.json when source files in `src/` are committed
- When deleting files, remove their entries from `modules` and update any `depends` arrays that referenced them
- When adding IPC channels, also add them to the `ipcChannels` section with `direction` and `handler`
- `architectureNotes` is for **structural patterns** (e.g., circular dependency workarounds, init ordering). Use PROJECT_NOTES.md for **decisions and session context**
- If function line numbers drift significantly after edits, re-run the pre-commit hook or update manually
---
## .subframe/docs-internal/ Directory
This directory holds project documentation that doesn't belong in the root:
| File | Purpose |
|------|---------|
| `changelog.md` | Track changes under `## [Unreleased]`, grouped by Added/Changed/Fixed/Removed |
| `*.md` (ADRs) | Architecture Decision Records for significant design choices |
**What goes here:** Changelog entries, architecture decision records, internal reference docs.
**What does NOT go here:** User-facing docs (those go in `docs/` or project root), task files (those go in `.subframe/tasks/`).
---
## .subframe/QUICKSTART.md Rules
### When to Update?
- When installation steps change
- When new requirements are added
- When important commands change
---
## Before Ending Work
After significant work (code changes, architecture decisions), verify SubFrame files are in sync:
1. **Sub-Tasks** — Was this work tracked? Check `.subframe/tasks.json` → create/complete as needed
2. **PROJECT_NOTES.md** — Any decisions worth preserving? Ask the user
3. **Changelog** — Does `.subframe/docs-internal/changelog.md` reflect the changes?
4. **STRUCTURE.json** — Source files changed? The pre-commit hook handles this automatically if configured; otherwise update manually
The stop hook (if configured) will flag untracked work automatically.
---
## General Rules
1. **Language:** Write documentation in English (except code examples)
2. **Date Format:** ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)
3. **After Commit:** Check sub-tasks (`.subframe/tasks/*.md`) and `.subframe/STRUCTURE.json`
4. **Session Start:** Read STRUCTURE.json, PROJECT_NOTES.md, and tasks.json before making changes
5. **Don't Duplicate:** Always check existing sub-tasks before creating new ones
---
*This file was automatically created by SubFrame.*
*Creation date: 2026-04-14*
<!-- subframe-template-version: 1 -->
# AGENTS.md
Universal agent instructions for **Hermes-Relay**. This is the entry point for any
coding agent (Claude Code, Codex, Cursor, etc.).
## Read this first
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
architecture, the upstream Hermes API reference, repository layout, per-language
code style, the dev loop, and the Key Files map. Read it before touching code,
then `docs/spec.md` and `docs/decisions.md`.
- Release process → **[RELEASE.md](RELEASE.md)**
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
## Non-negotiables (the short list)
- **Standard path = vanilla upstream only.** The default (no-plugin) connection —
chat via the API server, standard voice via the Hermes dashboard — must work
against unmodified upstream hermes-agent. Server-side needs go through upstream
PRs or the optional relay plugin, never fork patches.
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
## Public-repo writing hygiene
Everything committed is public. In CHANGELOG, DEVLOG, README, docs, and release
notes:
- **No personal names** — attribute impersonally; identity lives in git + the
signing cert.
- **No private infrastructure** — real hostnames/IPs, internal deployment names,
`~/SYSTEM.md`. (Generic example IPs in setup docs are fine.)
- **No AI/assistant process self-narration** ("I should have…", course
corrections) — state the technical conclusion only.
- **No internal jargon or fork/branch plumbing** in user-facing notes.
- **CHANGELOG** uses Keep-a-Changelog grouping; condense the version block to
crisp public bullets at release-prep (see RELEASE.md §2 "Scrub for public
distribution"). **DEVLOG** is a depersonalized, factual engineering log.
+209 -15
View File
@@ -6,6 +6,196 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [1.1.0] - 2026-06-16
### Added
- **Automated Play Console upload on release.** When a `PLAY_SERVICE_ACCOUNT_JSON` secret is configured, pushing a stable `android-v*` tag uploads the `googlePlay` App Bundle to the Production track as a draft (a human still starts the rollout). Prereleases are skipped, and the `sideload` flavor is structurally blocked from ever publishing to Play. Without the secret, the release builds publish to GitHub Releases exactly as before.
- **Desktop UI preview harness (`:ui-preview`).** A non-shipped Compose for Desktop module renders presentational composables in a window on the PC with Compose Hot Reload, for fast UI iteration without a device build/install loop. It reuses the shared sphere algorithm as its single source of truth.
- **Plugin: guided env-key setup.** The relay plugin declares its optional voice-provider keys (`XAI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`) in its manifest, so `hermes plugins install` prompts for them (masked, with a "get yours" link) instead of hand-editing `.env`. The standard no-plugin path needs none.
- **Plugin: native install path.** Tools-only setups can install via `hermes plugins install Codename-11/hermes-relay/plugin`; the full relay still uses the curl `install.sh`.
- **`/relay` slash commands.** `relay status · devices · pair` usable mid-conversation from any platform (CLI / Discord / TUI).
- **Dashboard relay-status widget.** A `Relay · connected / offline / unpaired` badge in the dashboard header, visible on every page.
- **Session-start relay health check.** A minimal, fully-guarded `on_session_start` hook records relay reachability without slowing the gateway.
### Changed
- **Release names normalized by surface.** Future GitHub Releases are named `Hermes-Relay-Android`, `Hermes-Relay-Plugin`, and `Hermes-Relay-CLI`, with future tags on `android-v*`, `plugin-v*`, and `cli-v*`. The CLI installer and updater still understand historical `desktop-v*` prereleases during the migration.
- **Per-surface release notes.** Plugin and CLI GitHub Releases now use hand-written `PLUGIN_RELEASE_NOTES.md` / `CLI_RELEASE_NOTES.md` files (Summary + Added/Changed/Fixed + Install/Verify) — the same format as Android's `RELEASE_NOTES.md` — instead of static boilerplate baked into the workflow. The release workflows substitute the version into the install commands automatically.
- **Settings screen overhaul (Android).** Status pills are now exception-only — they appear only when a surface needs attention and stay quiet when healthy. The Power tools section shows a single state-aware **Plugin active / required / offline** badge instead of an identical "Relay paired" chip on every card. Connections moved to the top (above the Hermes section), Diagnostics + Developer options moved into the App section, the status chips were restyled to match the app's translucent-bordered language, and the brand blue was deepened.
### Fixed
- **Force-close on connect when the stored credential keyset was corrupt.** A corrupt encrypted token store (which can happen after an app upgrade or device restore) threw during construction and crashed the app right after a successful pair, on both standard and relay connections. The token store now heals a corrupt keyset on the spot, and credential storage degrades to a re-pair instead of crashing if the device keystore is unusable.
- **Dashboard plugin: unreadable button labels.** Solid buttons in the relay dashboard panel inherited the container text colour, which matched their background. Solid button variants now keep their proper contrast colour.
- **Installer failed on uv-managed Hermes hosts.** `install.sh` assumed `pip` lived in the hermes-agent virtualenv, but environments created by `uv` (the upstream default) ship no `pip` module, so the editable install aborted at step 2. The installer now bootstraps `pip` via `ensurepip`, or falls back to `uv pip`, so the plugin installs cleanly on uv-managed cores.
- **Chat settings (Android).** The streaming-endpoint picker no longer wraps "Gateway"/"Sessions" onto a second line, and the system-prompt preview now reflects the enabled context toggles (foreground app, battery, safety rails) with representative placeholder values instead of looking inert.
- **Dashboard plugin: buttons rendered as blank boxes.** The host dashboard's Nous design-system `Button`/`Badge` use boolean variant flags (`outlined`/`ghost`/`invert`) and a `tone` prop — not the shadcn-style `variant` prop the plugin passed — so every button collapsed to a solid near-white fill with an invisible label. The plugin now translates its props to the design-system contract via an adapter, and drops a label-hiding CSS reset.
## [1.0.0] - 2026-06-14
### Added
- **Relay plugin diagnostics and install guidance.** `hermes relay doctor` now reports standard upstream API/dashboard reachability, Relay loopback state, dashboard plugin presence, plugin-manager layout, and whether the legacy bootstrap monkeypatch is installed. The plugin manifest now advertises its Android and desktop tools, and `after-install.md` gives the upstream plugin manager a first-run handoff.
- **Plugin-owned compatibility hook lifecycle.** `hermes relay compat status/install/remove` now owns the optional `hermes_relay_bootstrap.pth` startup hook, so the monkeypatch can be inspected, added, or removed without rerunning the legacy installer. The standard v1.0.0 path does not require this hook.
- **Legacy cleanup alignment.** The legacy installer now installs the optional `.pth` hook through the plugin compat lifecycle, and the uninstaller removes every shell shim it creates (`hermes-pair`, `hermes-status`, `hermes-relay`, `hermes-relay-update`, `hermes-relay-tailscale`) while delegating hook cleanup to `hermes relay compat remove` when available.
- **Gateway chat transport with live thinking.** Chat can ride the upstream dashboard `/api/ws` (the `tui_gateway` surface the official hermes-desktop client speaks) — the only vanilla-upstream path that streams reasoning *live*, so the Thinking block and sphere light up during generation. "Auto" prefers it when the dashboard is reachable and Manage is signed in, and falls back to the SSE endpoints per turn.
- **Gateway desktop parity.** Native image/PDF/file attachments (with an in-chat notice when a turn falls back to a transport that can't carry files), mid-turn **steering**, **edit & resend**, interactive **approval / clarify / sudo / secret** cards, live **subagent lanes**, a **context-window meter**, server **slash commands** in autocomplete, and **turn-complete notifications** when the app is backgrounded.
- **Gateway warm-start + Keep connected in background.** Pre-warming the gateway on foreground moves the cold session-setup cost off the send path. An opt-in foreground-service toggle (both flavors; `specialUse`, off by default) holds the socket open in the background so a long-backgrounded conversation resumes instantly.
- **Switch agent profiles from chat.** Pick a different agent — model, SOUL, personality, and skills — per conversation. The selection is **ephemeral** (bound to the session like the official desktop; it never changes the server's default agent for other clients). The session drawer scopes to the active profile and loads that profile's history, and the right agent is restored on cold start. The Manage tab's server-wide **Activate Profile** action now confirms first.
- **Manage parity with the desktop dashboard.** Change models from the full provider catalog, manage provider keys (write-only, masked, reveal), create/edit profiles and SOUL.md, and browse/install/update skills. Manage data is cached to disk for an instant cold launch.
- **Open & save chat images and attachments.** Tap an image for a full-screen viewer (pinch-zoom, double-tap, Share/Save); non-image attachments gain an Open/Share/Save menu. Saves land in `Pictures`/`Download/Hermes-Relay` with no permission on Android 10+, preserving the original bytes.
- **Persistent Realtime Agent voice + background runs (ADR 33).** The realtime engine keeps one session across turns (follow-ups retain context); a long Hermes run is promoted to a tracked background task and spoken when ready, so the conversation stays responsive.
- **Redesigned chat input bar.** A Telegram-clean pill field with one trailing button that morphs between Send / Voice / Stop / Steer / Queue; the slash button is gone (typing `/` still opens autocomplete).
- **Routes card reachability verdicts** ("Reachable", or the specific failure reason) and per-turn **latency tracing** (`TurnLatency`, durations only) for diagnosing transport speed.
### Changed
- **Relay plugin/server version aligned to v1.0.0.** The Python package, plugin manifest, dashboard manifest, and relay runtime now use the same `1.0.0` line as the stable Android release so a retagged source checkout describes one product version.
- **The standard (no-plugin) path is first-class.** Chat, Manage, and voice all work against an unmodified upstream Hermes agent; standard voice rides the dashboard audio surface (`/api/audio/*`) with the Manage sign-in, and relay-paired voice is the profile-aware fallback. The relay plugin is now purely additive.
- **Seamless connection UX.** LAN↔Tailscale handoffs and reconnects no longer reload the chat; connection and update status are now in-theme slide-down toasts over the content instead of banners that pushed the UI around.
- **Editable, roaming routes.** Add/edit/remove routes in Settings → Connections; bare-host URLs default their scheme and port (and preview what will be saved); remote-access (Tailscale) is surfaced in the main setup flow with a "Remote" readiness line.
- **Faster Manage.** A shared auth preamble plus concurrent payloads cut a full load from ~40 round trips to ~12; a process-lifetime cache and startup pre-warm render the last-seen data instantly, and Manage now names which dashboard URL it's talking to.
- **Faster, calmer cold start.** Key-less connections skip the multi-second keystore decrypt; the startup sphere is now the actual loading screen with narrated check lines, and the OS splash blends into it.
- **Docs + branding.** The docs site was rechromed to the app theme and repositioned around the two-path story; the README and Play listing were refreshed standard-first; product-name copy normalized to **Hermes-Relay**.
- **Quality-of-life.** Quote-in-reply, share-conversation-as-Markdown, ambient mode as a long-press gesture, a floating status pill, decluttered Manage cards, back buttons on pushed screens, and a softer active-connection card.
### Fixed
- **No "Connect to Hermes" flash on cold start.** The empty-state now distinguishes "still hydrating from disk" from "nothing configured" (`ConnectionStore.isHydrated` → `chatConnectState`), showing a quiet "Connecting to Hermes…" spinner until ready and the connect CTA only once hydration confirms no connection exists.
- **In-app What's New renders cleanly** — parsed into a version subtitle, bold section headers, and real bullets instead of raw text with literal `*`.
- **App-start UI freeze from Keystore lock contention.** The encrypted cookie store built its StrongBox-backed prefs eagerly in its constructor (1–4 s under a process-global lock) from several code paths at once; it now builds lazily on an I/O thread and is shared per connection.
- **Standard connections now follow LAN↔Tailscale changes**, standard voice follows the resolved route (not the persisted URL), and a stale probe cache can no longer pin a dead route after a handoff or resume.
- **Editing a URL no longer wipes fallback routes** (edits merge with stored extras instead of rebuilding from the edited URL alone); **"Re-check" / "Use now" no longer fail silently** (the probe always publishes its outcome and per-route failure reasons); and a network change can no longer resurrect a deliberately disconnected relay socket.
## [0.8.1] - 2026-05-26
### Fixed
- **Voice mode crash with barge-in on legacy TTS playback.** When barge-in was enabled and the relay served audio over the legacy `/voice/synthesize` (Media3) path, the first agent sentence played for ~2 syllables and then the app crashed with `IllegalStateException: Player is accessed on the wrong thread`. The barge-in listener's `Dispatchers.IO` reader was reading `ExoPlayer.getAudioSessionId()` (a thread-confined accessor) to attach the echo canceller. `VoicePlayer.audioSessionId` now serves a `@Volatile` cache populated from main-thread Media3 callbacks, so it is safe to read from any thread.
## [0.8.0] - 2026-05-23
### Added
- **Provider-native Realtime Agent voice.** Android can opt into a Realtime Agent voice engine where Android streams mic PCM to the relay, xAI or OpenAI owns realtime speech recognition and speech generation, and Hermes remains the governed authority for tools, memory, profiles, confirmations, current-data checks, side effects, and durable transcript context.
- **Hermes-brokered realtime tool timeline.** Realtime Agent turns now mirror transcript, assistant speech, Hermes task state, concise tool-status rows, confirmation state, path badges, and compact result provenance into chat/voice UI without dumping raw tool output aloud.
- **Connection diagnostics and activity logs.** Settings now includes a Diagnostics surface with sanitized recent API, relay, session, endpoint, and voice activity. API / Relay / Session detail drawers also tail the relevant recent activity so hung or unreachable relays are visible without ADB first.
- **Realtime and Voice Settings active-engine layout.** Voice Settings now separates **Voice Engine** from global voice controls, shows only the selected engine's provider card, keeps fallback TTS visible as a global safety-net card, and provides **Test Current Engine**: stable voice plays the saved Voice Output sample, while Realtime Agent opens a provider-native `/voice/realtime-agent/*` test session and plays streamed realtime audio.
- **Voice Lab text and mic demos.** The realtime voice test screen now offers two clearly separated demos: a **Text demo** that plays raw provider TTS, and a **Mic demo** that exercises the full agent path — real speech recognition, Hermes brokering, and a spoken reply — with tap-to-record / tap-to-stop capture. A `scripts/realtime-voice-lab-smoke.ps1` smoke script accompanies the lab.
- **Realtime playback diagnostics.** Playback now records a time-to-first-audio metric, logs requested-vs-actual AudioTrack buffer sizes, runs a first-frame watchdog, and cross-checks playback drain drift so cold-start and underrun regressions surface in the Diagnostics log instead of as silent dead air.
### Changed
- **Google Play Bridge Core split.** The Google Play Android track keeps relay pairing, chat, profiles, voice, terminal/TUI, media, notification companion, relay sessions, diagnostics, and status while removing AccessibilityService-backed Device Control declarations and permissions. Sideload remains the track for screen reading, gestures, screenshots, SMS/calls, contacts/location, overlays, wake locks, and unattended control.
- **Release lanes now use explicit product tags and names.** Future Android releases use `android-v*`, plugin/Python releases use `server-v*`, and CLI releases continue on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Plugin vX.Y.Z`, and `Hermes-Relay-CLI vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
- **Realtime voice instructions are provider-neutral.** Realtime providers receive active interface context, local date/time, provider/model/voice/profile metadata, and guidance to ask Hermes for current facts, research, device/desktop state, project context, precise/versioned data, and any requested checks instead of guessing from model knowledge.
- **Play/user docs now match the actual artifact.** Release-track docs, feature matrix, getting-started copy, privacy/security references, and Play listing copy now say Google Play has no AccessibilityService, screen reading, gestures, screenshots, or phone-control utility permissions.
### Fixed
- **Silent / choppy first-turn realtime voice playback.** The AudioTrack deep-buffer cold-start was parking the playback head at zero so the first turn dropped or stuttered. The streaming buffer was shrunk from 4000ms to 700ms, the low-latency prebuffer threshold retuned, and a preroll force-start removed, giving reliable low-latency playback from the first frame. Confirmed on-device.
- **Voice Lab waveform now tracks the playback cursor.** The waveform is driven by `RealtimePcmPlayer.playbackAmplitude()` at the playback position instead of socket-arrival time, so the visual matches what is actually being heard.
- **Realtime Hermes calls no longer depend on the phone's saved Hermes API key.** Provider-native Hermes tool calls are brokered by the relay with its server-side Hermes credential, so a phone can be paired for realtime voice without exposing or misusing its saved API bearer.
- **Hung relay voice turns fail visibly.** Voice turns run relay health preflight and shorter realtime/session timeouts so Settings and Voice mode surface unreachable relay state instead of sitting indefinitely on Thinking.
- **OpenAI realtime is no longer treated as render-after-Hermes fallback.** `openai_realtime` is registered as a native Realtime Agent provider path alongside xAI, with provider-native audio events normalized through the same broker contract.
- **Local release signing no longer falls back to debug when `local.properties` uses a repo-root relative keystore path.** The Android Gradle signing config now resolves relative keystore paths from the repo root, matching the documented `release.keystore` setup.
## [0.7.0] - 2026-05-19
### Added
- **Profile-aware Hermes sessions and voice settings.** Android now treats Hermes profiles as first-class connection state: profile selection resolves against the active server, profile-specific chat sessions are persisted separately, default/Victor display is normalized, and per-profile voice provider/model/voice settings can be read and saved through server-owned endpoints without depending on Hermes config mutations.
- **Realtime voice playground and provider lab.** The relay now includes standalone OpenAI/xAI/ElevenLabs-oriented voice lab tooling, provider adapters, provider option discovery routes, realtime playground routes, and generated WAV/JSONL artifact ignores for iterative voice quality testing outside production Hermes routes.
- **Streaming voice output routes.** server-owned `/voice/output/*`, realtime playground, profile voice config, and provider option endpoints support provider-neutral TTS rendering, dynamic voice/model option surfaces, and profile-scoped voice configuration for Android.
- **Experimental Android realtime voice overlay.** Android adds a richer voice overlay with tap-to-talk, continuous mode controls, optional system overlay mode, compact mode, realtime waveform visualization, playback controls, and an experimental badge around barge-in instead of treating all voice as experimental.
- **Experimental realtime Hermes voice-agent plan.** `docs/plans/2026-05-19-realtime-hermes-voice-agent.md` records the next architecture step: provider-native realtime speech with Hermes-brokered profiles, sessions, tools, confirmations, and transcript mirroring. The stable Hermes chat + voice-output path remains the default.
- **Desktop tray pairing and consent flow.** The desktop surface gained Tauri tray pairing, QR/consent affordances, sidecar preparation, and computer-action approval polish so desktop and Android pairing flows are closer to parity.
- **Shared relay/Quest scaffolding.** Experimental `relay-core`, `relay-ui`, and Quest prototype modules were added for shared pairing, terminal, transport, voice, and morphing-sphere work without changing the Android phone app's default route.
- **Desktop Chat tab with first-run route setup.** The Tauri tray dashboard now has a Chat tab inspired by the Hermes Desktop chat-first flow. It streams through the saved paired relay when `~/.hermes/remote-sessions.json` has an active session, or through a direct Hermes gateway/API URL when relay pairing is not available. The tab supports stop, retry, new chat, clear, current-session transcript history, and a setup panel that offers relay pairing or direct WebAPI configuration without saving the optional API key.
- **First-class desktop TUI tab.** The Tauri tray dashboard now gives the embedded xterm/PTY Hermes session its own sidebar tab instead of nesting it under Terminal / CLI. Terminal remains the external launcher, shim-state, and copyable-command surface, while plugin embeds route into the same TUI tab.
- **Desktop surface plugins.** The desktop CLI and Tauri tray now register built-in terminal surface plugins, starting with Herm (`herm-tui`) from `liftaris/herm`. Users can inspect plugin status, install or update Herm, launch a fresh dashboard, resume with `herm -c`, or embed the plugin in the tray's xterm/PTY surface with `bunx`/`npx` fallback when the `herm` binary is not installed.
- **Relay server release track.** Relay server and Python package releases now use `relay-v*` tags, validate relay-owned version metadata, build wheel/sdist artifacts, generate checksums, and publish through `.github/workflows/release-relay.yml`. This lets Relay server fixes ship independently from Android app `versionCode` bumps and desktop CLI alphas.
- **Dashboard plugin CI.** `.github/workflows/ci-dashboard.yml` builds the dashboard plugin, runs the dashboard API tests, and verifies the plugin-owned QR modal CSS markers are present in the built bundle.
- **Upstream integration sync reference.** `docs/upstream-integration-sync.md` now tracks which Hermes-Relay surfaces use upstream-supported extension points, which pieces are server-owned compatibility layers, and what has to be checked before changing relay, Android, desktop, dashboard, bootstrap, or user-doc surfaces.
- **Relay version sync verifier.** `scripts/check-relay-version-sync.py` validates the relay package version against plugin metadata and dashboard metadata so release and dashboard surfaces cannot silently drift.
### Changed
- **Stable voice is now the main Android voice path.** Voice mode defaults to Hermes chat streaming plus relay-managed voice output, with realtime-provider work kept as a standalone lab/testbench and future experimental mode instead of replacing Hermes session/tool authority.
- **Realtime voice output uses balanced coalescing.** Normal assistant speech is batched into more natural chunks while tool/status speech stays immediate, reducing provider render resets and tone/volume variation during voice replies.
- **Voice settings are profile-scoped and option-aware.** Android can fetch provider/model/voice options from relay endpoints, show profile context in voice settings, save voice choices per Hermes profile, and expose advanced manual entry when provider metadata is incomplete.
- **Voice UI state is synchronized with chat state.** Voice mode now reuses more of the chat session/profile state, preserves live transcript and tool timeline visibility, and improves overlay exit/minimize behavior for hands-free use.
- **Release versioning is split by surface.** Android app releases remain on `v*` and use `gradle/libs.versions.toml`; Relay releases use `relay-v*` and keep `pyproject.toml`, `plugin/relay/__init__.py`, `plugin/plugin.yaml`, and dashboard plugin metadata in lockstep; desktop remains on `desktop-v*` and `desktop/package.json`. `scripts/bump-version.sh` is now a backward-compatible Android alias, with new explicit `scripts/bump-android-version.sh` and `scripts/bump-relay-version.sh` helpers.
- **Upstream voice imports are isolated.** Relay voice routes now call upstream Hermes STT/TTS helpers through `plugin.relay.upstream_voice`, keeping private upstream voice helper imports in one adapter module until Hermes exposes a stable HTTP voice API.
- **CI paths and release actions tightened.** Relay CI now watches Relay-owned paths instead of all `plugin/**`, validates Relay version metadata during syntax checks, uses explicit timeouts, and runs the focused route/auth/session test slice instead of broad test discovery. Release workflows now use `softprops/action-gh-release@v3`.
### Fixed
- **Profile switching no longer silently falls back to the wrong local API host.** Profile API URL resolution now handles per-profile Hermes API servers, default/Victor compatibility, and relay-managed profile metadata so selecting a profile does not try to create sessions against `localhost` from the phone.
- **Non-default profile names remain visible in chat.** Agent display metadata is normalized so selected profile names persist above finalized assistant messages instead of disappearing back to the default label after stream completion.
- **Voice waveform and playback state are better aligned to real audio.** The output waveform waits for audio playback, handles processing separately, and avoids returning to the microphone too early at the end of an assistant response.
- **Continuous voice mode no longer starts a session just because auto mode is enabled.** Auto/continuous remains a preference, while explicit voice start/stop controls decide when a voice session is active.
- **Android voice mode no longer 403s when paired over plain-LAN `ws://` with a Hermes API key saved.** Symptom: tap the mic in Voice mode → red banner *"Voice access expired — extend or re-pair with voice grants"* even though the Connections card shows API Server / Relay / Session all green. Root cause: `RelayVoiceClient` preferred the saved Hermes API key over the paired Relay session token; the relay's `_request_is_secure_enough_for_api_bearer` correctly rejects API-bearer auth on `/voice/*` over plaintext outside loopback/Tailscale, returning a generic 403 that the client flattened to "expired." Fix: invert bearer precedence so paired devices use the session token first (no transport guard — it's the credential the QR/pair handshake already established), with the API key as fallback for chat+voice-only installs that never paired. `describeHttpError` now also reads the server's text/plain response body when present so future 403s show the relay's actual reason instead of a one-size-fits-all string.
## [0.6.1] - 2026-05-06
### Added
@@ -18,7 +208,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Desktop CLI alpha.14 — `Ctrl+A ?` chord re-displays the chord-help banner.** The attach-time banner scrolls off as soon as anything writes to the terminal, so users mid-session forgot the verb list and had to detach + re-attach (or guess). New `Ctrl+A ?` (and `Ctrl+A h` synonym) reprints the banner to stderr without leaving the session. Banner text refactored into a single `CHORD_HELP` constant so the attach-time print, the `?` chord, and the unknown-chord hint can't drift out of sync. Unknown-chord hint now also lists `?` as one of the known verbs.
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Bailey: *"This isn't cohesive — we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Reported gap: *"...we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
### Fixed
@@ -28,9 +218,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Android connection/profile state no longer leaks across switches.** Connection switches now clear the outgoing profile object immediately, load the destination connection's saved profile name only after that connection is active, and resolve it against the destination server's current profile list. The default local relay URL is now `ws://localhost:8767`, and auto-managed relay URLs are derived from the active API URL before reconnecting.
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** Bailey saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** A user saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** Bailey on alpha.9 ran `hermes-relay update --check`, expected to see alpha.10, got "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** On alpha.9, `hermes-relay update --check` expected to see alpha.10 but reported "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
- **Desktop CLI alpha.10 — `hermes-relay paste` always returned "No image on clipboard" on Windows even when an image was present.** Root cause: the PowerShell invocation in `captureClipboardWindows` (`src/chatAttach.ts`) was missing the `-STA` flag. `powershell.exe -Command` defaults to MTA (Multi-Threaded Apartment), and `[System.Windows.Forms.Clipboard]::GetImage()` only returns a valid image from STA threads — from MTA it silently returns null, indistinguishable from "no image present." Also affects the `chat` REPL's `/paste` command which routes through the same Windows code path. Fix: added `-STA` to the powershell args list (now `['-NoProfile', '-NonInteractive', '-STA', '-Command', ps]`). Live verification: empty clipboard returns null; a cyan 100×80 PNG placed via `[System.Windows.Forms.Clipboard]::SetImage` returns the expected 305-byte capture with correct dimensions. Affects `desktop-v0.3.0-alpha.7` through `desktop-v0.3.0-alpha.9`.
@@ -52,14 +242,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke added to `release-desktop.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke runs in `release-cli.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
- **`hermes-relay --version` printed `0.0.0` in compiled binaries.** `readVersion()` tried to read `package.json` via `__dirname + '../package.json'`, which doesn't resolve in a Bun `--compile` binary (no real filesystem layout). Replaced with a build-time-generated `src/version.ts` module (`npm run gen:version` writes the version from package.json before every build and every `build:bin:*`). `readVersion()` now just returns the embedded constant. Works identically in tsx / Node / Bun.
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from release-desktop.yml, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from the CLI release workflow, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
- **Installer couldn't find alpha-only releases.** GitHub's `/releases/latest/download/` URL deliberately skips prereleases, so the default `curl | sh` / `irm | iex` one-liner failed against alpha.1 with "maybe no Windows release for this version yet?" Both `install.sh` and `install.ps1` now query the Releases API directly (`GET /repos/.../releases`, filter to `desktop-v*` tags, take first) when `HERMES_RELAY_VERSION=latest`. Pinned versions unchanged.
### Added
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a relay server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a Server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://172.16.24.250:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
@@ -104,13 +294,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Changed
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of Bailey's double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of the double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
### Added
- **Card-dispatch → server session sync** (completes ADR 26). Every [HermesCardDispatch] now carries a `syncedToServer` idempotency flag; on the next chat send, `CardDispatchSyncBuilder` synthesizes unsynced dispatches into OpenAI-format `assistant`+`tool` pairs under a namespaced synthetic tool name `hermes_card_action` and splices them into the request body alongside the existing voice-intent synthetic messages. `ChatHandler.markCardDispatchesSynced` commits the flag after the API client accepts the request — same post-handoff timing as voice intents, so a thrown request-building exception leaves both streams retryable. Guarantees the LLM sees prior card interactions ("you approved the `Run shell command?` card") across server restarts and reconnects, including `open_url` dispatches that never go through `sendMessage`. Unit-tested under `CardDispatchSyncBuilderTest` (pure-function JVM tests, no Android deps).
- **Rich cards in chat via `CARD:{json}` inline markers** (ADR 26). Assistant messages can now surface structured Material 3 cards — skill results, approval prompts, link previews, calendar entries, weather — emitted as a single-line `CARD:{...}` alongside prose text. Follows the same streaming-endpoint-agnostic marker recipe as `MEDIA:`, so it works unchanged on `/v1/runs`, `/api/sessions/{id}/chat/stream`, and `/v1/chat/completions`. New `HermesCard` data class (`@Serializable`, `ignoreUnknownKeys=true` so newer agent schemas don't crash older phone builds) carries `title` / `subtitle` / `body` (markdown) / `fields` / `actions` / `footer` / `accent` (`info`/`success`/`warning`/`danger`). Built-in types: `skill_result`, `approval_request`, `link_preview`, `calendar_event`, `weather`; unknown types render via a generic fallback. `approval_request` intentionally mirrors Slack's exec-approval pattern (Allow / Deny with primary/danger button styles) so upstream Phase B adapter parity is a translation exercise, not a data-model rethink. Action dispatch (`send_text` default, `slash_command`, `open_url`) routes through `ChatViewModel.dispatchCardAction`, which stamps a `HermesCardDispatch` on the owning message before forwarding so the card collapses into a "Chose: X" confirmation even if the side effect fails. Renderer is `HermesCardBubble.kt` — accent stripe + Icon + Title/Subtitle + markdown body + fields table + FlowRow of action buttons. Cards render between the assistant's prose and any attachments in `MessageBubble`.
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-relay.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (Bailey's call: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (deliberate: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
- **MorphingSphere on the docs site.** New `SphereMark.vue` component (in `user-docs/.vitepress/theme/components/`) renders a 58×34 sphere directly above the "Install in 30 seconds" block — mounted in the `home-hero-after` slot alongside `InstallSection` for a hero → sphere → install stack. Imports `preview/web/sphere.js` directly so `MorphingSphereCore.kt` remains the single source of truth across app / preview / docs. The cursor reactivity is **eye-only** — the sphere body stays anchored while the bright-spot gaze tracks the pointer (no canvas translate / body bounce). Gaze composition: **scroll-tracking is the always-on baseline** — the eye anchors to the Install section's top edge (via `.install-section` DOM query), not to the viewport center. `installGap = installRect.top − viewportH` is the runway until install enters view; as it shrinks below 50 % viewport-height, `scrollVy` ramps linearly to 1, so by the time install's top crosses into the viewport the eye is already looking straight down at it. Before that runway, the eye sits forward (`scrollVy = 0`). **Cursor-tracking is a soft overlay** — inside a rectangular detection band (full viewport width × container height, linear falloff over 1.0 × container height past the top/bottom edges) the cursor's unit-vector direction crossfades into the scroll target via `cursorWeight`. The eye always has one coherent target — no mode switching, no fbm drift fighting the cursor at the band boundary, no eye-flip between modes. Palette retarget Idle ↔ Listening is gated on `cursorWeight` (0.2 / 0.5 hysteresis) so the sphere reads as *calmly watching* at the scroll baseline and *attentive* on direct hover. A tiny fbm wander (±0.07 on top of the target) keeps the eye breathing when both scroll and cursor are stationary. Fallback when the install element isn't on the page: viewport-center reference preserves the gaze-follows-scroll feel without the anchor. Pointer inputs pass through a per-frame EMA low-pass (180 ms direction / 280 ms proximity time constants) before any math runs — stops the per-event jitter from `pointermove`'s big discrete jumps; asin/acos inputs are capped at ±0.9 so we stay off the infinite-slope end of the inverse-trig curves. Canvas is square (`aspect-ratio: 1 / 1`, `clamp(280px, 48vw, 420px)`) so the sphere fills the frame at the algorithm's natural 0.60-envelope sizing — no dead space between the phone video and the Install block. Respects `prefers-reduced-motion` (zeroes the gaze blend so the eye stops tracking but the ambient animation continues), pauses drawing while scrolled off-screen via `IntersectionObserver`, and resizes via `ResizeObserver` on the container. SSR-safe without a `<ClientOnly>` wrapper — `sphere.js` has no side-effectful imports and all DOM access lives inside `onMounted`, which Vue 3 never runs on the server.
- **`SphereFrame` gaze-bias fields in `MorphingSphereCore.kt` (mirrored in `sphere.js`).** New `lightAngleBiasX`, `lightAngleBiasY`, `lightAngleBlend` (all default 0f / 0) let callers aim the sphere's bright spot at a specific direction without touching the sphere body. The light-angle computation blends between the natural `t * lightSpeedX + noise` rotation (`blend = 0`) and the caller-supplied bias (`blend = 1`). Defaults preserve byte-identical behavior for every existing caller — Android `MorphingSphere.kt` composable, the parity test, and the JS parity harness all stay green because they never set the new fields. First consumer: `SphereMark.vue` on the docs site, which uses the bias to make the sphere's eye track the reader's cursor without bouncing the canvas.
- **`SphereFrame.shadowStrength`** (mirrored in `sphere.js`, default 0f / 0). Darkens `distBrightness` on the hemisphere facing away from the light, scaling it by `(1 − shadowStrength · (1 − directionalLight))` — the lit side is untouched, the shadow side dims proportionally. At 0 the legacy uniform "pearl" shading is preserved byte-for-byte. Docs-site `SphereMark.vue` uses 0.6 so the eye reads clearly against the unlit half of the sphere; Android composable doesn't set it and stays on legacy shading.
@@ -217,7 +407,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Hermes-agent dashboard plugin** at `plugin/dashboard/` — surfaces
relay state in the gateway's web UI via four tabs. **Relay
Management** lists paired devices + health + relay version;
Management** lists paired devices + health + Server version;
**Bridge Activity** renders the in-memory ring buffer of recent
bridge commands (method / path / decision, with safety-rail
`executed` / `blocked` / `confirmed` / `timeout` / `error`
@@ -487,7 +677,7 @@ sees the toggle, never installs the wake lock, and never invokes
### Added — Voice intent → server session sync (v0.4.1 fast-follow)
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per Bailey's 2026-04-14 on-device repro).
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per a 2026-04-14 on-device repro).
- **Implementation.** Each phone-local voice intent now records a structured `VoiceIntentTrace` (tool name, JSON args, success, JSON result envelope) on the post-dispatch chat-trace bubble it produces. `VoiceIntentSyncBuilder` walks the chat history before each `POST /v1/runs` / `POST /api/sessions/{id}/chat/stream` call and synthesizes OpenAI-format `assistant` (with `tool_calls`) + `tool` (with `tool_call_id`) message pairs from any unsynced traces. The synthesized array rides under the existing payload's new `messages` field — additive, ignored by older servers, picked up by anything OpenAI Chat Completions–shaped. Idempotency: traces flip to `syncedToServer=true` the moment the API client takes ownership of the request, so subsequent turns don't re-emit them.
- **Zero server changes.** Frontend-only, no hermes-agent edits needed.
- **Files.** `data/ChatMessage.kt` (new `voiceIntent: VoiceIntentTrace?` field), `voice/VoiceIntentSyncBuilder.kt` (pure-function builder + helpers), `network/HermesApiClient.kt` (optional `voiceIntentMessages` parameter on both stream methods), `viewmodel/ChatViewModel.kt` (build + sync + flag flip in `startStream`), `viewmodel/VoiceViewModel.kt` (extended dispatch callback wires the structured trace into the chat-trace bubble), `voice/VoiceBridgeIntentHandler.kt` (new `androidToolName` + `androidToolArgsJson` on `IntentResult.Handled`), sideload `VoiceBridgeIntentHandlerImpl.kt` populates them per intent, sideload + googlePlay `VoiceBridgeIntentFactory.kt` typealias updates. Tests in `test/voice/VoiceIntentSyncBuilderTest.kt` (12 cases — empty input, single success, failure with error_code, idempotency, chronological order, prefix gate, blank-args gate, call-id pairing, helpers) and `test/network/handlers/ChatHandlerTest.kt` (4 new cases for trace storage + `markVoiceIntentsSynced`).
@@ -991,7 +1181,7 @@ picker.
- **Stats for Nerds enhancements** — reset button, tokens per message average, peak TTFT, slowest completion, seconds subtext on all ms values
- **Feature gating** — `FeatureFlags` singleton with compile-time defaults (`BuildConfig.DEV_MODE`) and runtime DataStore overrides
- **Developer Options** — hidden settings section, tap version 7 times to unlock (same pattern as Android system Developer Options)
- **Relay feature toggle** — relay server settings and pairing sections gated behind developer options in release builds
- **Relay feature toggle** — Server settings and pairing sections gated behind developer options in release builds
- **Dynamic onboarding** — terminal, bridge, and relay pages excluded from onboarding when relay feature disabled
- **Parse tool annotations** — experimental annotation parsing for Sessions mode (marked with badge, disabled for Runs mode)
- **Privacy policy link** — accessible from Settings → About
@@ -1025,7 +1215,7 @@ MVP release — native Android companion app for Hermes agent with direct API ch
#### Core Chat
- **Direct API chat** — connects to Hermes API Server via `/api/sessions/{id}/chat/stream` with SSE streaming
- **HermesApiClient** — full session CRUD + SSE streaming, health checks, cancel support
- **Dual connection model** — API Server (HTTP) for chat, Relay Server (WSS) for bridge/terminal
- **Dual connection model** — API Server (HTTP) for chat, Server (WSS) for bridge/terminal
- **API key auth** — optional Bearer token stored in EncryptedSharedPreferences
- **Cancel streaming** — stop button to cancel in-flight chat responses
- **Error retry** — retry button in error banner re-sends last failed message
@@ -1055,20 +1245,24 @@ MVP release — native Android companion app for Hermes agent with direct API ch
- **Auth flow** — 6-character pairing code with session token persistence
- **Material 3 + Material You** — dynamic theming with light/dark/auto
- **Onboarding** — multi-page pager with feature overview and connection setup
- **Settings** — API Server + Relay Server config, theme, reasoning toggle, data export/import/reset
- **Settings** — API Server + Server config, theme, reasoning toggle, data export/import/reset
- **Offline detection** — banner shown when network connectivity is lost
- **What's New dialog** — shown automatically when app version changes
- **Splash screen** — branded splash via core-splashscreen API
- **Network security** — cleartext restricted to localhost only
#### Infrastructure
- **Relay server** — Python aiohttp WSS server for bridge/terminal channels
- **Server** — Python aiohttp WSS server for bridge/terminal channels
- **CI/CD** — GitHub Actions for lint, build, test, and tag-driven releases
- **Claude Code automation** — issue triage, PR fix, chat, and code review workflows
- **Dependabot** — weekly Gradle + GitHub Actions dependency updates with auto-merge
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0...HEAD
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.0.0...HEAD
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
[0.8.0]: https://github.com/Codename-11/hermes-relay/compare/v0.7.0...android-v0.8.0
[0.7.0]: https://github.com/Codename-11/hermes-relay/compare/v0.6.1...v0.7.0
[0.1.0]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0-beta...v0.1.0
[0.1.0-beta]: https://github.com/Codename-11/hermes-relay/releases/tag/v0.1.0-beta
+101 -41
View File
@@ -4,18 +4,20 @@
## What This Is
A native Android app (Kotlin + Jetpack Compose) paired with a Python relay server (aiohttp) for the Hermes agent platform. Chat connects directly to the Hermes API Server via HTTP/SSE; bridge and terminal use a relay over WSS.
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Standard chat, Manage, and dashboard voice work against unmodified upstream Hermes. Relay adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management.
**Current state:** v0.7.x (unreleased on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode, bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative) and `sideload` (full-capability).
**Current state:** v1.0.0 stable. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Standard voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
## Architecture
```
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
Phone (WSS) → Relay Server (:8767) [bridge, terminal]
Phone (WS) -> Hermes dashboard (:9119) [standard gateway chat, live thinking]
Phone (HTTP/SSE) -> Hermes API Server (:8642) [standard chat fallback, sessions, runs]
Phone (HTTP) -> Hermes dashboard (:9119) [standard Manage + voice]
Phone (WSS/HTTP) -> Relay plugin/server (:8767) [optional bridge, terminal, relay voice, remote tools]
```
Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
The standard path must stay vanilla upstream only. API-server bearer auth and dashboard cookie auth are separate. Terminal and bridge require Relay pairing; standard chat, Manage, and dashboard voice must not.
### Upstream Hermes API Reference
@@ -29,45 +31,54 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
| `GET /v1/models` | List available models | — |
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
| `GET /health` | Health check | — |
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
**Non-standard endpoints (provided by fork OR by plugin bootstrap):**
**Compatibility endpoints (not all native upstream API-server routes):**
These endpoints are not in stock upstream `gateway/platforms/api_server.py`. There are three ways a hermes-agent install can serve them:
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
1. **Codename-11 fork** (`feat/session-api` branch, deployed on the `axiom` branch) — adds them natively. Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) *"feat(api-server): add session management API for frontend clients"* — scope is broader than the title: sessions CRUD + session chat/stream + memory + skills + config + available-models.
2. **Bootstrap injection** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file. Does NOT inject `/api/sessions/{id}/chat/stream` — use `/v1/runs` for chat.
3. **Upstream-merged** (post PR #8556) — bootstrap auto-detects and no-ops.
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
| Endpoint | Purpose | Provided by |
|----------|---------|-------------|
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Fork OR bootstrap OR upstream-merged |
| `GET /api/sessions/{id}/messages` | Conversation history | Fork OR bootstrap OR upstream-merged |
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap OR upstream-merged |
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Fork OR upstream-merged ONLY (NOT bootstrap) |
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap OR upstream-merged |
| `GET /api/skills`, `/{name}` | Skill discovery (list + detail) | Fork OR bootstrap OR upstream-merged |
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; mirrored into bootstrap |
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap OR upstream-merged |
| `GET /api/available-models` | Provider model list | Fork OR bootstrap OR upstream-merged |
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions` or `runs` based on the capability snapshot.
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions`, `completions`, or `runs` based on the capability snapshot.
**Dashboard web server (separate surface — loopback-only):**
**Dashboard web server (separate surface — standard Manage / Desktop remote gateway):**
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info`, `/api/logs`, `/api/analytics/usage`. Auth is a page-injected `window.__HERMES_SESSION_TOKEN__` — loopback-only, no external issuance. **Do not proxy this surface over the relay.** Phone consumes the narrower, fork/bootstrap `api_server.py` surface or relay-native profile-scoped endpoints.
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and **`POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **standard (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`. Android uses it for Manage, standard voice, and the gateway chat transport. `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths remain the standard fallback. Relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
**Tool call rendering paths:**
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
2. **Sessions API** — No structured tool events during streaming; reloads message history on stream complete ("session_end reload" pattern).
2. **Sessions API** — Native upstream emits structured SSE (`run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.started/completed/failed`, `assistant.completed`, `run.completed`, `done`). `run.completed.messages` can reconcile authoritative per-turn transcript.
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
## Key Instructions
- **Standard path = vanilla upstream only.** The default (no-plugin) connection path — gateway/API chat, Manage, and standard voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
- **Bootstrap maintenance:** Remove `hermes_relay_bootstrap/` in one PR once PR #8556 merges. It's no-op-compatible, so leaving it in place during rollout is harmless.
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
## Repository Layout
@@ -84,6 +95,10 @@ hermes-android/
│ ├── accessibility/ # HermesAccessibilityService, ScreenReader, ActionExecutor
│ ├── bridge/ # BridgeSafetyManager, BridgeForegroundService, BridgeStatusOverlay
│ └── notifications/ # HermesNotificationCompanion
├── relay-core/ ← [EXPERIMENTAL] Quest/XR shared core lib (com.axiomlabs.hermesrelay.core) — pairing, transport, terminal, voice, wire
├── relay-ui/ ← [EXPERIMENTAL] Quest/XR shared Compose UI lib — sphere, terminal WebView, QR scanner
├── quest/ ← [EXPERIMENTAL] Meta Spatial SDK Quest/XR app (gradle includeBuild; in development, not shipped)
├── ui-preview/ ← Desktop Compose Hot Reload harness for PC UI iteration (NOT shipped; shares MorphingSphereCore)
├── desktop/ ← Node thin-client CLI (`@hermes-relay/cli`)
│ ├── bin/hermes-relay.js # #!/usr/bin/env node shim → dist/cli.js
│ ├── src/
@@ -107,7 +122,7 @@ hermes-android/
│ ├── tools/ # android_navigate.py, android_notifications.py
│ └── dashboard/ # hermes-agent dashboard plugin — manifest, React UI, FastAPI proxy
├── relay_server/ ← Thin compat shim → plugin.relay (legacy entrypoint)
├── hermes_relay_bootstrap/ ← Runtime patch for vanilla upstream; removable after PR #8556
├── hermes_relay_bootstrap/ ← Legacy import shim for older startup hooks
├── skills/devops/hermes-relay-pair/ ← /hermes-relay-pair slash command
├── scripts/ ← dev.bat, bridge-smoke.sh, bump-version.sh
└── docs/ ← spec, decisions, security, relay-server, mcp-tooling
@@ -121,6 +136,17 @@ hermes-android/
- **DEVLOG.md** — update at end of each work session with what was done, what's next, blockers
- **CLAUDE.md hygiene:** Key Files entries must stay one line — implementation detail belongs in the file or `docs/`. Run `/revise-claude-md` after feature-heavy sessions to trim drift.
### Public-repo writing hygiene
This is a **public, distributed repo** — every committed file (CHANGELOG, DEVLOG, README, docs, release notes) is public-facing. Write accordingly:
- **No personal names** in prose — attribute impersonally ("a user reported", "observed"). Author identity lives in git history + the signing cert, not the changelog.
- **No private infrastructure** — real server hostnames/IPs, internal deployment names, `~/SYSTEM.md` contents. (Generic example IPs like `192.168.1.100` in setup docs are fine.)
- **No AI/assistant process self-narration** — no "I should have…", no course-correction confessionals. State the technical conclusion, not the path to it.
- **No internal jargon / fork-branch plumbing** in user-facing notes — keep *what changed*, drop *where we staged it*.
- **CHANGELOG** uses Keep-a-Changelog grouping (Added / Changed / Fixed). Detail may accumulate during iteration, but at **release-prep the version block is condensed to crisp public bullets** (1–2 lines each) — deep "how we debugged it" stays in commits/DEVLOG. See [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution".
- **DEVLOG.md** is a committed, factual engineering log — what changed, why, and verification — depersonalized and third-person, not a diary.
### Code Style — Android (Kotlin)
- **Jetpack Compose** — no XML layouts. Material 3 / Material You.
- **kotlinx.serialization** — not Gson. Type-safe, faster.
@@ -147,14 +173,14 @@ hermes-android/
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
- **Version bumps happen on `dev`, then release-merge to `main`.** Use `bash scripts/bump-version.sh <new-version>` to bump all three sources atomically (`gradle/libs.versions.toml`, `pyproject.toml`, `plugin/relay/__init__.py`). The `release: vX.Y.Z` commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the tag is cut from `main`.
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-plugin-version.sh` for `plugin-vX.Y.Z`, and `desktop/package.json` for `cli-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
### Testing
- **Android:** JUnit + Compose testing for UI, MockK for mocks
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-relay.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-plugin.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
## Key Files
@@ -162,14 +188,22 @@ hermes-android/
|------|-----|
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
| `AGENTS.md` | Tool usage patterns for the `android_*` toolset |
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp |
| `AGENTS.md` | Universal agent entry point — points here + the non-negotiables (standard-path, commits, writing hygiene) |
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp; `android_*` tool usage patterns |
| **App — Core** | |
| `ui/RelayApp.kt` | Main scaffold — bottom nav, Compose navigation |
| `viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
| `network/GatewayChatClient.kt` | Gateway chat transport — JSON-RPC over dashboard `/api/ws` (tui_gateway); live `reasoning.delta`; fresh ws-ticket per connect; per-turn SSE fallback via `onPreflightFailure`; `prewarm()` (connect+resume off the send path); `setKeepAliveInBackground()` suppresses the 120s idle-close |
| `network/GatewayKeepAliveService.kt` | Opt-in `specialUse` foreground service (BOTH flavors; declared in main manifest; Play needs a Console FGS declaration) holding the process up so the gateway socket survives background/Doze; driven by ConnectionViewModel from the `KEY_GATEWAY_KEEP_ALIVE` toggle; stops on task-removal |
| `data/GatewayKeepAlivePrefs.kt` | Shared `KEY_GATEWAY_KEEP_ALIVE` pref key + `Context.setGatewayKeepAlive()` — used by ConnectionViewModel (StateFlow/setter) and the FGS Stop action |
| `network/GatewayEventMapper.kt` | Pure-JVM gateway event→callback mapping for one turn; unknown event types silently ignored; tui_gateway usage-key translation |
| `network/GatewayModels.kt` | `GatewayAvailability`, `ActiveTurnHandle`, `GatewayTurnCallbacks` (all members REQUIRED — forces dispatchOn main-thread wrap), `GatewayAsk`, `GatewaySubagentEvent`, `resolveStreamingEndpointPreference()` |
| `ui/components/ChatInputBar.kt` | Redesigned input bar — pill field, one trailing slot morphing Send/Voice/Stop/Steer/Queue, no slash button (long-press + opens palette) |
| `ui/components/SubagentLane.kt` | Per-taskIndex subagent progress lane — guide rail, compact tool rows, auto-collapse |
| `notifications/TurnCompleteNotifier.kt` | Turn-complete local notification when backgrounded — channel `chat_turn_complete`, cancel on resume, settings-gated |
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
| `network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser |
@@ -197,7 +231,7 @@ hermes-android/
| **App — Voice** | |
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
| `audio/VoicePlayer.kt` | MediaPlayer + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine |
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
@@ -208,12 +242,16 @@ hermes-android/
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
| **App — Media + Notifications** | |
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic |
| `util/MediaSaver.kt` | Save/share/open for chat media — MediaStore scoped-storage save (Pictures/Download `Hermes-Relay`, no perms on API 29+; pre-Q → share sheet); FileProvider share staging; remote-byte fetch; magic-byte image-MIME sniff for correct extensions |
| `ui/components/ChatImageViewer.kt` | Full-screen image viewer — pinch-zoom/pan (`detectTransformGestures`), double-tap 1×/2.5×, Share/Save/Close; `ChatImageViewerSource` decouples Coil-model/bitmap display from a suspend `bytesProvider` so Save keeps original bytes |
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic; image tap → ChatImageViewer, file card long-press → Open/Share/Save menu |
| `ui/components/ChatImageContent.kt` | Parses `![alt](src)` out of assistant content; remote http(s) → Coil (tap → ChatImageViewer), server-local/failed → inline "can't render" notice with the path |
| `data/HermesCard.kt` | `CARD:{json}` envelope (ADR 26) — type/accent/fields/actions; kotlinx.serialization |
| `ui/components/HermesCardBubble.kt` | Rich-card renderer — accent stripe + FlowRow actions + dispatch stamp collapse |
| `viewmodel/CardDispatchSyncBuilder.kt` | Twin of VoiceIntentSyncBuilder — synthesizes card dispatches as `hermes_card_action` OpenAI pairs for session memory |
| `notifications/HermesNotificationCompanion.kt` | NotificationListenerService; cold-start buffer (50); forwards via ChannelMultiplexer |
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
| `util/TurnLatencyTracer.kt` | One `TurnLatency` INFO line per chat turn — `warm/cold` + `connect/session/submit/ttfe/ttft/done@…ms`; gateway + 3 SSE paths use it for desktop-comparable latency diagnosis; durations only |
| **Relay — Server** | |
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers. `handle_pairing_mint` mirrors `pair.py:762` — top-level = API server, `relay.{url,code}` nested |
| `plugin/relay/auth.py` | PairingManager, SessionManager, RateLimiter; `math.inf` for never-expire |
@@ -228,9 +266,12 @@ hermes-android/
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
| `hermes_relay_bootstrap/` | Runtime patch for vanilla upstream; no-op on fork/upstream-merged; remove after PR #8556 |
| `hermes_relay_bootstrap/` | Legacy import shim for old `.pth` files and editable installs |
| **Plugin — Dashboard** | |
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
@@ -276,10 +317,16 @@ hermes-android/
| **Desktop CLI — dev iteration** | |
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
| `release-desktop.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
| `release-cli.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
| **Server — Desktop tool routing (Phase B)** | |
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
| **Gradle modules — experimental Quest/XR (in development)** | |
| `relay-core/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.core`) — shared pairing/transport/terminal/voice/wire for the Quest port; not yet wired into the shipped `:app` |
| `relay-ui/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.ui`) — shared Compose UI (sphere, terminal WebView, QR scanner) for the Quest port; carries its own sphere copy |
| `quest/` | [EXPERIMENTAL] Meta Spatial SDK Quest/XR app — gradle `includeBuild("quest")`; needs further development, not shipped |
| **Tooling — dev iteration (not shipped)** | |
| `ui-preview/` | Desktop Compose Hot Reload harness — JVM Compose for Desktop; source-shares `MorphingSphereCore` from `:relay-ui`; `Main.kt` gallery; see `ui-preview/README.md` |
## What NOT to Do
@@ -346,6 +393,12 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
**Update:** `hermes-relay-update` (idempotent, re-fetches install.sh). Or manually: `git pull --ff-only && systemctl --user restart hermes-relay`.
**Compat hook:** `hermes relay compat status/install/remove` manages only the
optional `hermes_relay_bootstrap.pth` startup hook. New installs load the
plugin-owned bootstrap from `plugin/hermes_relay_bootstrap/`; the repo-root
package is only a legacy import shim. Standard chat, Manage, and dashboard voice
must not depend on this hook.
**Key conventions:**
- Phone re-pairs after each relay restart (SessionManager is in-memory; wiped on restart)
- Use `python -m unittest` not `pytest` — conftest imports `responses` which may not be installed
@@ -364,20 +417,25 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
See [RELEASE.md](RELEASE.md) for the full recipe.
- **Version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`)
- **Bump atomically:** `bash scripts/bump-version.sh <new-version>` — updates all three sources
- **`appVersionCode` is monotonic** — always increment across prereleases
- **Cut a release:** bump → commit → `git tag vMAJOR.MINOR.PATCH` → push tag → CI builds + GitHub Release
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
- **Relay plugin version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-plugin-version-sync.py`; bump with `scripts/bump-plugin-version.sh`
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, plugin, and CLI versions without forcing them to match
- **`appVersionCode` is monotonic** — always increment across Android prereleases
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `plugin-v*`, or `cli-v*` → push tag → CI builds + GitHub Release
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
## Integration Points
| Surface | Endpoint | Notes |
|---------|----------|-------|
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; preferred |
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | No live tool events; reloads history on stream complete |
| Chat (gateway) | Dashboard `POST /api/auth/ws-ticket` -> WS `/api/ws` | Standard upstream dashboard/tui_gateway path; live thinking/reasoning; requires dashboard auth |
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Non-standard; bootstrap or fork |
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Standard upstream dashboard surface; do not proxy through Relay |
| Standard voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Standard no-plugin voice; uses dashboard session from Manage |
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
@@ -388,9 +446,11 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
| Plugin diagnostics | `hermes relay doctor --json` | Reports upstream route reachability, Relay loopback state, plugin layout, and legacy bootstrap state |
| Compat hook lifecycle | `hermes relay compat status/install/remove` | Optional legacy API compatibility hook; not required for the standard path |
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
| Capabilities | `HEAD /api/sessions`, `HEAD /v1/runs`, etc. | HEAD avoids CORS 403 on OPTIONS preflight |
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
+64
View File
@@ -0,0 +1,64 @@
# Hermes-Relay-CLI v__VERSION__
**Release Date:** <!-- YYYY-MM-DD -->
**Since the previous CLI release:** <!-- one line: the theme of this release -->
<!-- One short paragraph: what this desktop/CLI release is about and who should care. -->
<!--
═══ RELEASE-PREP CHECKLIST (delete this comment block when done) ═══
• This file is the GitHub Release body for `cli-v*` tags. The release workflow
substitutes __VERSION__ (bare, e.g. 0.3.0) and __TAG__ (full, e.g. cli-v0.3.0) —
leave those tokens in the Install section; do NOT hardcode versions there.
• Rewrite the Summary + the Added/Changed/Fixed groups from the CLI/desktop-relevant
bullets in CHANGELOG.md's promoted version block.
• Keep-a-Changelog rules: include only the groups that have entries; delete empty ones.
• Keep the "Experimental phase" notice until the CLI reaches GA.
• Scrub for public distribution (RELEASE.md §2): no personal names, no private infra,
no fork-branch plumbing, no AI self-narration.
═══════════════════════════════════════════════════════════════════
-->
**Experimental phase.** Assets are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
## What's changed
### Added
-
### Changed
-
### Fixed
-
## Install
**Windows tray app (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
**Windows CLI only:**
```powershell
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
**macOS / Linux CLI:**
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
Pin this specific release with `HERMES_RELAY_VERSION=__TAG__`.
## Verify
```text
hermes-relay --version
hermes-relay pair --remote ws://<host>:8767
hermes-relay shell
```
Open **Hermes Relay Desktop** from the Windows Start menu for tray pairing, devices, task log, settings, pause, and emergency stop.
See [Desktop docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
+12 -2
View File
@@ -94,14 +94,24 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`,
**Branching model (as of 2026-04-19): `main` + `dev`.** Feature branches — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back into `dev` via `--no-ff` PRs. `main` is released state only; it receives release merges from `dev` and nothing else. There is no straight-to-main exemption — even single-file typos go through `dev`.
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a `release: vX.Y.Z` PR merges `dev` → `main` with `--no-ff`. The tag is cut from `main` after the merge. See [RELEASE.md](RELEASE.md) for the full release process.
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
## Changelog & writing conventions
This is a **public repo** — `CHANGELOG.md`, `DEVLOG.md`, the README, and everything under `docs/` ship publicly. Keep them clean:
- **`CHANGELOG.md`** follows [Keep a Changelog](https://keepachangelog.com/) (Added / Changed / Fixed). Append your change to the `## [Unreleased]` block in the PR. Entries can carry detail while they accumulate, but at release-prep the version block is **condensed to crisp public bullets** (1–2 lines each) — the deep "how we debugged it" narrative belongs in commit messages and `DEVLOG.md`, not the public changelog.
- **`DEVLOG.md`** is a factual engineering log — what changed, why, and how it was verified. Keep it depersonalized and third-person; it's a record, not a diary.
- **No non-public wording anywhere committed:** no personal names (attribute impersonally — identity lives in git history), no real server hostnames/IPs or internal deployment names, no AI/assistant process self-narration, no fork/branch plumbing in user-facing notes. Generic example IPs in setup docs are fine.
Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/play-store-listing.md`) are theme-framed and user-facing; see [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution" for the full checklist.
## Testing
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
CI is split into two path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes) and `.github/workflows/ci-relay.yml` (syntax check + unittest discover on plugin/Python changes). Both run on pushes to `main` and `dev` and on PRs targeting either.
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
## Questions?
+529 -132
View File
File diff suppressed because one or more lines are too long
+37
View File
@@ -0,0 +1,37 @@
# Hermes-Relay-Plugin v__VERSION__
**Release Date:** June 16, 2026
**Since the previous plugin release:** Easier setup and a fixed dashboard panel — plus mid-conversation `/relay` controls and a relay-status widget.
This release makes the relay plugin easier to install and live with. Setup now prompts for the optional voice-provider keys instead of asking you to hand-edit `.env`, tools-only hosts can install through the native `hermes plugins install` path, and the installer no longer breaks on `uv`-managed Hermes cores. The dashboard panel — which previously rendered as blank boxes on the host's design system — now displays correctly, and a header widget plus `/relay` slash commands surface relay state from anywhere. The standard no-plugin path needs none of this.
## What's changed
### Added
- **Guided env-key setup.** The plugin declares its optional voice-provider keys (`XAI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`) in its manifest, so `hermes plugins install` prompts for them (masked, with a "get yours" link) instead of requiring a hand-edited `.env`. The standard no-plugin path needs none.
- **Native install path.** Tools-only setups can install via `hermes plugins install Codename-11/hermes-relay/plugin`; the full relay still uses the curl `install.sh`.
- **`/relay` slash commands.** `relay status · devices · pair` are usable mid-conversation from any platform (CLI / Discord / TUI).
- **Dashboard relay-status widget.** A `Relay · connected / offline / unpaired` badge in the dashboard header, visible on every page.
- **Session-start relay health check.** A minimal, fully-guarded `on_session_start` hook records relay reachability without slowing the gateway.
### Fixed
- **Installer failed on uv-managed Hermes hosts.** `install.sh` assumed `pip` lived in the hermes-agent virtualenv, but environments created by `uv` (the upstream default) ship no `pip` module, so the editable install aborted at step 2. The installer now bootstraps `pip` via `ensurepip`, or falls back to `uv pip`, so the plugin installs cleanly on uv-managed cores.
- **Dashboard buttons rendered as blank boxes.** The host dashboard's Nous design-system `Button` / `Badge` use boolean variant flags (`outlined` / `ghost` / `invert`) and a `tone` prop — not the shadcn-style `variant` prop the plugin passed — so every button collapsed to a solid near-white fill with an invisible label. The plugin now translates its props to the design-system contract via an adapter and drops a label-hiding CSS reset.
- **Unreadable button labels.** Solid buttons in the relay dashboard panel inherited the container text colour, which matched their background; solid button variants now keep their proper contrast colour.
## Install
```bash
pip install hermes-relay==__VERSION__
```
## Verify
```bash
python -m relay_server --help
```
---
Tag prefixes: Android releases use `android-v*`, CLI releases use `cli-v*`. Historical
relay/plugin releases used `relay-v*` tags.
+224 -239
View File
@@ -1,21 +1,23 @@
<p align="center">
<img src="assets/logo.svg" alt="Hermes-Relay" width="120">
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — your Hermes agent, in your pocket" width="800">
</p>
<h1 align="center">Hermes-Relay</h1>
<p align="center">
<strong>Runs on your machine. Lives on your devices.</strong><br>
A native Android companion for your <a href="https://github.com/NousResearch/hermes-agent">Hermes agent</a> — streaming chat, hands-free voice,
and full agent management. Plus a single-binary CLI that gives the agent hands on any machine you pair.
</p>
<p align="center">
<strong>One Hermes agent. Two ways to use it.</strong><br>
A native Android remote-control app for your phone, plus a desktop CLI that lets you<br>
use a server-deployed Hermes from your laptop as if it were running locally.
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Surface%201-Android-green.svg" alt="Android"></a>
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI-orange.svg" alt="Desktop CLI"></a>
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Android-8.0%2B-3DDC84.svg?logo=android&logoColor=white" alt="Android 8.0+"></a>
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
<a href="https://github.com/Codename-11/hermes-relay/releases"><img src="https://img.shields.io/github/v/release/Codename-11/hermes-relay?filter=android-v*&label=release&color=8B5CF6" alt="Latest release"></a>
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-alpha-orange.svg" alt="CLI (alpha)"></a>
</p>
<p align="center">
@@ -25,112 +27,204 @@
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
</p>
<p align="center">
<video src="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo.mp4" poster="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo_poster.jpg" autoplay loop muted playsinline width="280"></video>
</p>
---
## Two surfaces, one pair
## What it is
| Surface | What | Status |
|---------|------|--------|
| **[Android app](#1a-android-app)** | Native phone control — chat, voice, the agent reads your screen and acts on it (tap, type, swipe), notification companion, multi-Connection. | Available — Google Play (Internal testing) + sideload APK |
| **[Desktop CLI](#1b-desktop-cli-experimental)** | Use a server-deployed Hermes from your laptop **like it's local** — same shell, same TUI, same `Win+Shift+S` → `Ctrl+A v` paste flow, same conversation continuity. The remote agent can also reach back through the relay and run tools on YOUR machine. | **Experimental** — `desktop-v0.3.0-alpha.14` (one-binary install, no Node required) |
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
Both share `~/.hermes/remote-sessions.json` and the same WSS relay. **Pair once from either, both work.**
- **📱 Android app** — streaming chat, hands-free voice, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. On sideload builds, the agent can read your screen and act on it.
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
---
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**. Add the optional relay only when you want terminal, phone control, or the CLI's tools. **Pair once from either surface; both work.**
## Quick Start
## Quick Start (Android)
Three steps: pick your surface (or install both), then install the relay plugin on your Hermes server.
Install → connect → talk, in about two minutes.
### 1a. Android app
### 1 · Install the app
<!-- TODO: Uncomment when Play Store listing is live
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80"></a>
-->
- **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).
- **Google Play** — coming soon (currently on Internal testing)
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases/latest)
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 APK (GitHub Releases)
### 2 · Have Hermes running
Prefer not to wait for Google Play? Grab the signed APK directly:
1. Download the file ending in **`-sideload-release.apk`** from [the latest release](https://github.com/Codename-11/hermes-relay/releases/latest) — that's the full-featured "Hermes Dev" build. (Skip any `.aab` file — those are the Google Play bundle format and won't install directly.)
2. On your phone: **Settings → Apps → Special app access → Install unknown apps** and allow your browser (first time only).
3. Open the APK from your downloads and tap **Install**.
4. Optionally verify integrity against `SHA256SUMS.txt` from the same release (`sha256sum` on macOS/Linux, `Get-FileHash -Algorithm SHA256` on Windows).
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
**Staying up to date (sideload):** the app checks GitHub for a newer release on cold start (at most once every 6 hours) and shows a dismissable banner when you're behind. Tapping **Update** opens the next APK in your browser so Android's Downloads notification hands it to the system installer — no second app required. You can also trigger a check manually under **Settings → About → Updates**. Google Play installs get auto-updates through the Play Store and don't show this banner.
### 1b. Desktop CLI (experimental)
A single-binary thin client (`hermes-relay`) that talks to a server-deployed Hermes over WSS — same shell, same TUI, same `/paste` flow as a local install. The remote agent can also reach back through the relay and run `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, etc. **on your machine** while its brain stays on the host. One pair, two surfaces (with the Android app), no `ssh`.
**Install** (Windows PowerShell):
```powershell
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
**Install** (macOS / Linux):
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 standard 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.
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
hermes setup --portal # install / log in / pick a provider — skip if already done
mkdir -p ~/.hermes
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
cat >> ~/.hermes/.env <<EOF
API_SERVER_ENABLED=true
API_SERVER_HOST=0.0.0.0
API_SERVER_PORT=8642
API_SERVER_KEY=$API_SERVER_KEY
EOF
chmod 600 ~/.hermes/.env
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
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).
### 3 · Connect and talk
Open the app and pick how to connect — any of:
- **Standard Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
- **Standard Hermes** → type the address (`http://<host>:8642`) and key by hand.
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
The wizard probes everything and finishes with a capability card:
| Line | What it means |
|------|---------------|
| **Chat** | API server reachable — you can talk |
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
| **Remote** | Fallback route configured — keeps working away from home |
| **Relay** | Optional power tools — fine to leave unpaired |
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole standard 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).
### 4 · Optional: install Relay for power tools
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
```bash
hermes-relay pair --remote ws://<host>:8767 # once
hermes-relay # interactive Hermes TUI in tmux
hermes-relay "summarize the last commit" # one-shot
hermes-relay --json "..." | jq # structured events for scripting
hermes-relay daemon # headless tool router (agent reaches you anytime)
hermes-relay update # self-update via GitHub Releases
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
hermes relay start --no-ssl
hermes pair
```
**Native paste workflow** (the killer demo): inside `hermes-relay shell`, hit `Win+Shift+S` to screenshot, then `Ctrl+A v` — the client reads your clipboard, ships the image to the server's inbox, and types `/paste` into the TUI for you. Identical UX to native local-Hermes paste. The same chord set works on macOS (`Cmd+Shift+4` → `Ctrl+A v`) and Linux (Wayland/X11 detected automatically).
**No Node required** — Bun-compiled native binaries (~60–110 MB per platform) installed via curl/irm. Version-aware install (`upgrading X → Y`), collision-safe `hermes` short alias, self-update via `hermes-relay update`. Binaries are **unsigned** during the experimental phase — SmartScreen/Gatekeeper warnings are expected; the install scripts show the one-line escape hatches. Code signing, multi-client server-side routing, and service installers (sc.exe / systemd / launchd) land with v1.0.
- **Docs**: [Desktop CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
- **Release track**: tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
- **AI-agent setup recipe**: `/hermes-relay-desktop-setup` (the agent can run `desktop_terminal` on your machine to diagnose install/pair issues live)
### 2. Install the server plugin (one-liner)
On the machine running your Hermes agent:
Use the legacy installer instead if you also want the systemd user service,
shell shims, and the full clone/update workflow:
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
```
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your client via either of these equivalent entry points:
The plugin-manager install owns the plugin code, dashboard tab, CLI commands,
and agent tools. `hermes relay compat status/install/remove` manages only the
optional legacy API compatibility hook when an older Hermes build needs it. Scan
the QR from the phone's Connections screen — or use
`hermes pair --register-code ABCD12` with the manual code from Android
**Settings → Connections → Advanced**.
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR + 6-char code inline. Shortest path if you're already chatting with the agent.
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. For Android: read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. For the desktop CLI: just pass it as `hermes-relay pair ABCD12 --remote ws://<host>:8767`. Composes with `--ttl` / `--grants`.
- **Plugin-manager uninstall:** `hermes relay compat remove --all` if you installed the optional hook, then `hermes plugins remove hermes-relay`.
- **Legacy installer update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
- **Legacy installer uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — removes the service, shims, clone, external skill path, editable package, and compat hook. It never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a **Relay** tab (paired devices, bridge activity, media tokens) appears in the web UI.
Scan the QR from the Android app's onboarding screen, OR paste the 6-char code into `hermes-relay pair --remote ws://<host>:8767` on your laptop, and you're connected. One pair configures **both** the direct-chat API server **and** the relay (WSS for terminal / bridge / TUI / desktop tools, HTTP for voice routes) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat from the Android app, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
**Dashboard plugin.** If your hermes-agent install has the Dashboard Plugin System (upstream `axiom` branch), Hermes-Relay ships a plugin at `plugin/dashboard/` that surfaces paired devices, bridge command activity, and active inbound-media tokens in the gateway's web UI. It auto-registers through the same `~/.hermes/plugins/hermes-relay` symlink created by `install.sh` — restart the gateway and a "Relay" tab appears. See [docs/relay-server.md](docs/relay-server.md) and `user-docs/features/dashboard.md` for details.
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
**Updating:** `hermes-relay-update` (shortest path — installed as part of the one-liner) or re-run the same `curl … | bash` from above. Both are equivalent and fully idempotent: pulls latest main, refreshes the editable install, recreates all three shims, restarts `hermes-relay`, and prompts before restarting `hermes-gateway`. Set `HERMES_RELAY_RESTART_GATEWAY=1` to opt into the gateway restart non-interactively. For routine plugin/skill updates without restarting anything, a plain `cd ~/.hermes/hermes-relay && git pull` is enough — the editable install picks up the new code on next process start.
## Screenshots
**Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
<table>
<tr>
<td align="center" width="25%"><img src="assets/screenshots/01_startup.png" alt="Cold start" width="100%"><br><sub><b>Cold start</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/02_chat.png" alt="Streaming chat" width="100%"><br><sub><b>Streaming chat</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/03_voice.png" alt="Hands-free voice" width="100%"><br><sub><b>Hands-free voice</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/04_sessions.png" alt="Session history" width="100%"><br><sub><b>Session history</b></sub></td>
</tr>
<tr>
<td align="center" width="25%"><img src="assets/screenshots/05_commands.png" alt="Command palette" width="100%"><br><sub><b>Command palette</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/06_manage.png" alt="Manage your agent" width="100%"><br><sub><b>Manage your agent</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/07_connections.png" alt="Connections and routes" width="100%"><br><sub><b>Connections &amp; routes</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/08_settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
</tr>
</table>
**Requirements:** Android 8.0+ (SDK 26) for the Android app · macOS / Linux / Windows for the desktop CLI · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server.
<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>
### For AI Agents
## Features
If you have an AI assistant (Claude, GPT, etc.) and want it to install or maintain Hermes-Relay for you, paste the block below into the chat. The agent will fetch the canonical setup recipe from this repo and walk you through it — verification, pairing, troubleshooting included.
### Android
- **Streaming chat** — rides standard Hermes, preferring the dashboard gateway (`/api/ws`, live thinking) when signed in to Manage and falling back to API-server SSE otherwise, with live markdown, tool-call cards, session history, a searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing.
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage keys (write-only, masked, rate-limited reveal), create and edit profiles including `SOUL.md`, and browse/install/update skills. One dashboard sign-in covers it all.
- **Hands-free voice** — talk on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice and an opt-in provider-native Realtime Agent with background task handoff.
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
- **Multi-Connection + profiles** — pair multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay a profile's model + `SOUL.md` per chat.
- **Phone control (bridge)** — with Relay paired, the agent reads the screen and acts: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros. Guarded by per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
- **Notification companion** — opt-in access so the agent can triage, summarize, and route incoming notifications.
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
## Hands on any machine — the Hermes-Relay CLI&nbsp;<sub>(alpha)</sub>
> **Alpha · Windows today** (macOS / Linux coming soon). A single self-contained binary — no Node required. Binaries are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
```powershell
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
```bash
hermes-relay pair --remote ws://<host>:8767 # once
hermes-relay daemon # headless tool router — agent reaches you anytime
hermes-relay update # self-update via GitHub Releases
```
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on a separate `cli-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=cli), with old alpha prereleases still visible under `desktop-v*`.
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
## How It Works
```
Phone (HTTP/WSS) --> Hermes Dashboard (:9119) [chat gateway, manage, standard voice]
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat fallback, sessions, runs]
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
CLI (WSS) --> Relay (:8767) [machine tools, tui, terminal]
```
Chat prefers the Hermes dashboard gateway when Manage auth is ready, then falls
back to the upstream API server SSE path with the API key. Manage and standard
voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla
install needs no plugin for either. The optional relay on `:8767` adds the power
surfaces: terminal, bridge phone control, media handoff, machine tools, and
relay-side voice, which is preferred automatically when paired. One QR can
configure API, dashboard, and relay routes without merging their auth models.
## Documentation
| | |
|---|---|
| **[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 |
| [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*`) |
<details>
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
<br>
If an AI assistant manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
```text
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a CLI + a Python plugin for the Hermes AI agent platform.
Read the canonical setup recipe before acting:
https://raw.githubusercontent.com/Codename-11/hermes-relay/main/skills/devops/hermes-relay-self-setup/SKILL.md
@@ -138,201 +232,92 @@ Read the canonical setup recipe before acting:
Then guide me through:
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair` (Android), OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the Hermes-Relay CLI (`irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (CLI)
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
```
Already have Hermes-Relay installed? The same recipe is auto-loaded as a Hermes skill — invoke it from any chat with `/hermes-relay-self-setup` for re-setup, troubleshooting, or "is everything wired correctly?" checks. Single source, two delivery modes (raw URL pre-install + Hermes skill post-install), no drift.
Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `/hermes-relay-self-setup` from any chat for re-setup or "is everything wired correctly?" checks.
## What It Does
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — native on Android, native in the terminal, with the agent able to reach back through the relay and act on either surface.
| Surface | Channel | What | Status |
|---------|---------|------|--------|
| Android | **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
| Android | **Voice** | Real-time voice conversation via relay TTS/STT | Available |
| Android | **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
| Android | **Terminal** | Secure remote shell via tmux | Phase 2 |
| Desktop CLI | **Shell** | Full Hermes Ink TUI piped over PTY in tmux on the host. Bare `hermes-relay` drops you in. | Available (experimental) |
| Desktop CLI | **Chat** | Structured-event REPL / one-shot / piped stdin. `--json` for scripting. REPL supports `/paste`, `/screenshot`, `/image <path>`. | Available (experimental) |
| Desktop CLI | **In-shell paste / screenshot** | `Ctrl+A v` (clipboard image → server inbox → `/paste` auto-typed). `/screenshot` is multi-monitor by default. | Available (experimental) |
| Desktop CLI | **Local tool routing** | Agent calls `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` — runs on YOUR machine over the same relay | Available (experimental) |
| Desktop CLI | **Daemon** | Headless tool router — keeps tools advertised even when no shell is open | Available (experimental) |
| Desktop CLI | **Self-update** | `hermes-relay update` polls GitHub Releases, atomic-swaps the binary | Available (experimental) |
## What's new in v0.6.0
- **Connect from anywhere** — multi-endpoint pairing with first-class Tailscale support; plug in any VPN or reverse proxy mode. See [`docs/remote-access.md`](docs/remote-access.md).
- **Multi-Connection support** — pair with multiple Hermes servers (home + work, dev + prod, etc.) and switch in one tap from the Chat top bar. Each Connection keeps its own sessions, personalities, profiles, and relay state; theme and safety preferences stay global. Existing installs migrate transparently.
- **Agent Profiles** — the relay auto-discovers upstream Hermes profiles at `~/.hermes/profiles/*/` and the phone overlays the selected profile's model + `SOUL.md` on chat turns. Ephemeral, chat-only, clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED` (default on).
- **Consolidated agent sheet** — Profile + Personality selection and per-session analytics now live in one scrollable bottom sheet opened from the Chat top-bar agent name.
See the [changelog](CHANGELOG.md) for the full list.
## Features
### Android
- **Streaming chat** — Direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, personality picker, searchable command palette (29+ gateway commands), file attachments, and send-while-streaming message queuing
- **Multi-Connection + agent profiles** — Pair with multiple Hermes servers and switch targets from the top bar; select an upstream-discovered agent profile to overlay model + `SOUL.md` on chat turns. Three-layer model: Connection (server) → Profile (agent directory) → Personality (prompt preset)
- **Voice mode** — Real-time voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Uses your server's configured TTS/STT providers (Edge TTS, ElevenLabs, OpenAI, MiniMax, Mistral, NeuTTS / faster-whisper, Groq, OpenAI Whisper)
- **Phone control (bridge)** — The agent can read what's on screen and act on it — tap, long-press, drag, swipe, scroll, type, and press system keys — plus take screenshots, read/write the clipboard, and control system-wide media playback. Gesture reliability is hardened for dim/idle screens, and a smarter tap-fallback cascade handles apps where labels sit inside non-clickable wrappers
- **Screen understanding** — Filtered accessibility-tree search, per-node property lookups with stable IDs, cheap screen-hash change detection, and multi-window reads (system overlays, popups, notification shade) so the agent can reason about UI without guessing
- **Workflow automation** — Batched macro execution for multi-step flows, real-time accessibility event streaming for "wait until something happens" waits, and a raw-Intent escape hatch for apps that expose deep-link actions
- **Notification companion** — Opt-in notification access so the agent can triage, summarize, and route incoming notifications
- **Bridge safety rails** — Per-app blocklist (banking, payments, 2FA default-blocked), destructive-verb confirmation modal (send, pay, delete, transfer…), idle auto-disable timer, optional persistent-status overlay, full activity log
- **Security & pairing** — QR-code pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
- **Analytics** — Stats for Nerds with TTFT, token usage, stream health, and peak-time charts
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the full sideload capability matrix.
### Desktop CLI
- **Shell mode (default)** — bare `hermes-relay` pipes the host's actual `hermes` Ink TUI through a PTY in tmux. Same banner, same skin, same slash commands as a local install. `Ctrl+A .` detaches (preserves tmux), `Ctrl+A k` kills, `Ctrl+A v` pastes a clipboard image, `Ctrl+A ?` re-prints chord help, `Ctrl+A Ctrl+A` literal.
- **Chat mode** — REPL or one-shot or piped stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. REPL slash commands `/paste` (clipboard), `/screenshot` (multi-monitor by default; `primary` / `1` / `2` to narrow), `/image <path>` attach the next message.
- **Local tool routing** — agent calls `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — all run on YOUR machine over the same WSS relay. One-time per-URL consent gate; `--no-tools` kill-switch; non-TTY stdin fails closed; agent-proposed patches render as colored diffs with `y/n/e/r` interactive approval.
- **Daemon mode** — `hermes-relay daemon` runs the tool router headless so the agent can reach you even when no shell is open. JSON-line lifecycle logs by default, auto-human on TTY. Fails closed on missing consent.
- **Self-update** — `hermes-relay update` polls GitHub Releases (SemVer-max picker, prerelease-aware), verifies SHA256, atomic-swaps the binary on POSIX (running daemon keeps inode), cooperative `.new.exe` swap on Windows.
- **Multi-endpoint pairing + reconnect-on-drop + TOFU cert pinning** — same as the Android app. One QR carries LAN + Tailscale + public; client races candidates in priority order, re-probes on every network change.
- **Workspace awareness** — on connect, client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay (server-side prompt-context consumption coming).
- **Conversation picker on attach** — without `--conversation` / `--new`, you get a numbered list of recent server-side hermes sessions to resume.
- **One install, one binary, no Node required** — Bun-compiled native binaries via curl/irm one-liners; collision-safe `hermes` short alias auto-installed.
## Getting Started
**Android:**
1. **Install the app** from the [link above](#1a-android-app)
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding, or scan a QR via `/hermes-relay-pair`
3. **Start chatting** — the app connects directly to the Hermes API Server
**Desktop CLI:**
1. **Install the binary** — [PowerShell `irm`](#1b-desktop-cli-experimental) (Windows) / curl (macOS / Linux) one-liner
2. **Pair once** — `hermes-relay pair --remote ws://<host>:8767` (mint code via `hermes-pair` or `/hermes-relay-pair` on the server first)
3. **Drop into the shell** — bare `hermes-relay` opens the full Hermes TUI in tmux on the host
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
## How It Works
```
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
Phone (HTTP) --> Relay Server (:8767) [voice routes — API key or relay session]
Phone (WSS/HTTP) --> Relay Server (:8767) [terminal, bridge, media, sessions]
Desktop CLI (WSS) --> Relay Server (:8767) [tui, terminal, desktop tools]
```
Chat from the Android app connects directly to the Hermes API Server with the Hermes API key — same pattern used by Open WebUI and other Hermes frontends. Voice calls the relay's `/voice/*` HTTP routes and authenticates with that Hermes API bearer when present, falling back to the relay session token for paired devices. Remote control surfaces such as terminal, bridge, TUI, media/session management, and desktop tools require relay pairing on `:8767`, so one scan can configure both the API route and the relay route without merging their auth models.
## Documentation
| | |
|---|---|
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, both surfaces, features, configuration — start here** |
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android-specific install + setup + features |
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — shell/chat, 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 |
| [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*` and desktop `desktop-v*`) |
---
</details>
## Development
### Quick Start
1. **File > Open** the repo root in Android Studio
2. Wait for Gradle sync
3. **Run** (Shift+F10) to deploy to emulator or device
### Dev Scripts
```bash
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
scripts/dev.bat build # Build debug APK
scripts/dev.bat release # Build signed release APK
scripts/dev.bat bundle # Build release AAB for Google Play
scripts/dev.bat run # Build + install + launch + logcat
scripts/dev.bat test # Run unit tests
scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start relay server (dev, no TLS)
```
### Repository Structure
```
hermes-relay/
├── app/ # Android app (Kotlin + Jetpack Compose)
├── desktop/ # Desktop CLI thin-client (@hermes-relay/cli — TS + Bun-compiled binary)
├── relay_server/ # WSS relay server (Python + aiohttp; thin shim → plugin/relay)
├── plugin/ # Hermes agent plugin
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, desktop tools)
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
├── skills/ # Hermes agent skills
│ └── devops/
│ ├── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
│ ├── hermes-relay-self-setup/ # AI-agent setup recipe (Android + desktop)
│ └── hermes-relay-desktop-setup/ # AI-agent recipe specifically for the desktop CLI
├── user-docs/ # VitePress documentation site (Android + desktop sections)
├── docs/ # Spec, decisions, security
├── scripts/ # Dev helper scripts
├── .github/workflows/ # CI + release pipelines (ci-android / ci-relay / ci-desktop)
└── gradle/ # Wrapper (8.13) + version catalog
scripts/dev.bat relay # Start the relay server (dev, no TLS)
```
### Tech Stack
| Component | Stack |
|-----------|-------|
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
| **Desktop CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
| **Relay Server** | Python 3.11+, aiohttp |
| **Android app** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
| **Server / plugin** | Python 3.11+, aiohttp |
| **Serialization** | kotlinx.serialization (Android) |
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (desktop) |
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
| **Min SDK** | 26 (Android 8.0) · Target SDK 35 |
### Relay Server (optional — bridge, terminal, TUI, media, and voice routes)
<details>
<summary><b>Repository structure</b></summary>
```
hermes-relay/
├── app/ # Android app (Kotlin + Jetpack Compose)
├── desktop/ # Hermes-Relay CLI thin-client (TS + Bun-compiled binary)
├── relay_server/ # WSS server (Python + aiohttp; thin shim → plugin/relay)
├── plugin/ # Hermes agent plugin
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, machine tools)
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
├── skills/devops/ # Hermes agent skills (pairing, self-setup, CLI setup recipes)
├── user-docs/ # VitePress documentation site
├── docs/ # Spec, decisions, security
├── scripts/ # Dev helper scripts
├── .github/workflows/ # CI + release pipelines (ci-android / ci-plugin / ci-desktop)
└── gradle/ # Wrapper (8.13) + version catalog
```
</details>
<details>
<summary><b>Running the server / plugin from a clone</b></summary>
<br>
End users should install via the [one-liner](#4--optional-install-relay-for-power-tools) above. For local development:
```bash
hermes relay start --no-ssl # if you installed the plugin
# or from a repo checkout:
python -m plugin.relay --no-ssl
```
python -m plugin.relay --no-ssl # or from a repo checkout
Or with Docker:
```bash
# Docker:
docker build -t hermes-relay relay_server/ && docker run -d --network host --name hermes-relay hermes-relay
```
See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
### Hermes Plugin (for contributors)
End users should install via the [one-liner](#2-install-the-server-plugin-one-liner) at the top. For local development from a clone:
```bash
cp -r plugin ~/.hermes/plugins/hermes-relay
# Or symlink for live edits:
# Live-edit the plugin against a local Hermes:
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
```
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
Then restart hermes and run `hermes pair` to verify. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
## Hermes Agent
</details>
## Built for Hermes Agent
Hermes-Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — an open-source AI agent platform by [Nous Research](https://nousresearch.com). See the [Hermes Agent docs](https://hermes-agent.nousresearch.com) for server setup, gateway configuration, and plugin development.
## Found a bug? Let us know!
## Found a bug? Let us know
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line "this didn't work on my Pixel 7" / "the alpha.14 Windows binary segfaults on my Surface" is genuinely useful.
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line *"this didn't work on my Pixel 7"* is genuinely useful.
## Star History
+314 -114
View File
@@ -3,7 +3,7 @@
> The full recipe for cutting a new release. Read this end-to-end before
> tagging your first release.
## Versioning
## Release Tracks And Versioning
Hermes-Relay follows [SemVer](https://semver.org/): `MAJOR.MINOR.PATCH`,
with optional prerelease identifiers.
@@ -13,6 +13,26 @@ with optional prerelease identifiers.
- `PATCH` — bug fixes, backwards compatible
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
Hermes-Relay now ships three independently versioned surfaces. Public GitHub
Release titles use product names (`Hermes-Relay-Android`,
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable
for automation.
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|---|---|---|---|---|
| Hermes-Relay-Android | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
| Hermes-Relay-Plugin | `plugin-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
| Hermes-Relay-CLI | `cli-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-cli.yml` |
This split is intentional. The plugin carries relay features for both Android
and CLI clients, so plugin fixes can ship without forcing an Android app
`versionCode` bump, and CLI alphas can continue on their own cadence. Historical
Android releases before this naming split used bare `v*` tags. Historical
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used
`desktop-v*` tags. New releases use the explicit tag prefixes above.
### Android app versioning
**Source of truth:** `gradle/libs.versions.toml`
```toml
@@ -44,30 +64,56 @@ Never decrement `appVersionCode` — Play Console rejects any upload whose
code is lower than or equal to a previous upload on the same track. Confirm
current values with `scripts\dev.bat version`.
### The three version sources (MUST stay in lockstep)
There are **three** places the version lives, and they MUST all match on
every release commit. Drift is silent and painful — we chased a "why does
/health say 0.2.0" bug for hours on 2026-04-12 because `pyproject.toml`
had drifted to `0.5.0` speculatively and `plugin/relay/__init__.py` was
still at a stale `0.2.0`.
| File | Line | Written by |
|---|---|---|
| `gradle/libs.versions.toml` | `appVersionName = "…"` | You (canonical) |
| `pyproject.toml` | `version = "…"` | You (Python package) |
| `plugin/relay/__init__.py` | `__version__ = "…"` | You (runtime, reported by `/health`) |
**Always bump them atomically via `scripts/bump-version.sh`**:
Always bump Android releases via:
```bash
bash scripts/bump-version.sh 0.3.0
bash scripts/bump-android-version.sh 0.6.2
```
The script validates SemVer, bumps `appVersionCode` monotonically, rewrites
all three files, runs a post-bump sanity grep, prints the diff, and tells
you the next steps. It deliberately does NOT commit, tag, or touch
`CHANGELOG.md` / `RELEASE_NOTES.md` — those need human prose.
`scripts/bump-version.sh` remains as a backward-compatible alias for the
Android script.
### Plugin / Python package versioning
Plugin version metadata lives in these plugin-owned files and must stay in
lockstep:
| File | Line | Purpose |
|---|---|---|
| `pyproject.toml` | `version = "..."` | Python package metadata |
| `plugin/relay/__init__.py` | `__version__ = "..."` | runtime version reported by `/health` and `/relay/info` |
| `plugin/plugin.yaml` | `version: ...` | Hermes plugin metadata |
| `plugin/dashboard/manifest.json` | `"version": "..."` | Hermes dashboard plugin metadata |
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
Always bump Plugin releases via:
```bash
bash scripts/bump-plugin-version.sh 0.6.2
```
Check the current metadata with:
```bash
python scripts/check-plugin-version-sync.py
```
Check all release tracks at once with:
```bash
python scripts/check-version-tracks.py
```
This aggregate check reports Android, plugin, and CLI versions
side by side and validates that each track's own source files are internally
consistent. It deliberately does not require all three tracks to share the same
SemVer.
The `plugin-v*` release workflow validates the tag against the same metadata,
runs plugin tests, builds a wheel and sdist, generates checksums, and
publishes a `Hermes-Relay-Plugin vX.Y.Z` GitHub Release with the package
artifacts.
## Branching policy
@@ -85,8 +131,8 @@ release" rule. The `[Unreleased]` section of `CHANGELOG.md` on `dev` is
the accumulator: every merged PR appends bullets there. A release is a
separate act, taken when the accumulated state on `dev` is worth shipping
(see "When to cut a release" below). Cutting a release means opening a
`release: vX.Y.Z` PR from `dev` into `main`, merging it `--no-ff`, then
tagging `main`.
surface-specific release PR from `dev` into `main`, merging it `--no-ff`,
then tagging `main`.
**Server tracks `dev` for staging.** The hermes-host deployment pulls
`dev` so merged features get exercised against real data before they
@@ -125,23 +171,24 @@ Squash merges lose that detail and are **not** the house style.
### Version bumps happen at release-prep on `dev`, NOT on feature branches
Feature branches **never** touch `gradle/libs.versions.toml`,
`pyproject.toml`, or `plugin/relay/__init__.py`. If two feature branches
both bumped the version, they'd collide on `appVersionCode` (which must
be monotonic) and you'd hit a merge conflict for no good reason.
plugin-owned version metadata, or `desktop/package.json`.
If two feature branches both bumped a release version, they'd collide on
version files and, for Android, on `appVersionCode` (which must be
monotonic).
The version-bump commit lives on `dev`, created via
`scripts/bump-version.sh`, as the last commit of the release-prep work.
It's a dedicated commit with the message `release: vX.Y.Z` that also
lands the CHANGELOG and RELEASE_NOTES updates. A release PR then merges
`dev` → `main` with `--no-ff`, and the `v<version>` tag is cut from the
resulting `main` tip.
Version-bump commits live on `dev` as the last commit of release-prep
work. Android commits use `release(android): android-vX.Y.Z`; plugin commits
use `release(plugin): plugin-vX.Y.Z`; CLI commits use
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` →
`main` with `--no-ff`, and the matching tag is cut from the resulting
`main` tip.
### Branch protection
Light branch protection is enabled:
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
here. PR must pass CI (Android + Relay) before merge. Force push and
here. PR must pass CI (Android + Plugin) before merge. Force push and
branch deletion blocked.
- **`dev`** — direct pushes blocked for non-trivial work; feature
branches PR in. PR must pass CI. Force push and branch deletion
@@ -177,10 +224,12 @@ hermes.key.password=YOUR_KEY_PASSWORD
```
`local.properties`, `*.keystore`, and `*.jks` are already gitignored.
Relative `hermes.keystore.path` values resolve from the repo root, so
`release.keystore` works when the keystore lives beside this file.
> If the keystore at `hermes.keystore.path` is missing, `app/build.gradle.kts`
> silently falls back to debug signing. The build succeeds but Play Console
> rejects the AAB — always verify with `keytool -list -printcert` (step 3
> rejects the AAB — always verify with `keytool -printcert` (step 3
> below).
#### CI builds
@@ -241,27 +290,40 @@ for the full text.
### 3. Play Developer API service account (optional)
Required only if you want `gradlew publishReleaseBundle` to upload directly
to Play Console. Manual UI uploads work without this.
Required for automated upload (the `android-v*` workflow's Play step, or local
`gradlew publishGooglePlayReleaseBundle`). Manual UI uploads work without this.
1. Open <https://console.cloud.google.com/> and select the project linked
to your Play Console account (Play Console > Setup > API access shows
which one).
2. **IAM & Admin > Service Accounts > Create Service Account** (e.g.
`hermes-relay-publisher`). No project roles needed.
3. On the new service account, **Keys > Add key > Create new key > JSON**
and download the file.
4. In Play Console > **Setup > API access**, find the service account,
click **Grant access**, and assign the **Release manager** role.
5. Save the JSON as `play-service-account.json` in the repo root (already
in `.gitignore`).
6. Verify with `gradlew bootstrapReleasePlayResources` — should succeed
without auth errors.
The service account is **created in Google Cloud Console** and then **authorized
in Play Console** — two separate consoles. (Play Console's older "Setup > API
access" page has been reorganized; there is no longer a "Setup" group. Use the
paths below.)
1. **Create the service account (Google Cloud Console).** Open
<https://console.cloud.google.com/iam-admin/serviceaccounts>, pick the project
(any project works; if Play Console's **API access** page already names a linked
project, use that one). **Create service account** → name it e.g.
`hermes-relay-publisher` → **Done**. No project roles needed.
2. **Create a JSON key.** On the new service account → **Keys** tab → **Add key >
Create new key > JSON** → download. This file's *contents* are the secret.
3. **Authorize it in Play Console.** Open the Play Console account-level left
sidebar → **Users and permissions** → **Invite new users** → paste the service
account's email (`...@...iam.gserviceaccount.com`). Under **App permissions**
(for `com.axiomlabs.hermesrelay`) or **Account permissions**, grant the
**Release** permissions — "Release apps to testing tracks" and "Release to
production, exclude devices, and use Play App Signing" — plus "View app
information". (Granting **Admin (all permissions)** also works but is broader
than needed.) **Invite user**.
4. **Use it.** For CI, paste the JSON contents into the `PLAY_SERVICE_ACCOUNT_JSON`
repo secret (step 4 / secrets table). For local publish, save the JSON as
`play-service-account.json` in the repo root (already in `.gitignore`).
5. Verify locally with `gradlew bootstrapGooglePlayReleaseResources` — succeeds
without auth errors once permissions propagate (allow a few minutes).
### 4. GitHub Actions secrets
In the repo: **Settings > Secrets and variables > Actions > New repository
secret.** Add all four (see the table in "Required GitHub Secrets" below).
secret.** Add all four (see the table in "Required Android Release Secrets"
below).
If `HERMES_KEYSTORE_BASE64` is missing, CI release builds fall back to
debug signing and print a warning in the workflow summary — those
@@ -288,21 +350,20 @@ is a statement to users that "this is a thing worth updating to," so
the threshold is intent-driven, not event-driven.
If you want to dogfood accumulated `main` state without declaring GA,
tag a **pre-release** (`vX.Y.Z-rc.N`). Users can opt in via
tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
the unstable build.
## Release Process
### 1. Bump the version (atomic across all three sources)
### 1. Bump the Android app version
Use `scripts/bump-version.sh` — it rewrites `libs.versions.toml`,
`pyproject.toml`, AND `plugin/relay/__init__.py::__version__` in lockstep,
increments `appVersionCode` monotonically, and runs a sanity check. Don't
edit the files by hand; drift is silent and painful.
Use `scripts/bump-android-version.sh`. It rewrites
`gradle/libs.versions.toml`, increments `appVersionCode` monotonically,
and runs a sanity check. Don't edit the Android version files by hand.
```bash
bash scripts/bump-version.sh 0.3.0
bash scripts/bump-android-version.sh 0.6.2
```
Confirm the bump:
@@ -311,11 +372,17 @@ Confirm the bump:
scripts\dev.bat version
```
The script's diff output should show exactly three files changed and all
three carrying the new version string.
The script's diff output should show `gradle/libs.versions.toml` carrying
the new app version and a higher `appVersionCode`.
### 2. Update release notes and changelog
> Each surface has its own GitHub-Release-body file, all in the same format
> (Summary + Added/Changed/Fixed + Install/Verify): `RELEASE_NOTES.md` (Android),
> `PLUGIN_RELEASE_NOTES.md` (plugin), `CLI_RELEASE_NOTES.md` (CLI). This step covers
> the Android artifacts; the plugin/CLI files are filled in their own release
> sections below but follow the identical scrub and Keep-a-Changelog grouping.
- `CHANGELOG.md` — promote the accumulated `[Unreleased]` block to a
versioned header. The block already exists: every feature PR has
been appending to it. All you do here is:
@@ -340,13 +407,40 @@ three carrying the new version string.
(v0.4.0 shipped with 0.1.0 content until caught post-release).
- `docs/play-store-listing.md` — Play Store listing copy. Update
the version reference and the "Release Notes" section that gets
pasted into the Play Console "What's new" field.
pasted into the Play Console "What's new" field. Keep the Play
"What's new" within **500 characters** and framed around the
release's themes, not a feature dump.
#### Scrub for public distribution
This is a **public repo** and these four files are user-facing. Before
promoting the `[Unreleased]` block and writing the notes, scrub the
versioned CHANGELOG block and all three release-notes artifacts for
wording that shouldn't ship publicly. The CHANGELOG accumulates in a
dev-log voice during the iteration phase — release-prep is where it
becomes public copy. Check for and remove/rewrite:
- **Personal names / quoted asides** — `git grep -niE "bailey|: \"" CHANGELOG.md`
on the new block. Attribute fixes impersonally ("a user reported"),
not by name. (Author identity already lives in git + the signing cert.)
- **Private infrastructure** — server hostnames/IPs, `~/SYSTEM.md`,
internal deployment names, anything that should stay in the operator's
environment and not the repo. `grep -niE "192\.168|10\.0\.|hermes-host|SYSTEM\.md"`.
(Example IPs like `192.168.1.100` in install docs are fine.)
- **Fork / branch plumbing + internal nicknames** — references to private
fork branches, rollout channels, or in-team incident nicknames read as
internal. Keep the *what changed*, drop the *where we staged it*.
- **Personal example data** — genericize sample profile/agent names to
neutral placeholders so the copy doesn't expose a specific setup.
The goal is that someone who has never seen the repo can read the block
and the release notes and learn only what the software does.
### 3. Build and verify locally
```bat
scripts\dev.bat bundle
keytool -list -printcert -jarfile app\build\outputs\bundle\googlePlayRelease\hermes-relay-*-googlePlay-release.aab
keytool -printcert -jarfile app\build\outputs\bundle\googlePlayRelease\hermes-relay-*-googlePlay-release.aab
```
The `keytool` output must show your release certificate (the CN/OU/O
@@ -366,7 +460,7 @@ Optional device smoke test: `scripts\dev.bat release` then
### 4. Commit on `dev`, merge to `main`, tag from `main`
The release-prep commit lands on `dev` first. Then a release PR merges
`dev` → `main` with `--no-ff`, and the `v<version>` tag is cut from the
`dev` → `main` with `--no-ff`, and the `android-v<version>` tag is cut from the
resulting merge commit on `main`:
```bash
@@ -374,55 +468,111 @@ resulting merge commit on `main`:
git checkout dev
git pull --ff-only origin dev
git add gradle/libs.versions.toml pyproject.toml plugin/relay/__init__.py \
RELEASE_NOTES.md CHANGELOG.md \
git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md \
app/src/main/assets/whats_new.txt docs/play-store-listing.md
git commit -m "release: v0.3.0"
git commit -m "release(android): android-v0.6.2"
git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff.
# After merge, tag from the new main tip:
git checkout main
git pull --ff-only origin main
git tag v0.3.0
git push origin v0.3.0
git tag android-v0.6.2
git push origin android-v0.6.2
```
Pushing a tag matching `v*` triggers `.github/workflows/release.yml`,
Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.yml`,
which builds, signs, checksums, and creates a GitHub Release. Watch the
run under the **Actions** tab.
> **Why all three files in the commit?** See "The three version sources"
> above — `bump-version.sh` rewrites them atomically, so they must be
> staged + committed atomically too. Missing one creates the same drift
> the script was built to prevent.
Plugin/Python version files are intentionally not part of an Android app
release unless the plugin package itself is also being released.
### Plugin / Python package release
Use this when plugin or relay behavior changes independently of Android app
delivery, for example CLI channel support, bridge routes, pairing server fixes,
voice auth, dashboard plugin UI, or packaging changes.
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for
`plugin-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
Summary and the Added/Changed/Fixed groups from the plugin-relevant bullets in the
promoted `CHANGELOG.md` block, keep the `__VERSION__` token in the Install command
(the workflow substitutes it), and apply the same public-distribution scrub as §2.
```bash
git checkout dev
git pull --ff-only origin dev
bash scripts/bump-plugin-version.sh 0.6.2
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md PLUGIN_RELEASE_NOTES.md
git commit -m "release(plugin): plugin-v0.6.2"
git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff.
# After merge, tag from the new main tip:
git checkout main
git pull --ff-only origin main
git tag plugin-v0.6.2
git push origin plugin-v0.6.2
```
Pushing `plugin-v*` triggers `.github/workflows/release-plugin.yml`, which
validates all plugin-owned version metadata with
`scripts/check-plugin-version-sync.py`. Run
`python scripts/check-version-tracks.py` locally before tagging when a change
touches more than one release surface. The workflow also runs plugin tests,
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
Release named `Hermes-Relay-Plugin v<version>` for the plugin package.
### 5. Upload to Play Console
**Manual upload (default):**
> **If `PLAY_SERVICE_ACCOUNT_JSON` is configured as a repo secret, this step is
> automated for stable tags.** The release workflow runs
> `publishGooglePlayReleaseBundle --track=production` and the build appears as a
> Production **draft** — skip to the Play Console, confirm the draft, and click
> **Start rollout**. The manual path below is the fallback when the secret is
> unset (or for staging on a non-production track).
**Pick the track first.** The AAB is track-agnostic — the same
`-googlePlay-release.aab` goes to whichever track you publish on. Choose by intent,
not habit:
- **Production** — the default for a stable GA release (`android-vX.Y.Z`). The
listing is live, so this is where real releases land. The org account is
D-U-N-S-verified, so the 14-day / 12-tester closed-testing gate does **not**
apply — you can publish straight to Production.
- **Open / Closed testing** — only when you actually want a public/private beta
channel for this build.
- **Internal testing** — only for a throwaway pre-release smoke check (e.g. a
prerelease tag), not for a GA. Don't default here.
**Manual upload:**
1. Download the file ending in `-googlePlay-release.aab` from the GitHub
Release assets (for example, `hermes-relay-0.3.0-googlePlay-release.aab`),
Release assets (for example, `hermes-relay-1.0.0-googlePlay-release.aab`),
or use your local build at
`app\build\outputs\bundle\googlePlayRelease\hermes-relay-<version>-googlePlay-release.aab`.
2. In Play Console: **Release > Testing > Internal testing** (the 14-day
closed-testing rule does NOT apply to this account — see "Google Play
Console developer account" above).
2. In Play Console, open the track you chose above — for a GA that's
**Release > Production**.
3. **Create new release** > upload the AAB.
4. Paste `RELEASE_NOTES.md` into the release notes field.
5. **Review release** > **Start rollout.**
4. Paste the Play "What's new" from `docs/play-store-listing.md` (≤500 chars) into
the release notes field. (`RELEASE_NOTES.md` is the GitHub-Release body, not the
Play field — don't paste that; it's over the limit.)
5. **Review release** > **Start rollout** (set the staged-rollout percentage if you
want a gradual production ramp).
**Automated upload (if `play-service-account.json` is configured):**
```bat
scripts\dev.bat bundle
gradlew publishReleaseBundle
gradlew publishReleaseBundle --track=production
```
Defaults to the `internal` track with `DRAFT` status (configured in the
`play { }` block in `app/build.gradle.kts`). Override per-invocation with
`--track=alpha` (= Closed testing), `--track=beta` (= Open testing), or
`--track=production`.
The `play { }` block in `app/build.gradle.kts` defaults to the `internal` track
with `DRAFT` status as a safety net for unattended runs, so pass `--track` explicitly
for a real release: `--track=production` (GA), or `--track=alpha` (Closed) /
`--track=beta` (Open) for a beta channel.
To promote an existing release between tracks without rebuilding:
@@ -430,18 +580,24 @@ To promote an existing release between tracks without rebuilding:
gradlew promoteReleaseArtifact --from-track=internal --promote-track=alpha
```
### 6. Promote through tracks
### 6. Tracks (a menu, not a mandatory ladder)
Typical path:
The org account is exempt from the 14-day / 12-tester closed-testing rule, so a
stable GA publishes **straight to Production** — there is no required promotion
chain. The other tracks are opt-in tools, not steps you must climb:
1. **Internal testing** — personal smoke test (no tester or time minimum)
2. **Closed testing (alpha)** — optional for staged rollout; Axiom-Labs'
org account is exempt from the 14-day / 12-tester rule, so you can skip
straight from Internal to Production if the build is ready
3. **Open testing (beta)** — optional public beta
4. **Production** — live on the Play Store
- **Production** — live on the Play Store. Where GA releases go.
- **Open testing (beta)** — opt-in public beta channel.
- **Closed testing (alpha)** — opt-in private beta (named tester lists).
- **Internal testing** — throwaway smoke check (e.g. a prerelease tag), no tester
or time minimum.
Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
If you *do* stage through tracks, promote an existing release without rebuilding via
the Play Console UI or:
```bat
gradlew promoteReleaseArtifact --from-track=internal --promote-track=production
```
### 7. After release
@@ -451,9 +607,9 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
`RELEASE_NOTES.md` this will already be baked in. If for some reason
it's missing, edit the body with:
```bash
gh release view vX.Y.Z --repo Codename-11/hermes-relay --json body --jq .body > /tmp/body.md
gh release view android-vX.Y.Z --repo Codename-11/hermes-relay --json body --jq .body > /tmp/body.md
# edit /tmp/body.md to add/fix the Download section
gh release edit vX.Y.Z --repo Codename-11/hermes-relay --notes-file /tmp/body.md
gh release edit android-vX.Y.Z --repo Codename-11/hermes-relay --notes-file /tmp/body.md
```
(This step was only needed as a retrofit for v0.1.0 — v0.1.1+ inherit
the Download section automatically from `RELEASE_NOTES.md`.)
@@ -462,23 +618,52 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
## CI Behavior
On every push of a tag matching `v*`, `.github/workflows/release.yml`:
Android, Plugin, dashboard, and desktop now have separate CI/release lanes.
This keeps a dashboard CSS fix from running the full server suite, and keeps
plugin changes from forcing an Android app `versionCode` bump.
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
1. Validates the tag matches `appVersionName` in
`gradle/libs.versions.toml` (mismatches fail the workflow).
2. Runs `./gradlew assembleDebug` and `./gradlew test`.
2. Runs the Android debug build and the stable sideload pairing/connection
regression slice with explicit timeouts.
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
4. Builds both artifacts: `./gradlew bundleRelease assembleRelease`.
4. Builds both Android release artifacts:
`./gradlew bundleRelease assembleRelease`.
5. Generates `SHA256SUMS.txt` covering both.
6. Creates a GitHub Release named `v<version>` with `RELEASE_NOTES.md` as
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
containing a dash (e.g. `v0.2.0-beta.1`) as a prerelease automatically.
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
7. Prints a `$GITHUB_STEP_SUMMARY` showing whether release signing
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
that the artifacts are debug-signed and unsuitable for Play Store.
## Required GitHub Secrets
On every push of a tag matching `plugin-v*`,
`.github/workflows/release-plugin.yml`:
1. Validates the tag matches all plugin-owned version metadata checked by
`scripts/check-plugin-version-sync.py`.
2. Runs plugin syntax checks and the focused route/auth/session test slice.
3. Builds the Python wheel and sdist with `python -m build`.
4. Generates `dist/SHA256SUMS.txt`.
5. Creates a GitHub Release named `Hermes-Relay-Plugin v<version>` with the wheel,
sdist, and checksum file attached.
On every push of a tag matching `cli-v*`,
`.github/workflows/release-cli.yml` builds and publishes the CLI binaries and
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
substitutes `__VERSION__` (bare, e.g. `0.3.0`) and `__TAG__` (full, e.g.
`cli-v0.3.0`) so the install/pin commands stay accurate. Fill its Summary and
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
Dashboard-only changes are covered by
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
runs the dashboard API tests, and verifies the modal CSS markers are present
in the built bundle.
## Required Android Release Secrets
| Secret | Purpose | How to populate |
|-----------------------------|-------------------------------------|--------------------------------------------------|
@@ -486,37 +671,52 @@ On every push of a tag matching `v*`, `.github/workflows/release.yml`:
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
| `HERMES_KEY_ALIAS` | Key alias | Alias set during `keytool -genkey` |
| `HERMES_KEY_PASSWORD` | Key password | Usually the same as the store password |
| `PLAY_SERVICE_ACCOUNT_JSON` | **Optional** — Play auto-upload | Paste the full Play Developer API service-account JSON (step 3) |
If `PLAY_SERVICE_ACCOUNT_JSON` is set, the `android-v*` release workflow uploads
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable
tags only — prereleases are skipped). CI does the upload; you still click **Start
rollout** in Play Console. If the secret is unset, the workflow skips the upload
and you upload manually (§5) — nothing else changes.
## Hotfix Recipe
When production has a bug and you need to ship a fix without picking up
unreleased work from `dev`:
unreleased work from `dev`, branch from the affected release tag and only
bump the version source for the surface you are shipping.
1. `git checkout -b fix/short-name v0.1.0` — branch from the released
tag (not from `main` or `dev`).
For an Android app hotfix:
1. `git checkout -b fix/short-name android-v0.6.1` — branch from the released
Android tag (not from `main` or `dev`).
2. Apply the fix, add a test, commit.
3. Bump `appVersionName` and `appVersionCode` in
`gradle/libs.versions.toml` (and the other two version sources via
`scripts/bump-version.sh`).
4. Update `RELEASE_NOTES.md` and `CHANGELOG.md`.
3. Run `bash scripts/bump-android-version.sh 0.6.2` to update
`gradle/libs.versions.toml`.
4. Update `RELEASE_NOTES.md`, `CHANGELOG.md`, in-app What's New, and Play
listing notes as needed.
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
6. `git tag v0.1.1` from the new `main` tip and `git push origin v0.1.1`
— CI builds and publishes.
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
so Android release CI builds and publishes.
7. Upload to Play Console as normal.
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
so `dev` picks up the hotfix and the version bumps. Without this,
`dev`'s `appVersionCode` lags behind `main` and the next release
so `dev` picks up the hotfix and the versionCode bump. Without this,
`dev`'s `appVersionCode` lags behind `main` and the next app release
bump collides.
For a Plugin hotfix, branch from the affected `plugin-v*` tag, apply
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
`main`, and tag `plugin-v<next-version>`. Do not touch
`gradle/libs.versions.toml` unless an Android app release is also shipping.
## Troubleshooting
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
You pushed a tag before bumping `gradle/libs.versions.toml`, or vice versa.
Fix: update the file, commit, delete the remote tag
(`git push --delete origin vX`), re-tag, and push again.
(`git push --delete origin android-vX`), re-tag, and push again.
**Play Console rejects the AAB as debug-signed**
Run `keytool -list -printcert -jarfile <aab>` locally — if it shows
Run `keytool -printcert -jarfile <aab>` locally — if it shows
`CN=Android Debug`, fix `local.properties` for local builds or
`HERMES_KEYSTORE_BASE64` for CI. For CI, check the workflow summary; if it
says "Debug-signed", one of the four `HERMES_*` secrets is missing or the
+30 -39
View File
@@ -1,64 +1,55 @@
# Hermes-Relay v0.6.1
# Hermes-Relay-Android v1.1.0
**Release Date:** May 6, 2026
**Since v0.6.0:** API-key voice auth, durable pairing/session recovery, route failover hardening, dashboard pairing UI polish, and full Android bridge media/MMS handoff support.
**Release Date:** June 16, 2026
**Since v1.0.0:** A settings + chat-UX overhaul — quieter status surfaces, a single state-aware plugin badge, and chat-settings polish — plus a force-close fix and release-pipeline upgrades.
v0.6.1 is a compatibility and bridge-contract release. It keeps the v0.6.0 multi-connection model, then tightens the real-world paths we just exercised: LAN/Tailscale pairing, API-key voice mode, stale session recovery, and Android phone bridge tools.
v1.1.0 is a refinement release on top of the 1.0 milestone. Settings is calmer and easier to read: status pills now appear only when a surface needs attention, the Power tools section shows one **Plugin active / required / offline** badge instead of an identical chip on every card, and the most-used controls sit where you reach for them. Chat settings render correctly, the system-prompt preview reflects your toggles, and a crash that could hit right after a successful pair is gone.
---
## Download
v0.6.1 ships in two build flavors. APK and AAB filenames are version-tagged:
v1.1.0 ships in two Android build flavors. APK and AAB filenames are version-tagged:
| Flavor | File | Who it's for |
|---|---|---|
| sideload | `hermes-relay-0.6.1-sideload-release.apk` | Recommended for full bridge/device-control features, including share/MMS/file handoff. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| Google Play | `hermes-relay-0.6.1-googlePlay-release.aab` | Conservative Play-track build for chat and voice without sideload-only bridge-control surfaces. |
| googlePlay APK | `hermes-relay-0.6.1-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-0.6.1-sideload-release.aab` | Parity/testing artifact. |
| Google Play | `hermes-relay-1.1.0-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
| sideload | `hermes-relay-1.1.0-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-1.1.0-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-1.1.0-sideload-release.aab` | Parity/testing artifact. |
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for install steps.
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
---
## Highlights
### Voice auth and pairing durability
### Settings screen overhaul
- Voice routes now accept either Relay session voice grants or a saved Hermes API bearer token for `/voice/config`, `/voice/transcribe`, and `/voice/synthesize`.
- Chat and voice can work from manual API URL + API key setup without a full QR pairing. Bridge, terminal, Android control, media, clipboard, and pair-session features still use the Relay session path.
- Relay sessions can recover through trusted device refresh after server restarts/updates, reducing forced re-pairing.
- Pairing and endpoint resolution were hardened for LAN/Tailscale transitions, including route normalization and clearer VPN fallback behavior.
Settings was reorganized around what you actually touch and quieted down everywhere else:
### Android bridge media, files, and MMS handoff
- **Exception-only status pills.** Status pills now appear only when a surface needs attention and stay quiet when everything is healthy — no more a wall of green chips to read past.
- **One state-aware plugin badge.** The Power tools section shows a single **Plugin active / required / offline** badge instead of an identical "Relay paired" chip repeated on every card.
- **Layout that follows your reach.** Connections moved to the top (above the Hermes section), and Diagnostics + Developer options moved into the App section.
- **Restyled to match the app.** The status chips now use the app's translucent-bordered language, and the brand blue was deepened.
- Added `android_share_media` for text, files, relay media tokens, screenshots, and attachment lists through Android's native share sheet.
- Added `android_send_mms` for MMS compose/share handoff with body text and attachments.
- Added the missing `/return_to_hermes` relay route and `android_return_to_hermes` tool so docs, tools, relay HTTP, and the phone command contract match.
- `android_send_sms` remains text-only and now returns structured states such as `sent`, `blocked`, `awaiting_confirmation`, `timeout`, and `failed`.
- The active plugin import now uses `plugin.tools.android_tool` as the single source of truth, preventing Hermes tool exposure drift.
### Chat settings polish
### Dashboard and operator flow
- **Streaming-endpoint picker fixed.** The picker no longer wraps "Gateway" / "Sessions" onto a second line.
- **Live system-prompt preview.** The system-prompt preview now reflects the context toggles you've enabled (foreground app, battery, safety rails) with representative placeholder values, instead of looking inert.
- Dashboard-minted pairing QRs and the QR modal styling were corrected so the plugin owns its dialog layout instead of inheriting host dashboard card styling.
- The relay CLI can toggle insecure LAN API-key voice auth at runtime with `hermes relay insecure-api-key status|on|off`.
- Docs now spell out the bridge HTTP routes, SMS schema, `current_app` limitations, share/MMS handoff behavior, sideload-only restrictions, and direct HTTP fallback route contract.
### Force-close fix
A corrupt encrypted token store — which can happen after an app upgrade or a device restore — used to throw during construction and crash the app right after a successful pair, on both standard and relay connections. The token store now heals a corrupt keyset in place, and credential storage degrades to a re-pair instead of crashing if the device keystore is unusable.
### Release pipeline
- **Automated Play Console upload.** When a `PLAY_SERVICE_ACCOUNT_JSON` secret is configured, pushing a stable `android-v*` tag uploads the `googlePlay` App Bundle to the Production track as a draft (a human still starts the rollout). Prereleases are skipped, and the `sideload` flavor is structurally blocked from ever publishing to Play. Without the secret, releases publish to GitHub Releases exactly as before.
- **Desktop UI preview harness (`:ui-preview`).** A non-shipped Compose for Desktop module renders presentational composables in a window on the PC with Compose Hot Reload, for fast UI iteration without a device build/install loop. It reuses the shared sphere algorithm as its single source of truth.
---
## Verification
## Upgrade notes
- Relay/tool regression slice: `134 passed`
- Android Kotlin compile: `:app:compileSideloadDebugKotlin` passed
- Android Kotlin compile: `:app:compileGooglePlayDebugKotlin` passed
- Dashboard build passed before deployment
- Remote staging deploy restarted `hermes-relay` and `hermes-gateway`, both active
## Post-install smoke
- Install the sideload APK over the existing sideload app with `adb install -r`.
- Existing pairing should survive a normal same-flavor update. Re-pair only if you uninstall app data, switch flavor/applicationId, revoke the device, or the server session store was intentionally cleared.
- Confirm chat works over the selected route, then test voice with the saved Hermes API key.
- For bridge media: try `android_share_media` or `POST /share_media` with a relay media token or host file path. The phone should show an on-device confirmation and then Android's native share UI.
- For MMS: `android_send_mms` opens the composer with the attachment; it does not silently send MMS.
- The force-close fix means devices that previously crashed on connect after an upgrade or restore will heal their token store automatically on first launch of this build — no manual re-pair required in most cases.
- `appVersionCode` is **13**.
+7 -4
View File
@@ -14,7 +14,7 @@ Native Android companion for the [Hermes agent platform](https://github.com/Nous
### Desktop track (parallel lane to Android) — **experimental**
Release tags: `desktop-v*` (separate cadence from Android `v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
Release tags: `cli-v*` (separate cadence from Android `android-v*` and Plugin `plugin-v*`). Historical alpha prereleases used `desktop-v*`, and the installer/updater keep a migration fallback. Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-cli.yml`](.github/workflows/release-cli.yml).
**Shipped (2026-04-23 — first tagged release `desktop-v0.3.0-alpha.1`):**
@@ -30,7 +30,9 @@ Release tags: `desktop-v*` (separate cadence from Android `v*`). Curl-installed
**Active — `desktop-v0.3.0-alpha.7` (native image paste):** Plan at [`docs/plans/2026-04-23-desktop-alpha-7-native-paste.md`](docs/plans/2026-04-23-desktop-alpha-7-native-paste.md). Two-repo workstream: client slash commands `/paste` (clipboard), `/screenshot` (primary display), `/image <path>` (file) land in `hermes-relay chat`, each echoes a one-line feedback and attaches the image to the next `prompt.submit` so the vision-capable model sees it in the same turn — parity with Claude Desktop's paste UX minus OS-level Ctrl+V (terminals don't pipe image bytes to stdin). Client half is new `desktop/src/chatAttach.ts` + slash-command branches in `desktop/src/commands/chat.ts`. Server half is ONE new `@method("image.attach.bytes")` on the fork's `tui_gateway/server.py` (branch `feat/image-attach-bytes` → merged to `axiom`); the fork's existing `_enrich_with_attached_images` already handles multimodal payload plumbing and session-scoped image state, so this release is almost entirely about bridging client-captured bytes to server-side state that's been there for months. Relay channel unchanged — `tui` is a transparent RPC forwarder. Graceful fallback when hermes-host hasn't been updated yet: client catches `method not found`, prints a pointer at the axiom rollout, REPL stays alive.
**Active — experimental desktop computer-use MVP:** Plan at [`docs/plans/desktop-computer-use-mvp.md`](docs/plans/desktop-computer-use-mvp.md). Current slice extends the existing desktop tool channel with `desktop_computer_*` contracts for status, screenshot observe mode, grant request/cancel, and Windows-only action execution behind desktop-tool consent and a visible, task-scoped grant approval. It does not implement unrestricted or silent mouse/keyboard automation and does not depend on npm publication.
**Active — desktop control / computer-use:** Enhanced plan at [`docs/plans/desktop-control-computer-use-enhanced.md`](docs/plans/desktop-control-computer-use-enhanced.md); earlier MVP implementation record at [`docs/plans/desktop-computer-use-mvp.md`](docs/plans/desktop-computer-use-mvp.md). Windows now has the first Tauri tray/overlay app as the primary Easy/Standard install surface: pair, start/pause daemon, Devices/Revoke, Task Log, Settings, overlay status chip, emergency stop, and bundled CLI sidecar. The existing CLI and daemon remain the primary advanced/headless surface. `desktop_computer_*` schemas are registered on the normal desktop tool channel but advertised only behind the explicit experimental computer-use flag. Host input still requires desktop-tool consent plus a visible, task-scoped assist/control grant; there is no unrestricted or silent mouse/keyboard automation.
**Desktop control UX direction:** Tauri v2 (Rust + static web UI) is the native shell for the polished Easy-tier experience: tray icon, always-visible overlay chip, task log, settings, and one-click pause/emergency stop. Easy tier pairs once, shows a connected/observing chip, and exposes Devices / Revoke / Task Log / Settings / Emergency Stop from the tray. Standard tier adds full tray management; Advanced tier remains CLI + daemon + JSON policy (`~/.hermes/desktop-control.json`) for operators. The default policy baseline blocks password managers, credential prompts, banking/payment/crypto surfaces, OS security/admin settings, and private-key/token material until locally overridden.
**Deferred to alpha.8 / alpha.9 / v1.0:**
@@ -40,15 +42,16 @@ Release tags: `desktop-v*` (separate cadence from Android `v*`). Curl-installed
- **Environment-variable passthrough** — security-sensitive; needs per-var prompt UX + threat model before shipping.
- **Global hotkey to summon a prompt** — OS-specific helper installers; out of scope for binary-only release.
- **Watch mode** (`hermes-relay daemon --watch`) — needs a DSL and clear safety bounds; own feature branch.
- **Native assist/control grant modal hardening** — the tray-managed daemon now has a local grant bridge and Grant Requests view. Next pass should polish native modal behavior, notification routing, and multi-client grant ownership.
- **Kitty / iTerm2 inline image protocols for paste feedback** — would show a thumbnail of the attached image directly in the terminal after `/paste` instead of a plain text line. Most terminals don't support them; the slash-command feedback line works anywhere. Revisit if users request it.
**Earlier alpha.2–alpha.5 workstreams (now in-flight / done — see DEVLOG 2026-04-23 entries for specifics):**
- **`hermes-relay update` subcommand + auto-update nudge.** The binary today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls GitHub Releases API (`/repos/Codename-11/hermes-relay/releases/latest`), compares to `readVersion()`, and either shells out to the installer or downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
- **`hermes-relay update` subcommand + auto-update nudge.** The binary self-update path polls the GitHub Releases API, prefers `cli-v*`, falls back to historical `desktop-v*` prereleases during migration, compares to `readVersion()`, and downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
- **Workspace-awareness — desktop client sends cwd/git/hostname on connect.** Biggest lingering "is the agent working against the right tree?" problem. On WSS auth, the client advertises an ephemeral workspace descriptor — `cwd`, `git_root`, `git_branch`, `git_status_summary` (staged/modified counts), `repo_name`, `hostname`, `platform`, `active_shell`. Server-side `DesktopHandler` stashes it as live session metadata (NOT persistent state). New hermes-agent plugin hook injects a one-line ephemeral prompt prefix into the session context — *"Active desktop workspace: machine=Bailey-PC · repo=hermes-relay · branch=dev · staged=3"* — so the LLM reads it every turn without the operator having to explain. Also default `desktop_terminal` / `desktop_read_file` / `desktop_search_files` `cwd` to the repo root when unset. Expose the snapshot in `hermes-relay doctor` + `hermes-relay status` + a new `hermes-relay workspace` subcommand + a relay dashboard tab so both operator and agent have a common view. Pair with a `.hermes/workspace-context.json` file-based fallback for when the socket path can't be reached. Requires: new WSS envelope (`desktop.workspace` on connect), hermes-agent plugin hook for ephemeral context injection, schema coordination with the upstream `ContextVar` multi-client work.
- **Service installers** — `scripts/install-service-{win,linux,mac}.{ps1,sh}` — Windows Service via `sc.exe create`, `systemd --user` unit with `loginctl enable-linger`, `launchctl load` plist for macOS. Auto-start on login so the daemon is always reachable.
- **Multi-client routing on the `desktop` channel** — replace single-client MVP with per-token indexing + device-id reconnect handoff. Hermes session state carries `desktop_session_token` via a new `ContextVar` in `gateway/session_context.py` (hermes-agent PR candidate — won't affect Android). Natural pairing with the workspace-awareness envelope — the ContextVar scheme determines which client's workspace the active session sees.
- **Harden `release-desktop.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
- **Harden `release-cli.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
- **Signed binaries** — Windows EV code-signing (~$300/yr, DigiCert or SSL.com) + Apple Developer ID + notarization ($99/yr). Removes SmartScreen/Gatekeeper warnings. Prerequisite for the auto-update path.
- **npm registry publication** — future v1.0 distribution work. The package name is local workspace metadata today; current install paths are GitHub Release binaries or local clone + `npm link`.
- **HMAC verification on QR payloads** — defer until a client-accessible secret story exists (same deferral as the Android app). Not blocking GA.
+57
View File
@@ -6,6 +6,63 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Hands-free agentic voice backlog
Goal: make Hermes usable for hands-free work without leaving the operator blind
to tool state, safety prompts, or the current task.
- **Waveform output-start sync** — current input waveform timing feels good, but
the agent-output waveform can unfold and begin movement before audible speech
starts. Split "preparing audio" from "speaking audio" in the visual layer, or
gate the unfolded Speaking waveform on the first real playback frame/audio
amplitude. Processing can stay as the folded circular spinner until output is
actually audible.
- **Voice command layer** — reserve local commands that bypass normal agent
routing: "pause", "resume", "stop talking", "cancel", "repeat that", "open
overlay", "return to Hermes", and "new chat". These should work while the
agent is thinking, speaking, or using tools.
- **Spoken tool progress** — when Hermes uses tools, voice mode should speak
short status updates such as "I'm checking the relay logs" or "I found an
error" without waiting for final assistant text. Long tool calls should emit
periodic, low-noise progress updates.
- **Realtime tool timeline parity** — the voice overlay should render the same
live thinking blocks, streaming assistant text, and tool call progress as the
normal chat surface without requiring exit/reload.
- **Hands-free confirmation flow** — risky actions need first-class spoken and
visual confirmation: "yes", "no", "cancel", "confirm", plus a visible and
audible countdown for destructive actions.
- **Voice session memory/status** — add a compact "where are we?" summary for
the current voice task: active objective, last tool result, pending next step,
and whether the agent is waiting on the user.
- **Mode presets** — add presets such as Hands-free, Low latency, Careful tool
mode, and Quiet/visual-only. Hands-free should favor Continuous listening,
spoken tool progress, confirmations, and overlay availability.
- **Barge-in hardening** — keep barge-in experimental until echo/self-recording
is solved. The target path is proper AEC, playback-ducking, and a rule that
output audio can never become a user turn.
- **Audio quality guardrails** — normalize output volume across realtime and
fallback TTS providers, keep pronunciation hints/profile voice tuning, and
measure provider-specific delay, chunk gaps, and tail clipping.
- **Pluggable Realtime Agent media transports** — add an OpenAI-first WebRTC
transport option for Realtime Agent so mobile audio can use provider-native
jitter buffering, interruption, and media handling instead of only relay
WebSocket PCM. Design this as a provider transport interface
(`websocket`, `webrtc`, future `livekit`/SIP-style bridges) so other
realtime providers can opt in without forking the Hermes broker/tool
contract. Hermes must still own tools, memory, confirmations, current data,
and durable transcript state.
- **Voice engine selector** — implemented as an opt-in experimental Realtime
Agent engine in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
Follow-up work is provider-native turn-taking, richer confirmation handling,
and quality/latency evaluation before promotion beyond Experimental.
- **Realtime-native Hermes bridge prototype** — first relay-brokered slice
implemented in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
Remaining work: let OpenAI/xAI realtime sessions own more of the live speech
turn while still proxying every tool, confirmation, memory, and Android bridge
action through Hermes/relay safety.
---
## Research / open questions
### Proper Hermes plugin / skill / tool distribution
+35 -17
View File
@@ -54,11 +54,10 @@ android {
Properties().apply { localProps.inputStream().use { stream -> load(stream) } }
} else null
storeFile = file(
System.getenv("HERMES_KEYSTORE_PATH")
?: props?.getProperty("hermes.keystore.path")
?: "/nonexistent"
)
val keystorePath = System.getenv("HERMES_KEYSTORE_PATH")
?: props?.getProperty("hermes.keystore.path")
?: "/nonexistent"
storeFile = rootProject.file(keystorePath)
storePassword = System.getenv("HERMES_KEYSTORE_PASSWORD")
?: props?.getProperty("hermes.keystore.password") ?: ""
keyAlias = System.getenv("HERMES_KEY_ALIAS")
@@ -68,20 +67,18 @@ android {
}
}
// ─── Phase 3 — Bridge channel release tracks ────────────────────────────────
// Google Play scrutinizes AccessibilityService heavily (policy review + manual
// appeals are common), so Phase 3 ships two distinct tracks via flavor-merged
// manifests + flavor-scoped strings + flavor-scoped accessibility configs:
// ─── Bridge release tracks ─────────────────────────────────────────────────
// Google Play ships Bridge Core only: pairing, chat, voice, terminal/TUI,
// media, notification companion, relay sessions, and status. It does not
// declare AccessibilityService, overlay, MediaProjection, wake-lock device
// control, SMS/call/contact/location, or unattended-control permissions.
//
// googlePlay — conservative use-case description targeted at Play Store
// policy review. Subset of event types + flagDefault only.
// No gestures, no interactive-window reporting. Feature gates
// in BuildFlavor.kt hide tier 3/4/6 surfaces in the UI.
// googlePlay — canonical Play Store install. Bridge Core only.
//
// sideload — full agent-control description for users who install the
// APK directly (GitHub Releases, F-Droid, ADB). typeAllMask,
// gestures, interactive windows, view-id reporting. All six
// tiers enabled.
// sideload — Device Control for users who install directly (GitHub
// Releases, F-Droid, ADB). AccessibilityService, gestures,
// screenshots, overlay/status chip, and phone utilities are
// declared in the sideload manifest.
//
// applicationIdSuffix decision: sideload gets `.sideload` so both tracks can
// coexist on the same device. The Play build keeps the base
@@ -109,6 +106,19 @@ android {
}
}
// Structural guard: the sideload flavor is distributed via GitHub Releases /
// F-Droid / ADB and must NEVER be uploaded to Play Console (it declares the
// unattended Device Control surface Play forbids). gradle-play-publisher
// generates a publish task per variant, so the aggregate `publishReleaseBundle`
// would otherwise try BOTH flavors. Disabling sideload here means only
// `publishGooglePlayReleaseBundle` can ever reach Play — see the `play { }`
// block below and .github/workflows/release-android.yml.
playConfigs {
register("sideload") {
enabled.set(false)
}
}
buildTypes {
debug {
buildConfigField("boolean", "DEV_MODE", "true")
@@ -166,6 +176,9 @@ android {
// both failing with RuntimeException from unmocked Log.w calls.
testOptions {
unitTests.isReturnDefaultValues = true
// Robolectric (VoicePlayerTest) needs merged Android resources +
// manifest on the unit-test classpath to bootstrap its sandbox.
unitTests.isIncludeAndroidResources = true
}
}
@@ -230,6 +243,10 @@ dependencies {
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer.code)
// Coil 3 — async image loading for generated images in chat
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
// QR Code scanning (ML Kit + CameraX)
implementation(libs.mlkit.barcode)
implementation(libs.camera.core)
@@ -259,6 +276,7 @@ dependencies {
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.robolectric)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotlinx.serialization.json)
// MockWebServer for ADR 24 EndpointResolver tests — probes HEAD /health
@@ -4,7 +4,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNode
import androidx.compose.ui.test.performClick
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -0,0 +1,66 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import org.junit.Rule
import org.junit.Test
class PowerFeatureGateUiTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun requiresPairingCard_showsPairToUnlock() {
composeTestRule.setContent {
MaterialTheme {
PowerFeatureGateCard(
title = "Terminal",
summary = "Open a server shell through your paired relay session.",
status = PowerFeatureGateStatus.RequiresPairing,
onPrimaryAction = {},
)
}
}
composeTestRule.onNodeWithText("Requires pairing").assertIsDisplayed()
composeTestRule.onNodeWithText("Pair to unlock").assertIsDisplayed()
composeTestRule.onNodeWithText("This feature uses relay grants", substring = true).assertIsDisplayed()
}
@Test
fun expiredPairingCard_showsPairAgain() {
composeTestRule.setContent {
MaterialTheme {
PowerFeatureGateCard(
title = "Bridge",
summary = "Let Hermes send approved bridge commands to this phone.",
status = PowerFeatureGateStatus.PairingExpired,
onPrimaryAction = {},
)
}
}
composeTestRule.onNodeWithText("Pairing expired").assertIsDisplayed()
composeTestRule.onNodeWithText("Pair again").assertIsDisplayed()
}
@Test
fun dashboardSignInCard_usesDashboardLanguage() {
composeTestRule.setContent {
MaterialTheme {
PowerFeatureGateCard(
title = "Manage",
summary = "Open dashboard-backed management features.",
status = PowerFeatureGateStatus.DashboardSignInRequired,
onPrimaryAction = {},
)
}
}
composeTestRule.onNodeWithText("Dashboard sign-in required").assertIsDisplayed()
composeTestRule.onNodeWithText("Open sign-in").assertIsDisplayed()
}
}
@@ -6,7 +6,6 @@ import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNode
import androidx.compose.ui.test.onNodeWithText
import org.junit.Rule
import org.junit.Test
@@ -1,22 +1,19 @@
package com.hermesandroid.relay.ui.onboarding
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import org.junit.Rule
import org.junit.Test
/**
* Instrumented tests for the onboarding pager flow.
*
* These tests require an Android device or emulator because they use
* Compose UI testing APIs and interact with real Compose components.
* Instrumented tests for the Standard-first onboarding pager.
*/
class OnboardingFlowTest {
@@ -24,270 +21,175 @@ class OnboardingFlowTest {
val composeTestRule = createComposeRule()
private fun setOnboardingContent() {
val app = ApplicationProvider.getApplicationContext<Application>()
val connectionViewModel = ConnectionViewModel(app)
composeTestRule.setContent {
HermesRelayTheme {
OnboardingScreen(
onComplete = { _, _, _ -> }
connectionViewModel = connectionViewModel,
onComplete = {},
)
}
}
}
// --- Page 1: Welcome ---
@Test
fun firstPage_showsHermesRelayTitle() {
fun firstPage_showsHermesForAndroidTitle() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Hermes-Relay")
.onNodeWithText("Hermes-Relay for Android")
.assertIsDisplayed()
}
@Test
fun firstPage_showsWelcomeDescription() {
fun firstPage_showsStandardFirstDescription() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Your AI agent, in your pocket. Chat, control, and connect — all from your phone.")
.onNodeWithText("Chat with Hermes and manage your dashboard from your phone.")
.assertIsDisplayed()
}
// --- Skip button ---
@Test
fun skipButton_isAlwaysVisible_onFirstPage() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Skip")
.onNodeWithText("Standard")
.assertIsDisplayed()
}
// --- Navigation: Next button ---
@Test
fun nextButton_isDisplayed_onFirstPage() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Next")
.onNodeWithText("Advanced")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Setup Guide")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Hermes Docs")
.assertIsDisplayed()
}
@Test
fun nextButton_navigatesForward_toPage2() {
fun nextButton_navigatesForward_toChatPage() {
setOnboardingContent()
// Page 1 -> Page 2
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 2 is "Talk to Your Agent"
composeTestRule
.onNodeWithText("Talk to Your Agent")
.onNodeWithText("Chat")
.assertIsDisplayed()
}
@Test
fun canNavigateForward_throughAllPages() {
fun canNavigateForward_throughStandardAndPowerPages() {
setOnboardingContent()
// Page 1: Hermes-Relay (Welcome)
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 2: Talk to Your Agent (Chat)
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 3: Remote Terminal
composeTestRule.onNodeWithText("Remote Terminal").assertIsDisplayed()
composeTestRule.onNodeWithText("Manage").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 4: Device Bridge
composeTestRule.onNodeWithText("Device Bridge").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.onNodeWithText("Power tools").assertIsDisplayed()
composeTestRule.onNodeWithText("Connect").performClick()
composeTestRule.waitForIdle()
// Page 5: Connect to Hermes
composeTestRule.onNodeWithText("Connect to Hermes").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
// Page 6: Relay Server (last page)
composeTestRule.onNodeWithText("Relay Server").assertIsDisplayed()
}
// --- Back button ---
@Test
fun backButton_hiddenOnFirstPage() {
setOnboardingContent()
// On page 1, Back should not exist
composeTestRule
.onNodeWithText("Back")
.assertDoesNotExist()
}
@Test
fun backButton_visibleOnPage2() {
setOnboardingContent()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Back")
.assertIsDisplayed()
}
@Test
fun backButton_navigatesBackward() {
setOnboardingContent()
// Go to page 2
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
// Go back to page 1
composeTestRule.onNodeWithText("Back").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
}
// --- Page 5: Connect page ---
@Test
fun connectPage_hasApiServerUrlField() {
setOnboardingContent()
navigateToPage(4) // 0-indexed, page 5 is index 4
composeTestRule
.onNodeWithText("API Server URL")
.assertIsDisplayed()
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
}
@Test
fun connectPage_hasApiKeyField() {
fun connectPage_showsStandardChoiceFirst() {
setOnboardingContent()
navigateToPage(4)
composeTestRule
.onNodeWithText("API Key (optional)", substring = true)
.onNodeWithText("Standard Hermes")
.assertIsDisplayed()
}
@Test
fun connectPage_whereDoIFindThis_showsHelpDialog() {
fun standardSetup_showsApiFields() {
setOnboardingContent()
navigateToPage(4)
// Tap "Where do I find this?"
composeTestRule
.onNodeWithText("Where do I find this?")
.performClick()
composeTestRule.onNodeWithText("Standard Hermes").performClick()
composeTestRule.waitForIdle()
// Dialog should show
composeTestRule
.onNodeWithText("Do I need an API key?")
.onNodeWithText("API server URL")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("API key")
.assertIsDisplayed()
}
@Test
fun connectPage_helpDialog_canBeDismissed() {
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Where do I find this?").performClick()
composeTestRule.onNodeWithText("Standard Hermes").performClick()
composeTestRule.waitForIdle()
// Dialog is showing
composeTestRule.onNodeWithText("Do I need an API key?").assertIsDisplayed()
// Dismiss it
composeTestRule.onNodeWithText("Got it").performClick()
composeTestRule.waitForIdle()
// Dialog should be gone
composeTestRule
.onNodeWithText("Do I need an API key?")
.assertDoesNotExist()
}
// --- Page 6: Relay page ---
@Test
fun relayPage_showsOptionalMessaging() {
setOnboardingContent()
navigateToPage(5) // Last page
composeTestRule
.onNodeWithText("This is optional", substring = true)
.assertIsDisplayed()
}
@Test
fun relayPage_showsRelayUrlField() {
setOnboardingContent()
navigateToPage(5)
composeTestRule
.onNodeWithText("Relay URL (optional)")
.assertIsDisplayed()
}
// --- Get Started button ---
@Test
fun lastPage_showsGetStartedButton() {
setOnboardingContent()
navigateToPage(5)
composeTestRule
.onNodeWithText("Get Started")
.assertIsDisplayed()
}
@Test
fun lastPage_getStartedButton_isEnabled_withDefaultUrl() {
setOnboardingContent()
navigateToPage(5)
// Default URL is "http://localhost:8642" which is non-blank
composeTestRule
.onNodeWithText("Get Started")
.onNodeWithText("Connect")
.assertIsEnabled()
}
// --- Skip button visibility across pages ---
@Test
fun skipButton_visibleOnAllPages() {
fun connectPage_keepsPairingOptional() {
setOnboardingContent()
navigateToPage(4)
// Check skip on first page
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
// Navigate through all pages and check skip
for (i in 0 until 5) {
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
}
composeTestRule
.onNodeWithText("Pair Relay by code")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Power-user path for Terminal, Bridge, Relay sessions, and grants")
.assertIsDisplayed()
}
// --- Helper ---
@Test
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
setOnboardingContent()
repeat(4) {
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
composeTestRule.waitForIdle()
}
composeTestRule
.onNodeWithText("Skip for now — set up later in Settings")
.assertIsDisplayed()
}
private fun navigateToPage(pageIndex: Int) {
repeat(pageIndex) {
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
composeTestRule.waitForIdle()
}
}
@@ -1,157 +1,52 @@
package com.hermesandroid.relay.ui.screens
import android.app.Application
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.material3.MaterialTheme
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.TerminalViewModel
import org.junit.Rule
import org.junit.Test
/**
* Instrumented tests for Terminal and Bridge empty state screens.
* Instrumented smoke tests for the current Terminal and Bridge surfaces.
*/
class EmptyStateTest {
@get:Rule
val composeTestRule = createComposeRule()
// --- Terminal Screen ---
@Test
fun terminalScreen_showsTitle() {
fun terminalScreen_showsCurrentTopBar() {
val app = ApplicationProvider.getApplicationContext<Application>()
val terminalViewModel = TerminalViewModel(app)
val connectionViewModel = ConnectionViewModel(app)
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
TerminalScreen(
terminalViewModel = terminalViewModel,
connectionViewModel = connectionViewModel,
)
}
}
composeTestRule
.onNodeWithText("Remote Terminal")
.assertIsDisplayed()
composeTestRule.onNodeWithText("Terminal").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Search scrollback").assertIsDisplayed()
}
@Test
fun terminalScreen_showsPhase2Chip() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Coming in Phase 2")
.assertIsDisplayed()
}
@Test
fun terminalScreen_showsDescription() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Secure shell access", substring = true)
.assertIsDisplayed()
}
@Test
fun terminalScreen_showsTopBarTitle() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Terminal")
.assertIsDisplayed()
}
@Test
fun terminalScreen_showsPlannedFeatures() {
composeTestRule.setContent {
MaterialTheme {
TerminalScreen()
}
}
composeTestRule
.onNodeWithText("Full ANSI terminal emulator", substring = true)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("tmux session management", substring = true)
.assertIsDisplayed()
}
// --- Bridge Screen ---
@Test
fun bridgeScreen_showsTitle() {
fun bridgeScreen_showsCurrentTopBar() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Device Bridge")
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsPhase3Chip() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Coming in Phase 3")
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsDescription() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Let your Hermes agent interact with your phone", substring = true)
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsTopBarTitle() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Bridge")
.assertIsDisplayed()
}
@Test
fun bridgeScreen_showsPlannedFeatures() {
composeTestRule.setContent {
MaterialTheme {
BridgeScreen()
}
}
composeTestRule
.onNodeWithText("Agent-controlled device interaction", substring = true)
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Permission management", substring = true)
.assertIsDisplayed()
composeTestRule.onNodeWithText("Bridge").assertIsDisplayed()
}
}
+10 -11
View File
@@ -5,24 +5,23 @@
Merged on top of `app/src/main/AndroidManifest.xml` by AGP when the
`googlePlayDebug` / `googlePlayRelease` variants are built.
The AccessibilityService is declared exactly once in `app/src/main/AndroidManifest.xml`.
The flavor distinction is purely at the resource layer: this flavor's
`res/xml/accessibility_service_config.xml` carries the conservative use-case
description required for Google Play policy review, and `res/values/strings.xml`
carries the description string. Gradle's resource merger picks the right
files at build time, so we don't need to redeclare the <service> here.
Google Play ships Hermes Bridge Core only. It intentionally does not merge
any Device Control services or permissions.
This file is intentionally kept as an empty overlay so future flavor-specific
permissions / activities have an obvious home. Mirror structural additions
in `app/src/sideload/AndroidManifest.xml` unless the change is intentionally
track-specific.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Media3 ExoPlayer contributes a power-management permission from its
library manifest. Strip it from the merged Play artifact. -->
<uses-permission
android:name="android.permission.WAKE_LOCK"
tools:node="remove" />
<!-- googlePlay inherits main manifest's specialUse-only FGS type
directly — no override needed. The sideload manifest ADDS
mediaProjection via tools:replace; googlePlay gets the safe
default. -->
<application />
</manifest>
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Google Play flavor strings.
`a11y_description_googleplay` is the user-facing description shown in
Android's Accessibility settings when enabling the Hermes Bridge service.
It is ALSO what Play Store reviewers read when evaluating our
AccessibilityService use-case declaration, so phrasing matters: stay
narrowly scoped, emphasize user confirmation, emphasize dormancy until
the user opts in inside the app.
Do not reference voice or vision features here — tier 3/4/6 are gated
off for this flavor via FeatureFlags.BuildFlavor.
-->
<resources>
<string name="a11y_service_label">Hermes-Bridge</string>
<string name="a11y_description_googleplay">Hermes assists you by reading on-screen content and summarizing notifications. The service is read-only — it does not perform taps, type text, or control other apps. It is dormant until you explicitly enable Bridge mode in the app.</string>
</resources>
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Google Play AccessibilityService configuration.
Conservative event-type subset targeted at the "read notifications,
summarize messages, reply with confirmation" use case Play Store policy
review expects. Does NOT subscribe to typeAllMask, does NOT request
gestures, does NOT request flagRetrieveInteractiveWindows.
Keep these attributes aligned with the description in strings.xml
(`a11y_description_googleplay`) — if the description widens, reviewers
will expect the config to widen too.
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/a11y_description_googleplay"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewClicked"
android:accessibilityFlags="flagDefault"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canRetrieveWindowContent="true" />
+18 -119
View File
@@ -6,74 +6,22 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- === A8 wake-lock: keep CPU awake while dispatching bridge gestures === -->
<!-- Normal-protection permission (no runtime prompt). Held only inside
WakeLockManager.wakeForAction { ... }, with a 10s hard timeout and
ref-counted release. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- === PHASE3-accessibility: AccessibilityService + bridge permissions === -->
<!-- BIND_ACCESSIBILITY_SERVICE is intentionally NOT declared as a
<uses-permission> here — it's a system-only permission granted to
services that declare android:permission on their <service> tag
(see the BridgeAccessibilityService entry below). Declaring it as
a uses-permission trips lint's [ProtectedPermissions] check.
FOREGROUND_SERVICE* are for the persistent notification that
Agent safety-rails will wire in Wave 2 for MediaProjection-backed
screenshots. POST_NOTIFICATIONS is required on API 33+ for that
same foreground-service notification. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- FOREGROUND_SERVICE_MEDIA_PROJECTION moved to sideload manifest.
googlePlay doesn't need screen recording: /screenshot route is
gated sideload-only in BridgeCommandHandler. Declaring the
permission on the Play track would flag review since our
accessibility use-case ("read-only screen reading") doesn't
justify screen capture. -->
<!-- Turn-complete chat notification (TurnCompleteNotifier) — runtime-requested
on API 33+ from the Chat Settings toggle. Lives in main (not just the
sideload overlay) so the googlePlay flavor can notify too. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- === END PHASE3-accessibility === -->
<!-- === PHASE3-safety-rails: safety service + overlay === -->
<!-- SYSTEM_ALERT_WINDOW is user-granted via Settings.ACTION_MANAGE_OVERLAY_PERMISSION.
Used for (a) the destructive-verb confirmation modal that must be
visible even when Hermes isn't in the foreground, and (b) the optional
floating "Hermes active" status chip. The permission is declared here
so the user-visible grant flow triggers, but the overlay itself only
appears when the user has explicitly consented.
FOREGROUND_SERVICE_SPECIAL_USE is required on Android 14+ for the
persistent "Bridge active" notification (BridgeForegroundService),
because the specialUse type needs its own declared permission. -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- Opt-in "Keep connected in background" (GatewayKeepAliveService). In main
(not the sideload overlay) so the googlePlay flavor ships it too — the
Home-Assistant-class persistent-connection use case Play permits. The
specialUse type requires a one-time Play Console foreground-service
declaration at submission. (Also already present in the sideload overlay
for the device-control bridge service; the merger dedups.) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- === END PHASE3-safety-rails === -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- === PHASE3-baseline-handlers: package visibility for /get_apps + /open_app === -->
<!-- On Android 11+ (API 30+), apps can only see other packages that
are implicitly visible (own UID, system apps, etc.) unless they
declare a <queries> filter or hold QUERY_ALL_PACKAGES. The
BridgeCommandHandler /get_apps route uses
queryIntentActivities(ACTION_MAIN + CATEGORY_LAUNCHER) to enumerate
launchable apps, and BridgeSafetySettingsScreen uses the same call
to populate the blocklist UI — both need this declaration to see
the full launcher set. Without it queryIntentActivities silently
returns a near-empty list (typical symptom: blocklist UI shows
only Hermes-Relay itself + a handful of system apps).
Declaring an <intent> filter with ACTION_MAIN + CATEGORY_LAUNCHER
is the Play-policy-safe approach — it does NOT require the
restricted QUERY_ALL_PACKAGES permission, which Play would
otherwise demand a policy declaration for. -->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<!-- === END PHASE3-baseline-handlers === -->
<application
android:name=".HermesRelayApp"
android:allowBackup="true"
@@ -89,6 +37,7 @@
android:exported="true"
android:launchMode="singleTask"
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.HermesRelay.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -106,30 +55,6 @@
android:resource="@xml/file_provider_paths" />
</provider>
<!-- === PHASE3-accessibility: AccessibilityService declaration === -->
<!-- The @xml/accessibility_service_config resource is provided by
the flavor-specific source sets (app/src/googlePlay/ and
app/src/sideload/) owned by Agent flavor-split. Each flavor declares its
own accessibility use-case description and flag bitset — the
googlePlay track declares a conservative "notifications + reply
with confirmation" use case, the sideload track declares the
full agent-control use case. Gradle merges the flavor XML into
main at build time.
-->
<service
android:name=".accessibility.HermesAccessibilityService"
android:exported="true"
android:label="@string/a11y_service_label"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
<!-- === END PHASE3-accessibility === -->
<!-- === PHASE3-notif-listener: notification companion service === -->
<service
android:name=".notifications.HermesNotificationCompanion"
@@ -142,45 +67,19 @@
</service>
<!-- === END PHASE3-notif-listener === -->
<!-- === PHASE3-safety-rails: safety service + overlay === -->
<!-- BridgeForegroundService is a plain (non-exported) foreground
service driven by BridgeViewModel based on the master toggle.
It owns the persistent "Hermes agent has device control"
notification.
foregroundServiceType is the OR of two API 34+ subtypes:
- specialUse — backs the persistent "bridge active"
indicator we shipped with Tier 5 safety rails. Comes
with the SPECIAL_USE foreground-service permission and
the Play Console policy declaration.
- mediaProjection — REQUIRED by Android 14+ before any
call to MediaProjectionManager.getMediaProjection().
Without this declaration, getMediaProjection() returns
a projection that the system auto-revokes within a
frame, leaving us with a permanently-null
MediaProjectionHolder.projection. Symptom on the
device: consent dialog appears, user allows full
screen, dialog closes, grant evaporates. Sample-tested
on a Samsung S24 / Android 14 on 2026-04-12.
Both types share the same notification + same lifecycle —
one service, one notification, two type slots.
Android 14+ requires a <property> tag justifying the
specialUse subtype. The mediaProjection subtype does NOT
need a property tag because it has its own dedicated
permission (FOREGROUND_SERVICE_MEDIA_PROJECTION). -->
<!-- Opt-in "Keep connected in background" — holds the gateway chat
socket open while backgrounded. In main so BOTH flavors ship it
(Home-Assistant-class persistent connection). Off by default; only
runs while the user has explicitly enabled the toggle. specialUse
needs a Play Console foreground-service declaration at submission. -->
<service
android:name=".bridge.BridgeForegroundService"
android:name=".network.GatewayKeepAliveService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains a persistent WebSocket connection to the user's Hermes server for real-time chat relay and notification mirroring. The service is dormant until the user explicitly enables Bridge mode in the app." />
android:value="Keeps the user's chat connection to their Hermes agent open while the app is backgrounded, only when the user has explicitly enabled 'Keep connected in background'." />
</service>
<!-- === END PHASE3-safety-rails === -->
</application>
+33 -13
View File
@@ -1,16 +1,36 @@
v0.6.1 - Voice auth, route recovery, and media bridge
v1.0.0 - The 1.0 release
Voice and pairing
* Voice mode can now use your saved Hermes API key directly.
* Relay sessions recover better after server restarts and updates.
* LAN/Tailscale pairing and route fallback are more reliable.
Standard path
* Chat, Manage, and voice now work on a plain Hermes agent — no relay
plugin required. The plugin is optional and only adds power tools.
Android bridge
* Added share-sheet support for files, screenshots, relay media, and attachments.
* Added MMS compose handoff with attachments.
* SMS now reports clearer sent, blocked, timeout, and failed states.
* Return-to-Hermes and bridge route docs now match the actual relay API.
Chat
* New gateway transport streams the agent's reasoning live, so the
Thinking block fills in during generation instead of after.
* Warm-start + opt-in "Keep connected in background" make returning to a
conversation fast.
* Attachments at desktop parity: images, PDFs, and files upload over the
gateway. If a connection can't carry a file, you'll see a notice
instead of a silent drop.
* Steer a running turn, edit & resend your messages, watch subagent
lanes, and a context-window meter — plus turn-complete notifications.
* Tap an image to open it full-screen (pinch to zoom); save or share
images and other attachments.
* Redesigned input bar: pill field, one morphing Send/Voice/Stop button.
Update note
* Same-flavor installs should keep pairing. Re-pair only after uninstalling data,
switching app flavor, revoking the device, or clearing the server session store.
Profiles
* Switch the whole agent — model, persona, and skills — per conversation.
The drawer scopes to the active profile, and switching is ephemeral: it
never changes your server's default agent.
Manage
* Models, provider keys, profiles + SOUL.md, and a skills hub — parity
with the desktop dashboard. Cached for instant cold-launch.
Voice
* Realtime Agent keeps one session across turns; long runs continue in
the background and are spoken when ready.
Polish
* Seamless LAN/Tailscale handoffs (no chat reload), slide-down status
toasts, and a broad round of fixes.
@@ -1,33 +1,32 @@
package com.hermesandroid.relay
import android.app.Application
import android.os.Build
import androidx.compose.ui.ComposeUiFlags
import androidx.compose.ui.ExperimentalComposeUiApi
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.power.WakeLockManager
import com.hermesandroid.relay.util.AppForegroundTracker
class HermesRelayApp : Application() {
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
@OptIn(ExperimentalComposeUiApi::class)
override fun attachBaseContext(base: android.content.Context?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
}
super.attachBaseContext(base)
}
/**
* Coil's singleton image loader for the whole app. Registering the OkHttp
* network fetcher EXPLICITLY guarantees `http(s)` image URLs (e.g. a
* generated-image link in a chat reply) load, rather than relying on
* artifact auto-registration. Crossfade for a clean fade-in.
*/
override fun newImageLoader(context: PlatformContext): ImageLoader =
ImageLoader.Builder(context)
.components { add(OkHttpNetworkFetcherFactory()) }
.crossfade(true)
.build()
@OptIn(ExperimentalComposeUiApi::class)
override fun onCreate() {
super.onCreate()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// Compose's adaptive refresh-rate hint path on API 35 can emit
// `setRequestedFrameRate frameRate=NaN` from inside AndroidComposeView
// on every draw pass. Disable ARR globally until the upstream fix lands.
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
}
instance = this
AppAnalytics.initialize(this)
// A8 — wire the bridge-gesture wake-lock wrapper so
@@ -18,6 +18,8 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
import com.hermesandroid.relay.bridge.BridgeForegroundService
import com.hermesandroid.relay.bridge.UnattendedAccessManager
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
import com.hermesandroid.relay.ui.RelayApp
import com.hermesandroid.relay.util.ComposeArrWorkaround
import com.hermesandroid.relay.util.NavRouteRequest
@@ -36,7 +38,7 @@ class MainActivity : ComponentActivity() {
// We do NOT call MediaProjectionHolder directly from here. On Android
// 14+, getMediaProjection() must run from inside a foreground service
// that has already called startForeground(type=mediaProjection), and
// that startForeground call must happen AFTER consent. So we hand the
// that startForeground call must happen AFT consent. So we hand the
// result off to BridgeForegroundService, which:
// 1. Upgrades its FGS type to SPECIAL_USE | MEDIA_PROJECTION
// 2. Calls MediaProjectionHolder.acceptGrantInsideForegroundService
@@ -50,6 +52,10 @@ class MainActivity : ComponentActivity() {
ActivityResultContracts.StartActivityForResult()
) { result ->
val data = result.data
if (!BuildFlavor.isSideload) {
Log.w(TAG, "Ignoring MediaProjection result on Google Play Bridge Core build")
return@registerForActivityResult
}
if (result.resultCode == RESULT_OK && data != null) {
Log.i(TAG, "MediaProjection consent granted — handing off to FGS")
BridgeForegroundService.grantMediaProjection(this, result.resultCode, data)
@@ -88,13 +94,15 @@ class MainActivity : ComponentActivity() {
// Hand the launcher to the process-singleton rendezvous so
// BridgeViewModel.requestScreenCapture() can fire the consent
// dialog without holding an Activity reference.
ScreenCaptureRequester.install {
val mgr = getSystemService(Context.MEDIA_PROJECTION_SERVICE)
as MediaProjectionManager
try {
mediaProjectionLauncher.launch(mgr.createScreenCaptureIntent())
} catch (t: Throwable) {
Log.w(TAG, "failed to launch MediaProjection consent: ${t.message}")
if (BuildFlavor.isSideload) {
ScreenCaptureRequester.install {
val mgr = getSystemService(Context.MEDIA_PROJECTION_SERVICE)
as MediaProjectionManager
try {
mediaProjectionLauncher.launch(mgr.createScreenCaptureIntent())
} catch (t: Throwable) {
Log.w(TAG, "failed to launch MediaProjection consent: ${t.message}")
}
}
}
// === END PHASE3-bridge-ui-followup ===
@@ -135,20 +143,29 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
// Returning to the app clears the one-slot "Hermes finished
// responding" notification — the chat surface is the answer.
TurnCompleteNotifier.cancel(this)
// v0.4.1 — register this activity as the host for
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
// we don't leak the Activity past its lifecycle. The unattended-
// access manager only attempts dismiss when an activity is
// registered AND the user has opted in.
UnattendedAccessManager.setHostActivity(this)
if (BuildFlavor.isSideload) {
UnattendedAccessManager.setHostActivity(this)
}
// Re-probe the credential-lock state on resume so the Bridge
// tab badge updates immediately if the user just changed their
// lock screen in system Settings between app sessions.
UnattendedAccessManager.refreshKeyguardState()
if (BuildFlavor.isSideload) {
UnattendedAccessManager.refreshKeyguardState()
}
}
override fun onPause() {
UnattendedAccessManager.setHostActivity(null)
if (BuildFlavor.isSideload) {
UnattendedAccessManager.setHostActivity(null)
}
super.onPause()
}
@@ -157,7 +174,9 @@ class MainActivity : ComponentActivity() {
// Drop the launcher closure so we don't hold a stale Activity ref
// after destroy. ScreenCaptureRequester.request() will return false
// until the next MainActivity instance reinstalls itself.
ScreenCaptureRequester.uninstall()
if (BuildFlavor.isSideload) {
ScreenCaptureRequester.uninstall()
}
// === END PHASE3-bridge-ui-followup ===
super.onDestroy()
}
@@ -65,10 +65,10 @@ import kotlinx.serialization.json.put
* }
* ```
*
* `unattended.supported` is false on the googlePlay flavor — the Play
* APK has no wake-lock path — which lets the agent distinguish "user
* hasn't opted in" from "this build can't do unattended at all" without
* a separate probe.
* `bridge.device_control_supported` and `unattended.supported` are false on
* the googlePlay flavor — the Play APK ships Bridge Core without
* AccessibilityService, wake locks, overlays, screenshots, or unattended
* control — which lets the agent avoid attempting sideload-only tools.
*
* The legacy top-level keys (`screen_on`, `battery`, `current_app`,
* `accessibility_enabled`, `ts`) are ALSO emitted for backwards
@@ -188,6 +188,7 @@ class BridgeStatusReporter(
val currentApp = HermesAccessibilityService.instance?.currentApp
val accessibilityGranted = HermesAccessibilityService.instance != null
val masterEnabled = HermesAccessibilityService.instance?.isMasterEnabled() ?: false
val deviceControlSupported = BuildFlavor.isSideload
// Screen-capture grant — the process-singleton holder is non-null
// iff the user granted MediaProjection consent this session.
@@ -257,10 +258,17 @@ class BridgeStatusReporter(
})
})
put("bridge", buildJsonObject {
put("master_enabled", masterEnabled)
put("accessibility_granted", accessibilityGranted)
put("screen_capture_granted", screenCaptureGranted)
put("overlay_granted", overlayGranted)
put("device_control_supported", deviceControlSupported)
put("master_enabled", if (deviceControlSupported) masterEnabled else false)
put(
"accessibility_granted",
if (deviceControlSupported) accessibilityGranted else false,
)
put(
"screen_capture_granted",
if (deviceControlSupported) screenCaptureGranted else false,
)
put("overlay_granted", if (deviceControlSupported) overlayGranted else false)
put("notification_listener_granted", notificationListenerGranted)
})
put("safety", buildJsonObject {
@@ -285,11 +293,18 @@ class BridgeStatusReporter(
// when both `enabled=true` and this is true, commands
// will wake the screen but stop at the lock screen.
put("unattended", buildJsonObject {
put("supported", BuildFlavor.isSideload)
put("enabled", UnattendedAccessManager.enabled.value)
put("supported", deviceControlSupported)
put(
"enabled",
if (deviceControlSupported) UnattendedAccessManager.enabled.value else false,
)
put(
"credential_lock_detected",
UnattendedAccessManager.credentialLockDetected.value,
if (deviceControlSupported) {
UnattendedAccessManager.credentialLockDetected.value
} else {
false
},
)
})
@@ -300,8 +315,8 @@ class BridgeStatusReporter(
// groups above.
put("screen_on", screenOn)
put("battery", batteryFinal)
put("current_app", currentApp ?: "unknown")
put("accessibility_enabled", accessibilityGranted)
put("current_app", if (deviceControlSupported) currentApp ?: "unknown" else "unknown")
put("accessibility_enabled", if (deviceControlSupported) accessibilityGranted else false)
put("ts", System.currentTimeMillis())
}
)
@@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
*
* # Master enable / disable
*
* The Android system toggle in `Settings → Accessibility → Hermes Relay` is
* The Android system toggle in `Settings → Accessibility → Hermes-Relay` is
* the hard switch — if it's off we never receive events. On top of that the
* user can flip a soft master in Settings (`bridge_master_enabled`); when
* that's false we still run (Android requires it to stay connected) but we
@@ -267,13 +267,12 @@ class HermesAccessibilityService : AccessibilityService() {
* # Fallback semantics
*
* `service.windows` returns an empty list unless the accessibility
* config XML requests `flagRetrieveInteractiveWindows`. That flag is
* **only** set in the `sideload` flavor — the `googlePlay` flavor
* deliberately runs on the conservative config subset to pass Play
* Store policy review. When `windows` is empty (or throws, or every
* window's root is null) we fall back to a single-element list
* wrapping [rootInActiveWindow], preserving pre-P1 behaviour on
* `googlePlay` builds.
* config XML requests `flagRetrieveInteractiveWindows`. The service is
* declared only by the `sideload` manifest, and that sideload config sets
* the flag. When `windows` is empty (or throws, or every window's root is
* null) we fall back to a single-element list wrapping
* [rootInActiveWindow], preserving pre-P1 behaviour for tests and
* defensive runtime fallback.
*
* Returns an empty list only if the service cannot read any window
* root at all (e.g. lock screen, master-off state). Callers should
@@ -8,6 +8,7 @@ import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -175,11 +176,26 @@ class BargeInListener internal constructor(
_aecAttached.value = false
readerJob = scope.launch(readerDispatcher) {
try {
audioSource.start()
try {
audioSource.start()
} catch (t: CancellationException) {
throw t
} catch (t: Throwable) {
Log.w(TAG, "AudioFrameSource.start failed: ${t.message}")
return@launch
}
Log.i(TAG, "Barge-in AudioRecord reader started")
maybeAttachEffects()
while (isActive) {
val read = audioSource.read(frameBuffer, VadEngine.FRAME_SIZE_SAMPLES)
val read = try {
audioSource.read(frameBuffer, VadEngine.FRAME_SIZE_SAMPLES)
} catch (t: CancellationException) {
throw t
} catch (t: Throwable) {
Log.w(TAG, "AudioFrameSource.read failed; stopping reader: ${t.message}")
break
}
if (read <= 0) {
// Negative values are AudioRecord error codes; 0 means
// no data yet. Either way, yield briefly and retry
@@ -196,7 +212,16 @@ class BargeInListener internal constructor(
continue
}
val result = vadEngine.analyze(frameBuffer)
if (!isActive) break
val result = try {
vadEngine.analyze(frameBuffer)
} catch (t: CancellationException) {
throw t
} catch (t: Throwable) {
Log.w(TAG, "VadEngine.analyze failed; stopping reader: ${t.message}")
break
}
if (result.probability > 0f) {
_maybeSpeech.tryEmit(Unit)
}
@@ -228,9 +253,14 @@ class BargeInListener internal constructor(
* actual release happens in the reader coroutine's `finally` block, which
* is typically a single frame later.
*/
fun stop() {
readerJob?.cancel()
fun stop(): Job? {
val job = readerJob
if (job?.isActive == true) {
Log.i(TAG, "Stopping barge-in AudioRecord reader")
}
job?.cancel()
readerJob = null
return job
}
private suspend fun maybeAttachEffects() {
@@ -253,6 +283,7 @@ class BargeInListener internal constructor(
created.enabled = true
aec = created
_aecAttached.value = true
Log.i(TAG, "AcousticEchoCanceler attached to session=$sessionId")
} else {
Log.i(TAG, "AcousticEchoCanceler.create returned null; continuing without")
}
@@ -0,0 +1,830 @@
package com.hermesandroid.relay.audio
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Build
import android.os.SystemClock
import android.util.Log
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlin.math.max
import kotlin.math.sqrt
/**
* Small streaming PCM sink for the realtime voice dev testbench.
*
* The relay sends mono 16-bit little-endian PCM chunks over the websocket. This
* writes them directly to an AudioTrack so the Android Studio dev build can
* hear provider output without waiting for an encoded file.
*/
class RealtimePcmPlayer(context: Context? = null) {
private val trackLock = Any()
private val writeLock = Any()
private val audioManager =
context?.applicationContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
private val realtimeAudioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { change ->
Log.i(TAG, "Realtime PCM audio focus change=$change")
}
private var audioTrack: AudioTrack? = null
private var audioFocusRequest: AudioFocusRequest? = null
private var audioFocusHeld: Boolean = false
private var currentSampleRate: Int = 0
private var currentVolume: Float = 1f
private var estimatedPlaybackEndAtMs: Long = 0L
private var playbackStarted: Boolean = false
private var pendingStartBytes: Int = 0
private var firstBufferedAtMs: Long = 0L
private var lastUnderrunCount: Int = 0
private var lastHeadPositionLogAtMs: Long = 0L
private var lastLoggedHeadFrames: Int = 0
private var headAdvanceConfirmed: Boolean = false
private var playbackStartedAtMs: Long = 0L
private var totalFramesWritten: Long = 0L
// (endFrame, rms) per written chunk — lets [playbackAmplitude] report the
// amplitude of the audio actually at the hardware cursor right now, instead
// of the chunk that most recently *arrived* over the socket.
private val playbackAmpQueue = ArrayDeque<FrameAmp>()
private var lastPlaybackGapDiagnosticAtMs: Long = 0L
private var lastMutedVolumeDiagnosticAtMs: Long = 0L
private var adaptiveStartPrebufferMs: Long = RealtimePcmBufferPolicy.START_PREBUFFER_MS
private var playbackGapSeenThisTrack: Boolean = false
private val _amplitude = MutableStateFlow(0f)
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
val isActive: Boolean
get() = synchronized(trackLock) { audioTrack != null }
val audioSessionId: Int
get() = synchronized(trackLock) { audioTrack?.audioSessionId ?: 0 }
fun write(pcm: ByteArray, sampleRate: Int): Float {
if (pcm.isEmpty()) return 0f
val level = computePcm16LeRms(pcm)
val now = SystemClock.elapsedRealtime()
val written = synchronized(writeLock) {
val track = try {
synchronized(trackLock) {
val currentTrack = ensureTrackLocked(sampleRate)
notePlaybackGapLocked(currentTrack, now)
currentTrack
}
} catch (e: Exception) {
Log.w(TAG, "PCM track preparation failed: ${e.message}")
synchronized(trackLock) { releaseTrackLocked(reason = "PCM track preparation failure") }
return@synchronized 0
}
try {
val prerollWritten = maybeWriteStartupPreroll(track, sampleRate)
if (prerollWritten < 0) {
Log.w(TAG, "PCM preroll write returned $prerollWritten; restarting track")
synchronized(trackLock) {
if (audioTrack === track) releaseTrackLocked(reason = "PCM preroll write error")
}
return@synchronized 0
}
// Intentionally do NOT start playback on the bare silent preroll.
// Starting here would begin draining ~120ms of silence with zero
// real audio queued, guaranteeing an immediate underrun on the
// first speech chunk. The real audio written just below feeds the
// normal start decision, and the end-of-turn flush
// (voice.output_audio.done) force-starts anything still buffered.
val writtenBytes = writeBlocking(track, pcm)
if (writtenBytes < 0) {
Log.w(TAG, "PCM write returned $writtenBytes; restarting track")
synchronized(trackLock) {
if (audioTrack === track) releaseTrackLocked(reason = "PCM write error")
}
return@synchronized 0
}
var accepted = 0
if (writtenBytes > 0) {
synchronized(trackLock) {
if (audioTrack === track) {
noteWrittenBytesLocked(writtenBytes, sampleRate)
enqueuePlaybackAmplitudeLocked(level)
maybeStartPlaybackLocked(track, sampleRate, force = false)
updateUnderrunCursorLocked(track)
logPlaybackHealthLocked(track, now)
accepted = writtenBytes
}
}
}
accepted
} catch (e: Exception) {
Log.w(TAG, "PCM write failed: ${e.message}")
synchronized(trackLock) {
if (audioTrack === track) releaseTrackLocked(reason = "PCM write failure")
}
return@synchronized 0
}
}
if (written > 0) {
_amplitude.value = level
}
return level
}
private fun writeBlocking(track: AudioTrack, pcm: ByteArray): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
} else {
@Suppress("DEPRECATION")
track.write(pcm, 0, pcm.size)
}
fun flushBufferedPlayback(cushionMs: Long = DEFAULT_DRAIN_CUSHION_MS): Long {
val now = SystemClock.elapsedRealtime()
return synchronized(trackLock) {
val track = audioTrack ?: return@synchronized 0L
maybeStartPlaybackLocked(track, currentSampleRate, force = true)
val remaining = remainingPlaybackMsLocked(now, cushionMs)
Log.i(
TAG,
"Realtime PCM flush playState=${readPlayState(track)} " +
"headFrames=${readHeadFrames(track)} remainingMs=$remaining " +
"underruns=${readUnderrunCount(track)}",
)
remaining
}
}
fun stop() {
synchronized(writeLock) {
synchronized(trackLock) {
releaseTrackLocked(reason = "stop")
currentSampleRate = 0
estimatedPlaybackEndAtMs = 0L
}
}
_amplitude.value = 0f
}
fun estimatedRemainingPlaybackMs(cushionMs: Long = DEFAULT_DRAIN_CUSHION_MS): Long {
val now = SystemClock.elapsedRealtime()
return synchronized(trackLock) {
remainingPlaybackMsLocked(now, cushionMs)
}
}
fun setVolume(volume: Float) {
val clamped = volume.coerceIn(0f, 1f)
synchronized(trackLock) {
currentVolume = clamped
try { audioTrack?.setVolume(clamped) } catch (_: Exception) { }
}
}
fun duck() {
setVolume(0.3f)
}
fun unduck() {
setVolume(1f)
}
private fun releaseTrackLocked(reason: String) {
audioTrack?.let { track ->
Log.i(TAG, "Stopping streaming PCM playback ($reason)")
try { track.pause() } catch (_: Exception) { }
try { track.flush() } catch (_: Exception) { }
try { track.release() } catch (_: Exception) { }
}
abandonAudioFocusLocked()
settleAdaptivePrebufferLocked()
audioTrack = null
playbackStarted = false
pendingStartBytes = 0
firstBufferedAtMs = 0L
lastUnderrunCount = 0
lastHeadPositionLogAtMs = 0L
lastLoggedHeadFrames = 0
headAdvanceConfirmed = false
playbackStartedAtMs = 0L
totalFramesWritten = 0L
playbackAmpQueue.clear()
playbackGapSeenThisTrack = false
}
private fun enqueuePlaybackAmplitudeLocked(rms: Float) {
// [totalFramesWritten] has already been advanced past this chunk, so it
// is the chunk's end frame. The cursor reaches this amplitude once
// playbackHeadPosition passes the previous end frame.
playbackAmpQueue.addLast(FrameAmp(endFrame = totalFramesWritten, rms = rms))
while (playbackAmpQueue.size > MAX_AMP_QUEUE) playbackAmpQueue.removeFirst()
}
/**
* Amplitude of the audio currently at the hardware cursor (0 if not playing
* or drained). This is the playback-synced signal a UI waveform should draw:
* it advances with [AudioTrack.getPlaybackHeadPosition], so it matches what
* the user hears rather than what most recently arrived over the socket.
*/
fun playbackAmplitude(): Float = synchronized(trackLock) {
val track = audioTrack ?: return@synchronized 0f
if (!playbackStarted) return@synchronized 0f
val head = readHeadFrames(track).toLong()
// Drop fully-played chunks so the head of the queue is the one playing now.
while (playbackAmpQueue.size > 1 && playbackAmpQueue.first().endFrame <= head) {
playbackAmpQueue.removeFirst()
}
amplitudeAtHead(playbackAmpQueue, head)
}
private fun ensureTrackLocked(sampleRate: Int): AudioTrack {
val existing = audioTrack
if (existing != null && currentSampleRate == sampleRate) {
return existing
}
releaseTrackLocked(reason = "sample rate changed")
val minBuffer = AudioTrack.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(sampleRate / 10 * 2)
val bufferSize = RealtimePcmBufferPolicy.streamBufferSize(
minBufferBytes = minBuffer,
sampleRate = sampleRate,
)
val format = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
val track = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
AudioTrack.Builder()
.setAudioAttributes(realtimeAudioAttributes)
.setAudioFormat(format)
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(bufferSize)
.build()
} else {
@Suppress("DEPRECATION")
AudioTrack(
AudioManager.STREAM_MUSIC,
sampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize,
AudioTrack.MODE_STREAM,
)
}
if (track.state != AudioTrack.STATE_INITIALIZED) {
try { track.release() } catch (_: Exception) { }
throw IllegalStateException("AudioTrack failed to initialize")
}
requestAudioFocusLocked()
audioTrack = track
currentSampleRate = sampleRate
playbackStarted = false
pendingStartBytes = 0
firstBufferedAtMs = 0L
totalFramesWritten = 0L
lastUnderrunCount = readUnderrunCount(track)
// Log requested vs. actual allocated frames. If a device coerces our
// sub-second request back up to a multi-second allocation, that's the
// tell-tale of deep-buffer routing (the cold-start parking class) and
// explains a regression of the silent-first-turn bug on new hardware.
val requestedFrames = bufferSize / BYTES_PER_FRAME
val actualFrames = try { track.bufferSizeInFrames } catch (_: Exception) { -1 }
Log.i(
TAG,
"Initialized streaming PCM playback at ${sampleRate}Hz " +
"session=${track.audioSessionId} buffer=${bufferSize}B " +
"requestedFrames=$requestedFrames actualFrames=$actualFrames " +
"(${frameMs(actualFrames, sampleRate)}ms)",
)
return track
}
private fun frameMs(frames: Int, sampleRate: Int): Long {
if (frames <= 0 || sampleRate <= 0) return 0L
return (frames * 1000L / sampleRate)
}
private fun noteWrittenBytesLocked(writtenBytes: Int, sampleRate: Int) {
if (writtenBytes <= 0 || sampleRate <= 0) return
totalFramesWritten += (writtenBytes / BYTES_PER_FRAME).toLong()
val durationMs = ((writtenBytes / 2.0) / sampleRate * 1000.0)
.toLong()
.coerceAtLeast(1L)
val now = SystemClock.elapsedRealtime()
if (!playbackStarted) {
if (firstBufferedAtMs == 0L) firstBufferedAtMs = now
pendingStartBytes += writtenBytes
return
}
val base = max(now, estimatedPlaybackEndAtMs)
estimatedPlaybackEndAtMs = base + durationMs
}
private fun maybeWriteStartupPreroll(track: AudioTrack, sampleRate: Int): Int {
if (
synchronized(trackLock) {
playbackStarted ||
pendingStartBytes > 0 ||
firstBufferedAtMs > 0L ||
sampleRate <= 0 ||
audioTrack !== track
}
) {
return 0
}
val prerollMs = startupPrerollMsLocked()
val silenceBytes = RealtimePcmBufferPolicy.bytesForDurationMs(sampleRate, prerollMs)
if (silenceBytes <= 0) return 0
val written = writeBlocking(track, ByteArray(silenceBytes))
if (written > 0) {
synchronized(trackLock) {
if (audioTrack === track) {
noteWrittenBytesLocked(written, sampleRate)
enqueuePlaybackAmplitudeLocked(0f) // preroll is silence
Log.i(
TAG,
"Primed realtime PCM playback with " +
"${RealtimePcmBufferPolicy.durationMsForBytes(written, sampleRate)}ms " +
"silent preroll",
)
}
}
}
return written
}
private fun startupPrerollMsLocked(): Long {
return RealtimePcmBufferPolicy.STARTUP_PREROLL_MS
}
private fun maybeStartPlaybackLocked(
track: AudioTrack,
sampleRate: Int,
force: Boolean,
) {
if (playbackStarted || pendingStartBytes <= 0 || sampleRate <= 0) return
val now = SystemClock.elapsedRealtime()
val waitedMs = if (firstBufferedAtMs > 0L) now - firstBufferedAtMs else 0L
val decision = RealtimePcmBufferPolicy.startDecision(
pendingBytes = pendingStartBytes,
sampleRate = sampleRate,
waitedMs = waitedMs,
force = force,
startPrebufferMs = adaptiveStartPrebufferMs,
)
if (!decision.shouldStart) return
try {
requestAudioFocusLocked()
track.play()
try { track.setVolume(currentVolume) } catch (_: Exception) { }
} catch (e: Exception) {
try { track.release() } catch (_: Exception) { }
audioTrack = null
throw e
}
playbackStarted = true
estimatedPlaybackEndAtMs = now + decision.bufferedMs
playbackStartedAtMs = now
lastHeadPositionLogAtMs = now
lastLoggedHeadFrames = readHeadFrames(track)
headAdvanceConfirmed = false
Log.i(
TAG,
"Started streaming PCM playback at ${sampleRate}Hz " +
"session=${track.audioSessionId} prebuffer=${decision.bufferedMs}ms " +
"waited=${waitedMs}ms target=${adaptiveStartPrebufferMs}ms " +
"reason=${decision.reason} playState=${readPlayState(track)} " +
"headFrames=$lastLoggedHeadFrames ${mediaVolumeSummaryLocked()}",
)
pendingStartBytes = 0
firstBufferedAtMs = 0L
lastUnderrunCount = readUnderrunCount(track)
}
/**
* Periodically logs whether the AudioTrack hardware cursor is actually
* advancing. This is the decisive signal for the "speaking animation + valid
* PCM logs but no sound" class of bug:
*
* - head frames advancing + still no sound → output route / volume problem
* (e.g. the Samsung HAL not opening the path until a volume key nudges it).
* - head frames pinned at the start value → the track was play()'d but the
* mixer never pulled from it (focus / state problem on this device).
*/
private fun logPlaybackHealthLocked(track: AudioTrack, now: Long) {
if (!playbackStarted) return
val headFrames = readHeadFrames(track)
// First-frame detection runs on EVERY write until confirmed (not gated by
// the throttle) and uses a fresh timestamp, so time-to-first-audio is
// accurate to write cadence rather than the 1s health-log window — the
// throttle/stale-`now` combination otherwise inflates it by ~1.5s.
if (!headAdvanceConfirmed && headFrames > 0) {
headAdvanceConfirmed = true
val freshNow = SystemClock.elapsedRealtime()
val ttfaMs = if (playbackStartedAtMs > 0L) freshNow - playbackStartedAtMs else -1L
Log.i(
TAG,
"Realtime PCM time-to-first-audio=${ttfaMs}ms (headFrames=$headFrames)",
)
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Info,
title = "Realtime audio started",
detail = "First sample reached the speaker after ${ttfaMs}ms.",
)
}
if (now - lastHeadPositionLogAtMs < HEAD_POSITION_LOG_THROTTLE_MS) return
val advancedFrames = headFrames - lastLoggedHeadFrames
Log.i(
TAG,
"Realtime PCM playback health playState=${readPlayState(track)} " +
"headFrames=$headFrames advanced=$advancedFrames " +
"underruns=${readUnderrunCount(track)} ${mediaVolumeSummaryLocked()}",
)
if (advancedFrames <= 0) {
Log.w(
TAG,
"Realtime PCM hardware cursor not advancing (headFrames=$headFrames " +
"playState=${readPlayState(track)}); audio queued but mixer is not pulling",
)
maybeRecordStuckCursorDiagnosticLocked(track, now)
}
lastHeadPositionLogAtMs = now
lastLoggedHeadFrames = headFrames
}
/**
* If the hardware cursor never started after [STUCK_CURSOR_DIAGNOSTIC_MS] of
* "playing", surface it to the in-app Diagnostics screen once per track —
* this is the field-visible signal for the cold-start parking class when no
* logcat cable is attached. Write-sampled here; the [VoiceViewModel] watchdog
* provides the timer-driven guarantee when writes stall.
*/
private fun maybeRecordStuckCursorDiagnosticLocked(track: AudioTrack, now: Long) {
if (headAdvanceConfirmed || playbackStartedAtMs <= 0L) return
val stuckMs = now - playbackStartedAtMs
if (stuckMs < STUCK_CURSOR_DIAGNOSTIC_MS) return
if (now - lastPlaybackGapDiagnosticAtMs < PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS) return
lastPlaybackGapDiagnosticAtMs = now
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Warning,
title = "Realtime audio not starting",
detail = "Playback running ${stuckMs}ms but no audio reached the speaker " +
"(${mediaVolumeSummaryLocked()}).",
)
}
/**
* Immutable snapshot of playback progress for the [VoiceViewModel] watchdog
* and drain cross-check. Reads are cheap and lock-guarded.
*/
fun snapshot(): RealtimePlaybackSnapshot = synchronized(trackLock) {
val track = audioTrack
RealtimePlaybackSnapshot(
active = track != null,
playbackStarted = playbackStarted,
headFrames = track?.let { readHeadFrames(it) } ?: 0,
framesWritten = totalFramesWritten,
sampleRate = currentSampleRate,
playStatePlaying = track != null && readPlayState(track) == "playing",
startedAtElapsedMs = playbackStartedAtMs,
)
}
private fun readHeadFrames(track: AudioTrack): Int =
try { track.playbackHeadPosition } catch (_: Exception) { lastLoggedHeadFrames }
private fun readPlayState(track: AudioTrack): String =
try {
when (track.playState) {
AudioTrack.PLAYSTATE_PLAYING -> "playing"
AudioTrack.PLAYSTATE_PAUSED -> "paused"
AudioTrack.PLAYSTATE_STOPPED -> "stopped"
else -> "unknown"
}
} catch (_: Exception) {
"error"
}
private fun notePlaybackGapLocked(track: AudioTrack, now: Long) {
if (!playbackStarted) return
val underrunCount = readUnderrunCount(track)
val platformUnderrun = underrunCount > lastUnderrunCount
val estimatedDrained = estimatedPlaybackEndAtMs > 0L &&
now > estimatedPlaybackEndAtMs + RealtimePcmBufferPolicy.UNDERFLOW_GRACE_MS
if (!platformUnderrun && !estimatedDrained) return
val reason = if (platformUnderrun) {
"platform underrun ${lastUnderrunCount}→$underrunCount"
} else {
"stream gap ${now - estimatedPlaybackEndAtMs}ms"
}
Log.w(TAG, "Realtime PCM continuing after $reason")
recordPlaybackGapDiagnosticLocked(now, reason)
playbackGapSeenThisTrack = true
increaseAdaptivePrebufferLocked(reason)
// Provider-native realtime streams can legitimately arrive in uneven
// bursts while the model decides to call tools. Keep the AudioTrack
// alive so already queued speech is not flushed and the next chunk can
// resume naturally after Android's underrun recovery.
if (estimatedDrained) {
estimatedPlaybackEndAtMs = now
}
lastUnderrunCount = underrunCount
}
private fun increaseAdaptivePrebufferLocked(reason: String) {
val previous = adaptiveStartPrebufferMs
adaptiveStartPrebufferMs = (adaptiveStartPrebufferMs + ADAPTIVE_PREBUFFER_STEP_MS)
.coerceAtMost(RealtimePcmBufferPolicy.MAX_ADAPTIVE_START_PREBUFFER_MS)
if (adaptiveStartPrebufferMs != previous) {
Log.i(
TAG,
"Realtime PCM adaptive prebuffer increased to ${adaptiveStartPrebufferMs}ms " +
"after $reason",
)
}
}
private fun settleAdaptivePrebufferLocked() {
if (playbackGapSeenThisTrack) return
val previous = adaptiveStartPrebufferMs
adaptiveStartPrebufferMs = (adaptiveStartPrebufferMs - ADAPTIVE_PREBUFFER_DECAY_MS)
.coerceAtLeast(RealtimePcmBufferPolicy.START_PREBUFFER_MS)
if (adaptiveStartPrebufferMs != previous) {
Log.i(
TAG,
"Realtime PCM adaptive prebuffer relaxed to ${adaptiveStartPrebufferMs}ms",
)
}
}
private fun recordPlaybackGapDiagnosticLocked(now: Long, reason: String) {
if (now - lastPlaybackGapDiagnosticAtMs < PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS) return
lastPlaybackGapDiagnosticAtMs = now
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Warning,
title = "Realtime audio stream gap",
detail = reason,
)
}
private fun requestAudioFocusLocked() {
val manager = audioManager ?: return
val now = SystemClock.elapsedRealtime()
val mediaVolume = runCatching { manager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
val maxVolume = runCatching { manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
if (mediaVolume == 0 && now - lastMutedVolumeDiagnosticAtMs > MUTED_VOLUME_DIAGNOSTIC_THROTTLE_MS) {
lastMutedVolumeDiagnosticAtMs = now
Log.w(TAG, "Realtime PCM playback is starting while media volume is muted")
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Warning,
title = "Realtime voice volume muted",
detail = "Media volume is 0/${maxVolume ?: "?"}.",
)
}
if (audioFocusHeld) {
Log.i(TAG, "Realtime PCM audio focus already held ${mediaVolumeSummaryLocked()}")
return
}
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val request = audioFocusRequest ?: AudioFocusRequest.Builder(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
)
.setAudioAttributes(realtimeAudioAttributes)
.setAcceptsDelayedFocusGain(false)
.setOnAudioFocusChangeListener(audioFocusChangeListener)
.build()
.also { audioFocusRequest = it }
manager.requestAudioFocus(request)
} else {
@Suppress("DEPRECATION")
manager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
)
}
audioFocusHeld = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
Log.i(
TAG,
"Realtime PCM audio focus result=$result held=$audioFocusHeld ${mediaVolumeSummaryLocked()}",
)
}
private fun abandonAudioFocusLocked() {
val manager = audioManager ?: return
if (!audioFocusHeld) return
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { manager.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
manager.abandonAudioFocus(audioFocusChangeListener)
}
}.onFailure {
Log.w(TAG, "Realtime PCM audio focus abandon failed: ${it.message}")
}
audioFocusHeld = false
}
private fun mediaVolumeSummaryLocked(): String {
val manager = audioManager ?: return "mediaVolume=unknown"
val volume = runCatching { manager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
val maxVolume = runCatching { manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
val musicActive = runCatching { manager.isMusicActive }.getOrNull()
return "mediaVolume=${volume ?: "?"}/${maxVolume ?: "?"} musicActive=${musicActive ?: "?"}"
}
private fun updateUnderrunCursorLocked(track: AudioTrack) {
val underrunCount = readUnderrunCount(track)
if (underrunCount > lastUnderrunCount) {
lastUnderrunCount = underrunCount
}
}
private fun readUnderrunCount(track: AudioTrack): Int =
try { track.underrunCount } catch (_: Exception) { lastUnderrunCount }
private fun remainingPlaybackMsLocked(now: Long, cushionMs: Long): Long {
if (audioTrack == null) return 0L
if (!playbackStarted) {
return RealtimePcmBufferPolicy.durationMsForBytes(
bytes = pendingStartBytes,
sampleRate = currentSampleRate,
) + cushionMs.coerceAtLeast(0L)
}
return (estimatedPlaybackEndAtMs - now + cushionMs).coerceAtLeast(0L)
}
private fun computePcm16LeRms(pcm: ByteArray): Float {
val usable = pcm.size - (pcm.size % 2)
if (usable <= 0) return 0f
var sumSquares = 0.0
var samples = 0
var index = 0
while (index < usable) {
val low = pcm[index].toInt() and 0xff
val high = pcm[index + 1].toInt()
val sample = ((high shl 8) or low).toShort().toInt()
val normalized = sample / Short.MAX_VALUE.toDouble()
sumSquares += normalized * normalized
samples++
index += 2
}
if (samples == 0) return 0f
val rms = sqrt(sumSquares / samples)
val lifted = sqrt((rms / 0.28).coerceIn(0.0, 1.0))
return if (lifted.isNaN() || lifted.isInfinite()) 0f else lifted.toFloat().coerceIn(0f, 1f)
}
companion object {
private const val TAG = "RealtimePcmPlayer"
private const val DEFAULT_DRAIN_CUSHION_MS = 250L
private const val PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS = 5_000L
private const val MUTED_VOLUME_DIAGNOSTIC_THROTTLE_MS = 10_000L
private const val ADAPTIVE_PREBUFFER_STEP_MS = 240L
private const val ADAPTIVE_PREBUFFER_DECAY_MS = 120L
private const val HEAD_POSITION_LOG_THROTTLE_MS = 1_000L
private const val STUCK_CURSOR_DIAGNOSTIC_MS = 1_200L
private const val BYTES_PER_FRAME = 2 // mono 16-bit PCM
private const val MAX_AMP_QUEUE = 1_024
}
}
/**
* Lock-free value snapshot of realtime playback progress, consumed by the
* [com.hermesandroid.relay.viewmodel.VoiceViewModel] first-frame watchdog and
* drain cross-check.
*/
data class RealtimePlaybackSnapshot(
val active: Boolean,
val playbackStarted: Boolean,
val headFrames: Int,
val framesWritten: Long,
val sampleRate: Int,
val playStatePlaying: Boolean,
val startedAtElapsedMs: Long,
)
/** A written PCM chunk's RMS amplitude tagged with the frame it finishes at. */
internal data class FrameAmp(val endFrame: Long, val rms: Float)
/**
* Returns the amplitude of the first chunk that has not finished playing
* ([FrameAmp.endFrame] > [headFrames]) — i.e. the audio at the cursor right now.
* 0 when the queue is empty or fully drained. Pure for unit testing.
*/
internal fun amplitudeAtHead(queue: List<FrameAmp>, headFrames: Long): Float {
for (entry in queue) {
if (entry.endFrame > headFrames) return entry.rms
}
return 0f
}
internal data class RealtimePcmStartDecision(
val shouldStart: Boolean,
val bufferedMs: Long,
val reason: String,
)
internal object RealtimePcmBufferPolicy {
// Realtime voice is latency-sensitive: the provider streams PCM at (or faster
// than) realtime, so the start prebuffer only needs to cover network jitter,
// not the whole turn. The large [STREAM_BUFFER_MS] AudioTrack buffer absorbs
// bursts *after* playback starts; the start thresholds just decide when the
// very first sample is allowed to leave the queue.
//
// A short turn whose audio arrives faster than realtime used to satisfy
// neither the (2.4s) prebuffer nor the (1.2s) max-wait, so it never started
// mid-stream and depended entirely on the end-of-turn flush. Lowering these
// lets streaming start on the first few chunks while keeping enough cushion
// to ride out jitter.
const val STARTUP_PREROLL_MS = 120L
const val START_PREBUFFER_MS = 320L
const val MIN_PREBUFFER_MS = 160L
const val MAX_PREBUFFER_WAIT_MS = 280L
const val MAX_ADAPTIVE_START_PREBUFFER_MS = 1_200L
// Keep the AudioTrack buffer modest. A multi-second buffer gets routed to
// Samsung's "deep buffer" output mixer, whose thread is suspended at rest and
// cold-starts very slowly — the hardware cursor (playbackHeadPosition) stays
// pinned at 0 for ~2-5s after play() even though playState=PLAYING, focus is
// held and volume is up. That parked window is the inaudible first/short
// turn. A sub-second buffer keeps playback on the primary (fast) mixer path,
// which begins pulling immediately. The ~700ms still absorbs normal network
// jitter; longer provider gaps (tool calls) underrun-and-resume regardless of
// buffer size and are handled by notePlaybackGapLocked.
const val STREAM_BUFFER_MS = 700L
const val UNDERFLOW_GRACE_MS = 180L
fun streamBufferSize(minBufferBytes: Int, sampleRate: Int): Int {
val target = bytesForDurationMs(sampleRate, STREAM_BUFFER_MS)
return max(minBufferBytes, target)
}
fun startDecision(
pendingBytes: Int,
sampleRate: Int,
waitedMs: Long,
force: Boolean,
startPrebufferMs: Long = START_PREBUFFER_MS,
): RealtimePcmStartDecision {
val bufferedMs = durationMsForBytes(pendingBytes, sampleRate)
val targetPrebufferMs = startPrebufferMs.coerceIn(
START_PREBUFFER_MS,
MAX_ADAPTIVE_START_PREBUFFER_MS,
)
val reason = when {
force && pendingBytes > 0 -> "flush"
bufferedMs >= targetPrebufferMs -> "prebuffer"
bufferedMs >= MIN_PREBUFFER_MS && waitedMs >= MAX_PREBUFFER_WAIT_MS -> "max-wait"
else -> "buffering"
}
return RealtimePcmStartDecision(
shouldStart = reason != "buffering",
bufferedMs = bufferedMs,
reason = reason,
)
}
fun durationMsForBytes(bytes: Int, sampleRate: Int): Long {
if (bytes <= 0 || sampleRate <= 0) return 0L
return ((bytes / 2.0) / sampleRate * 1000.0)
.toLong()
.coerceAtLeast(1L)
}
fun bytesForDurationMs(sampleRate: Int, durationMs: Long): Int {
if (sampleRate <= 0 || durationMs <= 0L) return 0
return (sampleRate * 2L * durationMs / 1000L).toInt()
}
fun startupPrerollBytes(sampleRate: Int): Int =
bytesForDurationMs(sampleRate, STARTUP_PREROLL_MS)
}
@@ -0,0 +1,145 @@
package com.hermesandroid.relay.audio
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import kotlin.math.min
import kotlin.math.sqrt
/**
* Captures mono 16-bit PCM for realtime voice test runs.
*
* [capture] grabs a fixed short window (legacy/back-compat). [captureUntilStopped]
* records open-endedly until [requestStop] is called (tap-to-stop), which is what
* the Mic demo needs to capture a full spoken sentence.
*/
class RealtimePcmRecorder(
private val sampleRate: Int = 16_000,
) {
@Volatile
private var capturing = false
/** Signals an in-flight [captureUntilStopped] to finish and return. */
fun requestStop() {
capturing = false
}
val isCapturing: Boolean
get() = capturing
/**
* Records until [requestStop] is called or [maxDurationMs] elapses, invoking
* [onLevel] (0..1 RMS) per read so the UI can show a live input waveform.
*/
@SuppressLint("MissingPermission")
suspend fun captureUntilStopped(
maxDurationMs: Long = 15_000,
onLevel: ((Float) -> Unit)? = null,
): ByteArray = withContext(Dispatchers.IO) {
val minBuffer = AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(sampleRate / 10 * 2)
val maxBytes = ((sampleRate * maxDurationMs) / 1000L * 2L).toInt()
val recorder = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(minBuffer)
.build()
val out = ByteArrayOutputStream(minBuffer * 4)
val buffer = ByteArray(minBuffer)
capturing = true
try {
recorder.startRecording()
while (capturing && out.size() < maxBytes) {
val read = recorder.read(buffer, 0, buffer.size)
if (read > 0) {
out.write(buffer, 0, read)
onLevel?.invoke(rms16Le(buffer, read))
} else {
break
}
}
} finally {
capturing = false
try { recorder.stop() } catch (_: Exception) { }
recorder.release()
}
out.toByteArray()
}
@SuppressLint("MissingPermission")
suspend fun capture(durationMs: Long = 800): ByteArray = withContext(Dispatchers.IO) {
val minBuffer = AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(sampleRate / 10 * 2)
val targetBytes = ((sampleRate * durationMs) / 1000L * 2L)
.toInt()
.coerceAtLeast(minBuffer)
val recorder = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(minBuffer)
.build()
val out = ByteArrayOutputStream(targetBytes)
val buffer = ByteArray(minBuffer)
try {
recorder.startRecording()
while (out.size() < targetBytes) {
val read = recorder.read(
buffer,
0,
min(buffer.size, targetBytes - out.size()),
)
if (read > 0) {
out.write(buffer, 0, read)
} else {
break
}
}
} finally {
try { recorder.stop() } catch (_: Exception) { }
recorder.release()
}
out.toByteArray()
}
private fun rms16Le(buffer: ByteArray, length: Int): Float {
val usable = length - (length % 2)
if (usable <= 0) return 0f
var sum = 0.0
var i = 0
while (i < usable) {
val low = buffer[i].toInt() and 0xff
val high = buffer[i + 1].toInt()
val sample = ((high shl 8) or low).toShort().toInt() / 32768.0
sum += sample * sample
i += 2
}
val rms = sqrt(sum / (usable / 2))
return sqrt((rms / 0.28).coerceIn(0.0, 1.0)).toFloat()
}
}
@@ -9,6 +9,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -83,9 +84,33 @@ class VoicePlayer(
private var visualizer: Visualizer? = null
private var visualizerAttached = false
// Thread-safe mirror of [ExoPlayer.getAudioSessionId]. ExoPlayer is
// thread-confined — every accessor (the audioSessionId getter included)
// calls verifyApplicationThread() and throws "Player is accessed on the
// wrong thread" if touched off the player's construction thread. The
// barge-in pipeline reads [audioSessionId] from BargeInListener's
// Dispatchers.IO reader coroutine to attach AcousticEchoCanceler, so we
// can't expose the raw getter. Instead we cache the id from the
// main-thread Media3 callbacks below and serve the getter from this
// @Volatile field. (Fixes the legacy-TTS + barge-in crash where the
// first sentence played for ~2 syllables before the IO read threw.)
@Volatile private var cachedAudioSessionId: Int = 0
private val exoPlayer: ExoPlayer = exoPlayerFactory(context.applicationContext)
init {
// AnalyticsListener callbacks are delivered on the player's
// application (main) thread, so caching the id here is the
// authoritative, thread-correct way to track it as Media3 allocates
// and reallocates the underlying AudioTrack.
exoPlayer.addAnalyticsListener(object : AnalyticsListener {
override fun onAudioSessionIdChanged(
eventTime: AnalyticsListener.EventTime,
audioSessionId: Int,
) {
cachedAudioSessionId = audioSessionId
}
})
exoPlayer.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_isPlaying.value = isPlaying
@@ -94,8 +119,16 @@ class VoicePlayer(
// actually begins — the audio session id is stable from
// player construction on Media3 1.x but some OEM pipelines
// don't allocate the track until playback starts.
if (isPlaying && !visualizerAttached) {
attachVisualizer(exoPlayer.audioSessionId)
if (isPlaying) {
// Belt-and-braces with the analytics listener above: this
// runs on the main thread too, so reading the getter here
// is safe and guarantees the cache is warm by the time
// playback is audible (and thus by the time barge-in
// starts its IO reader).
cachedAudioSessionId = exoPlayer.audioSessionId
if (!visualizerAttached) {
attachVisualizer(cachedAudioSessionId)
}
}
}
@@ -211,13 +244,20 @@ class VoicePlayer(
* poll this property briefly rather than assume it's hot-ready at
* [VoicePlayer] construction time.
*
* Exposed read-only. Internally the same id drives the Visualizer
* attach logic in [attachVisualizer]; B4 reads it via a provider
* lambda so the listener can re-check across the 1 s poll window
* without holding a stale reference.
* **Thread-safe.** Backed by [cachedAudioSessionId] rather than the raw
* `ExoPlayer.getAudioSessionId()` getter, because ExoPlayer is
* thread-confined and [BargeInListener] reads this from its
* `Dispatchers.IO` reader coroutine. Reading the raw getter off-main
* throws `IllegalStateException: Player is accessed on the wrong thread`.
* The cache is populated from main-thread Media3 callbacks (the
* [AnalyticsListener.onAudioSessionIdChanged] hook and `onIsPlayingChanged`).
*
* Exposed read-only. B4 reads it via a provider lambda so the listener
* can re-check across the 1 s poll window without holding a stale
* reference.
*/
val audioSessionId: Int
get() = exoPlayer.audioSessionId
get() = cachedAudioSessionId
/**
* Set the playback volume of the underlying ExoPlayer.
@@ -2,60 +2,47 @@ package com.hermesandroid.relay.audio
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Build
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.sqrt
/**
* Captures the user's voice into an `.m4a` (AAC-in-MP4) file for V2a voice
* mode. The relay's `/voice/transcribe` endpoint feeds this to whisper-1 via
* OpenAI, which accepts m4a/mp4 natively.
* Captures the user's voice as 16 kHz mono PCM and writes a `.wav` file for
* the relay STT endpoint. The raw PCM is retained for the server-mediated
* `/voice/realtime/{session}` path so the main voice UI can send the same utterance
* through the realtime websocket without opening a second microphone stream.
*
* A live [amplitude] flow is exposed for the UI (MorphingSphere + meter) —
* driven by polling `MediaRecorder.maxAmplitude` every ~16 ms. The polling
* coroutine runs on the caller-supplied [scope] so it dies with the owning
* ViewModel.
*
* One recorder instance owns at most one active recording at a time. Calling
* [startRecording] again while a recording is in flight will stop the
* previous one first. [stopRecording] is safe to call when nothing is
* running (it just returns the last file, or throws if there never was one).
* A live [amplitude] flow is exposed for the UI (MorphingSphere + meter). The
* value is computed from the same PCM frames that are written to disk, which
* keeps legacy STT fallback and realtime voice testing on a single capture
* path.
*/
class VoiceRecorder(
private val context: Context,
private val scope: CoroutineScope,
@Suppress("UNUSED_PARAMETER") private val scope: kotlinx.coroutines.CoroutineScope,
) {
companion object {
private const val TAG = "VoiceRecorder"
private const val SAMPLE_RATE = 16_000
private const val BIT_RATE = 64_000
private const val AMPLITUDE_POLL_MS = 16L
private const val BYTES_PER_SAMPLE = 2
private const val CHANNEL_COUNT = 1
private const val MAX_AMPLITUDE_SHORT = 32_767f
private const val MAX_PCM_BYTES = 25 * 1024 * 1024
// Perceptual amplitude mapping constants. Raw PCM peak values from
// MediaRecorder.maxAmplitude for a phone at arm's length:
// silence / ambient : 100..500 (≤0.015 of max)
// quiet speech : 500..3000 (0.015..0.09)
// normal speech : 3000..8000 (0.09..0.24)
// loud speech : 8000..18000 (0.24..0.55)
// shout / clipping : 18000..32767 (0.55..1.0)
//
// Linear 0..1 puts normal conversation between 0.09 and 0.24 — the
// meter barely moves. Subtract a noise floor, rescale into the
// speech-ceiling window, then apply a sqrt curve so quiet speech
// still registers visually without drowning loud speech at the top.
// Keep the perceptual curve from the previous MediaRecorder-backed
// implementation so the on-screen meter feels the same.
private const val NOISE_FLOOR = 0.01f
private const val SPEECH_CEILING = 0.35f
}
@@ -63,162 +50,231 @@ class VoiceRecorder(
private val _amplitude = MutableStateFlow(0f)
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
private var mediaRecorder: MediaRecorder? = null
val sampleRate: Int get() = SAMPLE_RATE
private val bufferLock = Any()
private val stopRequested = AtomicBoolean(false)
private var audioRecord: AudioRecord? = null
private var currentOutputFile: File? = null
private var pollJob: Job? = null
private var readThread: Thread? = null
private var readDone: CountDownLatch? = null
private var pcmBuffer = ByteArrayOutputStream(SAMPLE_RATE * BYTES_PER_SAMPLE * 4)
private var lastPcmBytes: ByteArray = ByteArray(0)
/**
* Begin a new recording. Returns the output [File] that will receive the
* audio once [stopRecording] is called. Throws on permission failure or
* encoder init failure — callers should catch and surface to the UI.
* Begin a new recording. Returns the output [File] that will contain WAV
* audio once [stopRecording] is called.
*/
@SuppressLint("MissingPermission")
fun startRecording(): File {
// Defensive: if a recording is somehow still running, tear it down
// before starting a new one. MediaRecorder transitions are strict.
if (mediaRecorder != null) {
Log.w(TAG, "startRecording called while another recording is in flight — stopping it first")
if (audioRecord != null) {
Log.w(TAG, "startRecording called while another recording is in flight; stopping it first")
try {
stopRecording()
} catch (_: Exception) {
// Swallow — we're about to overwrite state anyway.
releaseRecorder()
}
}
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.m4a")
currentOutputFile = outFile
val minBuffer = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(SAMPLE_RATE / 10 * BYTES_PER_SAMPLE)
val recorder = buildRecorder()
try {
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
recorder.setAudioSamplingRate(SAMPLE_RATE)
recorder.setAudioEncodingBitRate(BIT_RATE)
recorder.setAudioChannels(1)
recorder.setOutputFile(outFile.absolutePath)
recorder.prepare()
recorder.start()
} catch (e: IllegalStateException) {
Log.e(TAG, "MediaRecorder failed to start: ${e.message}")
try {
recorder.reset()
} catch (_: Exception) { /* ignore */ }
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.wav")
currentOutputFile = outFile
synchronized(bufferLock) {
pcmBuffer = ByteArrayOutputStream(SAMPLE_RATE * BYTES_PER_SAMPLE * 4)
lastPcmBytes = ByteArray(0)
}
stopRequested.set(false)
_amplitude.value = 0f
val recorder = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(minBuffer * 2)
.build()
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
recorder.release()
mediaRecorder = null
currentOutputFile = null
throw e
throw IllegalStateException("AudioRecord failed to initialize")
}
try {
recorder.startRecording()
} catch (e: Exception) {
Log.e(TAG, "MediaRecorder setup failed: ${e.message}")
try {
recorder.reset()
} catch (_: Exception) { /* ignore */ }
recorder.release()
mediaRecorder = null
currentOutputFile = null
throw e
}
mediaRecorder = recorder
startAmplitudePolling()
audioRecord = recorder
val done = CountDownLatch(1)
readDone = done
readThread = Thread(
{
readPcmLoop(recorder, minBuffer)
done.countDown()
},
"HermesVoiceRecorder",
).also { it.start() }
return outFile
}
/**
* Stop the active recording, flush the encoder, and return the completed
* output [File]. Safe to call when nothing is recording — in that case
* it returns the last file produced, or throws if there never was one.
* Stop the active recording, write the WAV container, and return it.
*/
fun stopRecording(): File {
val file = currentOutputFile
?: throw IllegalStateException("stopRecording called with no active recording")
stopAmplitudePolling()
val recorder = mediaRecorder
if (recorder != null) {
val record = audioRecord
stopRequested.set(true)
if (record != null) {
try {
recorder.stop()
record.stop()
} catch (e: IllegalStateException) {
// MediaRecorder.stop throws if called before any audio was
// captured (sub-300ms recordings). Treat as recoverable —
// the output file may be 0 bytes but the caller can check.
Log.w(TAG, "MediaRecorder.stop threw — recording may be empty: ${e.message}")
} catch (e: RuntimeException) {
Log.w(TAG, "MediaRecorder.stop runtime error: ${e.message}")
} finally {
releaseRecorder()
Log.w(TAG, "AudioRecord.stop threw; recording may be empty: ${e.message}")
}
}
readDone?.await(1, TimeUnit.SECONDS)
releaseRecorder()
val pcm = synchronized(bufferLock) {
pcmBuffer.toByteArray().also { lastPcmBytes = it }
}
writeWav(file, pcm)
_amplitude.value = 0f
return file
}
/**
* True if a recording is currently active. Cheap — just checks whether
* we have a live [MediaRecorder] reference.
*/
fun isRecording(): Boolean = mediaRecorder != null
fun isRecording(): Boolean = audioRecord != null && !stopRequested.get()
fun lastPcmBytes(): ByteArray = synchronized(bufferLock) {
lastPcmBytes.copyOf()
}
/**
* Release any recorder resources without returning a file. Safe fallback
* for error paths where the output file is known-invalid.
* Release any recorder resources without returning a file.
*/
fun cancel() {
stopAmplitudePolling()
mediaRecorder?.let { r ->
try {
r.stop()
} catch (_: Exception) { /* ignore */ }
stopRequested.set(true)
audioRecord?.let { record ->
try { record.stop() } catch (_: Exception) { }
}
readDone?.await(500, TimeUnit.MILLISECONDS)
releaseRecorder()
currentOutputFile?.let { f ->
try { f.delete() } catch (_: Exception) { /* ignore */ }
currentOutputFile?.let { file ->
try { file.delete() } catch (_: Exception) { }
}
currentOutputFile = null
synchronized(bufferLock) {
pcmBuffer.reset()
lastPcmBytes = ByteArray(0)
}
_amplitude.value = 0f
}
private fun releaseRecorder() {
mediaRecorder?.let { r ->
try { r.reset() } catch (_: Exception) { /* ignore */ }
try { r.release() } catch (_: Exception) { /* ignore */ }
}
mediaRecorder = null
}
@Suppress("DEPRECATION")
private fun buildRecorder(): MediaRecorder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
MediaRecorder()
}
private fun startAmplitudePolling() {
pollJob?.cancel()
pollJob = scope.launch(Dispatchers.Default) {
while (isActive) {
val recorder = mediaRecorder ?: break
val raw = try {
recorder.maxAmplitude
} catch (e: IllegalStateException) {
// Recorder torn down under us — exit quietly.
break
private fun readPcmLoop(record: AudioRecord, minBuffer: Int) {
val buffer = ByteArray(minBuffer)
while (!stopRequested.get()) {
val read = try {
record.read(buffer, 0, buffer.size)
} catch (e: Exception) {
Log.w(TAG, "AudioRecord.read failed: ${e.message}")
break
}
if (read > 0) {
synchronized(bufferLock) {
if (pcmBuffer.size() + read <= MAX_PCM_BYTES) {
pcmBuffer.write(buffer, 0, read)
} else {
stopRequested.set(true)
}
}
val raw01 = (raw.toFloat() / MAX_AMPLITUDE_SHORT).coerceIn(0f, 1f)
val floored = ((raw01 - NOISE_FLOOR) / (SPEECH_CEILING - NOISE_FLOOR))
.coerceIn(0f, 1f)
_amplitude.value = sqrt(floored)
delay(AMPLITUDE_POLL_MS)
updateAmplitude(buffer, read)
}
}
}
private fun stopAmplitudePolling() {
pollJob?.cancel()
pollJob = null
private fun updateAmplitude(buffer: ByteArray, read: Int) {
var peak = 0
var index = 0
val usable = read - (read % BYTES_PER_SAMPLE)
while (index < usable) {
val low = buffer[index].toInt() and 0xff
val high = buffer[index + 1].toInt()
val sample = (high shl 8) or low
val abs = kotlin.math.abs(sample.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()))
if (abs > peak) peak = abs
index += BYTES_PER_SAMPLE
}
val raw01 = (peak.toFloat() / MAX_AMPLITUDE_SHORT).coerceIn(0f, 1f)
val floored = ((raw01 - NOISE_FLOOR) / (SPEECH_CEILING - NOISE_FLOOR))
.coerceIn(0f, 1f)
_amplitude.value = sqrt(floored)
}
private fun releaseRecorder() {
audioRecord?.let { record ->
try { record.release() } catch (_: Exception) { }
}
audioRecord = null
readThread = null
readDone = null
}
private fun writeWav(file: File, pcm: ByteArray) {
try {
file.outputStream().use { out ->
out.write(wavHeader(pcm.size))
out.write(pcm)
}
} catch (e: IOException) {
throw IOException("Failed to write WAV recording: ${e.message}", e)
}
}
private fun wavHeader(pcmBytes: Int): ByteArray {
val totalDataLen = pcmBytes + 36
val byteRate = SAMPLE_RATE * CHANNEL_COUNT * BYTES_PER_SAMPLE
return ByteArray(44).also { header ->
fun ascii(offset: Int, value: String) {
value.encodeToByteArray().copyInto(header, offset)
}
fun leInt(offset: Int, value: Int) {
header[offset] = (value and 0xff).toByte()
header[offset + 1] = ((value shr 8) and 0xff).toByte()
header[offset + 2] = ((value shr 16) and 0xff).toByte()
header[offset + 3] = ((value shr 24) and 0xff).toByte()
}
fun leShort(offset: Int, value: Int) {
header[offset] = (value and 0xff).toByte()
header[offset + 1] = ((value shr 8) and 0xff).toByte()
}
ascii(0, "RIFF")
leInt(4, totalDataLen)
ascii(8, "WAVE")
ascii(12, "fmt ")
leInt(16, 16)
leShort(20, 1)
leShort(22, CHANNEL_COUNT)
leInt(24, SAMPLE_RATE)
leInt(28, byteRate)
leShort(32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
leShort(34, 16)
ascii(36, "data")
leInt(40, pcmBytes)
}
}
}
@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -40,6 +41,15 @@ sealed class AuthState {
data class Failed(val reason: String) : AuthState()
}
@Serializable
data class ConnectionAuthSecrets(
val sessionToken: String? = null,
val refreshToken: String? = null,
val deviceId: String? = null,
val apiKey: String? = null,
val pairedSessionMetaJson: String? = null,
)
/**
* Orchestrates pairing + session token lifecycle for the relay channel.
*
@@ -90,6 +100,7 @@ class AuthManager(
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_API_KEY = "api_server_key"
private const val HINT_API_KEY_PRESENT = "api_key_present"
private const val KEY_PAIRED_META = "paired_session_meta_json"
private const val PAIRING_CODE_LENGTH = 6
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
@@ -103,6 +114,16 @@ class AuthManager(
*/
const val CONNECTION_ID_LEGACY: String = "legacy"
internal fun shouldPreservePairedSessionOnAuthFail(
currentState: AuthState,
rawReason: String,
): Boolean {
val lower = rawReason.lowercase()
return currentState is AuthState.Paired &&
"timeout" in lower &&
("auth" in lower || "authentication" in lower)
}
/**
* Best-effort read of a connection's stored device id without making
* that connection active. Used by the connection removal path so it
@@ -124,6 +145,56 @@ class AuthManager(
}
}
suspend fun exportStoredSecrets(
context: Context,
tokenStoreKey: String,
): ConnectionAuthSecrets = withContext(Dispatchers.IO) {
val store = tokenStoreForBackup(context, tokenStoreKey)
ConnectionAuthSecrets(
sessionToken = store.getString(KEY_SESSION_TOKEN),
refreshToken = store.getString(KEY_REFRESH_TOKEN),
deviceId = store.getString(KEY_DEVICE_ID),
apiKey = store.getString(KEY_API_KEY),
pairedSessionMetaJson = store.getString(KEY_PAIRED_META),
)
}
suspend fun importStoredSecrets(
context: Context,
tokenStoreKey: String,
secrets: ConnectionAuthSecrets,
) {
withContext(Dispatchers.IO) {
val store = tokenStoreForBackup(context, tokenStoreKey)
writeOrRemove(store, KEY_SESSION_TOKEN, secrets.sessionToken)
writeOrRemove(store, KEY_REFRESH_TOKEN, secrets.refreshToken)
writeOrRemove(store, KEY_DEVICE_ID, secrets.deviceId)
writeOrRemove(store, KEY_API_KEY, secrets.apiKey)
writeOrRemove(store, KEY_PAIRED_META, secrets.pairedSessionMetaJson)
}
}
private fun tokenStoreForBackup(
context: Context,
tokenStoreKey: String,
): SessionTokenStore {
val appContext = context.applicationContext
return KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
}
private fun writeOrRemove(
store: SessionTokenStore,
key: String,
value: String?,
) {
if (value == null) {
store.remove(key)
} else {
store.putString(key, value)
}
}
/**
* Parse the `profiles` array from an `auth.ok` payload into a list of
* [Profile] entries. Extracted out of [handleAuthOk] so it's
@@ -147,6 +218,9 @@ class AuthManager(
* metadata) are optional on the wire. Missing / malformed values
* fall back to `false` / `false` / `0` so older relays stay
* compatible and bad server data can't crash the pairing handshake.
* - `api_server_*` metadata is optional. When present, it lets the
* client route chat through a profile's isolated Hermes API
* server without exposing that profile server's key.
*/
fun parseAgentProfiles(array: JsonArray): List<Profile> {
return array.mapNotNull { entry ->
@@ -164,6 +238,16 @@ class AuthManager(
?.jsonPrimitive?.booleanOrNull ?: false
val skillCount = obj["skill_count"]
?.jsonPrimitive?.intOrNull ?: 0
val apiServerEnabled = obj["api_server_enabled"]
?.jsonPrimitive?.booleanOrNull ?: false
val apiServerUrl = obj["api_server_url"]
?.jsonPrimitive?.contentOrNull
val apiServerHost = obj["api_server_host"]
?.jsonPrimitive?.contentOrNull
val apiServerPort = obj["api_server_port"]
?.jsonPrimitive?.intOrNull
val apiServerKeyPresent = obj["api_server_key_present"]
?.jsonPrimitive?.booleanOrNull ?: false
Profile(
name = name,
model = model,
@@ -172,6 +256,11 @@ class AuthManager(
gatewayRunning = gatewayRunning,
hasSoul = hasSoul,
skillCount = skillCount,
apiServerEnabled = apiServerEnabled,
apiServerUrl = apiServerUrl,
apiServerHost = apiServerHost,
apiServerPort = apiServerPort,
apiServerKeyPresent = apiServerKeyPresent,
)
}
}
@@ -184,6 +273,48 @@ class AuthManager(
private var _store: SessionTokenStore? = null
private val storeMutex = Mutex()
/**
* The encrypted-store filename for this connection — shared by [store]
* and the plain hint file below so they always describe the same store.
*/
private val tokenPrefsName: String =
tokenStoreKey ?: if (connectionId == CONNECTION_ID_LEGACY) {
Connection.LEGACY_TOKEN_STORE_KEY
} else {
Connection.buildTokenStoreKey(connectionId)
}
/**
* Plain (non-encrypted) mirror of one boolean fact: "does this
* connection have an API key stored?". Read at startup WITHOUT touching
* the Keystore, so [ConnectionViewModel] can build the API client
* immediately for key-less connections — the common local setup —
* instead of queueing behind the encrypted store's first decrypt.
*
* Why this exists: on StrongBox devices every keystore operation runs
* ~550ms and Tink serializes them process-globally; a measured S25
* Ultra cold start spent 15 seconds in that marathon before
* `getApiKey()` could return — only to answer "there is no key".
*
* The hint stores ONLY presence, never key material. It defaults to
* `true` (unknown ⇒ assume a key exists ⇒ wait for the real decrypt),
* so a missing or stale hint can never strip auth off a keyed
* connection — the failure mode is "slow like before", never "401s".
* It converges in [setApiKey]/[clearApiKey], in init's store
* hydration, and after legacy migration.
*/
private val hintPrefs by lazy {
context.getSharedPreferences("${tokenPrefsName}_plain_hints", Context.MODE_PRIVATE)
}
/** True only when a previously-recorded hint says "no API key stored". */
fun apiKeyKnownAbsent(): Boolean = !hintPrefs.getBoolean(HINT_API_KEY_PRESENT, true)
private fun recordApiKeyHint(present: Boolean) {
_apiKeyPresent.value = present
hintPrefs.edit().putBoolean(HINT_API_KEY_PRESENT, present).apply()
}
/**
* Lazily construct the best available token store. First tries
* [KeystoreTokenStore] — if that fails on broken OEM keystores we fall
@@ -199,19 +330,26 @@ class AuthManager(
return storeMutex.withLock {
_store?.let { return it }
withContext(Dispatchers.IO) {
// Multi-connection: pick the EncryptedSharedPreferences
// filename based on the bound connection. The legacy sentinel
// keeps the pre-multi-connection install on its original file
// so the existing paired device keeps working with no
// migration.
val prefsName = tokenStoreKey ?: if (connectionId == CONNECTION_ID_LEGACY) {
Connection.LEGACY_TOKEN_STORE_KEY
} else {
Connection.buildTokenStoreKey(connectionId)
}
// Multi-connection: [tokenPrefsName] picks the
// EncryptedSharedPreferences filename for the bound
// connection. The legacy sentinel keeps the pre-multi-
// connection install on its original file so the existing
// paired device keeps working with no migration.
// Both encrypted backends decrypt their Tink keyset eagerly on
// construction, so a corrupt file can throw AEADBadTagException
// here. KeystoreTokenStore.tryCreate already degrades to null;
// the legacy store self-heals its file in its constructor. If
// even that rebuild fails (a fundamentally broken keystore),
// fall back to a non-persistent store rather than force-close —
// the user re-pairs, but the app stays up.
val picked: SessionTokenStore =
KeystoreTokenStore.tryCreate(context, prefsName)
?: LegacyEncryptedPrefsTokenStore(context, prefsName)
KeystoreTokenStore.tryCreate(context, tokenPrefsName)
?: runCatching {
LegacyEncryptedPrefsTokenStore(context, tokenPrefsName)
}.getOrElse { e ->
Log.w(TAG, "Legacy token store unavailable (${e.message}) — using in-memory fallback; re-pair required")
InMemoryTokenStore()
}
migrateFromLegacyIfNeeded(picked)
_store = picked
picked
@@ -401,7 +539,9 @@ class AuthManager(
} else {
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
}
_apiKeyPresent.value = !s.getString(KEY_API_KEY).isNullOrBlank()
// Converge the plain api-key-present hint with the decrypted
// truth (also repairs a hint that predates legacy migration).
recordApiKeyHint(!s.getString(KEY_API_KEY).isNullOrBlank())
}
}
@@ -729,16 +869,16 @@ class AuthManager(
val s = store()
if (trimmed.isBlank()) {
s.remove(KEY_API_KEY)
_apiKeyPresent.value = false
recordApiKeyHint(false)
} else {
s.putString(KEY_API_KEY, trimmed)
_apiKeyPresent.value = true
recordApiKeyHint(true)
}
}
suspend fun clearApiKey() {
store().remove(KEY_API_KEY)
_apiKeyPresent.value = false
recordApiKeyHint(false)
}
val isPaired: Boolean
@@ -870,6 +1010,13 @@ class AuthManager(
?: "Unknown error"
val humanized = humanizeAuthFailReason(rawReason)
Log.w(TAG, "handleAuthFail: raw=$rawReason humanized=$humanized")
if (shouldPreservePairedSessionOnAuthFail(_authState.value, rawReason)) {
Log.w(
TAG,
"handleAuthFail: preserving paired session after transient auth timeout"
)
return
}
clearPendingPairContextAfterAuthFailure(rawReason)
_authState.value = AuthState.Failed(humanized)
} catch (e: Exception) {
@@ -72,9 +72,12 @@ class KeystoreTokenStore private constructor(
) : SessionTokenStore {
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
// file is deleted. Built lazily via [buildPrefs] so the constructor can't
// throw — [tryCreate] still controls the "is this device usable at all"
// decision via its init probe below.
// file is deleted. This field initializer runs [buildPrefs] eagerly, so it
// CAN throw (e.g. AEADBadTagException on a corrupt keyset) — but the
// constructor is private and only reachable via [tryCreate], which wraps
// construction in try/catch and degrades to the legacy store. The
// directly-constructed legacy path self-heals instead; see
// [LegacyEncryptedPrefsTokenStore.buildPrefsResilient].
private var prefs: SharedPreferences = buildPrefs()
private fun buildPrefs(): SharedPreferences {
@@ -257,7 +260,38 @@ class LegacyEncryptedPrefsTokenStore(
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
// file is deleted. See [KeystoreTokenStore.resetPrefs] for the rationale.
private var prefs: SharedPreferences = buildPrefs()
//
// Built via [buildPrefsResilient] so a corrupt keyset can't crash the
// constructor. Unlike [KeystoreTokenStore], this class is `new`-ed
// directly (it's the fallback when KeystoreTokenStore.tryCreate returns
// null, and the migration source), so there's no tryCreate-style guard
// upstream — the healing has to live here.
private var prefs: SharedPreferences = buildPrefsResilient()
/**
* Build the encrypted prefs, healing a corrupted keyset on the way.
*
* [EncryptedSharedPreferences.create] decrypts the Tink keyset eagerly, so
* a stale/corrupt legacy file throws [javax.crypto.AEADBadTagException]
* (AES-GCM tag mismatch) right here in the constructor. This is the classic
* post-upgrade / post-restore failure: the encrypted blob persists but the
* hardware master key it was sealed against is gone or rotated. Delete the
* file and rebuild a fresh keyset against the current master key rather
* than letting the exception escape and force-close the app — the token in
* the unreadable file was lost anyway, so the user simply re-pairs.
*/
private fun buildPrefsResilient(): SharedPreferences =
try {
buildPrefs()
} catch (e: Exception) {
Log.w(TAG, "Initial legacy prefs build failed — wiping corrupted file and rebuilding: ${e.message}")
try {
appContext.deleteSharedPreferences(prefsName)
} catch (e2: Exception) {
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e2.message}")
}
buildPrefs()
}
private fun buildPrefs(): SharedPreferences {
val masterKey = MasterKey.Builder(appContext)
@@ -342,3 +376,24 @@ class LegacyEncryptedPrefsTokenStore(
}
}
}
// ---------------------------------------------------------------------------
// In-memory last-resort implementation
// ---------------------------------------------------------------------------
/**
* Non-persistent [SessionTokenStore]. Used only when BOTH the Keystore and the
* (self-healing) legacy encrypted store fail to construct — i.e. the device's
* AndroidKeystore is so broken it can't even build a fresh key. Tokens live for
* the process lifetime only, so the user re-pairs on the next cold start, but
* the app stays up instead of force-closing. See [AuthManager.store].
*/
class InMemoryTokenStore : SessionTokenStore {
private val map = java.util.concurrent.ConcurrentHashMap<String, String>()
override val hasHardwareBackedStorage: Boolean = false
override fun getString(key: String): String? = map[key]
override fun putString(key: String, value: String) { map[key] = value }
override fun remove(key: String) { map.remove(key) }
override fun contains(key: String): Boolean = map.containsKey(key)
override fun clearAll() { map.clear() }
}
@@ -70,8 +70,10 @@ class AutoDisableWorker(private val context: Context) {
// call is also wrapped in runCatching to swallow SecurityException as
// a belt-and-braces. Suppress here rather than inlining the check —
// the helper exists so the same gate can grow more conditions later
// without each call site re-implementing it.
@SuppressLint("MissingPermission")
// without each call site re-implementing it. Both IDs are needed:
// `NotificationPermission` is the notify()-specific check (POST_NOTIFICATIONS
// on API 33+); `MissingPermission` is the generic fallback.
@SuppressLint("MissingPermission", "NotificationPermission")
private fun postNotification() {
ensureChannel()
if (!hasPostNotificationsPermission()) {
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.bridge
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
@@ -300,6 +301,16 @@ class BridgeForegroundService : Service() {
super.onDestroy()
}
// ForegroundServiceType: lint requires the manifest `<service>` to declare
// `foregroundServiceType` for targetSdk >= 34. The SIDELOAD manifest does
// (specialUse|mediaProjection) + declares the matching FOREGROUND_SERVICE_*
// permissions. The GOOGLEPLAY flavor deliberately omits this service AND
// those permissions (no device-control capability for Play-Store
// compliance), so this code is unreachable there — the service can't be
// started without a manifest declaration. Lint analyzes the merged
// googlePlay manifest and can't see the sideload guarantee, so suppress
// here rather than weaken googlePlay by granting it specialUse.
@SuppressLint("ForegroundServiceType")
private fun startForegroundNotification() {
ensureChannel()
val notification = buildNotification()
@@ -0,0 +1,137 @@
package com.hermesandroid.relay.data
/**
* Shared profile/personality display and request identity helpers.
*
* A null profile name is the app's explicit "Server default" state. The
* relay also advertises the root Hermes config as a synthetic profile named
* "default"; for request/session identity that row is an alias of server
* default so it does not split chat, voice, or session scope.
*/
object AgentDisplay {
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
private val GENERIC_MODEL_ALIASES = setOf(
"hermes-agent",
"hermes_agent",
"hermes agent",
)
// Only an EXPLICIT pick drives request/session identity. The advertised
// "default" profile is an alias for server default, so falling back to it
// here would split chat, voice, or session scope.
@Suppress("UNUSED_PARAMETER")
fun effectiveProfile(
selectedProfile: Profile?,
profiles: List<Profile>,
): Profile? = selectedProfile
// Display can use the synthetic default profile's metadata without making
// it a request/session override. Verbose SOUL summaries are filtered by
// profileDisplayName below, so this is safe for headers/cards.
fun effectiveDisplayProfile(
selectedProfile: Profile?,
profiles: List<Profile>,
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
// The NAME goes in the name slot. Non-default profiles use their profile
// name first. The synthetic default profile uses its description only when
// that description looks like a concise human agent name ("Victor"), not a
// verbose SOUL summary.
fun profileDisplayName(profile: Profile?): String? {
if (profile == null) return null
if (isServerDefaultAlias(profile.name)) {
return defaultProfileDisplayName(profile)
}
return when {
profile.name.isNotBlank() -> titleCase(profile.name.trim())
profile.description.isNotBlank() -> profile.description.trim()
else -> null
}
}
fun defaultProfileDisplayName(profile: Profile?): String? =
profile
?.description
?.trim()
?.takeIf { it.looksLikeConciseAgentName() }
?.let(::titleCase)
fun agentName(
profile: Profile?,
selectedPersonality: String,
defaultPersonality: String,
connectionLabel: String?,
localDisplayAlias: String? = null,
): String {
localDisplayAlias(localDisplayAlias)?.let { return it }
profileDisplayName(profile)?.let { return it }
val personalityName = if (
selectedPersonality == "default" &&
defaultPersonality.isNotBlank()
) {
defaultPersonality
} else {
selectedPersonality
}
return when {
personalityName.isNotBlank() && personalityName != "default" ->
titleCase(personalityName.trim())
!connectionLabel.isNullOrBlank() -> connectionLabel.trim()
else -> "Hermes"
}
}
fun personalityLabel(
selectedPersonality: String,
defaultPersonality: String,
): String = when {
selectedPersonality != "default" && selectedPersonality.isNotBlank() ->
titleCase(selectedPersonality.trim())
defaultPersonality.isNotBlank() -> titleCase(defaultPersonality.trim())
else -> "Default"
}
fun displayModelName(model: String?): String? =
model
?.trim()
?.takeIf { it.isNotEmpty() }
?.takeUnless { it.lowercase() in GENERIC_MODEL_ALIASES }
fun isServerDefaultAlias(profileName: String?): Boolean =
profileName?.trim()?.equals("default", ignoreCase = true) == true
fun normalizeSelection(profile: Profile?): Profile? =
if (isServerDefaultAlias(profile?.name)) null else profile
fun profileRequestName(profileName: String?): String? =
profileName
?.trim()
?.takeIf { it.isNotEmpty() && !isServerDefaultAlias(it) }
fun profileSessionKey(profileName: String?): String =
profileRequestName(profileName) ?: SERVER_DEFAULT_PROFILE_KEY
fun profileContextKey(connectionId: String?, profileName: String?): String =
"${connectionId.orEmpty()}::${profileSessionKey(profileName)}"
fun localDisplayAlias(value: String?): String? =
value
?.trim()
?.replace(Regex("\\s+"), " ")
?.takeIf { it.isNotEmpty() }
private fun String.looksLikeConciseAgentName(): Boolean {
if (isBlank() || length > 40 || contains('\n') || contains('\r')) {
return false
}
if (any { it == '.' || it == ':' || it == ';' }) {
return false
}
return trim().split(Regex("\\s+")).size <= 4
}
private fun titleCase(value: String): String =
value.replaceFirstChar { it.uppercase() }
}
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
@@ -87,15 +88,17 @@ class BargeInPreferencesRepository(
booleanPreferencesKey("barge_in_resume_after_interruption")
}
val flow: Flow<BargeInPreferences> = dataStore.data.map { prefs ->
BargeInPreferences(
enabled = prefs[KEY_ENABLED] ?: DEFAULT_ENABLED,
sensitivity = prefs[KEY_SENSITIVITY]?.let { decodeSensitivity(it) }
?: DEFAULT_SENSITIVITY,
resumeAfterInterruption = prefs[KEY_RESUME_AFTER_INTERRUPTION]
?: DEFAULT_RESUME_AFTER_INTERRUPTION,
)
}
val flow: Flow<BargeInPreferences> = dataStore.data
.map { prefs ->
BargeInPreferences(
enabled = prefs[KEY_ENABLED] ?: DEFAULT_ENABLED,
sensitivity = prefs[KEY_SENSITIVITY]?.let { decodeSensitivity(it) }
?: DEFAULT_SENSITIVITY,
resumeAfterInterruption = prefs[KEY_RESUME_AFTER_INTERRUPTION]
?: DEFAULT_RESUME_AFTER_INTERRUPTION,
)
}
.distinctUntilChanged()
suspend fun setEnabled(value: Boolean) {
dataStore.edit { it[KEY_ENABLED] = value }
@@ -39,6 +39,8 @@ data class ChatMessage(
val estimatedCost: Double? = null,
// Agent/personality name for display on assistant messages
val agentName: String? = null,
// Small provenance badges rendered on assistant bubbles.
val badges: List<String> = emptyList(),
// File attachments (images, documents, etc.)
val attachments: List<Attachment> = emptyList(),
/**
@@ -77,7 +79,17 @@ data class ChatMessage(
* to true via [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
* so they're not re-sent on the next turn.
*/
val voiceIntent: VoiceIntentTrace? = null
val voiceIntent: VoiceIntentTrace? = null,
/**
* Provider-native Realtime Agent turns can answer without calling Hermes.
* Those local-only assistant turns need to be spliced into the next Hermes
* chat/run payload so switching back to normal chat preserves context.
*
* Hermes-backed realtime turns leave this null because Hermes already owns
* the durable session turn; the provider's spoken summary is UI/runtime
* provenance, not another canonical assistant message.
*/
val realtimeTurn: RealtimeTurnTrace? = null
)
/**
@@ -129,6 +141,24 @@ data class VoiceIntentTrace(
val syncedToServer: Boolean = false,
)
/**
* Local provider-native realtime turn that has not necessarily been absorbed
* into the Hermes session yet.
*
* Stored on the assistant message so the next normal chat send can emit a
* compact OpenAI-format user/assistant pair before the live user message. This
* keeps Realtime Agent and Hermes Chat + Voice Output as one conversation even
* when the realtime provider answered directly.
*/
data class RealtimeTurnTrace(
val userText: String,
val assistantText: String,
val provider: String? = null,
val model: String? = null,
val voice: String? = null,
val syncedToServer: Boolean = false,
)
/**
* A file attachment sent with a message.
*
@@ -204,9 +234,31 @@ data class ToolCall(
val success: Boolean?,
val isComplete: Boolean = false,
val error: String? = null,
val runId: String? = null,
val provenance: String? = null,
// Duration tracking
val startedAt: Long = System.currentTimeMillis(),
val completedAt: Long? = null
val completedAt: Long? = null,
/**
* Gateway `tool.generating` pre-start phase — the model is still
* streaming this tool's arguments. Cleared (flipped false) when the
* matching `tool.start` arrives and the call begins executing. Renders
* as the quiet "preparing" state in ToolProgressCard / CompactToolCall
* rather than the active running spinner.
*/
val isGenerating: Boolean = false,
/**
* Subagent lane index from gateway `subagent.*` events (`task_index`).
* Null = top-level tool call, rendered exactly as before. Non-null
* calls are grouped per index into a SubagentLane under the bubble.
*/
val taskIndex: Int? = null,
/**
* Human label for the owning subagent lane — the `subagent.start`
* goal truncated to 60 chars. Carried on each child call so the lane
* header can render without a separate lane registry.
*/
val taskLabel: String? = null
)
enum class MessageRole {
@@ -220,5 +272,16 @@ data class ChatSession(
val title: String?,
val model: String?,
val messageCount: Int = 0,
val updatedAt: Long = 0L
)
val updatedAt: Long = 0L,
val startedAt: Long = 0L,
val lastActivityAt: Long = 0L
) {
val activityTimestamp: Long
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
val startTimestamp: Long
get() = firstPositive(startedAt, updatedAt, lastActivityAt)
private fun firstPositive(vararg values: Long): Long =
values.firstOrNull { it > 0L } ?: 0L
}
@@ -3,6 +3,18 @@ package com.hermesandroid.relay.data
import kotlinx.serialization.Serializable
import java.net.URI
@Serializable
data class DashboardConnectionStatus(
val checkedAtMillis: Long? = null,
val reachable: Boolean = false,
val authRequired: Boolean? = null,
val authProviders: List<String> = emptyList(),
val authenticated: Boolean? = null,
val authProvider: String? = null,
val gatewayTicketAvailable: Boolean? = null,
val message: String? = null,
)
/**
* A "connection" = a distinct Hermes server connection the app can switch between.
*
@@ -30,9 +42,9 @@ import java.net.URI
*
* **Terminology note (2026-04-18):** earlier drafts of this feature called the
* concept "Profile". Renamed to [Connection] so that the term "Profile" is
* free to mean what Hermes's server config means by it (agent profiles —
* name + model + description defined under `agent.profiles` in config.yaml).
* A follow-up pass will introduce the new `Profile` concept on top.
* free to mean upstream Hermes profiles: separate host-side Hermes homes
* under `~/.hermes/profiles/<name>/`, each with its own config, SOUL, memory,
* sessions, skills, cron, and provider state.
*/
@Serializable
data class Connection(
@@ -41,6 +53,24 @@ data class Connection(
val apiServerUrl: String,
val relayUrl: String,
val tokenStoreKey: String,
/**
* Hermes dashboard/admin URL. Dashboard management features use this
* separately from the relay pairing channel; a blank/null value means
* "derive from [apiServerUrl] using the conventional same-host :9119".
*/
val dashboardUrl: String? = null,
val dashboardAuthRequired: Boolean? = null,
val dashboardAuthProviders: List<String> = emptyList(),
val dashboardLastStatus: DashboardConnectionStatus? = null,
/**
* Candidate host routes for this saved Hermes server. Standard setup
* stores at least one candidate here so API, dashboard, voice, and Relay
* helpers can follow LAN/Tailscale/public handoff before Relay pairing.
* Older installs and legacy serialized records default to an empty list.
*/
val routeCandidates: List<EndpointCandidate> = emptyList(),
/** Optional user preference such as "lan" or "tailscale"; null means Auto. */
val preferredRouteRole: String? = null,
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
val pairedAt: Long? = null,
val lastActiveSessionId: String? = null,
@@ -48,6 +78,12 @@ data class Connection(
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
val expiresAt: Long? = null,
) {
val resolvedDashboardUrl: String
get() = dashboardUrl
?.trim()
?.takeIf { it.isNotBlank() }
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
companion object {
/**
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
@@ -57,6 +93,8 @@ data class Connection(
*/
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
const val DEFAULT_DASHBOARD_PORT: Int = 9119
/**
* Derive a stable per-connection EncryptedSharedPreferences filename
* from a connection UUID. Trimmed to the first 8 characters of the
@@ -80,5 +118,213 @@ data class Connection(
apiServerUrl
}
}
fun deriveDefaultDashboardUrl(
apiServerUrl: String,
dashboardPort: Int = DEFAULT_DASHBOARD_PORT,
): String? {
val trimmed = apiServerUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
val scheme = when (uri.scheme?.lowercase()) {
"http" -> "http"
"https" -> "https"
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
"[$host]"
} else {
host
}
return "$scheme://$hostPart:$dashboardPort"
}
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
if (trimmed.isEmpty()) return true
val derived = deriveDefaultDashboardUrl(apiServerUrl) ?: return false
return trimmed.equals(derived, ignoreCase = true)
}
fun deriveDefaultRelayUrl(
apiServerUrl: String,
relayPort: Int = 8767,
): String? {
val trimmed = apiServerUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
val scheme = when (uri.scheme?.lowercase()) {
"http" -> "ws"
"https" -> "wss"
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
"[$host]"
} else {
host
}
return "$scheme://$hostPart:$relayPort"
}
fun buildRouteCandidates(
apiServerUrl: String,
relayUrl: String,
extraApiUrls: List<Pair<String, String>> = emptyList(),
): List<EndpointCandidate> {
val routes = buildList {
endpointCandidateFromApiUrl(
role = inferRouteRole(apiServerUrl),
priority = 0,
apiServerUrl = apiServerUrl,
relayUrl = relayUrl.takeIf { it.isNotBlank() }
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
)?.let(::add)
extraApiUrls
.map { it.first.trim() to it.second.trim() }
.filter { (_, url) -> url.isNotBlank() }
.forEachIndexed { index, (role, url) ->
endpointCandidateFromApiUrl(
role = role.ifBlank { inferRouteRole(url) },
priority = index + 1,
apiServerUrl = url,
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
)?.let(::add)
}
}
return routes
.distinctBy {
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
}
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
/**
* Overlay a freshly-rebuilt candidate list onto an existing stored
* one, preserving the stored extras (priority > 0) that the rebuild
* doesn't already cover. URL edits rebuild only the route(s) the
* user actually touched — without this merge, saving an API or
* Relay URL collapsed the stored list to a single candidate,
* silently dropping the setup wizard's Tailscale route (or a
* pairing payload's extra endpoints) and killing LAN/VPN roaming.
*
* Stored extras are preserved **verbatim** (role, priority, relay
* URL) rather than re-derived, so payload-specified relay URLs
* survive. Host:port collisions defer to the rebuilt entry.
*/
fun mergeRouteCandidates(
rebuilt: List<EndpointCandidate>,
existing: List<EndpointCandidate>,
): List<EndpointCandidate> {
val rebuiltHostPorts = rebuilt
.map { "${it.api.host.lowercase()}:${it.api.port}" }
.toSet()
val preserved = existing
.filter { it.priority > 0 }
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
return (rebuilt + preserved)
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
/**
* Normalize hand-typed API-URL input: trim, strip trailing slashes,
* default a missing scheme to `http://`, and default a missing port
* to [defaultPort] — most Hermes API servers speak plain HTTP on
* 8642, and a bare `192.168.1.10` / Tailscale `100.x.y.z` is by far
* the most common thing users type.
*
* URLs that already carry a scheme are preserved **verbatim**
* (including a wrong one like `ws://`, so downstream validators can
* complain precisely): an explicit `https://hermes.example.com` may
* be a reverse proxy on 443, and force-appending :8642 would break
* it. Port-defaulting applies only to scheme-less input, where the
* user is visibly relying on our defaults.
*/
fun normalizeApiUrlInput(raw: String, defaultPort: Int = 8642): String {
val trimmed = raw.trim().trimEnd('/')
if (trimmed.isEmpty()) return trimmed
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
val withScheme = "http://$trimmed"
val uri = runCatching { URI(withScheme) }.getOrNull()
val canAppendPort = uri != null &&
!uri.host.isNullOrBlank() &&
uri.port <= 0 &&
uri.rawPath.isNullOrEmpty() &&
uri.rawQuery == null
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
}
private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*://")
fun endpointCandidateFromApiUrl(
role: String,
priority: Int,
apiServerUrl: String,
relayUrl: String,
): EndpointCandidate? {
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
?: return null
val scheme = uri.scheme?.lowercase()
val tls = when (scheme) {
"http" -> false
"https" -> true
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val port = if (uri.port > 0) uri.port else 8642
val resolvedRelayUrl = relayUrl.trim().takeIf { it.isNotBlank() }
?: deriveDefaultRelayUrl(apiServerUrl)
?: return null
val transportHint = when {
resolvedRelayUrl.startsWith("wss://", ignoreCase = true) -> "wss"
resolvedRelayUrl.startsWith("ws://", ignoreCase = true) -> "ws"
else -> null
}
return EndpointCandidate(
role = role.ifBlank { inferRouteRole(apiServerUrl) },
priority = priority,
api = ApiEndpoint(host = host, port = port, tls = tls),
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
)
}
fun inferRouteRole(apiServerUrl: String): String {
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
.getOrNull()
?.lowercase()
?: return "custom"
return when {
host.endsWith(".ts.net") || isTailscaleIpv4(host) -> "tailscale"
host == "localhost" ||
host == "127.0.0.1" ||
host == "::1" ||
isPrivateLanIpv4(host) -> "lan"
else -> "public"
}
}
private fun isTailscaleIpv4(host: String): Boolean {
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
if (parts.size != 4) return false
return parts[0] == 100 && parts[1] in 64..127
}
private fun isPrivateLanIpv4(host: String): Boolean {
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
if (parts.size != 4) return false
return when {
parts[0] == 10 -> true
parts[0] == 172 && parts[1] in 16..31 -> true
parts[0] == 192 && parts[1] == 168 -> true
parts[0] == 169 && parts[1] == 254 -> true
else -> false
}
}
}
}
@@ -100,6 +100,17 @@ class ConnectionStore private constructor(
private val _activeConnectionId = MutableStateFlow<String?>(null)
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
/**
* Flips to `true` once the initial DataStore hydrate completes (success OR
* failure). Until then [connections] / [activeConnection] hold their empty
* seed values, which are indistinguishable from a genuinely empty store.
* Consumers that must not mistake "still loading" for "nothing configured"
* — e.g. the chat empty-state, which would otherwise flash a "Connect to
* Hermes" CTA on every cold start — gate on this instead of on emptiness.
*/
private val _isHydrated = MutableStateFlow(false)
val isHydrated: StateFlow<Boolean> = _isHydrated.asStateFlow()
/**
* Derived: the active connection, or null when the active ID is missing
* or points to a deleted connection. Recomputes every time either
@@ -144,6 +155,11 @@ class ConnectionStore private constructor(
}
} catch (e: Exception) {
Log.w(TAG, "Initial hydrate failed: ${e.message}")
} finally {
// Mark hydration done even on failure — a failed read still
// means "we now know the store's state is empty", so the UI
// should stop showing the neutral loading gate.
_isHydrated.value = true
}
}
}
@@ -158,7 +174,8 @@ class ConnectionStore private constructor(
// callers shouldn't rely on insertion order of a duplicate
// add, and the alternative (throwing) makes migration code
// more brittle than it needs to be.
val next = current.filterNot { it.id == connection.id } + connection
val normalized = connection.withDashboardDefaults()
val next = current.filterNot { it.id == connection.id } + normalized
prefs[KEY_CONNECTIONS] = encodeConnections(next)
_connections.value = next
}
@@ -177,7 +194,8 @@ class ConnectionStore private constructor(
Log.w(TAG, "updateConnection: no connection with id=${connection.id} — ignored")
return@edit
}
val next = current.map { if (it.id == connection.id) connection else it }
val normalized = connection.withDashboardDefaults()
val next = current.map { if (it.id == connection.id) normalized else it }
prefs[KEY_CONNECTIONS] = encodeConnections(next)
_connections.value = next
}
@@ -212,27 +230,83 @@ class ConnectionStore private constructor(
_activeConnectionId.value = null
}
}
removed?.let { connection ->
context?.let { ctx ->
val storeKeys = buildSet {
add(connection.tokenStoreKey)
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
// Pre-StrongBox fallback path used this file. If
// connection 0 is removed, scrub it alongside the
// hardware-backed legacy filename.
add("hermes_companion_auth")
}
}
for (storeKey in storeKeys) {
try {
ctx.deleteSharedPreferences(storeKey)
} catch (e: Exception) {
Log.w(
TAG,
"deleteSharedPreferences($storeKey) failed: ${e.message}",
)
}
}
removed?.let { deleteTokenStoresFor(it) }
}
}
/**
* Factory-reset helper: clear the persisted connection list, active
* pointer, legacy profile aliases, and every known per-connection auth
* store. Unlike removing one connection, this intentionally does not pick
* a successor; callers are resetting the app back to "no connection".
*/
suspend fun clearAllConnections() {
writeMutex.withLock {
var removed: List<Connection> = emptyList()
dataStore.edit { prefs ->
removed = decodeConnections(prefs[KEY_CONNECTIONS])
prefs.remove(KEY_CONNECTIONS)
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
_connections.value = emptyList()
_activeConnectionId.value = null
}
removed.forEach { deleteTokenStoresFor(it) }
}
}
suspend fun replaceConnections(
connections: List<Connection>,
activeConnectionId: String? = null,
) {
writeMutex.withLock {
var removed: List<Connection> = emptyList()
val normalizedConnections = connections.map { it.withDashboardDefaults() }
val normalizedActiveId = activeConnectionId
?.takeIf { id -> normalizedConnections.any { it.id == id } }
?: normalizedConnections.firstOrNull()?.id
dataStore.edit { prefs ->
removed = decodeConnections(prefs[KEY_CONNECTIONS])
if (normalizedConnections.isEmpty()) {
prefs.remove(KEY_CONNECTIONS)
} else {
prefs[KEY_CONNECTIONS] = encodeConnections(normalizedConnections)
}
if (normalizedActiveId == null) {
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
} else {
prefs[KEY_ACTIVE_CONNECTION_ID] = normalizedActiveId
}
prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
_connections.value = normalizedConnections
_activeConnectionId.value = normalizedActiveId
}
removed.forEach { deleteTokenStoresFor(it) }
}
}
private fun deleteTokenStoresFor(connection: Connection) {
context?.let { ctx ->
val storeKeys = buildSet {
add(connection.tokenStoreKey)
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
// Pre-StrongBox fallback path used this file. If
// connection 0 is removed, scrub it alongside the
// hardware-backed legacy filename.
add("hermes_companion_auth")
}
}
for (storeKey in storeKeys) {
try {
ctx.deleteSharedPreferences(storeKey)
} catch (e: Exception) {
Log.w(
TAG,
"deleteSharedPreferences($storeKey) failed: ${e.message}",
)
}
}
}
@@ -294,6 +368,8 @@ class ConnectionStore private constructor(
pairedAt = pairedAtMillis,
transportHint = transportHint,
expiresAt = expiresAtMillis,
dashboardUrl = target.dashboardUrl
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
)
} else {
it
@@ -340,6 +416,8 @@ class ConnectionStore private constructor(
apiServerUrl = apiUrl,
relayUrl = relayUrl,
tokenStoreKey = Connection.LEGACY_TOKEN_STORE_KEY,
dashboardUrl = Connection.deriveDefaultDashboardUrl(apiUrl),
routeCandidates = Connection.buildRouteCandidates(apiUrl, relayUrl),
pairedAt = null,
lastActiveSessionId = legacyLastSessionId,
transportHint = null,
@@ -355,6 +433,33 @@ class ConnectionStore private constructor(
}
}
suspend fun setDashboardStatus(
connectionId: String,
status: DashboardConnectionStatus,
) {
writeMutex.withLock {
dataStore.edit { prefs ->
val current = decodeConnections(prefs[KEY_CONNECTIONS])
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
val next = current.map {
if (it.id == connectionId) {
target.copy(
dashboardUrl = target.dashboardUrl
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
dashboardAuthRequired = status.authRequired,
dashboardAuthProviders = status.authProviders,
dashboardLastStatus = status,
)
} else {
it
}
}
prefs[KEY_CONNECTIONS] = encodeConnections(next)
_connections.value = next
}
}
}
// --- Encoding helpers ---------------------------------------------------
private fun encodeConnections(list: List<Connection>): String =
@@ -364,12 +469,36 @@ class ConnectionStore private constructor(
if (raw.isNullOrBlank()) return emptyList()
return try {
json.decodeFromString(connectionListSerializer, raw)
.map { it.withDashboardDefaults() }
} catch (e: Exception) {
Log.w(TAG, "decodeConnections failed, returning empty list: ${e.message}")
emptyList()
}
}
private fun Connection.withDashboardDefaults(): Connection {
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
val normalizedRoutes = routeCandidates.ifEmpty {
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
}
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
}
return if (
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
normalizedRoutes != routeCandidates ||
normalizedPreferredRouteRole != preferredRouteRole
) {
copy(
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
routeCandidates = normalizedRoutes,
preferredRouteRole = normalizedPreferredRouteRole,
)
} else {
this
}
}
companion object {
private const val TAG = "ConnectionStore"
@@ -6,6 +6,10 @@ import android.util.Log
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.auth.ConnectionAuthSecrets
import com.hermesandroid.relay.network.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.StoredDashboardCookie
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -20,8 +24,8 @@ import java.io.File
/**
* Manages app data: backup, restore, and reset.
*
* Backup format is a JSON file containing settings and connection info.
* Tokens are NOT included in backups for security.
* Backup format is a JSON file containing full connection metadata and
* credentials. Treat exported files as sensitive secrets.
*/
class DataManager(
private val context: Context,
@@ -51,8 +55,7 @@ class DataManager(
}
/**
* Backup data model -- only non-sensitive settings.
* Tokens and device IDs are never included.
* Backup data model.
*
* **Schema history:**
* - v1: `serverUrl` only (single endpoint, pre-API-split).
@@ -66,22 +69,50 @@ class DataManager(
* re-mapped to `connections` (see [importSettings]). v1/v2 imports
* get `connections = emptyList()` since the old string list was not
* structurally compatible.
* - v5 (2026-06-08): full connection backups. Adds active connection id
* and `connectionSecrets`, including API keys, relay tokens, device id,
* paired metadata, and dashboard cookies.
*/
@Serializable
data class AppBackup(
val version: Int = 4,
val version: Int = 5,
val serverUrl: String? = null, // legacy (v1 compat)
val apiServerUrl: String? = null,
val relayUrl: String? = null,
val theme: String = "auto",
val onboardingCompleted: Boolean = false,
val connections: List<Connection> = emptyList(),
val exportedAt: Long = System.currentTimeMillis()
val activeConnectionId: String? = null,
val containsSensitiveData: Boolean = true,
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
val exportedAt: Long = System.currentTimeMillis(),
)
@Serializable
data class ConnectionSecretBackup(
val connectionId: String,
val tokenStoreKey: String,
val auth: ConnectionAuthSecrets = ConnectionAuthSecrets(),
val dashboardCookies: List<DashboardCookieBackup> = emptyList(),
)
@Serializable
data class DashboardCookieBackup(
val name: String,
val value: String,
val expiresAt: Long,
val domain: String,
val path: String,
val secure: Boolean,
val httpOnly: Boolean,
val hostOnly: Boolean,
val persistent: Boolean,
)
/**
* Export app settings to a JSON string.
* Does NOT include session tokens or device IDs (security).
* Includes connection credentials. The export UI must warn the user that
* the resulting JSON file is sensitive.
*
* The `sessionLabels` parameter is a legacy dead parameter — it was
* previously sourced from `AuthManager.sessionLabels`, a field removed
@@ -100,7 +131,7 @@ class DataManager(
apiServerUrl: String? = null,
relayUrl: String? = null
): String {
val connectionsSnapshot = connectionStore?.connections?.value
val connectionsSnapshot = connectionStore?.connections?.value.orEmpty()
if (connectionStore == null) {
Log.w(
TAG,
@@ -108,19 +139,56 @@ class DataManager(
"(caller constructed DataManager without the multi-connection ctor arg)",
)
}
val connectionSecrets = connectionsSnapshot.map { connection ->
ConnectionSecretBackup(
connectionId = connection.id,
tokenStoreKey = connection.tokenStoreKey,
auth = AuthManager.exportStoredSecrets(context, connection.tokenStoreKey),
dashboardCookies = EncryptedDashboardCookieStore(
context = context,
connectionId = connection.id,
).load().map { it.toBackup() },
)
}
val backup = AppBackup(
version = 4,
version = 5,
serverUrl = serverUrl, // legacy compat
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
theme = theme,
onboardingCompleted = onboardingCompleted,
connections = connectionsSnapshot ?: emptyList(),
exportedAt = System.currentTimeMillis()
connections = connectionsSnapshot,
activeConnectionId = connectionStore?.activeConnectionId?.value,
containsSensitiveData = true,
connectionSecrets = connectionSecrets,
exportedAt = System.currentTimeMillis(),
)
return json.encodeToString(backup)
}
suspend fun restoreConnectionBackup(backup: AppBackup) {
val store = connectionStore ?: return
deleteSensitivePreferenceFiles()
store.replaceConnections(
connections = backup.connections,
activeConnectionId = backup.activeConnectionId,
)
val connectionsById = backup.connections.associateBy { it.id }
backup.connectionSecrets.forEach { secret ->
val connection = connectionsById[secret.connectionId] ?: return@forEach
AuthManager.importStoredSecrets(
context = context,
tokenStoreKey = connection.tokenStoreKey,
secrets = secret.auth,
)
EncryptedDashboardCookieStore(
context = context,
connectionId = connection.id,
).save(secret.dashboardCookies.map { it.toStoredCookie() })
}
}
/**
* Import settings from a JSON string.
* Returns the parsed backup, or null if invalid.
@@ -221,6 +289,11 @@ class DataManager(
// Preserve onboarding state before clearing
val onboarding = isOnboardingCompleted()
// Multi-connection reset: clear the hot ConnectionStore state and
// delete every per-connection token store before the global
// DataStore is wiped.
connectionStore?.clearAllConnections()
// Clear all DataStore preferences
context.relayDataStore.edit { it.clear() }
@@ -231,15 +304,7 @@ class DataManager(
}
}
// Delete the EncryptedSharedPreferences file for auth tokens
withContext(Dispatchers.IO) {
val prefsDir = File(context.filesDir.parent, "shared_prefs")
val authFile = File(prefsDir, "$AUTH_PREFS_NAME.xml")
if (authFile.exists()) {
authFile.delete()
Log.d(TAG, "Deleted auth preferences file")
}
}
deleteSensitivePreferenceFiles()
// Clear cache directory
withContext(Dispatchers.IO) {
@@ -254,6 +319,61 @@ class DataManager(
}
}
private suspend fun deleteSensitivePreferenceFiles() {
withContext(Dispatchers.IO) {
val prefsDir = File(context.filesDir.parent, "shared_prefs")
val stores = buildSet {
add(AUTH_PREFS_NAME)
add(Connection.LEGACY_TOKEN_STORE_KEY)
prefsDir.listFiles()?.forEach { file ->
if (file.extension == "xml") {
val name = file.nameWithoutExtension
if (
name.startsWith("hermes_auth_") ||
name.startsWith("hermes_dashboard_")
) {
add(name)
}
}
}
}
stores.forEach { storeName ->
try {
context.deleteSharedPreferences(storeName)
Log.d(TAG, "Deleted auth preferences file: $storeName")
} catch (e: Exception) {
Log.w(TAG, "deleteSharedPreferences($storeName) failed: ${e.message}")
}
}
}
}
private fun StoredDashboardCookie.toBackup(): DashboardCookieBackup =
DashboardCookieBackup(
name = name,
value = value,
expiresAt = expiresAt,
domain = domain,
path = path,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
persistent = persistent,
)
private fun DashboardCookieBackup.toStoredCookie(): StoredDashboardCookie =
StoredDashboardCookie(
name = name,
value = value,
expiresAt = expiresAt,
domain = domain,
path = path,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
persistent = persistent,
)
/**
* Reset only the onboarding completion flag.
* Next app launch will show onboarding again.
@@ -75,21 +75,18 @@ object FeatureFlags {
/**
* Compile-time gating based on the active Gradle product flavor.
*
* Phase 3 ships Bridge on two tracks with very different AccessibilityService
* scope: the `googlePlay` flavor carries a conservative event-type subset and
* a "notifications + confirmations" description for Play Store policy review,
* and the `sideload` flavor carries the full agent-control surface. The tier
* flags below let UI code hide tier 3/4/6 surfaces on the Play build without
* a runtime check — Kotlin's `val … get() = current == SIDELOAD` resolves at
* each call site, but because `current` is a compile-time string, R8 is able
* to fold the check away in release builds.
* Phase 3 keeps "Hermes Bridge" as the umbrella, but only the `sideload`
* flavor ships AccessibilityService-backed Device Control. The `googlePlay`
* flavor is Bridge Core: relay pairing, chat, voice, terminal, notification
* companion, media, and session-grant surfaces without screen reading, taps,
* typing, screenshots, overlays, or unattended control.
*
* Tier definitions (see `Phase 3 — Bridge Channel.md` in the vault):
* 1. baseline — both tracks (app open, tap, navigate within app)
* 2. notifications — both tracks (read notifications, summarize, reply)
* Device Control tier definitions (see `Phase 3 — Bridge Channel.md`):
* 1. baseline — sideload only (app open, tap, navigate within app)
* 2. screen context — sideload only (Accessibility tree / screen reads)
* 3. voice-first — sideload only (always-on voice capture)
* 4. vision-first — sideload only (always-on screen reading)
* 5. safety rails — both tracks (confirmation dialogs, action log)
* 5. safety rails — sideload only (confirmation dialogs, action log)
* 6. ambitious future — sideload only (cross-app macros, scheduling)
*/
object BuildFlavor {
@@ -112,11 +109,11 @@ object BuildFlavor {
*/
val isSideload: Boolean get() = current == SIDELOAD
val bridgeTier1: Boolean = true // baseline — both tracks
val bridgeTier2: Boolean = true // notifications, calendar — both tracks
val bridgeTier1: Boolean get() = current == SIDELOAD // baseline device control
val bridgeTier2: Boolean get() = current == SIDELOAD // screen context
val bridgeTier3: Boolean get() = current == SIDELOAD // voice-first
val bridgeTier4: Boolean get() = current == SIDELOAD // vision-first
val bridgeTier5: Boolean = true // safety rails — always on
val bridgeTier5: Boolean get() = current == SIDELOAD // safety rails
val bridgeTier6: Boolean get() = current == SIDELOAD // future ambitious
/** Human-readable badge label for the Settings → About version row. */
@@ -0,0 +1,22 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
/**
* Single source of truth for the opt-in "keep the gateway chat connection
* alive in the background" preference. Off by default.
*
* Shared by [com.hermesandroid.relay.viewmodel.ConnectionViewModel] (the
* StateFlow + setter that drive the foreground service and the client's
* no-background-close flag) and
* [com.hermesandroid.relay.network.GatewayKeepAliveService]'s Stop notification
* action, so both read/write the same key.
*/
val KEY_GATEWAY_KEEP_ALIVE = booleanPreferencesKey("gateway_keep_alive_background")
/** Persist the keep-alive preference. Used by the FGS Stop action. */
suspend fun Context.setGatewayKeepAlive(enabled: Boolean) {
relayDataStore.edit { it[KEY_GATEWAY_KEEP_ALIVE] = enabled }
}
@@ -53,8 +53,22 @@ data class HermesCard(
* which action (if any) has been dispatched, so the same card reloaded
* from session history doesn't re-prompt. Falls back to the card's
* position in the message when null.
*
* For the gateway ask types this is the ask's `request_id` (or
* `approval-<sid>-<ts>` for approval, which has no request id) — the
* dispatch tracker keys answer-once semantics off it.
*/
val id: String? = null,
/**
* Interactive input slot rendered between [fields] and [actions] —
* the answer surface for the gateway ask cards (`ask.clarify` choice
* chips + free text, `ask.secret` masked field, `ask.sudo`
* hold-to-confirm). Null for every plain card. Submissions flow
* through the renderer's `onInputSubmit(cardKey, value)` callback and
* collapse the card via the same [HermesCardDispatch] list as button
* actions.
*/
val input: HermesCardInput? = null,
) {
object BuiltInTypes {
const val SKILL_RESULT = "skill_result"
@@ -62,6 +76,14 @@ data class HermesCard(
const val LINK_PREVIEW = "link_preview"
const val CALENDAR_EVENT = "calendar_event"
const val WEATHER = "weather"
// Gateway interactive asks (desktop-parity wave). Locally built
// from clarify/approval/sudo/secret request events — never parsed
// out of the text stream.
const val ASK_APPROVAL = "ask.approval"
const val ASK_CLARIFY = "ask.clarify"
const val ASK_SUDO = "ask.sudo"
const val ASK_SECRET = "ask.secret"
}
object Accents {
@@ -72,6 +94,67 @@ data class HermesCard(
}
}
/**
* Interactive input slot on a [HermesCard]. The flags compose rather than
* branch — a sudo ask can be `masked + holdToConfirm` (password field whose
* submit is the 650ms press-fill button), while clarify is
* `choices + allowFreeText` and secret is `masked` alone.
*
* Security contract: when [masked] is true the submitted value is a secret.
* It must never be echoed into chat content, logged, or synced via
* CardDispatchSyncBuilder — record [SECRET_PROVIDED_STAMP] as the dispatch's
* actionValue instead of the real value. The renderer masks the collapse
* stamp for masked inputs regardless, but the dispatch record itself is
* persisted and synced, so the caller must not put the secret there.
*/
@Serializable
data class HermesCardInput(
/**
* Input kind — one of [Kinds]. Drives which composite the renderer
* builds; unknown kinds degrade to a plain free-text field so newer
* asks still get an answer surface.
*/
val kind: String,
/** Quick-answer chips (clarify). Empty = no chip row. */
val choices: List<String> = emptyList(),
/** Render the inline free-text mini field under the chips. */
val allowFreeText: Boolean = false,
/** Password-style field: masked glyphs + reveal toggle (secret/sudo). */
val masked: Boolean = false,
/** Submit is a 650ms hold-to-confirm press-fill instead of a tap (sudo). */
val holdToConfirm: Boolean = false,
/**
* Wall-clock expiry for timed asks (sudo 120s, clarify/secret 300s).
* The renderer shows a countdown footer (Amber under 30s) and
* self-collapses to "Expired — not granted" past it. Null = no timeout
* (approval is session-scoped).
*/
val expiresAtMillis: Long? = null,
) {
object Kinds {
const val CHOICE = "choice"
const val TEXT = "text"
const val SECRET = "secret"
const val CONFIRM = "confirm"
}
companion object {
/**
* Sentinel recorded as [HermesCardDispatch.actionValue] when a
* [masked] input is submitted. The real secret value goes only to
* the ask-respond RPC — never into the dispatch record, chat
* content, or session sync.
*/
const val SECRET_PROVIDED_STAMP = "secret-provided"
/**
* Value submitted by a bare hold-to-confirm (no text field) — the
* sudo/approval "yes" that carries no payload of its own.
*/
const val CONFIRM_VALUE = "confirm"
}
}
/**
* A label/value row inside a card. [value] is rendered as markdown so the
* agent can embed emphasis, inline code, or links.
@@ -117,6 +200,16 @@ data class HermesCardAction(
const val SEND_TEXT = "send_text"
const val SLASH_COMMAND = "slash_command"
const val OPEN_URL = "open_url"
/**
* Ask-card answer: dispatch [value] straight to the gateway
* ask-respond RPC (clarify/sudo/secret/approval.respond), never
* as chat text. Dispatches in this mode are EXCLUDED from
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder] —
* the server already absorbed the answer through the blocking
* ask, and for secrets the value must not enter session memory.
*/
const val SUBMIT_ASK = "submit_ask"
}
}
@@ -12,14 +12,19 @@ import kotlinx.serialization.Serializable
* upstream layout (one directory per profile under `~/.hermes/profiles/`)
* and added [systemMessage], sourced from each profile's `SOUL.md`.
*
* A Profile is a NAMED AGENT CONFIG within a Connection. Switching profile
* changes:
* - which model the phone asks the server to use on the next chat
* request (via [model]);
* - which system message the phone sends for that request (via
* [systemMessage], when non-blank).
* A Profile is an upstream Hermes profile context within a Connection.
* Upstream stores named profiles as separate Hermes homes under
* `~/.hermes/profiles/<name>/`. Switching profile changes the active agent
* identity for the Android chat surface:
* - which profile API server the phone routes chat/session calls to when
* the relay advertises [apiServerUrl];
* - which profile name the phone sends to the server for new sessions and
* chat turns when isolated routing is not available;
* - which model and system message the phone can send as compatibility
* fallback (via [model] and [systemMessage]);
* - which profile-scoped session id Android resumes for local chat context.
*
* It does not change the server, sessions, or memory.
* It does not mutate the server's configured default profile.
*
* Wire shape uses snake_case (`system_message`), this class uses camelCase
* (`systemMessage`) — translated via [SerialName].
@@ -39,6 +44,12 @@ import kotlinx.serialization.Serializable
* All three default to safe zero-values and are optional on the wire, so
* older relays without the fields deserialize cleanly as
* `gatewayRunning = false, hasSoul = false, skillCount = 0`.
*
* **Hermes profile API metadata.** A relay can advertise an isolated
* profile API server without exposing its secret. When [apiServerUrl] is
* present, Android routes chat/session traffic to that URL and reuses the
* active connection's stored API key. Operators that use distinct API keys
* per profile should pair those profile API servers as separate connections.
*/
@Serializable
data class Profile(
@@ -53,4 +64,17 @@ data class Profile(
val hasSoul: Boolean = false,
@SerialName("skill_count")
val skillCount: Int = 0,
)
@SerialName("api_server_enabled")
val apiServerEnabled: Boolean = false,
@SerialName("api_server_url")
val apiServerUrl: String? = null,
@SerialName("api_server_host")
val apiServerHost: String? = null,
@SerialName("api_server_port")
val apiServerPort: Int? = null,
@SerialName("api_server_key_present")
val apiServerKeyPresent: Boolean = false,
) {
val hasIsolatedApi: Boolean
get() = !apiServerUrl.isNullOrBlank()
}
@@ -0,0 +1,68 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Local-only display aliases for agent profiles.
*
* These names are phone UI labels. They are never sent to Hermes and are keyed
* by connection + profile context so the server-default agent can be called
* something different on each configured Hermes host.
*/
class ProfileDisplayAliasStore(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.profileDisplayAliasesDataStore)
companion object {
private const val PREFIX = "profile_alias__"
private fun keyName(connectionId: String, profileName: String?): String =
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
private fun keyFor(connectionId: String, profileName: String?) =
stringPreferencesKey(keyName(connectionId, profileName))
private fun connectionPrefix(connectionId: String): String =
"$PREFIX${connectionId}__"
}
suspend fun setAlias(connectionId: String, profileName: String?, alias: String?) {
dataStore.edit { prefs ->
val key = keyFor(connectionId, profileName)
if (alias.isNullOrBlank()) {
prefs.remove(key)
} else {
prefs[key] = alias
}
}
}
fun aliasFlow(connectionId: String, profileName: String?): Flow<String?> {
val key = keyFor(connectionId, profileName)
return dataStore.data.map { prefs -> prefs[key] }
}
suspend fun clearConnection(connectionId: String) {
val prefix = connectionPrefix(connectionId)
dataStore.edit { prefs ->
prefs.asMap().keys
.filter { it.name.startsWith(prefix) }
.forEach { prefs.remove(it) }
}
}
suspend fun clearAll() {
dataStore.edit { prefs -> prefs.clear() }
}
}
internal val Context.profileDisplayAliasesDataStore: DataStore<Preferences>
by preferencesDataStore(name = "profile_display_aliases")
@@ -87,6 +87,12 @@ class ProfileSelectionStore(
prefs.remove(keyFor(connectionId))
}
}
suspend fun clearAll() {
dataStore.edit { prefs ->
prefs.clear()
}
}
}
/**
@@ -0,0 +1,137 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Which chat transport created (and can resume) a stored session.
*
* The two chat transports do NOT share session storage, so their ids are not
* interchangeable on a non-default profile:
* - [GATEWAY] — the `/api/ws` tui_gateway path. `session.create`/`session.resume`
* bind the profile's own HERMES_HOME, so sessions live in that profile's
* `state.db`. Ids look like `YYYYMMDD_HHMMSS_<hex>`.
* - [SSE] — the api_server chat path (`/api/sessions/.../chat/stream`,
* `/v1/runs`). The api_server has no per-request profile scoping; it always
* persists to its launch `state.db`. Ids look like `api_<unixsecs>_<hex>`.
*
* Resuming an [SSE] id over the [GATEWAY] (which opens the profile DB) — or vice
* versa — fails with "session not found" and silently forks a new session. So
* each transport gets its own persisted slot, and a stored id is only ever
* restored for the transport that can actually resume it.
*/
enum class SessionTransport(val key: String) {
GATEWAY("gw"),
SSE("sse");
companion object {
/**
* Bucket a stored session id by the subsystem that created it — the
* id's namespace is the server's own ground truth about which transport
* can resume it, more reliable than re-deriving the resolved endpoint
* (a turn can fall back from gateway to SSE per-turn).
*/
fun forSessionId(sessionId: String): SessionTransport =
if (sessionId.startsWith("api_")) SSE else GATEWAY
/**
* Bucket a resolved streaming endpoint. Only `"gateway"` resumes from
* the per-profile DB; every SSE-family member (`"sessions"` /
* `"completions"` / `"runs"`) rides the api_server's launch DB.
*/
fun forEndpoint(resolvedEndpoint: String): SessionTransport =
if (resolvedEndpoint == "gateway") GATEWAY else SSE
}
}
/**
* Per-connection, per-Hermes-profile, per-transport last active chat session.
*
* This is intentionally separate from [ProfileSelectionStore]. Selection says
* which agent is active; this store says which chat session belongs to that
* agent on that connection. Null profile name is the explicit Server default
* context.
*
* **Transport dimension (v1.0.0).** The slot is keyed by [SessionTransport] too,
* because a gateway session and an api_server (SSE) session are stored in
* different databases and cannot be cross-resumed on a non-default profile.
* Keying by transport keeps the two from clobbering one slot and guarantees a
* restored id is always resumable by the transport asking for it. The key shape
* changed in this release, so pre-existing (untransported) slots are not read —
* a one-time drop of the "last session" pointer that also clears the exact stale
* cross-transport ids that caused mid-conversation forks.
*/
class ProfileSessionStore(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.profileSessionsDataStore)
companion object {
private const val PREFIX = "profile_session__"
private fun keyName(
connectionId: String,
profileName: String?,
transport: SessionTransport,
): String =
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}__${transport.key}"
private fun keyFor(
connectionId: String,
profileName: String?,
transport: SessionTransport,
) = stringPreferencesKey(keyName(connectionId, profileName, transport))
private fun connectionPrefix(connectionId: String): String =
"$PREFIX${connectionId}__"
}
suspend fun setSessionId(
connectionId: String,
profileName: String?,
transport: SessionTransport,
sessionId: String?,
) {
dataStore.edit { prefs ->
val key = keyFor(connectionId, profileName, transport)
if (sessionId.isNullOrBlank()) {
prefs.remove(key)
} else {
prefs[key] = sessionId
}
}
}
fun sessionIdFlow(
connectionId: String,
profileName: String?,
transport: SessionTransport,
): Flow<String?> {
val key = keyFor(connectionId, profileName, transport)
return dataStore.data.map { prefs -> prefs[key] }
}
suspend fun clearConnection(connectionId: String) {
val prefix = connectionPrefix(connectionId)
dataStore.edit { prefs ->
prefs.asMap().keys
.filter { it.name.startsWith(prefix) }
.forEach { prefs.remove(it) }
}
}
suspend fun clearAll() {
dataStore.edit { prefs ->
prefs.clear()
}
}
}
internal val Context.profileSessionsDataStore: DataStore<Preferences>
by preferencesDataStore(name = "profile_sessions")
@@ -0,0 +1,11 @@
package com.hermesandroid.relay.data
/**
* Compact text context sent to the relay when opening a provider-native
* Realtime Agent session.
*/
data class RealtimeConversationContextMessage(
val role: MessageRole,
val content: String,
val source: String? = null,
)
@@ -1,11 +1,14 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
@@ -20,48 +23,118 @@ import kotlinx.coroutines.flow.map
* (V1 doesn't accept a language param).
*/
data class VoiceSettings(
val engineMode: String = VoiceEngineMode.HermesVoiceOutput.storageValue,
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
val interactionMode: String = "tap",
val silenceThresholdMs: Long = 3000L,
val autoTts: Boolean = false,
val language: String = "",
val realtimeTraceDetails: Boolean = false,
/**
* When true (default), Realtime Agent keeps one provider session/socket open
* across turns (persistent conversation). When false, falls back to the
* legacy one-session-per-utterance path. See
* docs/plans/2026-05-24-realtime-persistent-session.md.
*/
val realtimePersistentSession: Boolean = true,
)
class VoicePreferencesRepository(private val context: Context) {
enum class VoiceEngineMode(val storageValue: String) {
HermesVoiceOutput("hermes_voice_output"),
RealtimeAgent("realtime_agent");
companion object {
fun fromStorage(value: String?): VoiceEngineMode =
values().firstOrNull { it.storageValue == value } ?: HermesVoiceOutput
}
}
enum class VoiceAudioRoute(val storageValue: String) {
Auto("auto"),
Standard("standard"),
Relay("relay");
companion object {
fun fromStorage(value: String?): VoiceAudioRoute =
values().firstOrNull { it.storageValue == value } ?: Auto
}
}
class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>) {
constructor(context: Context) : this(context.relayDataStore)
companion object {
private val KEY_ENGINE_MODE = stringPreferencesKey("voice_engine_mode")
private val KEY_AUDIO_ROUTE = stringPreferencesKey("voice_audio_route")
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
private val KEY_AUTO_TTS = booleanPreferencesKey("voice_auto_tts")
private val KEY_LANGUAGE = stringPreferencesKey("voice_language")
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
private val KEY_REALTIME_PERSISTENT_SESSION =
booleanPreferencesKey("voice_realtime_persistent_session")
const val DEFAULT_ENGINE_MODE = "hermes_voice_output"
const val DEFAULT_AUDIO_ROUTE = "auto"
const val DEFAULT_INTERACTION_MODE = "tap"
const val DEFAULT_SILENCE_THRESHOLD_MS = 3000L
const val DEFAULT_AUTO_TTS = false
const val DEFAULT_LANGUAGE = ""
const val DEFAULT_REALTIME_TRACE_DETAILS = false
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
}
val settings: Flow<VoiceSettings> = context.relayDataStore.data.map { prefs ->
VoiceSettings(
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
)
val settings: Flow<VoiceSettings> = dataStore.data
.map { prefs ->
VoiceSettings(
engineMode = VoiceEngineMode.fromStorage(
prefs[KEY_ENGINE_MODE] ?: DEFAULT_ENGINE_MODE,
).storageValue,
audioRoute = VoiceAudioRoute.fromStorage(
prefs[KEY_AUDIO_ROUTE] ?: DEFAULT_AUDIO_ROUTE,
).storageValue,
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
?: DEFAULT_REALTIME_TRACE_DETAILS,
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
?: DEFAULT_REALTIME_PERSISTENT_SESSION,
)
}
.distinctUntilChanged()
suspend fun setEngineMode(mode: VoiceEngineMode) {
dataStore.edit { it[KEY_ENGINE_MODE] = mode.storageValue }
}
suspend fun setAudioRoute(route: VoiceAudioRoute) {
dataStore.edit { it[KEY_AUDIO_ROUTE] = route.storageValue }
}
suspend fun setInteractionMode(mode: String) {
context.relayDataStore.edit { it[KEY_INTERACTION_MODE] = mode }
dataStore.edit { it[KEY_INTERACTION_MODE] = mode }
}
suspend fun setSilenceThresholdMs(ms: Long) {
context.relayDataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
}
suspend fun setAutoTts(enabled: Boolean) {
context.relayDataStore.edit { it[KEY_AUTO_TTS] = enabled }
dataStore.edit { it[KEY_AUTO_TTS] = enabled }
}
suspend fun setLanguage(language: String) {
context.relayDataStore.edit { it[KEY_LANGUAGE] = language }
dataStore.edit { it[KEY_LANGUAGE] = language }
}
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
}
suspend fun setRealtimePersistentSession(enabled: Boolean) {
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
}
}
@@ -0,0 +1,110 @@
package com.hermesandroid.relay.diagnostics
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
enum class DiagnosticCategory(val label: String) {
Api("API"),
Relay("Relay"),
Session("Session"),
Voice("Voice"),
Endpoint("Route"),
Auth("Auth"),
}
enum class DiagnosticSeverity {
Info,
Warning,
Error,
}
data class DiagnosticLogEntry(
val timestampMs: Long,
val category: DiagnosticCategory,
val severity: DiagnosticSeverity,
val title: String,
val detail: String? = null,
val endpointRole: String? = null,
val url: String? = null,
val elapsedMs: Long? = null,
)
object DiagnosticsLog {
private const val MAX_ENTRIES = 200
private const val MAX_TEXT_LENGTH = 180
private val lock = Any()
private val _entries = MutableStateFlow<List<DiagnosticLogEntry>>(emptyList())
val entries: StateFlow<List<DiagnosticLogEntry>> = _entries.asStateFlow()
fun record(
category: DiagnosticCategory,
severity: DiagnosticSeverity = DiagnosticSeverity.Info,
title: String,
detail: String? = null,
endpointRole: String? = null,
url: String? = null,
elapsedMs: Long? = null,
) {
val entry = DiagnosticLogEntry(
timestampMs = System.currentTimeMillis(),
category = category,
severity = severity,
title = clean(title) ?: title.take(MAX_TEXT_LENGTH),
detail = clean(detail),
endpointRole = clean(endpointRole),
url = sanitizeUrl(url),
elapsedMs = elapsedMs,
)
synchronized(lock) {
_entries.value = (_entries.value + entry).takeLast(MAX_ENTRIES)
}
}
fun recent(
categories: Set<DiagnosticCategory>? = null,
limit: Int = 30,
): List<DiagnosticLogEntry> {
val source = entries.value.asReversed()
val filtered = if (categories == null) {
source
} else {
source.filter { it.category in categories }
}
return filtered.take(limit.coerceAtLeast(0))
}
fun clear() {
synchronized(lock) {
_entries.value = emptyList()
}
}
fun sanitizeUrl(value: String?): String? {
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
val noQuery = trimmed.substringBefore('?').substringBefore('#')
val schemeEnd = noQuery.indexOf("://")
val noUserInfo = if (schemeEnd >= 0) {
val prefix = noQuery.substring(0, schemeEnd + 3)
val rest = noQuery.substring(schemeEnd + 3)
val slash = rest.indexOf('/').let { if (it < 0) rest.length else it }
val authority = rest.substring(0, slash)
val path = rest.substring(slash)
val safeAuthority = authority.substringAfterLast('@')
prefix + safeAuthority + path
} else {
noQuery
}
return noUserInfo.take(MAX_TEXT_LENGTH)
}
private fun clean(value: String?): String? {
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
return trimmed
.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
"${it.groupValues[1]}=[hidden]"
}
.take(MAX_TEXT_LENGTH)
}
}
@@ -9,6 +9,9 @@ import android.util.Log
import com.hermesandroid.relay.auth.CertPinStore
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.models.Envelope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -78,12 +81,21 @@ class ConnectionManager(
private val context: Context? = null,
/**
* ADR 24 multi-endpoint resolver. When provided alongside [context] and
* a non-null [deviceIdProvider], every call to [connect] first consults
* the resolver before opening the WSS; on network changes the resolver
* is re-run and we hot-swap to the new winner. When null the manager
* uses the caller-supplied URL verbatim (pre-ADR-24 behavior).
* either [endpointCandidatesProvider] or a non-null [deviceIdProvider],
* every call to [connect] first consults the resolver before opening the
* WSS; on network changes the resolver is re-run and we hot-swap to the
* new winner. When null the manager uses the caller-supplied URL verbatim
* (pre-ADR-24 behavior).
*/
private val endpointResolver: EndpointResolver? = null,
/**
* Candidate supplier for the active saved connection. This is the
* standard-Hermes route source: it works before Relay pairing, so API,
* dashboard, voice, and future Relay calls can hand off between LAN and
* Tailscale using the same resolver. If it returns an empty list, we fall
* back to the legacy per-device PairingPreferences source below.
*/
private val endpointCandidatesProvider: (suspend () -> List<EndpointCandidate>)? = null,
/**
* Suspending supplier for the active device id. Used to key into
* [PairingPreferences.getDeviceEndpoints] during resolution. `null`
@@ -121,7 +133,10 @@ class ConnectionManager(
@Volatile
private var client: OkHttpClient = buildClient()
@Volatile
private var webSocket: WebSocket? = null
@Volatile
private var serverUrl: String? = null
private var reconnectAttempt = 0
private var shouldReconnect = true
@@ -158,18 +173,68 @@ class ConnectionManager(
* user-preferred endpoint that doesn't respond to HEAD /health falls
* back through the normal priority chain.
*
* Cleared on [disconnect] per ADR 24's "clears on disconnect" semantics
* from the UI card.
* Two writers feed this: a sticky [Connection.preferredRouteRole] is
* restored into it on connection load, and the Routes card's transient
* "Use now" writes it directly without persisting. Cleared on
* [disconnect] per ADR 24's "clears on disconnect" semantics.
*
* Exposed as [manualRoleOverrideFlow] so the Routes card can label the
* current route as automatic / preferred / manually switched.
*/
@Volatile
private var manualRoleOverride: String? = null
private val _manualRoleOverride = MutableStateFlow<String?>(null)
val manualRoleOverrideFlow: StateFlow<String?> = _manualRoleOverride.asStateFlow()
private var networkCallback: ConnectivityManager.NetworkCallback? = null
/**
* Debounce job for network-change re-resolution. Android fires one
* onAvailable per satisfying network (Wi-Fi + cell + VPN can land within
* milliseconds of each other, and registration itself replays every
* current network), so each event cancels the previous pending resolve
* and the last one wins after a short settle window.
*/
@Volatile
private var networkResolveJob: kotlinx.coroutines.Job? = null
/** Deferred reaction to a network loss — cancelled if a network returns within the grace. */
private var networkLossJob: kotlinx.coroutines.Job? = null
/**
* Set when a network loss outlives [NETWORK_LOSS_GRACE_MS] — only then may
* a re-resolve switch DOWN to a lower-priority endpoint. Prevents a
* transient probe miss (Wi-Fi settling) from switching routes and
* cancelling an in-flight turn. Cleared once a resolution is published.
*/
@Volatile
private var sustainedLossDeclared = false
init {
// Register at construction, not on first connect(). Standard
// (no-Relay) connections never open the WSS socket, but their HTTP
// surfaces (chat, dashboard, voice) still need [activeEndpoint] to
// follow LAN/Tailscale handoffs — leaving registration inside
// connect() left the whole ADR 24 network-aware path dormant for
// exactly those users. No-op when [context] is null (tests).
ensureNetworkCallbackRegistered()
}
companion object {
private const val TAG = "ConnectionManager"
private const val MAX_BACKOFF_MS = 30_000L
private const val BASE_BACKOFF_MS = 1_000L
// Settle window before re-resolving after a network event. Long
// enough to coalesce the onAvailable burst of a handoff, short
// enough that a route swap still feels immediate.
private const val NETWORK_RESOLVE_DEBOUNCE_MS = 300L
/**
* Grace before reacting to a network loss. A transient blip (Wi-Fi
* power-save/roam, a brief drop, the OS swapping radios) recovers
* within this window and must NOT mark the active endpoint unreachable
* or switch routes — doing so rebuilds the chat client and cancels an
* in-flight turn. Only a loss sustained past the grace switches.
*/
private const val NETWORK_LOSS_GRACE_MS = 6_000L
// Matches plugin.relay.auth._BLOCK_SECONDS (5 min). If we see 429
// on the WSS upgrade, we're IP-banned server-side — retrying at
// our normal 1-30s cadence re-fills the ban bucket and keeps us
@@ -179,9 +244,18 @@ class ConnectionManager(
}
fun setInsecureMode(enabled: Boolean) {
if (_insecureMode.value == enabled) {
return
}
_insecureMode.value = enabled
if (enabled) {
Log.w(TAG, "⚠ INSECURE MODE ENABLED — ws:// connections allowed. Do NOT use in production.")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Insecure relay mode enabled",
detail = "ws:// connections are allowed",
)
}
}
@@ -202,9 +276,23 @@ class ConnectionManager(
_activeEndpoint.value = resolved
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
"relay=${resolved.relay.url} (fallback would have been $url)")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay route selected",
endpointRole = resolved.role,
url = resolved.relay.url,
)
} else {
_activeEndpoint.value = null
Log.d(TAG, "connect: no resolver winner — using supplied url $url")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Using configured relay URL",
detail = "No resolver winner",
url = url,
)
}
connectToUrlOnMainPath(targetUrl)
}
@@ -217,14 +305,31 @@ class ConnectionManager(
* the callback from re-running the resolve loop inside another
* resolve loop.
*/
private fun connectToUrlOnMainPath(url: String) {
private fun connectToUrlOnMainPath(
url: String,
replaceReason: String = "Relay socket replaced",
) {
val isInsecure = url.startsWith("ws://") && !url.startsWith("wss://")
if (isInsecure && !_insecureMode.value) {
Log.e(TAG, "Blocked ws:// connection — insecure mode is disabled. Use wss:// or enable insecure mode in Settings.")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay socket blocked",
detail = "ws:// is disabled",
url = url,
)
return
}
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
Log.e(TAG, "Invalid URL scheme — must start with ws:// or wss://")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay socket URL invalid",
detail = "URL must start with ws:// or wss://",
url = url,
)
return
}
@@ -233,16 +338,39 @@ class ConnectionManager(
// hits the HTTP root and comes back as 404 Not Found during the
// upgrade handshake. We still accept an explicit path if present.
val normalized = normalizeRelayUrl(url)
val existingState = _connectionState.value
if (serverUrl == normalized &&
(existingState == ConnectionState.Connecting ||
existingState == ConnectionState.Connected ||
existingState == ConnectionState.Reconnecting)
) {
Log.i(TAG, "connect: already ${existingState.name.lowercase()} to $normalized — skipping duplicate open")
return
}
val previousSocket = webSocket
_isInsecureConnection.value = isInsecure
if (isInsecure) {
Log.w(TAG, "⚠ Connecting over INSECURE ws:// to: $normalized")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Opening insecure relay socket",
url = normalized,
)
} else {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Opening relay socket",
url = normalized,
)
}
serverUrl = normalized
shouldReconnect = true
reconnectAttempt = 0
doConnect(normalized)
doConnect(normalized, previousSocket, replaceReason)
}
// ----- ADR 24 — multi-endpoint resolution --------------------------------
@@ -262,28 +390,37 @@ class ConnectionManager(
private suspend fun resolveBestEndpointSafe(): EndpointCandidate? {
val resolver = endpointResolver ?: return null
val ctx = context ?: return null
val devicePull = deviceIdProvider ?: return null
val deviceId = try {
withTimeoutOrNull(1_000L) { devicePull() }
} catch (_: Exception) {
null
} ?: return null
val endpoints: List<EndpointCandidate> = try {
val endpoints = try {
withTimeoutOrNull(1_000L) {
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
endpointCandidatesProvider?.invoke()
?.takeIf { it.isNotEmpty() }
}
} catch (_: Exception) {
null
} ?: emptyList()
} ?: run {
val devicePull = deviceIdProvider ?: return null
val deviceId = try {
withTimeoutOrNull(1_000L) { devicePull() }
} catch (_: Exception) {
null
} ?: return null
try {
withTimeoutOrNull(1_000L) {
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
}
} catch (_: Exception) {
null
} ?: emptyList()
}
if (endpoints.isEmpty()) return null
// Manual override: if the user pinned a role in the Endpoints card,
// try that one first; fall through to the strict-priority algorithm
// if it isn't reachable.
manualRoleOverride?.let { preferredRole ->
_manualRoleOverride.value?.let { preferredRole ->
val preferred = endpoints.firstOrNull {
it.role.equals(preferredRole, ignoreCase = true)
}
@@ -302,36 +439,58 @@ class ConnectionManager(
/**
* User-triggered re-probe. Forces a fresh resolve + reconnect regardless
* of cache state. Backs the "Probe now" row action in the Endpoints card.
* Fire-and-forget wrapper around [probeAndReconnectNow] for callers that
* don't need the outcome.
*/
fun probeAndReconnect() {
scope.launch { probeAndReconnectNow() }
}
/**
* Awaitable body of [probeAndReconnect]. Returns the resolved winner —
* or null when no candidate answered — so callers (probe-status UI) can
* report the outcome instead of guessing with a fixed delay.
*
* Unlike the pre-2026-06 version this ALWAYS publishes the resolve
* outcome to [activeEndpoint]: a standard (no relay socket) connection
* whose probes all failed used to early-return before publishing,
* leaving the Routes card stuck on "Resolving" with no feedback. The
* only exception is the live-socket transient-miss guard shared with
* [refreshActiveEndpoint].
*/
suspend fun probeAndReconnectNow(): EndpointCandidate? {
endpointResolver?.clearCache()
val current = serverUrl
scope.launch {
val resolved = resolveBestEndpointSafe()
val targetUrl = resolved?.relay?.url ?: current ?: return@launch
val normalizedTarget = normalizeRelayUrl(targetUrl)
_activeEndpoint.value = resolved
// Reconnect when the winner changed, and also when the socket is
// stale/disconnected on the same winner. The latter makes the
// "Use now" route action an actual recovery path after Wi-Fi drop
// instead of a no-op that only updates preference state.
if (current == null) {
if (shouldReconnect && reconnectGate()) {
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
connectToUrlOnMainPath(targetUrl)
}
} else if (normalizedTarget != current) {
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
webSocket?.close(1000, "Endpoint re-probe")
connectToUrlOnMainPath(targetUrl)
} else if (_connectionState.value == ConnectionState.Disconnected &&
shouldReconnect &&
reconnectGate()
) {
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
doConnect(current)
}
val resolved = resolveBestEndpointSafe()
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
// Transient probe miss while the relay socket is demonstrably up
// — keep the live route published rather than downgrading every
// HTTP surface to the saved URL. Mirrors refreshActiveEndpoint.
return _activeEndpoint.value
}
_activeEndpoint.value = resolved
val targetUrl = resolved?.relay?.url ?: current ?: return resolved
val normalizedTarget = normalizeRelayUrl(targetUrl)
// Reconnect when the winner changed, and also when the socket is
// stale/disconnected on the same winner. The latter makes the
// "Use now" route action an actual recovery path after Wi-Fi drop
// instead of a no-op that only updates preference state.
if (current == null) {
if (shouldReconnect && reconnectGate()) {
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
connectToUrlOnMainPath(targetUrl)
}
} else if (normalizedTarget != current) {
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
connectToUrlOnMainPath(targetUrl, "Endpoint re-probe")
} else if (_connectionState.value == ConnectionState.Disconnected &&
shouldReconnect &&
reconnectGate()
) {
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
doConnect(current)
}
return resolved
}
/**
@@ -339,9 +498,22 @@ class ConnectionManager(
* WSS reconnect. Used by HTTP-only surfaces (chat/voice/relay HTTP)
* so they can follow LAN/Tailscale/VPN route changes even when the relay
* socket is currently disconnected or intentionally not paired.
*
* @param clearProbeCache wipe the resolver's probe cache first. Pass
* `true` from "the world may have changed" triggers (app resume,
* network change) — otherwise a route that died within the positive
* cache TTL (60s) can still be returned as the winner.
*/
suspend fun refreshActiveEndpoint(): EndpointCandidate? {
suspend fun refreshActiveEndpoint(clearProbeCache: Boolean = false): EndpointCandidate? {
if (clearProbeCache) endpointResolver?.clearCache()
val resolved = resolveBestEndpointSafe()
if (resolved == null && _connectionState.value == ConnectionState.Connected) {
// Transient probe miss while the relay socket is demonstrably up
// (slow resume, mid-handoff blip) — keep publishing the live
// route instead of downgrading every HTTP surface to the saved
// URL. Mirrors scheduleNetworkReResolve's guard.
return _activeEndpoint.value
}
_activeEndpoint.value = resolved
return resolved
}
@@ -352,11 +524,11 @@ class ConnectionManager(
* cycle — call [probeAndReconnect] to apply immediately.
*/
fun setManualRoleOverride(role: String?) {
manualRoleOverride = role?.takeIf { it.isNotBlank() }
Log.i(TAG, "manualRoleOverride now=${manualRoleOverride ?: "(cleared)"}")
_manualRoleOverride.value = role?.takeIf { it.isNotBlank() }
Log.i(TAG, "manualRoleOverride now=${_manualRoleOverride.value ?: "(cleared)"}")
}
fun getManualRoleOverride(): String? = manualRoleOverride
fun getManualRoleOverride(): String? = _manualRoleOverride.value
private fun markActiveEndpointUnreachable(reason: String) {
val active = _activeEndpoint.value ?: return
@@ -364,27 +536,65 @@ class ConnectionManager(
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
}
private fun resolveAndSwitchIfNeeded(closeReason: String) {
/**
* Debounced network-change re-resolution, shared by both NetworkCallback
* events. Re-runs the resolver and publishes the winner to
* [activeEndpoint] so HTTP-only surfaces (chat, dashboard, standard
* voice) follow the route change even when no relay socket exists. When
* a socket IS up, additionally swaps it to a differing winner, or
* reconnects a disconnected socket on the same winner — preserving the
* pre-refactor relay-path behavior.
*/
private fun scheduleNetworkReResolve(closeReason: String) {
if (endpointResolver == null) return
val current = serverUrl ?: return
scope.launch {
networkResolveJob?.cancel()
networkResolveJob = scope.launch {
delay(NETWORK_RESOLVE_DEBOUNCE_MS)
val current = serverUrl
val resolved = resolveBestEndpointSafe()
if (resolved == null) {
_activeEndpoint.value = null
// Don't clear a live socket's endpoint on a transient probe
// miss — only drop the published route when nothing is
// actually connected.
if (_connectionState.value != ConnectionState.Connected) {
_activeEndpoint.value = null
}
return@launch
}
val newUrl = resolved.relay.url
val normalizedNew = normalizeRelayUrl(newUrl)
// Endpoint hysteresis: a transient blip can make the active
// (higher-priority) endpoint's health probe miss, so the resolver
// falls through to a LOWER-priority fallback. Switching on that
// transient miss rebuilds the chat client and CANCELS an in-flight
// turn. Don't switch DOWN in priority unless a sustained loss was
// actually declared (the onLost grace elapsed). Same/upgrade
// winners always publish.
val active = _activeEndpoint.value
if (active != null && resolved.priority > active.priority && !sustainedLossDeclared) {
Log.i(
TAG,
"re-resolve picked lower-priority ${resolved.role}(p${resolved.priority}) over " +
"active ${active.role}(p${active.priority}) not confirmed dead — keeping active",
)
return@launch
}
sustainedLossDeclared = false
_activeEndpoint.value = resolved
if (current == null) return@launch
// After an explicit disconnect() the route still publishes above
// (HTTP surfaces keep roaming), but no socket action: without
// this gate a network event whose winner differs from the last
// URL would resurrect a socket the user deliberately closed.
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
// the swap path never re-checked it.)
if (!shouldReconnect) return@launch
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
if (normalizedNew != current) {
Log.i(TAG, "endpoint fallback: swapping $current → $normalizedNew")
webSocket?.close(1000, closeReason)
connectToUrlOnMainPath(newUrl)
Log.i(TAG, "network change: swapping $current → $normalizedNew")
connectToUrlOnMainPath(resolved.relay.url, closeReason)
} else if (_connectionState.value == ConnectionState.Disconnected &&
shouldReconnect &&
reconnectGate()
) {
Log.i(TAG, "endpoint fallback: same winner is disconnected — reconnecting $current")
Log.i(TAG, "network change: same winner is disconnected — reconnecting $current")
doConnect(current)
}
}
@@ -397,35 +607,33 @@ class ConnectionManager(
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
if (endpointResolver == null) return
val url = serverUrl ?: return
scope.launch {
val resolved = resolveBestEndpointSafe()
val newUrl = resolved?.relay?.url
if (newUrl == null) {
if (_connectionState.value != ConnectionState.Connected) {
_activeEndpoint.value = null
}
return@launch
}
val normalizedNew = normalizeRelayUrl(newUrl)
_activeEndpoint.value = resolved
// Only swap if the winner actually differs from the
// currently-connected URL. Avoids dropping a healthy
// socket on a no-op network flap (Wi-Fi scan, cell
// handover that ends up on the same route, etc.).
if (normalizedNew != url) {
Log.i(TAG, "network change: swapping $url → $normalizedNew")
webSocket?.close(1000, "Network change — switching endpoint")
connectToUrlOnMainPath(newUrl)
}
}
// A network returned — cancel any pending loss reaction: the
// drop was transient, so don't switch routes / rebuild the chat
// client / cancel an in-flight turn. Re-resolve to pick the best
// route (usually the same one); the rebuild only fires if the
// URL actually moved.
networkLossJob?.cancel()
endpointResolver?.clearCache()
scheduleNetworkReResolve("Network change — switching endpoint")
}
override fun onLost(network: Network) {
Log.i(TAG, "network onLost — marking active endpoint unreachable and resolving fallback")
markActiveEndpointUnreachable("network lost")
resolveAndSwitchIfNeeded("Network lost — switching endpoint")
// Defer the reaction: a transient blip recovers within the grace
// (onAvailable cancels this job). Reacting immediately — marking
// the active endpoint unreachable + re-resolving to a fallback —
// switches routes mid-blip, which rebuilds the chat client and
// CANCELS the in-flight turn. The gateway client already handles
// its own socket reconnect across the blip.
Log.i(TAG, "network onLost — deferring fallback re-resolve by ${NETWORK_LOSS_GRACE_MS}ms")
networkLossJob?.cancel()
networkLossJob = scope.launch {
delay(NETWORK_LOSS_GRACE_MS)
Log.i(TAG, "network loss sustained past grace — marking active endpoint unreachable and resolving fallback")
sustainedLossDeclared = true
endpointResolver?.clearCache()
markActiveEndpointUnreachable("network lost (sustained)")
scheduleNetworkReResolve("Network lost — switching endpoint")
}
}
}
try {
@@ -442,6 +650,8 @@ class ConnectionManager(
}
private fun unregisterNetworkCallback() {
networkLossJob?.cancel()
networkLossJob = null
val ctx = context ?: return
val cb = networkCallback ?: return
try {
@@ -472,14 +682,21 @@ class ConnectionManager(
fun disconnect() {
shouldReconnect = false
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay socket disconnect requested",
url = serverUrl,
)
webSocket?.close(1000, "Client disconnect")
webSocket = null
_connectionState.value = ConnectionState.Disconnected
_isInsecureConnection.value = false
// ADR 24: clear manual override on explicit disconnect — the
// Routes card's "Prefer this route" menu contract is that it lasts
// until the user disconnects, then resets to resolver-picked.
manualRoleOverride = null
// ADR 24: clear manual override on explicit disconnect — a "Use
// now" switch lasts until the user disconnects, then resets to
// resolver-picked. A sticky preferredRouteRole is re-installed by
// the ViewModel on the next connection load.
_manualRoleOverride.value = null
_activeEndpoint.value = null
}
@@ -496,17 +713,38 @@ class ConnectionManager(
webSocket?.send(text)
}
private fun doConnect(url: String) {
private fun isActiveSocket(socket: WebSocket): Boolean = webSocket === socket
private fun doConnect(
url: String,
previousSocketToClose: WebSocket? = null,
replaceReason: String = "Relay socket replaced",
) {
val existingState = _connectionState.value
if (previousSocketToClose == null &&
serverUrl == url &&
(existingState == ConnectionState.Connecting ||
existingState == ConnectionState.Connected ||
existingState == ConnectionState.Reconnecting)
) {
Log.i(TAG, "doConnect: already ${existingState.name.lowercase()} to $url — skipping duplicate open")
return
}
_connectionState.value = if (reconnectAttempt > 0) {
ConnectionState.Reconnecting
} else {
ConnectionState.Connecting
}
scope.launch { doConnectInternal(url) }
scope.launch { doConnectInternal(url, previousSocketToClose, replaceReason) }
}
private fun doConnectInternal(url: String) {
private fun doConnectInternal(
url: String,
previousSocketToClose: WebSocket? = null,
replaceReason: String = "Relay socket replaced",
) {
// Rebuild the client so the CertificatePinner picks up the current
// pin store snapshot — crucial right after applyServerIssuedCodeAndReset
// wipes a pin for re-pair. buildClient() does a tiny DataStore read
@@ -518,12 +756,24 @@ class ConnectionManager(
.build()
Log.i(TAG, "doConnect: opening WSS to $url")
webSocket = client.newWebSocket(request, object : WebSocketListener() {
val newSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
if (!isActiveSocket(webSocket)) {
Log.i(TAG, "onOpen: stale WSS handshake ignored ($url)")
runCatching { webSocket.close(1000, "Stale relay socket") }
webSocket.cancel()
return
}
reconnectAttempt = 0
lastUpgradeResponseCode = null
_connectionState.value = ConnectionState.Connected
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay socket connected",
url = url,
)
// TOFU: record the peer cert fingerprint if we don't have one
// yet. OkHttp populates response.handshake when the connection
@@ -546,6 +796,10 @@ class ConnectionManager(
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (!isActiveSocket(webSocket)) {
Log.i(TAG, "onMessage: stale WSS envelope ignored ($url)")
return
}
try {
val envelope = json.decodeFromString<Envelope>(text)
multiplexer.route(envelope)
@@ -560,14 +814,40 @@ class ConnectionManager(
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (!isActiveSocket(webSocket)) {
Log.i(TAG, "onClosed: stale WSS close ignored ($url code=$code reason=$reason)")
return
}
Log.i(TAG, "onClosed: code=$code reason=$reason")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay socket closed",
detail = "code=$code reason=$reason",
url = url,
)
_connectionState.value = ConnectionState.Disconnected
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (!isActiveSocket(webSocket)) {
Log.i(TAG, "onFailure: stale WSS failure ignored ($url ${t.javaClass.simpleName}: ${t.message})")
return
}
val code = response?.code
Log.w(TAG, "onFailure: ${t.javaClass.simpleName}: ${t.message} (responseCode=$code)")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay socket failed",
detail = listOfNotNull(
t.javaClass.simpleName,
t.message,
code?.let { "HTTP $it" },
).joinToString(": "),
url = url,
)
lastUpgradeResponseCode = code
if (response == null) {
markActiveEndpointUnreachable("socket failure")
@@ -576,6 +856,13 @@ class ConnectionManager(
scheduleReconnect()
}
})
webSocket = newSocket
previousSocketToClose
?.takeIf { it !== newSocket }
?.let { staleSocket ->
runCatching { staleSocket.close(1000, replaceReason) }
staleSocket.cancel()
}
}
private fun scheduleReconnect() {
@@ -588,6 +875,13 @@ class ConnectionManager(
// the rate limiter and block ourselves.
if (!reconnectGate()) {
Log.i(TAG, "scheduleReconnect: gate says no pair context — aborting retry")
DiagnosticsLog.record(
category = DiagnosticCategory.Session,
severity = DiagnosticSeverity.Warning,
title = "Relay reconnect skipped",
detail = "No paired session or pending pair code",
url = serverUrl,
)
_connectionState.value = ConnectionState.Disconnected
return
}
@@ -601,11 +895,27 @@ class ConnectionManager(
// server's full block window instead.
val backoffMs = if (lastUpgradeResponseCode == 429) {
Log.i(TAG, "scheduleReconnect: rate-limited (429) — backing off ${RATE_LIMIT_BACKOFF_MS}ms")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay reconnect delayed",
detail = "Rate limited; retrying in ${RATE_LIMIT_BACKOFF_MS / 1000}s",
url = url,
)
RATE_LIMIT_BACKOFF_MS
} else {
(BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
.coerceAtMost(MAX_BACKOFF_MS)
}
if (lastUpgradeResponseCode != 429) {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay reconnect scheduled",
detail = "Retrying in ${backoffMs / 1000}s",
url = url,
)
}
scope.launch {
delay(backoffMs)
@@ -19,16 +19,46 @@ class ConnectivityObserver(private val context: Context) {
fun observe(): Flow<Status> = callbackFlow {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
if (connectivityManager == null) {
trySend(Status.Unavailable)
awaitClose { }
return@callbackFlow
}
@Suppress("DEPRECATION")
fun hasAnyInternetNetwork(): Boolean =
connectivityManager.allNetworks.any { network ->
hasInternetCapability(connectivityManager.getNetworkCapabilities(network))
}
fun sendCurrentStatus(fallbackWhenNone: Status) {
trySend(statusForInternetAvailability(hasAnyInternetNetwork(), fallbackWhenNone))
}
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(Status.Available)
sendCurrentStatus(Status.Available)
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
sendCurrentStatus(
if (hasInternetCapability(networkCapabilities)) {
Status.Available
} else {
Status.Lost
}
)
}
override fun onLost(network: Network) {
trySend(Status.Lost)
sendCurrentStatus(Status.Lost)
}
override fun onUnavailable() {
trySend(Status.Unavailable)
sendCurrentStatus(Status.Unavailable)
}
}
@@ -39,13 +69,19 @@ class ConnectivityObserver(private val context: Context) {
connectivityManager.registerNetworkCallback(request, callback)
// Emit current state
val activeNetwork = connectivityManager.activeNetwork
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
val isConnected = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
trySend(if (isConnected) Status.Available else Status.Unavailable)
sendCurrentStatus(Status.Unavailable)
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
}
internal fun hasInternetCapability(caps: NetworkCapabilities?): Boolean =
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
internal fun statusForInternetAvailability(
hasAnyInternetNetwork: Boolean,
fallbackWhenNone: ConnectivityObserver.Status,
): ConnectivityObserver.Status =
if (hasAnyInternetNetwork) ConnectivityObserver.Status.Available else fallbackWhenNone
File diff suppressed because it is too large Load Diff
@@ -2,18 +2,46 @@ package com.hermesandroid.relay.network
import android.util.Log
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
/**
* Last observed probe result for a single [EndpointCandidate], keyed by
* [EndpointResolver.cacheKey] in [EndpointResolver.probeOutcomes]. Unlike the
* probe *cache* (a short-TTL "don't re-ask the network" optimization), this is
* a UI-facing record of what actually happened — it survives [EndpointResolver
* .clearCache] so the Routes card can keep showing the most recent
* reachability verdict between probes.
*/
data class RouteProbeOutcome(
val reachable: Boolean,
/** Short human-readable failure reason; null when [reachable]. */
val detail: String? = null,
/** Resolver-clock timestamp of when the probe finished. */
val atMillis: Long,
)
/**
* Picks the highest-priority **reachable** [EndpointCandidate] from a
@@ -27,8 +55,9 @@ import java.util.concurrent.TimeUnit
* priority over a higher one. Reachability is **only** the tiebreaker
* among candidates that share the same priority.
* * **Reachability probe.** `HEAD ${api.url}/health` with a 2-second
* per-candidate timeout. The cache lives 60 seconds per `(role|host:port)`
* key so repeated `connect()` calls don't hammer the network.
* per-candidate timeout. Positive results are cached longer than negative
* results so repeated `connect()` calls don't hammer healthy routes, while
* transient handoff misses do not pin a good fallback offline.
* * **Network-change re-evaluate.** `ConnectionManager`'s network callback
* bumps the caller into `resolve()` again on `onAvailable`, and marks the
* active endpoint unreachable on `onLost` via [markUnreachable].
@@ -66,6 +95,26 @@ class EndpointResolver(
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
/**
* Last probe verdict per candidate, keyed by [cacheKey]. Drives the
* per-row reachability line in the Routes card. Deliberately NOT wiped by
* [clearCache] — the cache controls when we re-ask the network; this
* records what the network last said.
*/
val probeOutcomes: StateFlow<Map<String, RouteProbeOutcome>> = _probeOutcomes.asStateFlow()
private fun recordOutcome(candidate: EndpointCandidate, reachable: Boolean, detail: String?) {
_probeOutcomes.update { outcomes ->
outcomes + (cacheKey(candidate) to RouteProbeOutcome(
reachable = reachable,
detail = detail,
atMillis = clock(),
))
}
}
companion object {
private const val TAG = "EndpointResolver"
/**
@@ -77,7 +126,7 @@ class EndpointResolver(
*/
const val PROBE_TIMEOUT_MS = 4_000L
/**
* Probe-result cache TTL. Widened from ADR 24's 30s to 60s for
* Successful probe-result cache TTL. Widened from ADR 24's 30s to 60s for
* two reasons: (1) HEAD /health on every tab open was burning
* battery unnecessarily on mobile, (2) NetworkCallback's
* onAvailable / onLost invalidates the cache on real network
@@ -87,6 +136,17 @@ class EndpointResolver(
*/
const val CACHE_TTL_MS = 60_000L
/**
* Failed probe-result cache TTL. Keep this intentionally short:
* Android may report a new cellular/VPN network before Tailscale has
* finished routing, so a single early ConnectException must not keep a
* viable fallback route suppressed through the voice resume window.
*/
const val NEGATIVE_CACHE_TTL_MS = 2_000L
/** Shared timeout wording so HEAD-timeout and socket-timeout read the same. */
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
/**
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
* Roles are preserved case-verbatim (HMAC canonicalization contract)
@@ -127,11 +187,25 @@ class EndpointResolver(
if (winner != null) {
Log.i(TAG, "resolve winner: role=${winner.role} " +
"api=${winner.api.host}:${winner.api.port} priority=$priority")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Info,
title = "Endpoint selected",
detail = "priority=$priority",
endpointRole = winner.role,
url = winner.relay.url,
)
return winner
}
}
Log.w(TAG, "resolve: no reachable candidate across ${candidates.size} record(s)")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "No reachable endpoint",
detail = "${candidates.size} configured route(s) failed health probes",
)
return null
}
@@ -193,10 +267,8 @@ class EndpointResolver(
}
val reachable = probe(candidate)
probeCache[key] = CacheEntry(
expiresAt = now + CACHE_TTL_MS,
reachable = reachable,
)
val ttl = if (reachable) CACHE_TTL_MS else NEGATIVE_CACHE_TTL_MS
probeCache[key] = CacheEntry(expiresAt = now + ttl, reachable = reachable)
return reachable
}
@@ -209,9 +281,19 @@ class EndpointResolver(
* We never raise: a bad record shouldn't crash the connect loop.
*/
private suspend fun probe(candidate: EndpointCandidate): Boolean {
val startedAtMs = clock()
val url = "${candidate.api.url}/health".toHttpUrlOrNull()
?: run {
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Error,
title = "Endpoint probe invalid",
detail = "Invalid API URL",
endpointRole = candidate.role,
url = candidate.api.url,
)
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
return false
}
val fastClient = httpClient.newBuilder()
@@ -229,37 +311,106 @@ class EndpointResolver(
try {
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
fastClient.newCall(request).execute().use { resp ->
resp.isSuccessful
val ok = resp.isSuccessful
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
title = if (ok) "Endpoint probe ok" else "Endpoint probe failed",
detail = if (ok) null else "HTTP ${resp.code}",
endpointRole = candidate.role,
url = candidate.api.url,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(
candidate,
reachable = ok,
detail = if (ok) null else "HTTP ${resp.code} from /health",
)
ok
}
} ?: false
} ?: run {
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
endpointRole = candidate.role,
url = candidate.api.url,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
}
} catch (_: TimeoutCancellationException) {
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
endpointRole = candidate.role,
url = candidate.api.url,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
} catch (e: Exception) {
Log.d(TAG, "probe failed role=${candidate.role} " +
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = "Endpoint probe failed",
detail = e.javaClass.simpleName,
endpointRole = candidate.role,
url = candidate.api.url,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
false
}
}
}
/**
* Map a probe exception to a short, actionable string for the Routes
* card. The TLS case is the headline: a route saved with `https://`
* against a plain-HTTP Hermes API server fails its handshake on every
* probe and previously surfaced as a silent "never switches" mystery.
*/
private fun humanProbeFailure(e: Exception): String = when (e) {
is SSLException -> "TLS failed — server may be http://, not https://"
is ConnectException -> "Connection refused"
is UnknownHostException -> "Host not found"
is SocketTimeoutException -> PROBE_TIMEOUT_DETAIL
is NoRouteToHostException -> "No route to host"
else -> e.javaClass.simpleName
}
/**
* Mark [candidate] unreachable without re-probing. Called from
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
* skips the dead endpoint without waiting for its probe to time out.
*
* The entry is still TTL'd — after 30 seconds it expires and the next
* resolve() will re-probe. That matches "ADR 24 — cached for 30 seconds"
* and stops a permanently-cached stale result.
* The entry is still TTL'd with the short negative TTL so a network-change
* transition can skip the known-dead active route without suppressing a
* valid fallback for the whole positive cache window.
*/
fun markUnreachable(candidate: EndpointCandidate) {
val key = cacheKey(candidate)
probeCache[key] = CacheEntry(
expiresAt = clock() + CACHE_TTL_MS,
expiresAt = clock() + NEGATIVE_CACHE_TTL_MS,
reachable = false,
)
recordOutcome(candidate, reachable = false, detail = "Network changed — assumed offline")
}
/** Test-only: wipe the probe cache so a fresh run starts clean. */
/**
* Wipe the probe cache so the next resolve runs fresh probes. Called on
* "the world changed" triggers — NetworkCallback events, manual "Probe
* now", and [refreshActiveEndpoint][ConnectionManager.refreshActiveEndpoint]
* with `clearProbeCache = true` — where a positive entry for a
* just-died route must not outlive the handoff.
*/
internal fun clearCache() {
probeCache.clear()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,282 @@
package com.hermesandroid.relay.network
import com.hermesandroid.relay.network.models.UsageInfo
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
/**
* Maps tui_gateway events for ONE chat turn onto [GatewayTurnCallbacks].
*
* Pure JVM (no Android deps) so the whole mapping table is unit-testable.
* The caller (GatewayChatClient) filters events by live session id and
* invokes [onEvent] in arrival order; this class owns per-turn state
* (backfill guards, synthetic tool ids, turn-end detection).
*
* Forward-compat contract: unknown event types MUST be ignored — upstream
* adds event types freely and old clients are expected to skip them. That is
* why dispatch is a manual `when (type)` over [JsonObject] rather than a
* sealed polymorphic hierarchy (which throws on unknown discriminators).
*/
class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
/** True once `message.complete` or `error` has been seen — the turn is over. */
var turnEnded: Boolean = false
private set
private var sawMessageStart = false
private var sawTextDelta = false
private var sawThinkingDelta = false
private var syntheticToolCounter = 0
/**
* `tool.complete` events match their `tool.start` by `tool_id`; when a
* server omits the id we synthesize one per start and match completes by
* tool name, FIFO.
*/
private val openSyntheticIdsByName = mutableMapOf<String, ArrayDeque<String>>()
/**
* `tool.generating` pre-registrations awaiting their `tool.start`, per
* name FIFO — the start adopts the pre-minted id (unless the server
* supplied a real `tool_id`) so the "preparing" placeholder and the
* running card stay one ToolCall.
*/
private val generatingIdsByName = mutableMapOf<String, ArrayDeque<String>>()
fun onEvent(type: String, payload: JsonObject?) {
if (turnEnded) return
when (type) {
"reasoning.delta", "thinking.delta" -> {
val text = payload.string("text")
if (!text.isNullOrEmpty()) {
sawThinkingDelta = true
callbacks.onThinkingDelta(text)
}
}
// Post-hoc reasoning (providers that don't stream it) — only
// useful when nothing streamed live.
"reasoning.available" -> {
val text = payload.string("text")
if (!text.isNullOrEmpty() && !sawThinkingDelta) {
sawThinkingDelta = true
callbacks.onThinkingDelta(text)
}
}
"message.delta" -> {
val text = payload.string("text")
if (!text.isNullOrEmpty()) {
sawTextDelta = true
callbacks.onTextDelta(text)
}
}
"message.start" -> {
// Gateway has no server-side message id (placeholder UUID
// stays). A second start inside one turn means a new
// assistant message began — close out the previous one.
if (sawMessageStart) callbacks.onTurnComplete()
sawMessageStart = true
}
"tool.generating" -> {
// `{name?}` with NO tool_id — the model is still streaming
// this tool's arguments.
val name = payload.string("name")
if (name != null) {
generatingIdsByName.getOrPut(name) { ArrayDeque() }
.addLast("gateway-tool-$name-${syntheticToolCounter++}")
}
callbacks.onToolGenerating(name)
}
"tool.start" -> {
val name = payload.string("name") ?: "unknown"
// A pending generating placeholder for this name is adopted
// (consumed FIFO) whether or not the server sent a real id.
val adopted = generatingIdsByName[name]?.removeFirstOrNull()
val serverId = payload.string("tool_id")
val toolId = when {
serverId != null -> serverId
adopted != null -> adopted.also {
openSyntheticIdsByName.getOrPut(name) { ArrayDeque() }.addLast(it)
}
else -> syntheticToolId(name)
}
callbacks.onToolCallStart(toolId, name)
}
"tool.complete" -> {
val name = payload.string("name") ?: "unknown"
val toolId = payload.string("tool_id")
?: openSyntheticIdsByName[name]?.removeFirstOrNull()
?: return
val error = payload.string("error")
if (!error.isNullOrEmpty()) {
callbacks.onToolCallFailed(toolId, error)
} else {
callbacks.onToolCallDone(toolId, payload.string("summary"))
}
}
"message.complete" -> {
// Non-streaming servers (or error turns) deliver everything
// here; backfill whatever never streamed.
val text = payload.string("text")
if (!sawTextDelta && !text.isNullOrEmpty()) {
callbacks.onTextDelta(text)
}
val reasoning = payload.string("reasoning")
if (!sawThinkingDelta && !reasoning.isNullOrEmpty()) {
callbacks.onThinkingDelta(reasoning)
}
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
turnEnded = true
callbacks.onComplete()
}
"error" -> {
turnEnded = true
callbacks.onError(payload.string("message") ?: "Gateway error")
}
"subagent.start", "subagent.thinking", "subagent.tool",
"subagent.progress", "subagent.complete",
-> {
val phase = when (type) {
"subagent.start" -> GatewaySubagentEvent.Phase.START
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
else -> GatewaySubagentEvent.Phase.COMPLETE
}
callbacks.onSubagentEvent(
GatewaySubagentEvent(
phase = phase,
taskIndex = payload.int("task_index") ?: 0,
taskCount = payload.int("task_count") ?: 1,
goal = payload.string("goal") ?: "",
status = payload.string("status"),
summary = payload.string("summary"),
toolName = payload.string("tool_name"),
// subagent.tool sets tool_preview AND mirrors it into
// text; thinking/progress carry text only.
preview = payload.string("tool_preview") ?: payload.string("text"),
durationSeconds = payload.double("duration_seconds"),
),
)
}
"clarify.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
?.takeIf { it.isNotEmpty() },
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
),
)
"approval.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
// Upstream approvals correlate per-SESSION, never
// per-request — a stray request_id must not be adopted.
requestId = null,
text = listOfNotNull(payload.string("command"), payload.string("description"))
.joinToString(" — ")
.ifBlank { "a command approval" },
timeoutSeconds = 0,
),
)
"sudo.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.SUDO,
requestId = payload.string("request_id"),
// Payload carries request_id ONLY — no command to show.
text = "Elevated permissions requested",
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
),
)
"secret.request" -> callbacks.onInteractionRequest(
GatewayAsk(
kind = GatewayAsk.Kind.SECRET,
requestId = payload.string("request_id"),
text = payload.string("prompt") ?: "The agent needs a secret value",
envVar = payload.string("env_var"),
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
),
)
// Known-but-unrendered (notification.show, status.update, …) and
// unknown types alike: ignore.
else -> Unit
}
}
private fun syntheticToolId(name: String): String {
val id = "gateway-tool-$name-${syntheticToolCounter++}"
openSyntheticIdsByName.getOrPut(name) { ArrayDeque() }.addLast(id)
return id
}
companion object {
/**
* `message.complete.usage` uses tui_gateway's own key names
* (`input`/`output`/`total`, with `prompt`/`completion` as the raw
* counterparts — see upstream `_get_usage()`), NOT the
* `input_tokens`/`prompt_tokens` schemes [UsageInfo] decodes from the
* SSE paths. Translate explicitly. Values are session-cumulative.
*
* The context-window block (`context_used`/`context_max`/
* `context_percent`) exists only when the server's context compressor
* is active — absent fields stay null and the meter stays hidden.
*/
fun parseGatewayUsage(usage: JsonObject?): UsageInfo? {
if (usage == null) return null
val input = usage.int("input") ?: usage.int("prompt")
val output = usage.int("output") ?: usage.int("completion")
val total = usage.int("total")
val contextUsed = usage.int("context_used")
val contextMax = usage.int("context_max")
val contextPercent = usage.int("context_percent")
if (input == null && output == null && total == null &&
contextUsed == null && contextMax == null && contextPercent == null
) {
return null
}
return UsageInfo(
inputTokens = input,
outputTokens = output,
totalTokens = total,
contextUsed = contextUsed,
contextMax = contextMax,
contextPercent = contextPercent,
)
}
}
}
// Upstream `_block()` timeouts per ask kind (server.py) — the blocked thread
// resolves to "" when these elapse. Approval has none (session-scoped).
private const val CLARIFY_TIMEOUT_SECONDS = 300
private const val SUDO_TIMEOUT_SECONDS = 120
private const val SECRET_TIMEOUT_SECONDS = 300
private fun JsonObject?.string(key: String): String? =
(this?.get(key) as? JsonPrimitive)?.contentOrNull
private fun JsonObject?.int(key: String): Int? =
(this?.get(key) as? JsonPrimitive)?.intOrNull
private fun JsonObject?.double(key: String): Double? =
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
@@ -0,0 +1,172 @@
package com.hermesandroid.relay.network
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.setGatewayKeepAlive
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Opt-in foreground service that keeps the app process alive so the gateway
* chat WebSocket (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
* [GatewayChatClient]) survives Android's background-freeze / Doze — i.e.
* "keep connected in the background".
*
* # Both flavors (Play declaration required)
*
* Declared in the MAIN manifest (unlike the device-control
* [com.hermesandroid.relay.bridge.BridgeForegroundService], which is sideload
* only), so googlePlay ships it too — the Home-Assistant-class persistent-
* connection use case Google Play permits. The `specialUse` type is honest for
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
* SDK 35) but requires a one-time Play Console foreground-service declaration
* at submission. Off by default; only runs while the user enables the toggle.
*
* # It does NOT own the socket
*
* The service's only job is to hold the process in the foreground. The socket
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
* idle-close timer while the toggle is on. On task removal (user swipes the app
* away) the ViewModel + socket die with the process, so the service stops
* itself rather than leave a notification that lies about being connected.
*
* # Android 15 watchdog
*
* On target SDK 35 any intent to a service that declares a foregroundServiceType
* must call `startForeground` within 5s — so [onStartCommand] always does that
* first, before branching on the action. Shutdown goes through [stop]
* (`stopService`) to bypass [onStartCommand] entirely.
*/
class GatewayKeepAliveService : Service() {
companion object {
private const val TAG = "GatewayKeepAliveSvc"
const val CHANNEL_ID = "gateway_keepalive"
private const val CHANNEL_NAME = "Background connection"
const val NOTIFICATION_ID = 4713
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
fun start(context: Context) {
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.applicationContext.startForegroundService(intent)
} else {
context.applicationContext.startService(intent)
}
}
fun stop(context: Context) {
// stopService() bypasses onStartCommand, so a "please shut down"
// never trips the Android 15 foreground-start watchdog.
context.applicationContext.stopService(
Intent(context.applicationContext, GatewayKeepAliveService::class.java),
)
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForegroundNotification()
if (intent?.action == ACTION_STOP) {
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
// Flip the pref off so ConnectionViewModel's collector won't
// restart us on the next foreground.
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
return START_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
// The socket lives in the ViewModel, which dies when the task is
// removed — keeping the notification would be a lie. Stop cleanly.
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
// are all declared in the main manifest (both flavors), so the type is
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
// check is finicky about correlating the runtime type arg with the manifest.
@SuppressLint("ForegroundServiceType")
private fun startForegroundNotification() {
ensureChannel()
val notification = buildNotification()
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
} catch (t: Throwable) {
Log.w(TAG, "startForeground failed — stopping keep-alive", t)
stopSelf()
}
}
private fun buildNotification(): android.app.Notification {
val tapIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val tapPending = PendingIntent.getActivity(this, 0, tapIntent, pendingFlags)
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Hermes stays connected")
.setContentText("Keeping your chat connection warm in the background.")
.setContentIntent(tapPending)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.addAction(0, "Disconnect", stopPending)
.build()
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
nm.createNotificationChannel(
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).apply {
description =
"Persistent indicator while Hermes keeps your chat connection open in the background."
setShowBadge(false)
},
)
}
}
@@ -0,0 +1,199 @@
package com.hermesandroid.relay.network
import com.hermesandroid.relay.network.models.UsageInfo
/**
* Shared types for the Gateway chat transport — upstream hermes-agent's
* `tui_gateway` JSON-RPC-over-WebSocket surface at the dashboard's `/api/ws`.
*
* This is the same wire protocol the official hermes-desktop client and the
* Ink TUI speak (reference shapes vendored in `desktop/src/gatewayTypes.ts`).
* It is the only upstream surface that streams reasoning live
* (`reasoning.delta` / `thinking.delta`) — the api_server SSE paths only
* deliver reasoning after generation completes.
*/
/**
* Why the Gateway chat transport is or isn't usable right now. Mirrors
* [com.hermesandroid.relay.viewmodel.StandardVoiceAvailability] — both ride
* the dashboard surface and share the same probe — minus the audio-route
* requirement (`/api/ws` ships with every embedded-chat dashboard build).
*/
enum class GatewayAvailability {
/** No probe has completed yet (startup, connection switch). */
Unknown,
/** Dashboard reachable and authenticated (or auth not required). */
Ready,
/** Dashboard reachable and gated, but no signed-in session — Manage sign-in unlocks it. */
SignInRequired,
/** Dashboard URL configured but `/api/status` did not answer. */
Unreachable,
/**
* Runtime sticky downgrade: the WS upgrade or ticket mint was rejected in
* a way that says this server build has no usable `/api/ws` (404 on the
* route, dashboard build predating the embedded chat). Cleared on
* connection switch / fresh probe cycle.
*/
Unsupported,
}
/** Lifecycle of the gateway WebSocket, exposed for diagnostics. */
enum class GatewayConnectionState {
Idle,
MintingTicket,
Connecting,
AwaitingReady,
Ready,
}
/**
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
* is unit-testable without an AndroidViewModel. ConnectionViewModel
* delegates here with its live state.
*
* Manual picks pass through untouched (ChatViewModel handles per-turn
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
* otherwise it falls back to the capability-preferred SSE endpoint.
*/
fun resolveStreamingEndpointPreference(
preference: String,
gateway: GatewayAvailability,
capabilities: ServerCapabilities,
): String = when (preference) {
"sessions", "completions", "runs", "gateway" -> preference
else -> if (gateway == GatewayAvailability.Ready) {
"gateway"
} else {
capabilities.preferredChatEndpoint()
}
}
/**
* Cancellable handle for one in-flight chat turn, regardless of transport.
* SSE turns wrap their [okhttp3.sse.EventSource]; gateway turns wrap a
* `session.interrupt` dispatch. Replaces the raw `EventSource?` field in
* ChatViewModel so both transports share the cancel/teardown sites.
*/
fun interface ActiveTurnHandle {
fun cancel()
}
/**
* One server-side interactive ask. The agent thread upstream is BLOCKED
* until the matching respond RPC arrives, the ask times out (resolves to ""
* server-side), or the turn is cancelled (`session.interrupt` force-releases
* pending asks and force-denies approvals). Built by [GatewayEventMapper]
* from the four `*.request` events; answered via the
* [GatewayChatClient] `respond*` helpers.
*/
data class GatewayAsk(
val kind: Kind,
/**
* Correlates the answer with the blocked server thread. Null ONLY for
* [Kind.APPROVAL] — upstream approvals correlate per-session, not
* per-request (`approval.respond` carries `session_id` instead).
*/
val requestId: String?,
/** Question / command / prompt — whatever the ask wants the user to read. */
val text: String,
/** Clarify-only: server-suggested answers. */
val choices: List<String>? = null,
/** Secret-only: the env var the value will be stored under. */
val envVar: String? = null,
/**
* Upstream blocking timeout (clarify/secret 300s, sudo 120s). 0 means no
* countdown — approvals are session-scoped and never expire on their own.
*/
val timeoutSeconds: Int,
) {
enum class Kind { CLARIFY, APPROVAL, SUDO, SECRET }
}
/**
* One `subagent.*` lifecycle event, emitted on the PARENT session. Lifecycle
* per task: START → (THINKING | TOOL | PROGRESS)* → COMPLETE. Field
* availability varies by phase — [toolName]/[preview] ride TOOL,
* [status]/[summary]/[durationSeconds] ride COMPLETE — and older emitters
* omit everything beyond the three defaults-bearing fields.
*/
data class GatewaySubagentEvent(
val phase: Phase,
val taskIndex: Int,
val taskCount: Int,
val goal: String,
val status: String? = null,
val summary: String? = null,
val toolName: String? = null,
val preview: String? = null,
val durationSeconds: Double? = null,
) {
enum class Phase { START, THINKING, TOOL, PROGRESS, COMPLETE }
}
/**
* One provider from the gateway `model.options` RPC — the curated, authenticated
* provider/model list the upstream desktop + TUI model picker uses (NOT the
* api_server `/v1/models`, which collapses to a single generic agent alias).
*/
data class GatewayModelProvider(
val name: String,
val slug: String,
val models: List<String>,
val isCurrent: Boolean,
val warning: String?,
)
/** Result of the gateway `model.options` RPC. */
data class GatewayModelOptions(
val providers: List<GatewayModelProvider>,
val currentModel: String,
val currentProvider: String,
)
/** Result of the gateway `config.get {key:"reasoning"}` RPC. */
data class GatewayReasoningSettings(
val effort: String,
val display: String?,
)
/**
* Callback set for one gateway turn. Shapes intentionally mirror the SSE
* callback lambdas in ChatViewModel.startStream() so the gateway branch can
* forward to the exact same ChatHandler mutations.
*
* Every member is a REQUIRED constructor param on purpose: GatewayChatClient
* `dispatchOn` must wrap each one onto the main thread, and a defaulted
* member would compile unwrapped — running on the OkHttp reader thread.
*/
class GatewayTurnCallbacks(
/** Stored (DB) session id — fired on session create/rotate so the drawer + persistence stay correct. */
val onSessionId: (String) -> Unit,
val onTextDelta: (String) -> Unit,
val onThinkingDelta: (String) -> Unit,
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
val onToolCallFailed: (toolCallId: String, errorMsg: String?) -> Unit,
val onTurnComplete: () -> Unit,
val onComplete: () -> Unit,
val onUsage: (UsageInfo?) -> Unit,
val onError: (String) -> Unit,
/**
* `tool.generating` — the model is still writing this tool's arguments.
* Carries the tool name when upstream sent one. The next `tool.start`
* for the same name adopts the "preparing" placeholder (per name, FIFO).
*/
val onToolGenerating: (toolName: String?) -> Unit,
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
/**
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
* the turn until answered via the matching respond RPC or the turn is
* cancelled.
*/
val onInteractionRequest: (GatewayAsk) -> Unit,
)
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.network
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.network.models.CreateSessionRequest
import com.hermesandroid.relay.network.models.HermesSseEvent
@@ -15,16 +16,17 @@ import com.hermesandroid.relay.network.models.SessionResponse
import com.hermesandroid.relay.network.models.SkillInfo
import com.hermesandroid.relay.network.models.SkillListResponse
import com.hermesandroid.relay.network.models.UsageInfo
import com.hermesandroid.relay.util.TurnLatencyTracer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -57,16 +59,16 @@ enum class ChatMode {
* The Android client uses this to pick the best chat path automatically when
* `streamingEndpoint = "auto"`. The bootstrap-injected vanilla-upstream case
* is the interesting one: `sessionsApi=true` (we injected it) but
* `sessionsChatStream=false` (we deliberately didn't inject the chat
* handler — runs is better). The auto-resolver picks `runs` for chat in that
* case while still using sessions endpoints for browse/rename/delete.
* `sessionsChatStream=false` (the chat handler is absent). The auto-resolver
* now picks OpenAI-compatible chat completions for that case because the route
* returns an SSE stream, while `/v1/runs` may be an async JSON run-start API.
*/
data class ServerCapabilities(
/** `/api/sessions` (CRUD) — true on fork, upstream-merged, OR bootstrap-injected. */
/** `/api/sessions` (CRUD) — true on native upstream, fork, OR bootstrap-injected older builds. */
val sessionsApi: Boolean,
/** `/api/sessions/{id}/chat/stream` (SSE) — true ONLY on fork or upstream-merged. */
/** `/api/sessions/{id}/chat/stream` (SSE) — true on native upstream or legacy fork builds. */
val sessionsChatStream: Boolean,
/** `/v1/runs` (structured-event SSE) — standard upstream chat path. */
/** `/v1/runs` (structured-event SSE) — true only when explicitly advertised as SSE-compatible. */
val runs: Boolean,
/** `/v1/chat/completions` — OpenAI-compatible fallback. */
val portable: Boolean,
@@ -76,6 +78,7 @@ data class ServerCapabilities(
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
fun preferredChatEndpoint(): String = when {
sessionsChatStream -> "sessions"
portable -> "completions"
runs -> "runs"
else -> "sessions" // last-resort: try sessions, will surface a clear error
}
@@ -98,6 +101,62 @@ data class ServerCapabilities(
}
}
private fun JsonObject.childObject(key: String): JsonObject? = this[key] as? JsonObject
private fun JsonObject.booleanFlag(key: String): Boolean =
(this[key] as? JsonPrimitive)?.booleanOrNull == true
private fun JsonObject.hasEndpoint(key: String): Boolean {
val path = ((this[key] as? JsonObject)?.get("path") as? JsonPrimitive)?.contentOrNull
return !path.isNullOrBlank()
}
internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities? {
val root = try {
json.decodeFromString<JsonObject>(body)
} catch (_: Exception) {
return null
}
val features = root.childObject("features")
val endpoints = root.childObject("endpoints")
if (features == null && endpoints == null) return null
fun feature(name: String): Boolean = features?.booleanFlag(name) == true
fun endpoint(name: String): Boolean = endpoints?.hasEndpoint(name) == true
return ServerCapabilities(
sessionsApi = feature("session_resources") ||
endpoint("sessions") ||
endpoint("session_create"),
sessionsChatStream = feature("session_chat_streaming") ||
endpoint("session_chat_stream"),
runs = feature("run_events_sse") || endpoint("run_events"),
portable = feature("chat_completions_streaming") ||
feature("chat_completions") ||
endpoint("chat_completions"),
healthy = true,
)
}
internal val HERMES_SKILL_ENDPOINTS = listOf("/v1/skills", "/api/skills")
internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
try {
val parsed = json.decodeFromString<SkillListResponse>(body)
val skills = parsed.skills ?: parsed.items ?: parsed.data
if (skills != null) return skills
} catch (_: Exception) {
// Fall through to direct-array compatibility below.
}
try {
return json.decodeFromString<List<SkillInfo>>(body)
} catch (_: Exception) {
return null
}
}
/**
* Direct HTTP/SSE client for the Hermes API Server.
*
@@ -229,7 +288,7 @@ class HermesApiClient(
// --- Session CRUD ---
suspend fun listSessionsResult(limit: Int = 50): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
suspend fun listSessionsResult(limit: Int = 200): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
client.newCall(request).execute().use { response ->
@@ -241,7 +300,7 @@ class HermesApiClient(
return@withContext Result.failure(IOException("List sessions returned an empty response"))
}
val parsed = json.decodeFromString<SessionListResponse>(body)
Result.success(parsed.items ?: parsed.sessions ?: emptyList())
Result.success(parsed.data ?: parsed.items ?: parsed.sessions ?: emptyList())
}
} catch (e: Exception) {
Log.w(TAG, "Failed to list sessions: ${e.message}")
@@ -249,12 +308,22 @@ class HermesApiClient(
}
}
suspend fun listSessions(limit: Int = 50): List<SessionItem> =
suspend fun listSessions(limit: Int = 200): List<SessionItem> =
listSessionsResult(limit).getOrElse { emptyList() }
suspend fun createSessionResult(title: String? = null): Result<SessionItem> = withContext(Dispatchers.IO) {
suspend fun createSessionResult(
title: String? = null,
profileName: String? = null,
model: String? = null,
): Result<SessionItem> = withContext(Dispatchers.IO) {
try {
val reqBody = json.encodeToString(CreateSessionRequest(title = title))
val reqBody = json.encodeToString(
CreateSessionRequest(
title = title,
model = model,
profile = AgentDisplay.profileRequestName(profileName),
),
)
val request = authRequest("$baseUrl/api/sessions")
.post(reqBody.toRequestBody(JSON_MEDIA))
.build()
@@ -279,8 +348,12 @@ class HermesApiClient(
}
}
suspend fun createSession(title: String? = null): SessionItem? =
createSessionResult(title).getOrNull()
suspend fun createSession(
title: String? = null,
profileName: String? = null,
model: String? = null,
): SessionItem? =
createSessionResult(title, profileName, model).getOrNull()
suspend fun deleteSession(sessionId: String): Boolean = withContext(Dispatchers.IO) {
try {
@@ -316,7 +389,7 @@ class HermesApiClient(
if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext emptyList()
val parsed = json.decodeFromString<MessageListResponse>(body)
parsed.items ?: parsed.messages ?: emptyList()
parsed.data ?: parsed.items ?: parsed.messages ?: emptyList()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to get messages: ${e.message}")
@@ -327,25 +400,45 @@ class HermesApiClient(
// --- Skills ---
suspend fun getSkills(): List<SkillInfo> = withContext(Dispatchers.IO) {
for (endpoint in HERMES_SKILL_ENDPOINTS) {
try {
val request = authRequest("$baseUrl$endpoint").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use
val body = response.body?.string() ?: return@use
val skills = parseSkillListBody(json, body)
if (skills != null) return@withContext skills
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch skills from $endpoint: ${e.message}")
}
}
emptyList()
}
// --- Available models ---
/**
* Available model ids from `GET /v1/models` (OpenAI-compatible:
* `{"object":"list","data":[{"id":"…"}]}`). Backs the in-chat model
* picker. Returns ids in server order; empty on any failure (the picker
* then offers only "Server default").
*/
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/api/skills").get().build()
val request = authRequest("$baseUrl/v1/models").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext emptyList()
// Try structured response: { "skills": [...] } or { "items": [...] }
try {
val parsed = json.decodeFromString<SkillListResponse>(body)
val skills = parsed.skills ?: parsed.items
if (skills != null) return@withContext skills
} catch (_: Exception) { /* fall through */ }
// Try direct array: [...]
try {
return@withContext json.decodeFromString<List<SkillInfo>>(body)
} catch (_: Exception) { /* fall through */ }
emptyList()
val data = (json.parseToJsonElement(body) as? JsonObject)
?.get("data") as? JsonArray ?: return@withContext emptyList()
data.mapNotNull {
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch skills: ${e.message}")
Log.w(TAG, "Failed to fetch models: ${e.message}")
emptyList()
}
}
@@ -387,10 +480,22 @@ class HermesApiClient(
key to ((value as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "")
} ?: emptyMap()
// Default personality: config.display.personality
// Default display identity. Upstream Hermes currently uses
// config.display.personality for the active persona and often
// mirrors the same identity through skin. Accept name-style
// aliases too so older or profile-specific configs don't make
// Android fall back to the literal "Hermes" label.
val display = config["display"] as? JsonObject
val defaultName = (display?.get("personality") as? kotlinx.serialization.json.JsonPrimitive)
?.content ?: ""
val defaultPersonality = display.stringField("personality")
val defaultName = firstNonBlank(
defaultPersonality.takeUnless { it.equals("default", ignoreCase = true) },
display.stringField("agent_name"),
display.stringField("assistant_name"),
display.stringField("display_name"),
display.stringField("name"),
display.stringField("skin"),
defaultPersonality,
)
PersonalityConfig(
names = prompts.keys.toList(),
@@ -453,39 +558,23 @@ class HermesApiClient(
onComplete: () -> Unit,
onUsage: (UsageInfo?) -> Unit,
onError: (String) -> Unit,
modelOverride: String? = null
modelOverride: String? = null,
profileName: String? = null,
): EventSource {
val requestPayload = buildJsonObject {
put("message", message)
if (!systemMessage.isNullOrBlank()) {
put("system_message", systemMessage)
}
if (!modelOverride.isNullOrBlank()) {
// Agent-profile model override — top-level `model` mirrors
// the OpenAI Chat Completions shape and is what the
// upstream sessions handler looks at when deciding which
// model to route the turn through.
put("model", modelOverride)
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
}
if (!attachments.isNullOrEmpty()) {
putJsonArray("attachments") {
attachments.forEach { att ->
addJsonObject {
put("contentType", att.contentType)
put("content", att.content)
}
}
}
}
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
// Nest the synthesized OpenAI-format pairs under `messages`.
// Stays additive — the upstream sessions handler reads
// `message` for the live turn and treats `messages` as
// history context to seed the LLM with.
put("messages", voiceIntentMessages)
}
if (!modelOverride.isNullOrBlank()) {
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
}
AgentDisplay.profileRequestName(profileName)?.let {
Log.d(TAG, "sendChatStream: profile=$it")
}
val requestPayload = buildSessionChatStreamPayload(
message = message,
systemMessage = systemMessage,
attachments = attachments,
voiceIntentMessages = voiceIntentMessages,
modelOverride = modelOverride,
profileName = profileName,
)
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
val request = authRequest("$baseUrl/api/sessions/$sessionId/chat/stream")
@@ -494,6 +583,8 @@ class HermesApiClient(
.build()
val completeCalled = AtomicBoolean(false)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("sessions")
// Notify caller of the session ID being used
mainHandler.post { onSessionId(sessionId) }
@@ -505,6 +596,7 @@ class HermesApiClient(
type: String?,
data: String
) {
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
@@ -515,6 +607,14 @@ class HermesApiClient(
try {
val event = json.decodeFromString<HermesSseEvent>(data)
// First visible streamed token (reasoning OR text) — the
// metric that exposes SSE's reasoning dead-air vs gateway.
if (!event.delta.isNullOrEmpty() || !event.thinkingDelta.isNullOrEmpty() ||
!event.thinking.isNullOrEmpty()
) {
tracer.mark("ttft")
}
// Check for usage data on ANY event before type resolution
// (OpenAI-format chunks have no type/event field but may carry usage)
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
@@ -659,6 +759,7 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = when {
response != null && !response.isSuccessful ->
@@ -672,6 +773,7 @@ class HermesApiClient(
}
override fun onClosed(eventSource: EventSource) {
tracer.done()
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
@@ -681,6 +783,187 @@ class HermesApiClient(
return sseFactory.newEventSource(request, listener)
}
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
/**
* Stream chat through the OpenAI-compatible chat completions endpoint.
*
* This is the portable SSE fallback for servers that expose
* `/v1/chat/completions` but where `/v1/runs` is an async JSON run-start
* API rather than an EventSource-compatible stream.
*/
fun sendChatCompletionsStream(
message: String,
model: String? = null,
systemMessage: String? = null,
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
voiceIntentMessages: JsonArray? = null,
onSessionId: (String) -> Unit,
onMessageStarted: (String) -> Unit,
onTextDelta: (String) -> Unit,
onThinkingDelta: (String) -> Unit,
onToolCallStart: (String, String) -> Unit,
onToolCallDone: (String, String?) -> Unit,
onToolCallFailed: (String, String?) -> Unit,
onTurnComplete: () -> Unit,
onComplete: () -> Unit,
onUsage: (UsageInfo?) -> Unit,
onError: (String) -> Unit,
modelOverride: String? = null,
profileName: String? = null,
): EventSource {
if (!modelOverride.isNullOrBlank()) {
Log.d(TAG, "sendChatCompletionsStream: modelOverride=$modelOverride (profile pick, was model=$model)")
}
AgentDisplay.profileRequestName(profileName)?.let {
Log.d(TAG, "sendChatCompletionsStream: profile=$it")
}
val requestPayload = buildChatCompletionsStreamPayload(
message = message,
model = model,
systemMessage = systemMessage,
attachments = attachments,
voiceIntentMessages = voiceIntentMessages,
modelOverride = modelOverride,
profileName = profileName,
)
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
val request = authRequest("$baseUrl/v1/chat/completions")
.header("Accept", "text/event-stream")
.post(requestBody.toRequestBody(JSON_MEDIA))
.build()
val completeCalled = AtomicBoolean(false)
val messageStarted = AtomicBoolean(false)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("completions")
val listener = object : EventSourceListener() {
override fun onEvent(
eventSource: EventSource,
id: String?,
type: String?,
data: String
) {
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
return
}
try {
val event = json.decodeFromString<JsonObject>(data)
openAiErrorMessage(event)?.let { msg ->
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onError(msg) }
}
return
}
openAiUsage(event)?.let { usage ->
mainHandler.post { onUsage(usage) }
}
if (messageStarted.compareAndSet(false, true)) {
openAiMessageId(event)?.let { messageId ->
mainHandler.post { onMessageStarted(messageId) }
}
}
openAiReasoningDelta(event)?.let { reasoning ->
if (reasoning.isNotEmpty()) {
tracer.mark("ttft")
mainHandler.post { onThinkingDelta(reasoning) }
}
}
openAiTextDelta(event)?.let { delta ->
if (delta.isNotEmpty()) {
tracer.mark("ttft")
mainHandler.post { onTextDelta(delta) }
}
}
val finishReason = openAiFinishReason(event)
if (!finishReason.isNullOrBlank() && completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
} catch (e: Exception) {
Log.w(TAG, "Unparseable chat completion SSE event ($type): ${e.message}\nRaw: $data")
}
}
override fun onFailure(
eventSource: EventSource,
t: Throwable?,
response: Response?
) {
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = when {
response != null && !response.isSuccessful ->
"API error ${response.code}: ${response.message}"
t is IOException -> "Connection failed: ${t.message}"
t != null -> "Stream error: ${t.message}"
else -> "Unknown stream error"
}
mainHandler.post { onError(msg) }
}
}
override fun onClosed(eventSource: EventSource) {
tracer.done()
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
}
}
return sseFactory.newEventSource(request, listener)
}
private fun openAiChoice(event: JsonObject): JsonObject? =
(event["choices"] as? JsonArray)
?.firstOrNull()
?.let { it as? JsonObject }
private fun openAiDelta(event: JsonObject): JsonObject? =
openAiChoice(event)?.get("delta") as? JsonObject
private fun openAiTextDelta(event: JsonObject): String? =
(openAiDelta(event)?.get("content") as? JsonPrimitive)?.contentOrNull
private fun openAiReasoningDelta(event: JsonObject): String? {
val delta = openAiDelta(event) ?: return null
return (delta["reasoning_content"] as? JsonPrimitive)?.contentOrNull
?: (delta["reasoning"] as? JsonPrimitive)?.contentOrNull
?: (delta["thinking"] as? JsonPrimitive)?.contentOrNull
}
private fun openAiFinishReason(event: JsonObject): String? =
(openAiChoice(event)?.get("finish_reason") as? JsonPrimitive)?.contentOrNull
private fun openAiMessageId(event: JsonObject): String? =
(event["id"] as? JsonPrimitive)?.contentOrNull
private fun openAiErrorMessage(event: JsonObject): String? {
val error = event["error"] ?: return null
return when (error) {
is JsonPrimitive -> error.contentOrNull
is JsonObject -> (error["message"] as? JsonPrimitive)?.contentOrNull
?: (error["error"] as? JsonPrimitive)?.contentOrNull
else -> null
}
}
private fun openAiUsage(event: JsonObject): UsageInfo? =
(event["usage"] as? JsonObject)?.let { usage ->
runCatching { json.decodeFromJsonElement<UsageInfo>(usage) }.getOrNull()
}
// --- Run streaming via /v1/runs ---
/**
@@ -715,44 +998,24 @@ class HermesApiClient(
onComplete: () -> Unit,
onUsage: (UsageInfo?) -> Unit,
onError: (String) -> Unit,
modelOverride: String? = null
modelOverride: String? = null,
profileName: String? = null,
): EventSource {
// Resolution: modelOverride (profile pick) > model (caller default) > "default".
// Blank strings are treated as null so callers can't accidentally
// ship `"model": ""` over the wire.
val resolvedModel = when {
!modelOverride.isNullOrBlank() -> modelOverride
!model.isNullOrBlank() -> model
else -> "default"
}
if (!modelOverride.isNullOrBlank()) {
Log.d(TAG, "sendRunStream: modelOverride=$modelOverride (profile pick, was model=$model)")
}
val requestPayload = buildJsonObject {
put("model", resolvedModel)
put("input", message)
put("stream", true)
if (!systemMessage.isNullOrBlank()) {
put("system_message", systemMessage)
}
if (!attachments.isNullOrEmpty()) {
putJsonArray("attachments") {
attachments.forEach { att ->
addJsonObject {
put("contentType", att.contentType)
put("content", att.content)
}
}
}
}
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
// /v1/runs is OpenAI Responses-shaped — accepts an
// additional `messages` field for context-priming the
// run. Mirror the chat-stream branch so both endpoints
// ingest the synthetic voice-intent history identically.
put("messages", voiceIntentMessages)
}
AgentDisplay.profileRequestName(profileName)?.let {
Log.d(TAG, "sendRunStream: profile=$it")
}
val requestPayload = buildRunStreamPayload(
message = message,
model = model,
systemMessage = systemMessage,
attachments = attachments,
voiceIntentMessages = voiceIntentMessages,
modelOverride = modelOverride,
profileName = profileName,
)
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
val request = authRequest("$baseUrl/v1/runs")
@@ -761,6 +1024,8 @@ class HermesApiClient(
.build()
val completeCalled = AtomicBoolean(false)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("runs")
val listener = object : EventSourceListener() {
override fun onEvent(
@@ -769,6 +1034,7 @@ class HermesApiClient(
type: String?,
data: String
) {
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
@@ -779,6 +1045,14 @@ class HermesApiClient(
try {
val event = json.decodeFromString<HermesSseEvent>(data)
// First visible streamed token (reasoning OR text) — the
// metric that exposes SSE's reasoning dead-air vs gateway.
if (!event.delta.isNullOrEmpty() || !event.thinkingDelta.isNullOrEmpty() ||
!event.thinking.isNullOrEmpty()
) {
tracer.mark("ttft")
}
// Check for usage data before type resolution (catches OpenAI-format chunks)
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
mainHandler.post { onUsage(event.usage) }
@@ -839,6 +1113,7 @@ class HermesApiClient(
"reasoning.available" -> {
val reasoningText = event.text
if (!reasoningText.isNullOrEmpty()) {
tracer.mark("ttft")
mainHandler.post { onThinkingDelta(reasoningText) }
}
}
@@ -925,6 +1200,7 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = when {
response != null && !response.isSuccessful ->
@@ -938,6 +1214,7 @@ class HermesApiClient(
}
override fun onClosed(eventSource: EventSource) {
tracer.done()
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
@@ -964,14 +1241,16 @@ class HermesApiClient(
*
* Probe order:
* 1. `/health` — if this fails, everything else is moot.
* 2. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork OR
* bootstrap-injected upstream).
* 3. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
* 2. `GET /v1/capabilities` — native upstream feature + endpoint map.
* 3. `HEAD /api/sessions?limit=1` — sessions CRUD (true on fork,
* native upstream, OR bootstrap-injected older upstream).
* 4. `HEAD /api/sessions/probe/chat/stream` — chat-stream handler
* presence. The handler only accepts POST, so HEAD returns 405
* (Method Not Allowed) when the route is registered. 404 means
* the route doesn't exist at all.
* 4. `HEAD /v1/runs` — runs endpoint presence (same 405-vs-404 logic).
* 5. `HEAD /v1/models` — OpenAI-compat reachability.
* 5. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
* 6. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
* when the response explicitly advertises event-stream compatibility.
*
* **Why HEAD instead of OPTIONS:** The hermes-agent gateway runs CORS
* middleware (`security_headers_middleware`) that intercepts OPTIONS
@@ -981,10 +1260,13 @@ class HermesApiClient(
* for present, 404 for missing). Verified empirically against the
* production hermes-agent gateway on 2026-04-12.
*
* **Success criterion:** any HTTP response code that isn't 404 means
* the route is registered. We accept 200, 204, 401, 403, 405, 415,
* etc. as positive — even quirky middleware responses count, because
* the alternative (404) is the only signal that means "no such path."
* **Route presence criterion:** for sessions and completions, any HTTP
* response code that isn't 404 means the route is registered. We accept
* 200, 204, 401, 403, 405, 415, etc. as positive because the alternative
* (404) is the only signal that means "no such path." `/v1/runs` is
* stricter: route presence alone is not enough because async runs can
* return `202 application/json`; auto only uses it if event-stream support
* is explicitly advertised.
*
* Network errors (connection refused, DNS failure, etc.) count as
* "missing" since we can't differentiate from a server-down case.
@@ -999,6 +1281,20 @@ class HermesApiClient(
}
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
val advertisedCapabilities = try {
val req = authRequest("$baseUrl/v1/capabilities").get().build()
client.newCall(req).execute().use { response ->
if (!response.isSuccessful) {
null
} else {
parseCapabilitiesBody(json, response.body.string())
}
}
} catch (_: Exception) {
null
}
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
// Reusable HEAD probe — returns true if the route is registered
// (any status except 404 + network errors). Already inside the
// Dispatchers.IO context from the outer withContext, so the
@@ -1010,10 +1306,31 @@ class HermesApiClient(
false
}
fun Response.advertisesEventStream(): Boolean {
val contentType = header("Content-Type").orEmpty()
val streamMode = header("X-Hermes-Stream-Mode").orEmpty()
val runStreaming = header("X-Hermes-Run-Streaming").orEmpty()
return contentType.contains("text/event-stream", ignoreCase = true) ||
streamMode.equals("sse", ignoreCase = true) ||
runStreaming.equals("sse", ignoreCase = true)
}
fun routeExplicitlySupportsEventStream(path: String): Boolean = try {
val req = authRequest("$baseUrl$path")
.head()
.header("Accept", "text/event-stream")
.build()
client.newCall(req).execute().use { response ->
response.code != 404 && response.advertisesEventStream()
}
} catch (_: Exception) {
false
}
val sessionsApi = routeExists("/api/sessions?limit=1")
val sessionsChatStream = routeExists("/api/sessions/probe/chat/stream")
val runs = routeExists("/v1/runs")
val portable = routeExists("/v1/models")
val portable = routeExists("/v1/chat/completions")
val runs = routeExplicitlySupportsEventStream("/v1/runs")
ServerCapabilities(
sessionsApi = sessionsApi,
@@ -1055,4 +1372,10 @@ class HermesApiClient(
}
return IOException(message)
}
private fun JsonObject?.stringField(name: String): String =
((this?.get(name) as? JsonPrimitive)?.contentOrNull ?: "").trim()
private fun firstNonBlank(vararg values: String?): String =
values.firstOrNull { !it.isNullOrBlank() }.orEmpty()
}
@@ -0,0 +1,145 @@
package com.hermesandroid.relay.network
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.Attachment
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
internal fun buildSessionChatStreamPayload(
message: String,
systemMessage: String? = null,
attachments: List<Attachment>? = null,
voiceIntentMessages: JsonArray? = null,
modelOverride: String? = null,
profileName: String? = null,
): JsonObject = buildJsonObject {
put("message", message)
if (!systemMessage.isNullOrBlank()) {
put("system_message", systemMessage)
}
if (!modelOverride.isNullOrBlank()) {
put("model", modelOverride)
}
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
if (!attachments.isNullOrEmpty()) {
putJsonArray("attachments") {
attachments.forEach { att ->
addJsonObject {
put("contentType", att.contentType)
put("content", att.content)
}
}
}
}
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
put("messages", voiceIntentMessages)
}
}
internal fun buildRunStreamPayload(
message: String,
model: String? = null,
systemMessage: String? = null,
attachments: List<Attachment>? = null,
voiceIntentMessages: JsonArray? = null,
modelOverride: String? = null,
profileName: String? = null,
): JsonObject {
val resolvedModel = when {
!modelOverride.isNullOrBlank() -> modelOverride
!model.isNullOrBlank() -> model
else -> "default"
}
return buildJsonObject {
put("model", resolvedModel)
put("input", message)
put("stream", true)
if (!systemMessage.isNullOrBlank()) {
put("system_message", systemMessage)
}
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
if (!attachments.isNullOrEmpty()) {
putJsonArray("attachments") {
attachments.forEach { att ->
addJsonObject {
put("contentType", att.contentType)
put("content", att.content)
}
}
}
}
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
put("messages", voiceIntentMessages)
}
}
}
internal fun buildChatCompletionsStreamPayload(
message: String,
model: String? = null,
systemMessage: String? = null,
attachments: List<Attachment>? = null,
voiceIntentMessages: JsonArray? = null,
modelOverride: String? = null,
profileName: String? = null,
): JsonObject {
val resolvedModel = when {
!modelOverride.isNullOrBlank() -> modelOverride
!model.isNullOrBlank() -> model
else -> "default"
}
return buildJsonObject {
put("model", resolvedModel)
put("stream", true)
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
putJsonArray("messages") {
if (!systemMessage.isNullOrBlank()) {
addJsonObject {
put("role", "system")
put("content", systemMessage)
}
}
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
voiceIntentMessages.forEach { add(it) }
}
addJsonObject {
put("role", "user")
if (!attachments.isNullOrEmpty() && attachments.any { it.isImage }) {
put("content", buildJsonArray {
addJsonObject {
put("type", "text")
put("text", message)
}
attachments.filter { it.isImage }.forEach { att ->
addJsonObject {
put("type", "image_url")
putJsonObject("image_url") {
put("url", "data:${att.contentType};base64,${att.content}")
}
}
}
})
} else {
put("content", message)
}
}
}
if (!attachments.isNullOrEmpty() && attachments.any { !it.isImage }) {
putJsonArray("attachments") {
attachments.filter { !it.isImage }.forEach { att ->
addJsonObject {
put("contentType", att.contentType)
put("content", att.content)
}
}
}
}
}
}
@@ -0,0 +1,226 @@
package com.hermesandroid.relay.network
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkAddress
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.Inet4Address
import java.util.concurrent.TimeUnit
data class HermesLanDiscoveryResult(
val host: String,
val apiUrl: String,
val dashboardUrl: String?,
val apiReachable: Boolean,
val dashboardReachable: Boolean,
)
/**
* User-triggered local-network discovery for standard Hermes setup.
*
* This deliberately scans only the active RFC1918/link-local LAN around the
* phone, never broad public or Tailscale ranges. Tailscale/public routes still
* belong in the explicit advanced fields where the user controls the URL.
*/
object HermesLanDiscovery {
private const val TAG = "HermesLanDiscovery"
private const val MAX_HOSTS = 254
private const val MAX_CONCURRENT_PROBES = 32
private const val PROBE_TIMEOUT_MS = 650L
private const val IPV4_MASK = 0xFFFF_FFFFL
suspend fun scan(
context: Context,
apiPort: Int = 8642,
dashboardPort: Int = 9119,
): List<HermesLanDiscoveryResult> = withContext(Dispatchers.IO) {
val hosts = localLanHosts(context.applicationContext)
if (hosts.isEmpty()) return@withContext emptyList()
val client = OkHttpClient.Builder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
.build()
coroutineScope {
val semaphore = Semaphore(MAX_CONCURRENT_PROBES)
hosts.map { host ->
async {
semaphore.withPermit {
probeHost(client, host, apiPort, dashboardPort)
}
}
}.awaitAll()
.filterNotNull()
.sortedWith(
compareByDescending<HermesLanDiscoveryResult> { it.dashboardReachable }
.thenByDescending { it.apiReachable }
.thenBy { it.host },
)
}
}
private fun probeHost(
client: OkHttpClient,
host: String,
apiPort: Int,
dashboardPort: Int,
): HermesLanDiscoveryResult? {
val apiUrl = "http://$host:$apiPort"
val dashboardUrl = "http://$host:$dashboardPort"
val dashboardReachable = probe(
client = client,
url = "$dashboardUrl/api/status",
expectedBody = ::looksLikeDashboardStatus,
)
val apiReachable = probe(
client = client,
url = "$apiUrl/health",
expectedBody = ::looksLikeApiHealth,
)
if (!dashboardReachable && !apiReachable) return null
return HermesLanDiscoveryResult(
host = host,
apiUrl = apiUrl,
dashboardUrl = dashboardUrl.takeIf { dashboardReachable },
apiReachable = apiReachable,
dashboardReachable = dashboardReachable,
)
}
private fun probe(
client: OkHttpClient,
url: String,
expectedBody: (String, String) -> Boolean,
): Boolean {
val httpUrl = url.toHttpUrlOrNull() ?: return false
val request = Request.Builder()
.url(httpUrl)
.get()
.header("Accept", "application/json, text/plain, */*")
.build()
return try {
client.newCall(request).execute().use { response ->
if (response.code == 401 || response.code == 403) {
return true
}
if (!response.isSuccessful) {
return false
}
val contentType = response.header("Content-Type").orEmpty()
val body = response.body.string().take(2_048)
expectedBody(body, contentType)
}
} catch (e: Exception) {
Log.d(TAG, "probe failed url=$url type=${e.javaClass.simpleName}")
false
}
}
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
val lower = body.lowercase()
return contentType.contains("json", ignoreCase = true) && (
lower.contains("auth_required") ||
lower.contains("auth_providers") ||
lower.contains("authenticated") ||
lower.contains("hermes")
)
}
private fun looksLikeApiHealth(body: String, contentType: String): Boolean {
if (contentType.contains("json", ignoreCase = true)) return true
if (contentType.contains("text/plain", ignoreCase = true)) return true
return body.isBlank() || body.trimStart().startsWith("{")
}
private fun localLanHosts(context: Context): List<String> {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
?: return emptyList()
val networks = buildList {
connectivityManager.activeNetwork?.let(::add)
connectivityManager.allNetworks.forEach { network ->
if (!contains(network)) add(network)
}
}
val hosts = linkedSetOf<String>()
for (network in networks) {
val linkProperties = connectivityManager.getLinkProperties(network) ?: continue
for (linkAddress in linkProperties.linkAddresses) {
addHostsForLink(linkAddress, hosts)
if (hosts.size >= MAX_HOSTS) break
}
if (hosts.size >= MAX_HOSTS) break
}
return hosts.take(MAX_HOSTS)
}
private fun addHostsForLink(linkAddress: LinkAddress, hosts: MutableSet<String>) {
val address = linkAddress.address as? Inet4Address ?: return
if (address.isLoopbackAddress || address.isMulticastAddress) return
val local = ipv4ToLong(address)
if (!isScannableLanAddress(local)) return
val scanPrefix = when (linkAddress.prefixLength) {
in 24..30 -> linkAddress.prefixLength
else -> 24
}
val mask = subnetMask(scanPrefix)
val network = local and mask
val broadcast = network or (mask.inv() and IPV4_MASK)
val first = network + 1
val last = broadcast - 1
if (first > last) return
for (candidate in first..last) {
if (candidate == local) continue
hosts.add(longToIpv4(candidate))
if (hosts.size >= MAX_HOSTS) return
}
}
private fun subnetMask(prefixLength: Int): Long {
return (IPV4_MASK shl (32 - prefixLength)) and IPV4_MASK
}
private fun ipv4ToLong(address: Inet4Address): Long {
return address.address.fold(0L) { acc, byte ->
(acc shl 8) or (byte.toInt() and 0xFF).toLong()
} and IPV4_MASK
}
private fun longToIpv4(value: Long): String {
return listOf(
(value shr 24) and 0xFF,
(value shr 16) and 0xFF,
(value shr 8) and 0xFF,
value and 0xFF,
).joinToString(".") { it.toString() }
}
private fun isScannableLanAddress(value: Long): Boolean {
val first = ((value shr 24) and 0xFF).toInt()
val second = ((value shr 16) and 0xFF).toInt()
return when {
first == 10 -> true
first == 172 && second in 16..31 -> true
first == 192 && second == 168 -> true
first == 169 && second == 254 -> true
else -> false
}
}
}
@@ -0,0 +1,52 @@
package com.hermesandroid.relay.network
import java.net.URI
/**
* Resolves profile-scoped Hermes API URLs for phone use.
*
* Relays and Hermes gateways often bind profile API servers to loopback or
* 0.0.0.0 on the host machine. Those addresses are correct for the relay
* process but wrong on Android, where 127.0.0.1 means the phone. When the
* active connection uses a reachable LAN/Tailscale host, replace loopback
* profile hosts with that same host while preserving the profile port.
*/
object ProfileApiUrlResolver {
fun normalize(url: String?): String? =
url?.trim()?.takeIf { it.isNotBlank() }?.trimEnd('/')
fun resolveForConnection(profileApiUrl: String?, baseApiUrl: String?): String? {
val profile = normalize(profileApiUrl) ?: return null
val base = normalize(baseApiUrl) ?: return profile
val profileUri = runCatching { URI(profile) }.getOrNull() ?: return profile
val profileHost = profileUri.host?.takeIf { it.isNotBlank() } ?: return profile
if (!isLocalBindHost(profileHost)) return profile
val baseUri = runCatching { URI(base) }.getOrNull() ?: return profile
val baseHost = baseUri.host?.takeIf { it.isNotBlank() } ?: return profile
if (isLocalBindHost(baseHost)) return profile
val scheme = baseUri.scheme?.takeIf { it.isNotBlank() }
?: profileUri.scheme?.takeIf { it.isNotBlank() }
?: return profile
val hostPart = if (baseHost.contains(":") && !baseHost.startsWith("[")) {
"[$baseHost]"
} else {
baseHost
}
val portPart = profileUri.port.takeIf { it != -1 }?.let { ":$it" }.orEmpty()
val pathPart = profileUri.rawPath?.takeIf { it.isNotBlank() && it != "/" }.orEmpty()
val queryPart = profileUri.rawQuery?.let { "?$it" }.orEmpty()
val fragmentPart = profileUri.rawFragment?.let { "#$it" }.orEmpty()
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
}
private fun isLocalBindHost(host: String): Boolean {
return when (host.lowercase().trim('[', ']')) {
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
else -> false
}
}
}
@@ -2,6 +2,9 @@ package com.hermesandroid.relay.network
import android.util.Log
import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.builtins.ListSerializer
@@ -579,7 +582,10 @@ class RelayHttpClient(
* human-readable message on any failure (network, non-200, bad body,
* doesn't-look-like-hermes-relay).
*/
suspend fun probeHealth(relayUrl: String): Result<RelayHealth> = withContext(Dispatchers.IO) {
suspend fun probeHealth(
relayUrl: String,
logSuccess: Boolean = true,
): Result<RelayHealth> = withContext(Dispatchers.IO) {
val trimmed = relayUrl.trim()
if (trimmed.isEmpty()) {
return@withContext Result.failure(
@@ -591,10 +597,18 @@ class RelayHttpClient(
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val startedAtMs = System.currentTimeMillis()
val url = try {
"$httpBase/health".toHttpUrl()
} catch (e: IllegalArgumentException) {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay URL invalid",
detail = e.message,
url = relayUrl,
)
return@withContext Result.failure(
IOException("Invalid relay URL: ${e.message}")
)
@@ -606,6 +620,7 @@ class RelayHttpClient(
.connectTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.writeTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.build()
val request = Request.Builder()
@@ -617,12 +632,28 @@ class RelayHttpClient(
try {
fastClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
detail = "HTTP ${response.code}",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
return@withContext Result.failure(
IOException("Relay responded HTTP ${response.code}")
)
}
val body = response.body?.string().orEmpty()
if (body.isBlank()) {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
detail = "Empty response",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
return@withContext Result.failure(
IOException("Relay returned an empty response")
)
@@ -632,18 +663,42 @@ class RelayHttpClient(
val parsed: Map<String, kotlinx.serialization.json.JsonElement> = try {
sessionsJson.parseToJsonElement(body).jsonObject
} catch (e: Exception) {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
detail = "Non-JSON response",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
return@withContext Result.failure(
IOException("Relay returned non-JSON: ${e.message ?: "parse error"}")
)
}
val status = (parsed["status"] as? kotlinx.serialization.json.JsonPrimitive)?.content
if (status != "ok") {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
detail = "status=${status ?: "missing"}",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
return@withContext Result.failure(
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
)
}
val version = (parsed["version"] as? kotlinx.serialization.json.JsonPrimitive)?.content
if (version.isNullOrBlank()) {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
detail = "Missing version field",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
return@withContext Result.failure(
IOException("Response doesn't look like a hermes-relay — missing 'version' field")
)
@@ -652,19 +707,61 @@ class RelayHttpClient(
?.content?.toIntOrNull() ?: 0
val sessions = (parsed["sessions"] as? kotlinx.serialization.json.JsonPrimitive)
?.content?.toIntOrNull() ?: 0
if (logSuccess) {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = "Relay health ok",
detail = "version=$version clients=$clients sessions=$sessions",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
}
Result.success(RelayHealth(version = version, clients = clients, sessions = sessions))
}
} catch (e: java.net.SocketTimeoutException) {
Log.w(TAG, "probeHealth timeout: ${e.message}")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health timeout",
detail = "No HTTP response in 3s",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
Result.failure(IOException("Relay is not responding (3s timeout)"))
} catch (e: java.net.ConnectException) {
Log.w(TAG, "probeHealth connect refused: ${e.message}")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay connection refused",
detail = e.message,
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
Result.failure(IOException("Connection refused — is the relay running on this URL?"))
} catch (e: IOException) {
Log.w(TAG, "probeHealth IO error: ${e.message}")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = "Relay health failed",
detail = e.message ?: "Network error",
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
Result.failure(IOException("Network error: ${e.message ?: "unreachable"}"))
} catch (e: Exception) {
Log.w(TAG, "probeHealth unexpected error: ${e.message}")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay health failed",
detail = e.message ?: e.javaClass.simpleName,
url = httpBase,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
Result.failure(e)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,296 @@
package com.hermesandroid.relay.network
import android.content.Context
import com.hermesandroid.relay.data.VoiceAudioRoute
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.File
import java.io.IOException
import java.util.Base64
import java.util.concurrent.TimeUnit
interface VoiceAudioClient {
val route: VoiceAudioRoute
suspend fun transcribe(audioFile: File): Result<String>
suspend fun synthesize(text: String): Result<File>
}
class RelayVoiceAudioClientAdapter(
private val relayVoiceClient: RelayVoiceClient,
) : VoiceAudioClient {
override val route: VoiceAudioRoute = VoiceAudioRoute.Relay
override suspend fun transcribe(audioFile: File): Result<String> =
relayVoiceClient.transcribe(audioFile)
override suspend fun synthesize(text: String): Result<File> =
relayVoiceClient.synthesize(text)
}
/**
* Routes each STT/TTS call to the Standard (dashboard) or Relay voice client.
*
* Auto preference order is **Relay first, then Standard**: a paired Relay is
* the purpose-built mobile facade — profile-aware voice config, no dashboard
* sign-in dependency — so users who installed the plugin keep the richer
* path. Standard is the zero-plugin route for vanilla Hermes installs and is
* used whenever Relay isn't configured/paired (or fails mid-call). Power
* users can force either route in Voice Settings.
*/
class AutoVoiceAudioClient(
private val standardClient: VoiceAudioClient,
private val relayClient: VoiceAudioClient,
private val routeProvider: () -> VoiceAudioRoute,
private val standardReadyProvider: () -> Boolean,
private val relayReadyProvider: () -> Boolean,
) : VoiceAudioClient {
override val route: VoiceAudioRoute
get() = routeProvider()
override suspend fun transcribe(audioFile: File): Result<String> =
runWithSelectedRoute { it.transcribe(audioFile) }
override suspend fun synthesize(text: String): Result<File> =
runWithSelectedRoute { it.synthesize(text) }
private suspend fun <T> runWithSelectedRoute(
block: suspend (VoiceAudioClient) -> Result<T>,
): Result<T> {
return when (routeProvider()) {
VoiceAudioRoute.Standard -> {
if (!standardReadyProvider()) {
Result.failure(
IllegalStateException(
"Standard Hermes voice is not available — check dashboard sign-in in Manage",
),
)
} else {
block(standardClient)
}
}
VoiceAudioRoute.Relay -> {
if (!relayReadyProvider()) {
Result.failure(IllegalStateException("Relay voice is not available"))
} else {
block(relayClient)
}
}
VoiceAudioRoute.Auto -> runAuto(block)
}
}
private suspend fun <T> runAuto(
block: suspend (VoiceAudioClient) -> Result<T>,
): Result<T> {
var relayFailure: Result<T>? = null
if (relayReadyProvider()) {
val result = block(relayClient)
if (result.isSuccess || !standardReadyProvider()) return result
relayFailure = result
}
if (standardReadyProvider()) {
val result = block(standardClient)
if (result.isSuccess) return result
return relayFailure ?: result
}
return relayFailure ?: Result.failure(
IllegalStateException("Voice needs a reachable Hermes dashboard or Relay voice route"),
)
}
}
/**
* Standard (no-plugin) voice client — speaks the upstream **dashboard web
* server** contract that hermes-desktop's voice mode uses:
*
* POST {dashboard}/api/audio/transcribe {data_url, mime_type} → {ok, transcript}
* POST {dashboard}/api/audio/speak {text} → {ok, data_url, mime_type}
*
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
* on the API server (:8642) — current upstream api_server advertises
* `audio_api: false` and registers no audio routes. Auth is the dashboard
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
* same per-connection cookie jar the Manage tab signs in with; an API bearer
* header is meaningless on this surface. Revisit when upstream PR #8199
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md §6).
* (No glob spellings in block comments — Kotlin block comments nest.)
*/
class StandardHermesVoiceClient(
private val context: Context,
private val okHttpClient: OkHttpClient,
private val dashboardUrlProvider: () -> String?,
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
},
) : VoiceAudioClient {
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
private val callClient: OkHttpClient =
okHttpClient.newBuilder()
.callTimeout(90, TimeUnit.SECONDS)
.build()
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
val baseUrl = dashboardBaseUrl()
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
if (!audioFile.exists() || audioFile.length() == 0L) {
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
}
val dataUrl = buildAudioDataUrl(audioFile)
val payload = buildJsonObject {
put("data_url", dataUrl)
put("mime_type", mediaTypeForAudioFile(audioFile))
}
val request = Request.Builder()
.url("$baseUrl/api/audio/transcribe")
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.header("Accept", "application/json")
.build()
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
val transcript = root.stringField("transcript")
?: root.stringField("text")
?: root.stringField("message")
if (transcript.isNullOrBlank()) {
throw IOException("Hermes audio transcribe returned an empty transcript")
}
transcript
}
}
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
val baseUrl = dashboardBaseUrl()
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
val cleanText = text.trim()
if (cleanText.isBlank()) {
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
}
val payload = buildJsonObject { put("text", cleanText) }
val request = Request.Builder()
.url("$baseUrl/api/audio/speak")
.post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.header("Accept", "application/json")
.build()
executeJson(request, "Hermes audio speak").mapCatching { root ->
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
if (dataUrl.isNullOrBlank()) {
throw IOException("Hermes audio speak returned no audio")
}
val mimeType = root.stringField("mime_type")
?: root.stringField("mimeType")
?: mimeTypeFromDataUrl(dataUrl)
?: "audio/mpeg"
val bytes = decodeDataUrl(dataUrl)
if (bytes.isEmpty()) throw IOException("Hermes audio speak returned empty audio")
val extension = extensionForMimeType(mimeType)
File(context.cacheDir, "hermes_voice_${System.currentTimeMillis()}.$extension")
.also { it.writeBytes(bytes) }
}
}
private fun dashboardBaseUrl(): String? =
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
return try {
callClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return Result.failure(apiFailure(response, operation))
}
val body = response.body.string()
if (body.isBlank()) {
return Result.failure(IOException("$operation returned an empty response"))
}
val root = json.decodeFromString<JsonObject>(body)
val ok = (root["ok"] as? JsonPrimitive)?.contentOrNull
?.toBooleanStrictOrNull()
if (ok == false) {
val message = root.stringField("message")
?: root.stringField("error")
?: "$operation failed"
return Result.failure(IOException(message))
}
Result.success(root)
}
} catch (e: IOException) {
Result.failure(IOException("$operation failed: ${e.message ?: "network error"}", e))
} catch (e: Exception) {
Result.failure(IOException("$operation failed: ${e.message ?: "parse error"}", e))
}
}
private fun apiFailure(response: Response, operation: String): IOException {
val body = runCatching { response.body.string() }.getOrDefault("")
val detail = body.takeIf { it.isNotBlank() } ?: response.message
val message = when (response.code) {
401, 403 -> "$operation needs dashboard sign-in - open Manage to sign in"
404 -> "$operation unavailable on this Hermes build - update hermes-agent or use Relay"
in 500..599 -> "$operation failed - server error HTTP ${response.code}"
else -> "$operation failed - HTTP ${response.code}: $detail"
}
return IOException(message)
}
private fun buildAudioDataUrl(audioFile: File): String {
val mimeType = mediaTypeForAudioFile(audioFile)
val encoded = Base64.getEncoder().encodeToString(audioFile.readBytes())
return "data:$mimeType;base64,$encoded"
}
private fun mediaTypeForAudioFile(file: File): String =
when (file.extension.lowercase()) {
"wav" -> "audio/wav"
"m4a", "mp4" -> "audio/mp4"
"mp3" -> "audio/mpeg"
"ogg" -> "audio/ogg"
"webm" -> "audio/webm"
else -> "application/octet-stream"
}
private fun decodeDataUrl(dataUrl: String): ByteArray {
val comma = dataUrl.indexOf(',')
val payload = if (comma >= 0) dataUrl.substring(comma + 1) else dataUrl
return Base64.getDecoder().decode(payload)
}
private fun mimeTypeFromDataUrl(dataUrl: String): String? {
if (!dataUrl.startsWith("data:", ignoreCase = true)) return null
val semi = dataUrl.indexOf(';')
if (semi <= "data:".length) return null
return dataUrl.substring("data:".length, semi).takeIf { it.isNotBlank() }
}
private fun extensionForMimeType(mimeType: String): String =
when (mimeType.lowercase().substringBefore(';')) {
"audio/wav", "audio/wave", "audio/x-wav" -> "wav"
"audio/mp4", "audio/aac", "audio/m4a" -> "m4a"
"audio/ogg" -> "ogg"
"audio/webm" -> "webm"
else -> "mp3"
}
private fun JsonObject.stringField(name: String): String? =
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
private companion object {
val JSON_MEDIA = "application/json".toMediaType()
}
}
@@ -127,13 +127,16 @@ import kotlinx.serialization.json.contentOrNull
* contract documented on the Python `android_tap` / `android_scroll` tools.
* A non-resolvable nodeId returns a 404-style error envelope.
*
* # Master enable gate
* # Device Control gate
*
* Before dispatching any action we check
* The Google Play flavor ships Bridge Core without AccessibilityService or
* Device Control. It answers harmless bridge liveness/status probes above
* the dispatch layer, but any command that reaches Device Control fails closed
* before touching [HermesAccessibilityService]. The sideload flavor then checks
* [HermesAccessibilityService.instance] — if the user hasn't enabled the
* service in Android Settings, we fail fast with status 503. If the
* service is running but the soft master toggle is off we fail with 403
* and a body explaining that Bridge is disabled in the app.
* service in Android Settings, we fail fast with status 503. If the service is
* running but the soft master toggle is off we fail with 403 and a body
* explaining that Bridge is disabled in the app.
*/
class BridgeCommandHandler(
private val multiplexer: ChannelMultiplexer,
@@ -528,6 +531,28 @@ class BridgeCommandHandler(
return
}
if (!BuildFlavor.isSideload) {
respond(
requestId, 403,
buildJsonObject {
put(
"error",
"Device Control is not included in the Google Play build " +
"of Hermes-Relay. This build keeps Hermes Bridge Core " +
"features such as chat, voice, terminal, media, " +
"notifications, and relay status, but it does not " +
"ship AccessibilityService, screen reading, taps, " +
"typing, screenshots, SMS, calls, or unattended " +
"phone control. Install the sideload build for " +
"Device Control.",
)
put("error_code", "device_control_sideload_only")
put("flavor", "googlePlay")
}
)
return
}
val service = HermesAccessibilityService.instance
?: return respond(
requestId, 503,
@@ -537,7 +562,7 @@ class BridgeCommandHandler(
"Hermes accessibility service is not enabled. " +
"The phone IS paired and connected — this is " +
"NOT a pairing problem. The user must enable " +
"the Hermes Relay accessibility service in " +
"the Hermes-Relay accessibility service in " +
"Android Settings > Accessibility > " +
"Installed services before the bridge can " +
"dispatch phone-control commands.",
@@ -545,7 +570,7 @@ class BridgeCommandHandler(
put("error_code", "service_unavailable")
put(
"required_action",
"User enables Hermes Relay in Android Accessibility Settings",
"User enables Hermes-Relay in Android Accessibility Settings",
)
}
)
@@ -676,57 +701,6 @@ class BridgeCommandHandler(
}
// === END v0.4.1 unattended-access ===
// === Google Play flavor route gate ===
// The googlePlay build's AccessibilityService config declares a
// narrow use case ("read notifications, summarize messages") with
// NO gesture dispatch (canPerformGestures is absent) and NO
// flagRetrieveInteractiveWindows. Only READ-ONLY routes that
// match this declared scope are whitelisted; everything else
// returns a 403 so reviewers tracing the code see a capability
// surface that matches the manifest declaration.
//
// The whitelist is FAIL-CLOSED: any new route we add to the when
// block below defaults to sideload-only on the Play flavor unless
// explicitly added here. This prevents future routes from
// accidentally widening the Play APK's capability surface.
//
// Early-return routes (/ping, /events, /setup) are above this
// point so they work on both flavors — they're harmless liveness
// probes and don't need the a11y service. /return_to_hermes is
// whitelisted because it only foregrounds our OWN app (not a
// phone-control action). /clipboard is whitelisted for GET
// (read-only); POST (write) is gated inside the /clipboard case.
if (!BuildFlavor.isSideload) {
val playAllowed = setOf(
"/current_app",
"/screen",
"/get_apps",
"/apps",
"/clipboard",
"/return_to_hermes",
)
if (path !in playAllowed) {
respond(
requestId, 403,
buildJsonObject {
put(
"error",
"This bridge route ($path) is only available on the " +
"sideload flavor of Hermes Relay. The Google Play " +
"build supports read-only bridge operations (screen " +
"reading, app status, clipboard read) but not " +
"phone-control actions (tap, type, swipe, SMS, call). " +
"Install the sideload APK for full phone control.",
)
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
)
return
}
}
// === END Google Play flavor route gate ===
val executor = service.actionExecutor
when (path) {
@@ -839,13 +813,12 @@ class BridgeCommandHandler(
}
// === PHASE3-return-to-hermes ===
// Bring the Hermes Relay app back to foreground. Used by the
// Bring the Hermes-Relay app back to foreground. Used by the
// server-side agent as the final step of any multi-app task
// (e.g. after driving Messages to send an SMS) so the user
// sees the agent's reply in-context without manually switching
// apps. The phone knows its own package name via service — no
// parameter needed, works transparently on both sideload and
// googlePlay flavors.
// apps. The sideload phone knows its own package name via the
// accessibility service, so no parameter is needed.
//
// Allowed even when the master toggle is off: returning focus
// to our own app isn't a destructive action, and this tool
@@ -1246,7 +1219,7 @@ class BridgeCommandHandler(
respond(
requestId, 403,
buildJsonObject {
put("error", "android_location is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
put("error", "android_location is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
@@ -1261,7 +1234,7 @@ class BridgeCommandHandler(
respond(
requestId, 403,
buildJsonObject {
put("error", "android_search_contacts is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
put("error", "android_search_contacts is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
@@ -1285,7 +1258,7 @@ class BridgeCommandHandler(
respond(
requestId, 403,
buildJsonObject {
put("error", "android_call auto-dial is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
put("error", "android_call auto-dial is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
@@ -1339,7 +1312,7 @@ class BridgeCommandHandler(
respond(
requestId, 403,
buildJsonObject {
put("error", "android_send_sms is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
put("error", "android_send_sms is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
@@ -1397,7 +1370,7 @@ class BridgeCommandHandler(
respond(
requestId, 403,
buildJsonObject {
put("error", "$path is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
put("error", "$path is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
put("error_code", "sideload_only")
put("flavor", "googlePlay")
}
@@ -1438,9 +1411,9 @@ class BridgeCommandHandler(
val target = if (to.isBlank()) "the selected recipient" else to
"Send MMS compose to $target with $attachmentCount attachment(s)?"
} else if (attachmentCount > 0) {
"Share $attachmentCount attachment(s) from Hermes Relay?"
"Share $attachmentCount attachment(s) from Hermes-Relay?"
} else {
"Share text from Hermes Relay?"
"Share text from Hermes-Relay?"
}
val allowed = safetyManager.awaitConfirmation(path, confirmText)
if (!allowed) {
@@ -5,8 +5,10 @@ import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeTurnTrace
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoiceIntentTrace
import com.hermesandroid.relay.network.GatewaySubagentEvent
import com.hermesandroid.relay.network.models.MessageItem
import com.hermesandroid.relay.network.models.SessionItem
import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,7 +34,15 @@ class ChatHandler {
private const val TAG = "ChatHandler"
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
private const val MAX_MESSAGES = 500
internal const val MAX_MESSAGES = 500
private fun timestampToMillis(timestamp: Double?): Long {
val value = timestamp ?: return 0L
return if (value > 1e12) value.toLong() else (value * 1000).toLong()
}
private fun firstPositive(vararg values: Long): Long =
values.firstOrNull { it > 0L } ?: 0L
// Tool annotation patterns embedded as text markers by Hermes.
//
@@ -199,6 +209,70 @@ class ChatHandler {
}
}
/**
* Append a SYSTEM-role notice bubble (e.g. a gateway interactive ask the
* phone can't answer). SYSTEM role keeps it out of the voice TTS observer
* and renders with the muted system styling in MessageBubble.
*/
fun addSystemNotice(text: String) {
_messages.update { list ->
val notice = ChatMessage(
id = "system-notice-${java.util.UUID.randomUUID()}",
role = MessageRole.SYSTEM,
content = text,
timestamp = System.currentTimeMillis(),
)
(list + notice).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
}
}
/**
* Append an assistant message that carries ONLY a gateway ask card
* (clarify / approval / sudo / secret). Local-only — the server never
* stores the ask as a message, so [loadMessageHistory] preserves the
* `ask-` id prefix the same way it preserves voice-intent traces.
* Idempotent on [messageId] so a re-emitted ask never duplicates.
*/
fun appendAskCardMessage(messageId: String, card: HermesCard) {
_messages.update { list ->
if (list.any { it.id == messageId }) return@update list
val msg = ChatMessage(
id = messageId,
role = MessageRole.ASSISTANT,
content = "",
timestamp = System.currentTimeMillis(),
cards = listOf(card),
agentName = activeAgentName,
)
(list + msg).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
}
}
/**
* Edit-and-regenerate local truncation: drop [messageId] and everything
* after it. The gateway performs the authoritative truncation via
* `truncate_before_user_ordinal`; this keeps the visible list consistent
* until the post-turn history reload reconciles any divergence.
*/
fun truncateMessagesFrom(messageId: String) {
_messages.update { list ->
val idx = list.indexOfFirst { it.id == messageId }
if (idx < 0) list else list.take(idx)
}
}
fun replaceMessageContent(messageId: String, content: String) {
_messages.update { messages ->
messages.map { message ->
if (message.id == messageId) {
message.copy(content = content)
} else {
message
}
}
}
}
/**
* Append a local-only voice-intent trace to the chat scroll. Used by
* the sideload voice intent flow (`RealVoiceBridgeIntentHandler`) so
@@ -371,6 +445,35 @@ class ChatHandler {
}
}
fun attachRealtimeTurnTrace(messageId: String, trace: RealtimeTurnTrace) {
_messages.update { messages ->
var changed = false
val mapped = messages.map { msg ->
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
changed = true
msg.copy(realtimeTurn = trace)
} else {
msg
}
}
if (changed) mapped else messages
}
}
fun markRealtimeTurnsSynced() {
_messages.update { messages ->
var changed = false
val mapped = messages.map { msg ->
val trace = msg.realtimeTurn
if (trace != null && !trace.syncedToServer) {
changed = true
msg.copy(realtimeTurn = trace.copy(syncedToServer = true))
} else msg
}
if (changed) mapped else messages
}
}
/**
* Reusable lenient JSON parser for tool-result previews. [Json { ... }]
* is cheap to construct but we share one instance so per-tool-completion
@@ -552,6 +655,32 @@ class ChatHandler {
activeAnnotationTools.clear()
cardLineBuffer.clear()
dispatchedCardMarkers.clear()
subagentLabels.clear()
}
/**
* Repair assistant labels after late-arriving agent config. History can
* load before GET /api/config returns, leaving default-profile messages
* with the generic "Hermes" label. Keep local phone/voice action trace
* labels intact because those bubbles do not represent the server agent.
*/
fun relabelGenericAssistantMessages(agentName: String?) {
val trimmed = agentName?.trim()?.takeIf { it.isNotBlank() } ?: return
_messages.update { list ->
list.map { message ->
if (
message.role == MessageRole.ASSISTANT &&
!message.id.startsWith("voice-intent-") &&
message.agentName != "Voice action" &&
message.agentName != "Phone action" &&
(message.agentName.isNullOrBlank() || message.agentName == "Hermes")
) {
message.copy(agentName = trimmed)
} else {
message
}
}
}
}
/**
@@ -667,6 +796,15 @@ class ChatHandler {
isStreaming = false,
toolCalls = toolCalls,
cards = extractedCards,
agentName = if (role == MessageRole.ASSISTANT) activeAgentName else null,
// Server persists per-message reasoning — restore it so the
// Thought-process block survives returning to the chat
// instead of existing only for the live turn.
thinkingContent = if (role == MessageRole.ASSISTANT) {
item.resolvedReasoning?.trim() ?: ""
} else {
""
},
)
}
@@ -687,8 +825,15 @@ class ChatHandler {
// sync (so these traces reach the LLM's session memory too) is
// still a v0.4.1 follow-up, but preserving them client-side is
// enough to fix the disappearing-scrollback bug today.
// Gateway-local bubbles ride the same preservation: steered text
// (id "steer-…") lives inside a server-side tool result, never as a
// user message, and ask cards (id "ask-…") are built from gateway
// events that have no server-side message at all — a wholesale
// reload would silently erase both.
val preservedVoiceTraces = _messages.value.filter {
it.id.startsWith("voice-intent-")
it.id.startsWith("voice-intent-") ||
it.id.startsWith("steer-") ||
it.id.startsWith("ask-")
}
val merged = if (preservedVoiceTraces.isEmpty()) {
loaded
@@ -865,18 +1010,38 @@ class ChatHandler {
* Update sessions list from API response.
*/
fun updateSessions(items: List<SessionItem>) {
_sessions.value = items.map { item ->
// If > 1e12, already in milliseconds; otherwise convert from seconds
val ts = item.startedAt ?: 0.0
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
val mapped = items.map { item ->
val startedAtMs = timestampToMillis(item.startedAt)
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
ChatSession(
sessionId = item.id,
title = item.title,
model = item.model,
messageCount = item.messageCount ?: 0,
updatedAt = timestampMs
updatedAt = activityAtMs,
startedAt = startedAtMs,
lastActivityAt = lastActivityAtMs,
)
}.sortedByDescending { it.activityTimestamp }
// Preserve the active session's optimistic row when the server list
// doesn't include it yet: a freshly created chat has 0 messages and the
// drawer's `min_messages=1` query filters it out until its first turn
// persists. Keeping the local row (its title/preview is already set)
// stops a new chat from vanishing from the drawer between creation and
// the first message. Once it has messages the server returns it and the
// id-match below replaces the optimistic copy.
val activeId = _currentSessionId.value
val pending = if (activeId != null && mapped.none { it.sessionId == activeId }) {
_sessions.value.firstOrNull { it.sessionId == activeId }
} else {
null
}
_sessions.value = if (pending != null) listOf(pending) + mapped else mapped
}
fun clearSessions() {
_sessions.value = emptyList()
}
/**
@@ -1527,18 +1692,46 @@ class ChatHandler {
return null
}
fun onToolCallStart(messageId: String, toolCallId: String, toolName: String) {
_isStreaming.value = true
fun setMessageBadges(messageId: String, badges: List<String>) {
val cleaned = badges
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.take(4)
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
msg.copy(badges = cleaned)
} else {
msg
}
}
}
}
val toolCall = ToolCall(
id = toolCallId,
name = toolName,
/** Monotonic suffix for synthetic generating / subagent ToolCall ids. */
private var syntheticToolSeq = 0
/**
* Gateway `tool.generating` — the model is still streaming this tool's
* arguments. Appends a quiet "preparing" placeholder ToolCall; the
* matching `tool.start` (same name, FIFO — see [onToolCallStart])
* adopts it so the preparing card and the running card stay one entry.
* Nameless events get a blank-name placeholder that the next start
* adopts as a fallback; any never-adopted placeholders are swept in
* [onStreamComplete].
*/
fun onToolGenerating(messageId: String, toolName: String?) {
_isStreaming.value = true
val placeholder = ToolCall(
id = "generating-${toolName ?: "tool"}-${syntheticToolSeq++}",
name = toolName ?: "",
args = null,
result = null,
success = null,
isComplete = false
isComplete = false,
isGenerating = true,
)
_messages.update { messages ->
val target = messages.findLast {
it.id == messageId && it.role == MessageRole.ASSISTANT
@@ -1546,7 +1739,7 @@ class ChatHandler {
if (target != null) {
messages.map { msg ->
if (msg.id == messageId) {
msg.copy(toolCalls = msg.toolCalls + toolCall)
msg.copy(toolCalls = msg.toolCalls + placeholder)
} else {
msg
}
@@ -1558,14 +1751,220 @@ class ChatHandler {
content = "",
timestamp = System.currentTimeMillis(),
isStreaming = true,
toolCalls = listOf(toolCall),
toolCalls = listOf(placeholder),
agentName = activeAgentName
)
}
}
}
fun onToolCallComplete(messageId: String, toolCallId: String, resultPreview: String? = null) {
fun onToolCallStart(
messageId: String,
toolCallId: String,
toolName: String,
runId: String? = null,
provenance: String? = null,
) {
_isStreaming.value = true
_messages.update { messages ->
val target = messages.findLast {
it.id == messageId && it.role == MessageRole.ASSISTANT
}
if (target != null) {
// Adopt a pending "preparing" placeholder for this name
// (or, failing that, the oldest nameless one) so the
// generating card flips to running in place instead of a
// second card appearing.
val genIdx = target.toolCalls
.indexOfFirst { it.isGenerating && !it.isComplete && it.name == toolName }
.takeIf { it >= 0 }
?: target.toolCalls
.indexOfFirst { it.isGenerating && !it.isComplete && it.name.isEmpty() }
.takeIf { it >= 0 }
messages.map { msg ->
if (msg.id != messageId) return@map msg
if (genIdx != null) {
val calls = msg.toolCalls.toMutableList()
calls[genIdx] = calls[genIdx].copy(
id = toolCallId,
name = toolName,
isGenerating = false,
// Execution starts now — preparing time isn't runtime.
startedAt = System.currentTimeMillis(),
runId = runId ?: calls[genIdx].runId,
provenance = provenance ?: calls[genIdx].provenance,
)
msg.copy(toolCalls = calls)
} else {
msg.copy(
toolCalls = msg.toolCalls + ToolCall(
id = toolCallId,
name = toolName,
args = null,
result = null,
success = null,
isComplete = false,
runId = runId,
provenance = provenance,
),
)
}
}
} else {
messages + ChatMessage(
id = messageId,
role = MessageRole.ASSISTANT,
content = "",
timestamp = System.currentTimeMillis(),
isStreaming = true,
toolCalls = listOf(
ToolCall(
id = toolCallId,
name = toolName,
args = null,
result = null,
success = null,
isComplete = false,
runId = runId,
provenance = provenance,
),
),
agentName = activeAgentName
)
}
}
}
// --- Gateway subagent lanes ---
/**
* Lane labels by task index, captured from `subagent.start` (goal
* truncated to 60 chars) and stamped onto every child ToolCall so
* [com.hermesandroid.relay.ui.components.SubagentLane] can render its
* header without a separate registry. Per-run state — cleared on
* [onStreamComplete] / [clearMessages].
*/
private val subagentLabels = mutableMapOf<Int, String>()
/**
* Apply one gateway `subagent.*` lifecycle event to the streaming
* message's tool calls. Mutation model mirrors the upstream child
* mirror: a TOOL event closes the lane's open tool then starts the new
* one; COMPLETE closes whatever is still open and (for a lane that
* never surfaced a tool) appends a single completed summary entry so
* the lane is visible in history. THINKING/PROGRESS carry preview text
* the lanes don't render — ignored.
*/
fun onSubagentEvent(messageId: String, event: GatewaySubagentEvent) {
val label = event.goal.trim().take(60).ifBlank { null }
when (event.phase) {
GatewaySubagentEvent.Phase.START -> {
if (label != null) subagentLabels[event.taskIndex] = label
}
GatewaySubagentEvent.Phase.TOOL -> {
_isStreaming.value = true
val laneLabel = subagentLabels[event.taskIndex] ?: label
val newCall = ToolCall(
id = "subagent-${event.taskIndex}-${syntheticToolSeq++}",
name = event.toolName?.takeIf { it.isNotBlank() } ?: "tool",
args = event.preview,
result = null,
success = null,
isComplete = false,
taskIndex = event.taskIndex,
taskLabel = laneLabel,
)
_messages.update { messages ->
val target = messages.findLast {
it.id == messageId && it.role == MessageRole.ASSISTANT
}
if (target != null) {
messages.map { msg ->
if (msg.id != messageId) return@map msg
val closed = msg.toolCalls.map { call ->
if (call.taskIndex == event.taskIndex && !call.isComplete) {
call.copy(
success = true,
isComplete = true,
completedAt = System.currentTimeMillis(),
)
} else {
call
}
}
msg.copy(toolCalls = closed + newCall)
}
} else {
messages + ChatMessage(
id = messageId,
role = MessageRole.ASSISTANT,
content = "",
timestamp = System.currentTimeMillis(),
isStreaming = true,
toolCalls = listOf(newCall),
agentName = activeAgentName
)
}
}
}
GatewaySubagentEvent.Phase.COMPLETE -> {
// "interrupted" lanes never finished — not a success either.
val failed = event.status == "failed" || event.status == "interrupted"
val laneLabel = subagentLabels.remove(event.taskIndex) ?: label
val summaryId = "subagent-${event.taskIndex}-${syntheticToolSeq++}"
_messages.update { messages ->
messages.map { msg ->
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
val hasLaneCalls = msg.toolCalls.any { it.taskIndex == event.taskIndex }
val closed = msg.toolCalls.map { call ->
if (call.taskIndex == event.taskIndex && !call.isComplete) {
call.copy(
success = !failed,
isComplete = true,
result = event.summary ?: call.result,
error = if (failed) (event.summary ?: event.status) else call.error,
completedAt = System.currentTimeMillis(),
)
} else {
call
}
}
val withSummary = if (hasLaneCalls) {
closed
} else {
closed + ToolCall(
id = summaryId,
name = laneLabel ?: "subagent",
args = null,
result = event.summary,
success = !failed,
isComplete = true,
error = if (failed) (event.summary ?: event.status) else null,
completedAt = System.currentTimeMillis(),
taskIndex = event.taskIndex,
taskLabel = laneLabel,
)
}
msg.copy(toolCalls = withSummary)
}
}
}
GatewaySubagentEvent.Phase.THINKING,
GatewaySubagentEvent.Phase.PROGRESS,
-> Unit
}
}
fun onToolCallComplete(
messageId: String,
toolCallId: String,
resultPreview: String? = null,
provenance: String? = null,
) {
// Snapshot the matching tool call's name BEFORE mutating — we need it
// to decide whether to emit a phone-action result bubble below.
val toolName = _messages.value
@@ -1583,6 +1982,7 @@ class ChatHandler {
success = true,
isComplete = true,
result = resultPreview ?: call.result,
provenance = provenance ?: call.provenance,
completedAt = System.currentTimeMillis()
)
} else {
@@ -1715,6 +2115,19 @@ class ChatHandler {
// Finalize media markers unconditionally
finalizeMediaMarkers(messageId)
finalizeCardMarkers(messageId)
// Sweep "preparing" placeholders whose tool.start never arrived —
// they never executed and would otherwise breathe forever.
_messages.update { messages ->
messages.map { msg ->
if (msg.toolCalls.none { it.isGenerating && !it.isComplete }) {
msg
} else {
msg.copy(toolCalls = msg.toolCalls.filterNot { it.isGenerating && !it.isComplete })
}
}
}
subagentLabels.clear()
}
fun onStreamError(message: String) {
@@ -1728,6 +2141,19 @@ class ChatHandler {
} else msg
}
}
// Same sweep as onStreamComplete — error/watchdog/transport-failure
// turns must not leave "preparing" placeholders breathing forever.
_messages.update { messages ->
messages.map { msg ->
if (msg.toolCalls.none { it.isGenerating && !it.isComplete }) {
msg
} else {
msg.copy(toolCalls = msg.toolCalls.filterNot { it.isGenerating && !it.isComplete })
}
}
}
subagentLabels.clear()
}
fun onThinkingDelta(messageId: String, delta: String) {
@@ -16,6 +16,7 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.time.Instant
/**
* Models for the Hermes /api/sessions REST API.
@@ -75,12 +76,50 @@ object FlexibleIdNonNullSerializer : KSerializer<String> {
}
}
/** Timestamp serializer for Hermes session metadata.
*
* Upstream currently returns epoch seconds for `started_at` / `last_active`;
* some documented surfaces use ISO strings for update-style fields. Decode
* both into epoch seconds so callers can convert once at the UI boundary.
*/
@OptIn(ExperimentalSerializationApi::class)
object FlexibleTimestampSerializer : KSerializer<Double?> {
override val descriptor = PrimitiveSerialDescriptor("FlexibleTimestamp", PrimitiveKind.DOUBLE)
override fun deserialize(decoder: Decoder): Double? {
return try {
val jsonDecoder = decoder as? JsonDecoder
?: return decoder.decodeDouble()
val element = jsonDecoder.decodeJsonElement()
when (element) {
is JsonNull -> null
is JsonPrimitive -> parseTimestamp(element.content)
else -> null
}
} catch (_: Exception) {
null
}
}
override fun serialize(encoder: Encoder, value: Double?) {
if (value != null) encoder.encodeDouble(value) else encoder.encodeNull()
}
private fun parseTimestamp(raw: String): Double? {
val trimmed = raw.trim()
if (trimmed.isBlank()) return null
trimmed.toDoubleOrNull()?.let { return it }
return runCatching { Instant.parse(trimmed).toEpochMilli() / 1000.0 }.getOrNull()
}
}
// --- Session CRUD responses ---
@Serializable
data class SessionListResponse(
val items: List<SessionItem>? = null,
val sessions: List<SessionItem>? = null, // alternate key
val data: List<SessionItem>? = null, // upstream /api/sessions list envelope
val total: Int? = null
)
@@ -101,18 +140,38 @@ data class SessionItem(
val title: String? = null,
val model: String? = null,
val source: String? = null,
@SerialName("started_at") val startedAt: Double? = null,
@SerialName("ended_at") val endedAt: Double? = null,
@SerialName("started_at")
@Serializable(with = FlexibleTimestampSerializer::class)
val startedAt: Double? = null,
@SerialName("ended_at")
@Serializable(with = FlexibleTimestampSerializer::class)
val endedAt: Double? = null,
@SerialName("last_active")
@Serializable(with = FlexibleTimestampSerializer::class)
val lastActive: Double? = null,
@SerialName("last_activity")
@Serializable(with = FlexibleTimestampSerializer::class)
val lastActivity: Double? = null,
@SerialName("last_activity_at")
@Serializable(with = FlexibleTimestampSerializer::class)
val lastActivityAt: Double? = null,
@SerialName("updated_at")
@Serializable(with = FlexibleTimestampSerializer::class)
val updatedAt: Double? = null,
@SerialName("message_count") val messageCount: Int? = null,
@SerialName("tool_call_count") val toolCallCount: Int? = null,
@SerialName("input_tokens") val inputTokens: Int? = null,
@SerialName("output_tokens") val outputTokens: Int? = null
)
) {
val resolvedLastActivity: Double?
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
}
@Serializable
data class CreateSessionRequest(
val title: String? = null,
val model: String? = null
val model: String? = null,
val profile: String? = null,
)
@Serializable
@@ -126,6 +185,7 @@ data class RenameSessionRequest(
data class MessageListResponse(
val items: List<MessageItem>? = null,
val messages: List<MessageItem>? = null, // alternate key
val data: List<MessageItem>? = null, // upstream /api/sessions/{id}/messages list envelope
val total: Int? = null
)
@@ -144,8 +204,19 @@ data class MessageItem(
@Serializable(with = FlexibleIdSerializer::class)
val toolCallId: String? = null,
val timestamp: Double? = null,
@SerialName("finish_reason") val finishReason: String? = null
@SerialName("finish_reason") val finishReason: String? = null,
// Reasoning persisted with the assistant message (upstream serializes
// both names; reasoning is the canonical one). Restored into
// ChatMessage.thinkingContent so the Thought-process block survives a
// return to the chat instead of existing only for the live turn.
val reasoning: String? = null,
@SerialName("reasoning_content") val reasoningContent: String? = null,
) {
/** Reasoning text under whichever field name the server used. */
val resolvedReasoning: String?
get() = reasoning?.takeIf { it.isNotBlank() }
?: reasoningContent?.takeIf { it.isNotBlank() }
/** Extract content as plain text string. Handles both string and array-of-parts formats. */
val contentText: String?
get() = when (content) {
@@ -273,7 +344,13 @@ data class UsageInfo(
@SerialName("completion_tokens") val completionTokens: Int? = null,
// Cache tokens
@SerialName("cache_creation_input_tokens") val cacheCreationInputTokens: Int? = null,
@SerialName("cache_read_input_tokens") val cacheReadInputTokens: Int? = null
@SerialName("cache_read_input_tokens") val cacheReadInputTokens: Int? = null,
// Gateway context-window block (session-cumulative; present only when the
// server's context compressor is active — upstream _get_usage()). Render
// context UI only when contextMax is non-null.
@SerialName("context_used") val contextUsed: Int? = null,
@SerialName("context_max") val contextMax: Int? = null,
@SerialName("context_percent") val contextPercent: Int? = null
) {
/** Resolved input tokens — prefers Hermes naming, falls back to OpenAI. */
val resolvedInputTokens: Int? get() = inputTokens ?: promptTokens
@@ -299,5 +376,6 @@ data class SkillInfo(
@Serializable
data class SkillListResponse(
val skills: List<SkillInfo>? = null,
val items: List<SkillInfo>? = null
val items: List<SkillInfo>? = null,
val data: List<SkillInfo>? = null
)
@@ -0,0 +1,148 @@
package com.hermesandroid.relay.notifications
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
/**
* One-shot "Hermes finished responding" notification — posted from
* ChatViewModel's turn-complete path when the app is backgrounded, never
* for cancelled streams or errors. Structural twin of
* [com.hermesandroid.relay.bridge.AutoDisableWorker]'s notification half:
* same channel-ensure, permission-gate, and tap-intent anatomy.
*
* One stable slot ([NOTIFICATION_ID]) — chat is one stream, so the latest
* turn replaces any prior notification rather than stacking noise. The
* caller cancels it via [cancel] when the user returns to the app
* (MainActivity onResume).
*
* Tap routes through the existing cross-layer deep-link path: the intent
* carries [MainActivity.EXTRA_NAV_ROUTE] → MainActivity pumps it onto
* NavRouteRequest → RelayApp's collector navigates.
*/
object TurnCompleteNotifier {
private const val TAG = "TurnCompleteNotifier"
private const val CHANNEL_ID = "chat_turn_complete"
private const val CHANNEL_NAME = "Hermes replies"
const val NOTIFICATION_ID = 3822
/**
* Compose nav route for the Chat tab. Hardcoded on purpose to avoid
* pulling the ui.RelayApp graph into the notifications classpath — if
* Screen.Chat.route() changes in RelayApp.kt, change it here too.
* (Same convention as BridgeForegroundService's settings deep-link.)
*/
private const val CHAT_ROUTE = "chat"
/**
* Post (or replace) the turn-complete notification.
*
* @param agentName Display name for the title; blank falls back to
* "Hermes".
* @param responseText Final assistant text — collapsed line is the
* first 120 chars, BigTextStyle expands to 400.
* @param toolCount Number of tool calls the turn ran; 0 hides the
* subText line.
* @param durationSeconds Wall-clock turn duration for the subText
* ("3 tools · 42s"); null renders the count alone.
*/
// Lint can't trace through [hasPostNotificationsPermission] to see that
// we early-return when the runtime grant isn't held, and the notify()
// call is also wrapped in runCatching to swallow SecurityException as
// a belt-and-braces. Suppress here rather than inlining the check —
// the helper exists so the same gate can grow more conditions later
// without each call site re-implementing it. Both IDs are needed:
// `NotificationPermission` is the notify()-specific check (POST_NOTIFICATIONS
// on API 33+); `MissingPermission` is the generic fallback.
@SuppressLint("MissingPermission", "NotificationPermission")
fun notifyTurnComplete(
context: Context,
agentName: String?,
responseText: String,
toolCount: Int = 0,
durationSeconds: Long? = null,
) {
ensureChannel(context)
if (!hasPostNotificationsPermission(context)) {
Log.i(TAG, "POST_NOTIFICATIONS not granted — skipping turn-complete notification")
return
}
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(MainActivity.EXTRA_NAV_ROUTE, CHAT_ROUTE)
}
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val tapPending = PendingIntent.getActivity(context, 0, tapIntent, pendingFlags)
val title = agentName?.takeIf { it.isNotBlank() } ?: "Hermes"
val collapsed = responseText.take(120)
val expanded = responseText.take(400)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(collapsed)
.setStyle(NotificationCompat.BigTextStyle().bigText(expanded))
.setContentIntent(tapPending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
if (toolCount > 0) {
val tools = "$toolCount tool${if (toolCount == 1) "" else "s"}"
builder.setSubText(
durationSeconds?.let { "$tools · ${it}s" } ?: tools
)
}
runCatching {
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
}.onFailure { Log.w(TAG, "notifyTurnComplete: notify failed", it) }
}
/** Clear the slot — call from MainActivity.onResume so returning to the app dismisses it. */
fun cancel(context: Context) {
runCatching {
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}.onFailure { Log.w(TAG, "cancel: failed", it) }
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = context.getSystemService(NotificationManager::class.java) ?: return
val existing = nm.getNotificationChannel(CHANNEL_ID)
if (existing != null) return
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Notifies when Hermes finishes responding while the app is in the background."
// Unlike bridge_auto_disable, a reply badge is desirable.
setShowBadge(true)
}
nm.createNotificationChannel(channel)
}
private fun hasPostNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
}
File diff suppressed because it is too large Load Diff
@@ -94,30 +94,21 @@ import kotlinx.coroutines.launch
*/
/**
* Three tappable status rows (API / Relay / Session), always visible on
* the active card. Replaces the old "Active Connection" quick-look card
* that used to live at the top of `SettingsScreen` — same information
* density, same tap-for-info-sheet behavior.
*
* Tap on the Relay row while it's [RelayUiState.Stale] fires an immediate
* reconnect + toast; every other row falls through to the info sheet
* target via [onOpenApiInfo] / [onOpenRelayInfo] / [onOpenSessionInfo].
* Standard Hermes status rows (API / Dashboard). Dashboard auth is surfaced
* here so users do not have to open Manage just to discover sign-in is needed.
*/
@Composable
fun ActiveCardStatusSection(
fun ActiveCardStandardStatusSection(
connectionViewModel: ConnectionViewModel,
relayEnabled: Boolean,
onOpenApiInfo: () -> Unit,
onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit,
onOpenDashboard: () -> Unit,
) {
val context = LocalContext.current
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val dashboardStatus = activeConnection?.dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
ConnectionStatusRow(
label = "API Server",
@@ -132,46 +123,94 @@ fun ActiveCardStatusSection(
modifier = Modifier.fillMaxWidth(),
)
if (relayEnabled) {
// ADR 24: relayRowState carries both the phase and the active
// endpoint role. statusText appends " · <Role>" when the
// resolver has picked one, so the chip reads "Connected · LAN"
// etc. without any extra wiring here.
ConnectionStatusRow(
label = "Relay",
state = relayRowState.asBadgeState(),
statusText = relayRowState.statusText(connectedLabel = "Connected"),
onClick = {
if (relayUiState == RelayUiState.Stale) {
connectionViewModel.connectRelay()
Toast.makeText(
context,
"Reconnecting to relay…",
Toast.LENGTH_SHORT,
).show()
} else {
onOpenRelayInfo()
}
},
modifier = Modifier.fillMaxWidth(),
)
ConnectionStatusRow(
label = "Dashboard",
isConnected = dashboardStatus?.reachable == true && !dashboardSignInRequired,
statusText = when {
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> "Not configured"
dashboardStatus == null -> "Not checked"
!dashboardStatus.reachable -> "Unreachable"
dashboardSignInRequired -> "Sign-in required"
dashboardStatus.authenticated == true -> "Signed in"
dashboardStatus.authRequired == false -> "Available"
else -> "Available"
},
onClick = onOpenDashboard,
modifier = Modifier.fillMaxWidth(),
)
ConnectionStatusRow(
label = "Session",
isConnected = authState is AuthState.Paired,
isConnecting = authState is AuthState.Pairing,
statusText = when (authState) {
is AuthState.Paired -> "Paired"
is AuthState.Pairing -> "Pairing..."
is AuthState.Unpaired -> "Unpaired"
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
},
onClick = onOpenSessionInfo,
if (dashboardSignInRequired) {
Row(
modifier = Modifier.fillMaxWidth(),
)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Dashboard controls need a sign-in for this route.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onOpenDashboard) {
Text("Sign in")
}
}
}
}
/**
* Optional Relay status rows (transport / paired session). Kept separate from
* [ActiveCardStandardStatusSection] so API/dashboard setup does not visually
* read as incomplete when Relay is not paired.
*/
@Composable
fun ActiveCardRelayStatusSection(
connectionViewModel: ConnectionViewModel,
onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit,
) {
val context = LocalContext.current
val authState by connectionViewModel.authState.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
// ADR 24: relayRowState carries both the phase and the active endpoint
// role. statusText appends " · <Role>" when the resolver has picked one.
ConnectionStatusRow(
label = "Relay",
state = relayRowState.asBadgeState(),
statusText = relayRowState.statusText(connectedLabel = "Connected"),
onClick = {
if (relayUiState == RelayUiState.Stale) {
connectionViewModel.connectRelay()
Toast.makeText(
context,
"Reconnecting to relay…",
Toast.LENGTH_SHORT,
).show()
} else {
onOpenRelayInfo()
}
},
modifier = Modifier.fillMaxWidth(),
)
ConnectionStatusRow(
label = "Session",
isConnected = authState is AuthState.Paired,
isConnecting = authState is AuthState.Pairing,
statusText = when (authState) {
is AuthState.Paired -> "Paired"
is AuthState.Pairing -> "Pairing..."
is AuthState.Unpaired -> "Unpaired"
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
},
onClick = onOpenSessionInfo,
modifier = Modifier.fillMaxWidth(),
)
}
/**
* Advanced expandable section — three subsections:
* - Manual URL configuration (API URL + key + Save & Test,
@@ -326,7 +365,7 @@ private fun ManualUrlSubsection(
) { result ->
isTestingApi = false
apiVoiceSetupResult = result
if (!result.voiceConfigReachable && result.relayAutoDerived) {
if (!result.voiceConfigReachable && result.voiceRoute == "relay" && result.relayAutoDerived) {
relayOverrideVisible = true
result.relayUrl?.let { relayUrlInput = it }
}
@@ -334,9 +373,13 @@ private fun ManualUrlSubsection(
context,
when {
result.apiReachable && result.voiceConfigReachable ->
"API and voice relay reachable"
if (result.voiceRoute == "standard") {
"API and standard voice reachable"
} else {
"API and relay voice reachable"
}
result.apiReachable ->
"API reachable; relay URL needs review"
"API reachable; voice route needs review"
else -> "Cannot reach API server"
},
Toast.LENGTH_SHORT,
@@ -373,7 +416,7 @@ private fun ManualUrlSubsection(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Voice uses the relay's /voice routes. The app derives this from the API host unless a custom route is needed.",
text = "Relay is optional for voice. Standard voice uses the Hermes API; Relay voice uses this route when selected or needed.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -403,7 +446,7 @@ private fun ManualUrlSubsection(
placeholder = { Text("wss://your-server:8767") },
singleLine = true,
supportingText = {
Text("Only needed when Auto cannot reach /voice/config")
Text("Only needed when the optional Relay route cannot be auto-derived")
},
modifier = Modifier.fillMaxWidth(),
)
@@ -417,9 +460,13 @@ private fun ManualUrlSubsection(
}
Text(
text = if (result.voiceConfigReachable) {
"Voice ready via ${result.relayUrl ?: "relay"}"
if (result.voiceRoute == "standard") {
"Voice ready via standard Hermes API"
} else {
"Voice ready via ${result.relayUrl ?: "relay"}"
}
} else {
"Relay URL required: ${result.voiceConfigError ?: "voice config probe failed"}"
"Voice route needs review: ${result.voiceConfigError ?: "voice config probe failed"}"
},
style = MaterialTheme.typography.bodySmall,
color = color,
@@ -574,7 +621,7 @@ private fun InsecureToggleSubsection(
* the QR scanner isn't usable (no camera, headless host, bad lighting).
*
* 1. Copy the phone-generated code (with Refresh to regenerate)
* 2. Run `hermes-pair --register-code <code>` on the host
* 2. Run `hermes pair --register-code <code>` on the host
* 3. Tap Connect — with a 15s auth watcher that surfaces success /
* failure through the global snackbar host
*
@@ -696,7 +743,7 @@ private fun ManualPairingCodeSubsection(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Text(
text = "hermes-pair --register-code $pairingCode",
text = "hermes pair --register-code $pairingCode",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
@@ -705,10 +752,10 @@ private fun ManualPairingCodeSubsection(
)
IconButton(
onClick = {
val cmd = "hermes-pair --register-code $pairingCode"
val cmd = "hermes pair --register-code $pairingCode"
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("hermes-pair command", cmd)),
ClipEntry(ClipData.newPlainText("hermes pair command", cmd)),
)
snackbarHost.showSnackbar("Command copied")
}
@@ -717,7 +764,7 @@ private fun ManualPairingCodeSubsection(
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy hermes-pair command",
contentDescription = "Copy hermes pair command",
modifier = Modifier.size(16.dp),
)
}
@@ -783,9 +830,9 @@ private fun ManualPairingCodeSubsection(
text = "This is a fallback for when you can't scan the pairing QR " +
"— for example, no camera, the host can't render a QR, or you " +
"only have SSH access from a single device. The canonical flow " +
"is the QR scan from `/hermes-relay-pair` or `hermes-pair`.\n\n" +
"is the QR scan from `/hermes-relay-pair` or `hermes pair`.\n\n" +
"How it works: the phone generates a 6-character code locally. " +
"You paste that code into the host's `hermes-pair --register-code` " +
"You paste that code into the host's `hermes pair --register-code` " +
"command, which pre-registers it with the relay. When you tap " +
"Connect here, the phone presents the same code to the relay " +
"and gets a long-lived session token in return.\n\n" +
@@ -44,7 +44,7 @@ import com.hermesandroid.relay.viewmodel.BridgeStatus
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Visual style mirrors the status
* cards in `PairedDevicesScreen`: surfaceVariant background, 16dp padding,
* 10dp row spacing. Uses [ConnectionStatusBadge] for the pulsing status dot
* so the Bridge tab looks visually consistent with the Settings → Connection
* so the Bridge tab looks visually consistent with the Settings → Connections
* section.
*
* The headline switch is `enabled = allowEnable` so users can't flip it on
@@ -28,7 +28,7 @@ import com.hermesandroid.relay.viewmodel.BridgeStatus
* Phase 3 Wave 1 — bridge-ui (`bridge-screen-ui`). Kept distinct from
* [BridgeMasterToggle] so that Agent safety-rails in Wave 2 can relocate the master
* toggle without losing the status surface (and so we can reuse this card
* in the Settings → Connection section later if desired).
* in the Settings → Connections section later if desired).
*/
@Composable
fun BridgeStatusCard(
@@ -0,0 +1,197 @@
package com.hermesandroid.relay.ui.components
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.hermesandroid.relay.util.MediaSaver
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
/**
* One markdown image reference (`![alt](src)`) pulled out of an assistant
* message so it can be rendered as a real image (or a graceful inline notice)
* instead of the empty/blank element the markdown renderer produces for it.
*/
data class ChatInlineImage(val alt: String, val src: String)
// `![alt](src)` and `![alt](src "title")`. src = first non-space, non-`)` run.
private val MARKDOWN_IMAGE_REGEX = Regex("""!\[([^\]]*)]\(([^)\s]+)[^)]*\)""")
/**
* Split assistant [content] into (markdown body without image links, parsed
* images). The `![...]()` token is removed from the body so it doesn't render
* as a broken/empty element; surrounding prose is preserved.
*/
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
if (!content.contains("![")) return content to emptyList()
val images = mutableListOf<ChatInlineImage>()
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
images += ChatInlineImage(alt = m.groupValues[1].trim(), src = m.groupValues[2].trim())
""
}
if (images.isEmpty()) return content to emptyList()
// Collapse the blank lines the removal can leave behind.
return stripped.replace(Regex("\n{3,}"), "\n\n").trim() to images
}
private fun ChatInlineImage.isRemote(): Boolean {
val s = src.lowercase()
return s.startsWith("http://") || s.startsWith("https://")
}
/**
* Render generated/inline images for an assistant bubble. Remote `http(s)`
* URLs load via Coil with loading/error states; anything else (a server-local
* file path, `file://`, a relative path) degrades to an inline notice that
* explains WHY it can't be shown rather than rendering blank.
*/
@Composable
fun ChatInlineImages(
images: List<ChatInlineImage>,
modifier: Modifier = Modifier,
maxWidth: Dp = 280.dp,
) {
if (images.isEmpty()) return
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
images.forEach { image ->
if (image.isRemote()) {
RemoteChatImage(image, maxWidth)
} else {
UnrenderableImageNotice(image)
}
}
}
}
@Composable
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
var viewerOpen by remember { mutableStateOf(false) }
if (viewerOpen) {
ChatImageViewer(
source = ChatImageViewerSource.Coil(
model = image.src,
displayName = image.alt.ifBlank { "image" },
mime = "image/*",
bytesProvider = { MediaSaver.fetchRemoteBytes(image.src).first },
),
onDismiss = { viewerOpen = false },
)
}
SubcomposeAsyncImage(
model = image.src,
contentDescription = image.alt.ifBlank { "Generated image" },
contentScale = ContentScale.Fit,
modifier = Modifier
.widthIn(max = maxWidth)
.heightIn(max = 360.dp)
.clip(RoundedCornerShape(12.dp))
.clickable { viewerOpen = true },
) {
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Loading -> Box(
modifier = Modifier
.widthIn(max = maxWidth)
.height(120.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
}
// Error / Empty — couldn't load. Offer to open it externally.
else -> UnrenderableImageNotice(image, reason = "Couldn't load this image.")
}
}
}
@Composable
private fun UnrenderableImageNotice(
image: ChatInlineImage,
reason: String = "This image is on the server and can't be shown here.",
) {
val context = LocalContext.current
val remote = image.isRemote()
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier
.widthIn(max = 280.dp)
.then(
if (remote) {
Modifier.clickable {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, image.src.toUri()))
}
}
} else {
Modifier
},
),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
imageVector = if (remote) Icons.Filled.BrokenImage else Icons.Outlined.Image,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = image.alt.ifBlank { "Image" },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = if (remote) "Tap to open · ${image.src}" else "$reason\n${image.src}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -0,0 +1,222 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.compose.AsyncImage
import com.hermesandroid.relay.util.MediaSaver
import kotlinx.coroutines.launch
/**
* What the [ChatImageViewer] displays and how it obtains bytes for Save/Share.
*
* Display and byte-acquisition are decoupled on purpose: a remote image
* displays straight from its URL via Coil but fetches bytes over HTTP, while an
* inbound attachment displays from an already-decoded [ImageBitmap] but reads
* its original bytes back from the cached `content://` URI — so Save preserves
* the real file, not a re-encode.
*/
sealed interface ChatImageViewerSource {
val displayName: String
val mime: String
/** Returns the bytes to save/share, or null if they can't be obtained. */
val bytesProvider: suspend () -> ByteArray?
/** Display via Coil from any Coil-native model (URL string, content:// Uri). */
data class Coil(
val model: Any,
override val displayName: String,
override val mime: String,
override val bytesProvider: suspend () -> ByteArray?,
) : ChatImageViewerSource
/** Display from an already-decoded bitmap (outbound / cached attachments). */
data class Bitmap(
val bitmap: ImageBitmap,
override val displayName: String,
override val mime: String,
override val bytesProvider: suspend () -> ByteArray?,
) : ChatImageViewerSource
}
/**
* Full-screen image viewer dialog: pinch-to-zoom + pan (double-tap to toggle
* 1×/2.5×), with overlaid Share / Save / Close controls. Save lands in
* `Pictures/Hermes-Relay` on Android 10+; on older versions (or any failure
* path) it falls back to the share sheet via [MediaSaver].
*/
@Composable
fun ChatImageViewer(
source: ChatImageViewerSource,
onDismiss: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var busy by remember { mutableStateOf(false) }
val gestureModifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, 6f)
offset = if (scale > 1f) offset + pan else Offset.Zero
}
}
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (scale > 1f) {
scale = 1f
offset = Offset.Zero
} else {
scale = 2.5f
}
},
)
}
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.94f)),
contentAlignment = Alignment.Center,
) {
when (source) {
is ChatImageViewerSource.Coil -> AsyncImage(
model = source.model,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
is ChatImageViewerSource.Bitmap -> Image(
bitmap = source.bitmap,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
}
if (busy) {
CircularProgressIndicator(color = Color.White)
}
// Control bar — top-right, inset past the status bar / notch.
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
busy = false
if (bytes == null) {
toast(context, "Couldn't load this image")
return@launch
}
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
MediaSaver.share(context, uri, source.mime)
}
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = "Share")
}
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
if (bytes == null) {
busy = false
toast(context, "Couldn't load this image")
return@launch
}
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
toast(context, "Saved to ${result.location}")
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, "Save failed: ${result.message}")
}
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = "Save")
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
}
}
private fun toast(context: android.content.Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@@ -0,0 +1,528 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
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.SolidColor
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.purpleGlow
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
import kotlinx.coroutines.delay
/**
* What the single trailing slot of the input bar renders. The caller
* derives the state — the bar never widens, it morphs:
*
* ```
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
* !isStreaming -> VOICE // GraphicEq, primary
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
* canSteer (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
* else -> QUEUE // Send glyph + clock badge, tertiary
* ```
*/
enum class ChatInputTrailing { SEND, VOICE, STOP, STEER, QUEUE }
private val ChatComposerShape = RoundedCornerShape(18.dp)
private val ChatInputChipShape = RoundedCornerShape(12.dp)
data class ChatInputPickerOption(
val label: String,
val value: String?,
val provider: String? = null,
val secondary: String? = null,
val group: String? = null,
val selected: Boolean = false,
val enabled: Boolean = true,
)
data class ChatInputPickerControl(
val value: String,
val contentDescription: String,
val options: List<ChatInputPickerOption>,
val enabled: Boolean = true,
)
/**
* The minimal Telegram-style chat input bar — 3 elements, one trailing
* button. Replaces ChatScreen's Row of attach / slash / OutlinedTextField /
* Stop / smart-swap.
*
* - "+" tap = file picker ([onAttach]); long-press = CommandPalette
* ([onLongPressAttach]) — the app's quiet-gesture idiom. The dedicated
* slash button is gone; typing "/" still surfaces InlineAutocomplete.
* - Pill [BasicTextField] (surfaceContainerHigh, hairline border, grows
* to 5 lines) instead of OutlinedTextField chrome.
* - ONE trailing slot morphing through [ChatInputTrailing] with
* [AnimatedContent] — Stop stops being a separate slot so the bar never
* widens during streaming.
* - Char counter as a tiny mono overline above the bar (Amber, Danger at
* the limit) only when length > [charLimit] - 200 — supportingText
* reflows the bar, the overline doesn't.
* - [caption] renders a single relayMetadataStyle line above the bar
* (steer/queue hinting during streaming-with-text); Cyan when the slot
* is STEER, muted otherwise. Null collapses the row.
* - Voice: GraphicEq glyph ("voice session", not "record"); when
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
* ("needs setup" reads intentional, not broken) and the tap still goes
* to [onVoice] for the route-specific toast. [showVoiceHint] one-shot
* floats the "Live voice conversation" pill above the button for ~3s
* (DataStore flag owned by the caller, consumed via [onVoiceHintShown]).
* - [purpleGlow] on the trailing button (dark theme only) when it is an
* enabled SEND — the bar's one flourish, exactly as before.
*
* [onSend] fires for SEND, STEER, and QUEUE — the caller already encoded
* the meaning in the state it passed; [onVoice]/[onStop] for theirs.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatInputBar(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
trailing: ChatInputTrailing,
onSend: () -> Unit,
onVoice: () -> Unit,
onStop: () -> Unit,
onAttach: () -> Unit,
onLongPressAttach: () -> Unit,
charLimit: Int,
caption: String?,
voiceReady: Boolean,
showVoiceHint: Boolean,
onVoiceHintShown: () -> Unit,
isDarkTheme: Boolean,
modelControl: ChatInputPickerControl? = null,
onModelOptionSelected: (ChatInputPickerOption) -> Unit = {},
effortControl: ChatInputPickerControl? = null,
onEffortOptionSelected: (ChatInputPickerOption) -> Unit = {},
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
// Keep the last caption around so the AnimatedVisibility exit doesn't
// flash an empty line while collapsing.
var lastCaption by remember { mutableStateOf<String?>(null) }
LaunchedEffect(caption) {
if (caption != null) lastCaption = caption
}
// One-shot voice hint. Consumed-flag locally so flipping the DataStore
// flag (via onVoiceHintShown) can't restart-cancel the visible window;
// the hide timer is keyed on visibility alone so trailing-state morphs
// mid-delay don't strand the pill.
var hintVisible by remember { mutableStateOf(false) }
var hintConsumed by remember { mutableStateOf(false) }
LaunchedEffect(showVoiceHint, trailing) {
if (showVoiceHint && !hintConsumed && trailing == ChatInputTrailing.VOICE) {
hintConsumed = true
hintVisible = true
onVoiceHintShown()
}
}
LaunchedEffect(hintVisible) {
if (hintVisible) {
delay(3_000)
hintVisible = false
}
}
Column(modifier = modifier.fillMaxWidth()) {
// Caption row — steer/queue hinting, single line, no buttons.
AnimatedVisibility(visible = caption != null) {
Text(
text = caption ?: lastCaption.orEmpty(),
style = relayMetadataStyle(),
color = if (trailing == ChatInputTrailing.STEER) {
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.9f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp),
)
}
// Voice hint pill — floats above the trailing button.
AnimatedVisibility(
visible = hintVisible,
modifier = Modifier
.align(Alignment.End)
.padding(end = 12.dp, bottom = 2.dp),
enter = fadeIn(),
exit = fadeOut(),
) {
Text(
text = "Live voice conversation",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f))
.padding(horizontal = 12.dp, vertical = 5.dp),
)
}
// Char counter overline — same near-limit threshold as before, but
// it no longer reflows the bar.
if (value.length > charLimit - 200) {
Text(
text = "${value.length}/$charLimit",
style = relayMetadataStyle(),
color = if (value.length >= charLimit) RelayRefresh.Danger else RelayRefresh.Amber,
modifier = Modifier
.align(Alignment.End)
.padding(end = 16.dp, bottom = 2.dp),
)
}
Surface(
shape = ChatComposerShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
) {
BasicTextField(
value = value,
onValueChange = { if (it.length <= charLimit) onValueChange(it) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 34.dp)
.padding(horizontal = 8.dp, vertical = 4.dp),
maxLines = 5,
enabled = enabled,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { inner ->
Box(Modifier.fillMaxWidth()) {
if (value.isEmpty()) {
Text(
text = placeholder,
style = MaterialTheme.typography.bodyLarge,
color = RelayRefresh.Dim,
)
}
inner()
}
},
)
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 40.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// "+" tap attaches, long-press opens the command palette.
Box(
modifier = Modifier
.size(38.dp)
.clip(CircleShape)
.combinedClickable(
onClick = onAttach,
onLongClick = onLongPressAttach,
onLongClickLabel = "Browse commands",
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Attach file; hold for commands",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (modelControl != null) {
ChatInputPickerChip(
control = modelControl,
onSelect = onModelOptionSelected,
modifier = Modifier.widthIn(max = 126.dp),
)
}
if (effortControl != null) {
ChatInputPickerChip(
control = effortControl,
onSelect = onEffortOptionSelected,
modifier = Modifier.widthIn(max = 104.dp),
)
}
Spacer(modifier = Modifier.weight(1f))
// Trailing slot
val glow = trailing == ChatInputTrailing.SEND && enabled && isDarkTheme
Box(
modifier = if (glow) {
Modifier.purpleGlow(radius = 24.dp, alpha = 0.35f, isDarkTheme = true)
} else {
Modifier
},
) {
AnimatedContent(
targetState = trailing,
transitionSpec = {
(fadeIn(tween(150)) + scaleIn(initialScale = 0.8f))
.togetherWith(fadeOut(tween(100)))
},
label = "chatInputTrailing",
) { state ->
when (state) {
ChatInputTrailing.SEND -> IconButton(
onClick = onSend,
enabled = enabled,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send message",
tint = if (enabled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
ChatInputTrailing.VOICE -> Box {
IconButton(onClick = onVoice) {
Icon(
imageVector = Icons.Filled.GraphicEq,
contentDescription = if (voiceReady) "Start voice conversation"
else "Voice conversation — setup needed",
tint = MaterialTheme.colorScheme.primary,
)
}
// "Needs setup" badge — full-alpha button + Amber
// dot instead of a half-dimmed broken-looking mic.
if (!voiceReady) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 8.dp, end = 8.dp)
.size(6.dp)
.clip(CircleShape)
.background(RelayRefresh.Amber),
)
}
}
ChatInputTrailing.STOP -> IconButton(onClick = onStop) {
Box(
modifier = Modifier
.size(32.dp)
.border(1.dp, MaterialTheme.colorScheme.error, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = "Stop streaming",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
}
}
ChatInputTrailing.STEER -> IconButton(
onClick = onSend,
enabled = enabled,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Steer the response",
tint = MaterialTheme.colorScheme.tertiary,
)
}
ChatInputTrailing.QUEUE -> IconButton(
onClick = onSend,
enabled = enabled,
) {
Box {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Queue message",
tint = MaterialTheme.colorScheme.tertiary,
)
Icon(
imageVector = Icons.Filled.Schedule,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = 5.dp, y = (-3).dp)
.size(10.dp),
)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun ChatInputPickerChip(
control: ChatInputPickerControl,
onSelect: (ChatInputPickerOption) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val enabled = control.enabled && control.options.isNotEmpty()
val contentColor = if (enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
}
Box(modifier = modifier) {
Surface(
shape = ChatInputChipShape,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.32f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f)),
modifier = Modifier
.heightIn(min = 32.dp)
.clip(ChatInputChipShape)
.clickable(enabled = enabled) { expanded = true },
) {
Row(
modifier = Modifier.padding(start = 10.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = control.value,
style = MaterialTheme.typography.labelMedium,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = control.contentDescription,
tint = contentColor,
modifier = Modifier.size(16.dp),
)
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
var lastGroup: String? = null
control.options.forEachIndexed { index, option ->
val group = option.group?.takeIf { it.isNotBlank() }
if (group != null && group != lastGroup) {
if (index > 0) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
Text(
text = group,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.widthIn(max = 280.dp)
.padding(horizontal = 16.dp, vertical = 6.dp),
)
lastGroup = group
}
DropdownMenuItem(
text = {
Column(modifier = Modifier.widthIn(max = 280.dp)) {
Text(
text = option.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!option.secondary.isNullOrBlank()) {
Text(
text = option.secondary,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
},
onClick = {
expanded = false
onSelect(option)
},
enabled = option.enabled,
leadingIcon = if (option.selected) {
{
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
} else {
null
},
)
}
}
}
}
@@ -47,13 +47,30 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* A slash command entry — built-in, personality, or server skill.
* A slash command entry — built-in, personality, server skill, or (on the
* gateway transport) a server-catalog command from `commands.catalog`.
*/
data class SlashCommand(
val command: String,
val description: String,
val category: String = "built-in"
)
val category: String = "built-in",
/**
* Where the entry came from — [SOURCE_SERVER] for gateway
* `commands.catalog` entries, null for client-defined sources.
* Used by the merge in ChatScreen's `allCommands` to dedupe by name
* with the server description winning, and by the send path to route
* server-only commands through `slash.exec` / `command.dispatch`
* instead of plain text.
*/
val source: String? = null,
) {
companion object {
const val SOURCE_SERVER = "server"
/** Palette category for server-catalog commands without one. */
const val CATEGORY_SERVER = "server"
}
}
/**
* Full-screen command palette as a bottom sheet.
@@ -73,7 +90,10 @@ fun CommandPalette(
// Get unique categories in a logical order
val categories = remember(commands) {
val priorityOrder = listOf("session", "configuration", "info", "personality")
val priorityOrder = listOf(
"session", "configuration", "info", "personality",
SlashCommand.CATEGORY_SERVER,
)
commands.map { it.category }.distinct().sortedWith(
compareBy<String> {
val idx = priorityOrder.indexOf(it)
@@ -1,5 +1,10 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -10,6 +15,7 @@ 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.Close
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -28,9 +34,11 @@ fun CompactToolCall(
toolCall: ToolCall,
modifier: Modifier = Modifier
) {
val isPreparing = toolCall.isGenerating && !toolCall.isComplete
val statusText = when {
toolCall.isComplete && toolCall.success == true -> "completed"
toolCall.isComplete && toolCall.success == false -> "failed"
isPreparing -> "preparing"
else -> "running"
}
@@ -70,6 +78,24 @@ fun CompactToolCall(
// Status indicator
when {
// tool.generating parity with ToolProgressCard's "preparing"
// state — MoreHoriz in Muted with the same alpha breathe; the
// Cyan spinner stays reserved for actually-executing tools.
isPreparing -> {
val breathe = rememberInfiniteTransition(label = "compactToolGenerating")
val alpha = breathe.animateFloat(
initialValue = 0.35f,
targetValue = 0.9f,
animationSpec = infiniteRepeatable(tween(900), repeatMode = RepeatMode.Reverse),
label = "compactToolGeneratingAlpha",
).value
Icon(
imageVector = Icons.Filled.MoreHoriz,
contentDescription = "Preparing",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)
)
}
!toolCall.isComplete -> {
CircularProgressIndicator(
modifier = Modifier.size(10.dp),
@@ -0,0 +1,414 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.input.pointer.pointerInput
import com.hermesandroid.relay.viewmodel.ConnectionHandoffStatus
import com.hermesandroid.relay.viewmodel.ConnectionStatusSnapshot
import com.hermesandroid.relay.viewmodel.ConnectionStatusTone
import com.hermesandroid.relay.viewmodel.asConnectionStatusSnapshot
@Composable
fun ConnectionHandoffBanner(
status: ConnectionHandoffStatus?,
modifier: Modifier = Modifier,
includeStatusBarPadding: Boolean = false,
) {
ConnectionStatusBanner(
status = status?.asConnectionStatusSnapshot(),
modifier = modifier,
includeStatusBarPadding = includeStatusBarPadding,
)
}
@Composable
fun ConnectionStatusBanner(
status: ConnectionStatusSnapshot?,
modifier: Modifier = Modifier,
includeStatusBarPadding: Boolean = false,
onClick: (() -> Unit)? = null,
) {
val current = status ?: return
val containerColor = when {
current.tone == ConnectionStatusTone.Error -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.86f)
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.62f)
current.success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
current.active -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.86f)
}
val contentColor = when {
current.tone == ConnectionStatusTone.Error ||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
current.success -> MaterialTheme.colorScheme.onTertiaryContainer
current.active -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
val insetModifier = if (includeStatusBarPadding) {
Modifier.windowInsetsPadding(WindowInsets.statusBars)
} else {
Modifier
}
Column(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f))
.then(insetModifier)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Surface(
color = containerColor,
contentColor = contentColor,
shape = RoundedCornerShape(10.dp),
tonalElevation = 0.dp,
modifier = Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.animateContentSize(animationSpec = tween(durationMillis = 180)),
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 34.dp)
.padding(horizontal = 10.dp, vertical = 7.dp),
horizontalArrangement = Arrangement.spacedBy(9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when {
current.active -> PulsingSyncIcon(contentColor)
current.success -> Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(16.dp),
)
current.tone == ConnectionStatusTone.Warning ||
current.tone == ConnectionStatusTone.Error -> Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(16.dp),
)
else -> Icon(
imageVector = Icons.Filled.Sync,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(16.dp),
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = current.title,
style = MaterialTheme.typography.labelMedium,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
current.route?.takeIf { it.isNotBlank() }?.let { route ->
Text(
text = route,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.76f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
current.actionLabel?.takeIf { it.isNotBlank() }?.let { label ->
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.86f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
val outputLines = current.entries
.takeLast(2)
.mapNotNull { entry ->
val label = entry.label.trim().takeIf { it.isNotBlank() }
val detail = entry.detail?.trim()?.takeIf { it.isNotBlank() }
when {
label != null && detail != null -> "$label: $detail"
label != null -> label
detail != null -> detail
else -> null
}
}
.distinct()
outputLines.forEach { line ->
Text(
text = line,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.72f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
if (current.active) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 2.dp, max = 2.dp),
color = contentColor.copy(alpha = 0.76f),
trackColor = contentColor.copy(alpha = 0.16f),
)
}
}
}
}
}
private const val SWIPE_DISMISS_THRESHOLD_PX = 80f
/**
* Floating, in-theme connection status **toast** for connection switches,
* network handoffs, and disconnects.
*
* Unlike [ConnectionStatusBanner] (edge-to-edge, takes layout space above the
* Scaffold and so resizes the content), this is meant to be rendered as a
* top-aligned overlay inside a `Box` — it slides down OVER the UI without
* shifting it. Pair it with `AnimatedVisibility(enter = slideInVertically{-it})`
* at the call site.
*
* - Spinner while [ConnectionStatusSnapshot.active] (handoff / loading).
* - [onClick] acts on it (reconnect / open the relevant screen).
* - [onDismiss] is wired to a swipe-up; the host suppresses re-show until the
* status content changes.
*/
@Composable
fun ConnectionStatusToast(
status: ConnectionStatusSnapshot?,
modifier: Modifier = Modifier,
includeStatusBarPadding: Boolean = true,
onClick: (() -> Unit)? = null,
onDismiss: (() -> Unit)? = null,
) {
val current = status ?: return
val containerColor = when {
current.tone == ConnectionStatusTone.Error -> MaterialTheme.colorScheme.errorContainer
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.82f)
current.success -> MaterialTheme.colorScheme.tertiaryContainer
current.active -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
val contentColor = when {
current.tone == ConnectionStatusTone.Error ||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
current.success -> MaterialTheme.colorScheme.onTertiaryContainer
current.active -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
// Reset the swipe accumulator whenever a new status arrives.
val dragAccum = remember(current.updatedAtMs) { mutableFloatStateOf(0f) }
val swipeModifier = if (onDismiss != null) {
Modifier.pointerInput(onDismiss) {
detectVerticalDragGestures(
onDragEnd = {
if (dragAccum.floatValue < -SWIPE_DISMISS_THRESHOLD_PX) onDismiss()
dragAccum.floatValue = 0f
},
onVerticalDrag = { change, dy ->
if (dy < 0f) {
dragAccum.floatValue += dy
change.consume()
}
},
)
}
} else {
Modifier
}
Surface(
color = containerColor,
contentColor = contentColor,
shape = RoundedCornerShape(16.dp),
shadowElevation = 8.dp,
tonalElevation = 2.dp,
modifier = modifier
.then(
if (includeStatusBarPadding) {
Modifier.windowInsetsPadding(WindowInsets.statusBars)
} else {
Modifier
}
)
.padding(horizontal = 12.dp, vertical = 8.dp)
.fillMaxWidth()
.then(swipeModifier)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 40.dp)
.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when {
current.active -> CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = contentColor,
)
current.success -> Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(18.dp),
)
current.tone == ConnectionStatusTone.Warning ||
current.tone == ConnectionStatusTone.Error -> Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(18.dp),
)
else -> Icon(
imageVector = Icons.Filled.Sync,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(18.dp),
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = current.title,
style = MaterialTheme.typography.labelLarge,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
current.route?.takeIf { it.isNotBlank() }?.let { route ->
Text(
text = route,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.76f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
val outputLines = current.entries
.takeLast(2)
.mapNotNull { entry ->
val label = entry.label.trim().takeIf { it.isNotBlank() }
val detail = entry.detail?.trim()?.takeIf { it.isNotBlank() }
when {
label != null && detail != null -> "$label: $detail"
label != null -> label
detail != null -> detail
else -> null
}
}
.distinct()
outputLines.forEach { line ->
Text(
text = line,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.72f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
current.actionLabel?.takeIf { it.isNotBlank() }?.let { label ->
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.86f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
private fun PulsingSyncIcon(color: androidx.compose.ui.graphics.Color) {
val infinite = rememberInfiniteTransition(label = "connection-handoff-pulse")
val alpha by infinite.animateFloat(
initialValue = 0.45f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 900),
repeatMode = RepeatMode.Reverse,
),
label = "connection-handoff-alpha",
)
Icon(
imageVector = Icons.Filled.Sync,
contentDescription = null,
tint = color,
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.alpha(alpha),
)
}
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
@@ -33,12 +34,14 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -58,18 +61,20 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.AppAnalytics
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.network.ChatMode
import com.hermesandroid.relay.network.ConnectionState
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.launch
// ---------------------------------------------------------------------------
// Shared helpers
@@ -217,6 +222,7 @@ fun SessionInfoSheet(
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -348,6 +354,17 @@ fun SessionInfoSheet(
Text("Regenerate Code")
}
}
HorizontalDivider()
DiagnosticsLogPanel(
categories = setOf(
DiagnosticCategory.Session,
DiagnosticCategory.Auth,
DiagnosticCategory.Relay,
),
limit = 6,
showCategory = true,
)
}
}
}
@@ -382,6 +399,7 @@ fun ApiServerInfoSheet(
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -454,6 +472,17 @@ fun ApiServerInfoSheet(
) {
Text(if (testing) "Testing\u2026" else "Test connection")
}
HorizontalDivider()
DiagnosticsLogPanel(
categories = setOf(
DiagnosticCategory.Api,
DiagnosticCategory.Auth,
DiagnosticCategory.Endpoint,
),
limit = 6,
showCategory = true,
)
}
}
}
@@ -484,6 +513,7 @@ fun RelayInfoSheet(
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -562,6 +592,18 @@ fun RelayInfoSheet(
Text("Disconnect")
}
}
HorizontalDivider()
DiagnosticsLogPanel(
categories = setOf(
DiagnosticCategory.Relay,
DiagnosticCategory.Endpoint,
DiagnosticCategory.Session,
DiagnosticCategory.Voice,
),
limit = 8,
showCategory = true,
)
}
}
}
@@ -589,15 +631,27 @@ fun AgentInfoSheet(
chatViewModel: ChatViewModel,
onDismiss: () -> Unit,
onNavigateToConnections: () -> Unit,
onNavigateToProfileInspector: (String) -> Unit = {},
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// Profile + personality state — same flows the old pickers consumed.
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
val personalityNames by chatViewModel.personalityNames.collectAsState()
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
val availableModels by chatViewModel.availableModels.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
// Pull the gateway's curated provider/model list (model.options) when the
// sheet opens — the real switchable models, grouped by provider.
LaunchedEffect(Unit) { chatViewModel.refreshModelOptions() }
// Pull the host's agent profiles from the dashboard so they appear in the
// Profile picker even on a dashboard-only (non-relay) connection.
LaunchedEffect(Unit) { connectionViewModel.refreshDashboardProfiles() }
// Connection summary state.
val authState by connectionViewModel.authState.collectAsState()
@@ -637,6 +691,16 @@ fun AgentInfoSheet(
val profileOverridesPersonality =
selectedProfile?.systemMessage?.isNotBlank() == true
var endpointsExpanded by remember { mutableStateOf(false) }
val effectiveDisplayProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
val serverResolvedAgentName = AgentDisplay.agentName(
profile = effectiveDisplayProfile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = null,
)
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
@@ -668,12 +732,22 @@ fun AgentInfoSheet(
) {
// ---- Header: avatar + agent name + live status ----
AgentSheetHeader(
selectedProfile = selectedProfile,
profile = effectiveDisplayProfile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
localDisplayAlias = profileDisplayAlias,
serverModelName = serverModelName,
apiServerReachable = apiServerReachable,
chatMode = chatMode,
isCustomized = selectedProfile != null ||
selectedPersonality != "default" ||
profileDisplayAlias != null,
)
DisplayAliasSection(
currentAlias = profileDisplayAlias,
fallbackName = serverResolvedAgentName,
onSave = connectionViewModel::setProfileDisplayAlias,
)
HorizontalDivider()
@@ -689,34 +763,100 @@ fun AgentInfoSheet(
// important when one of the profiles is literally named
// "default" so "Server default" vs "default" would be
// otherwise indistinguishable.
val serverDefaultProfile = agentProfiles
.firstOrNull { AgentDisplay.isServerDefaultAlias(it.name) }
val selectableProfiles = agentProfiles
.filterNot { AgentDisplay.isServerDefaultAlias(it.name) }
val apparentActiveProfile = agentProfiles
.firstOrNull { it.gatewayRunning }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
SectionLabel(
title = "Profile",
hint = "Overlay an agent's model + SOUL",
)
CollapsiblePickerSection(
title = "Profile",
hint = "Host-side Hermes contexts",
currentValue = profileDisplayAlias
?: AgentDisplay.profileDisplayName(selectedProfile)
?: "Server default",
) {
// "Server default" row — clears the profile override
// so the request uses whatever the server's own
// config.yaml picks. Renamed from "Default" so a
// profile literally named "default" doesn't collide
// with this row's label.
val defaultDotColor = serverDefaultProfile?.let { profile ->
if (profile.gatewayRunning) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
}
}
val defaultDotA11y = serverDefaultProfile?.let { profile ->
if (profile.gatewayRunning) "Gateway running" else "Gateway idle"
}
val defaultRunning = serverDefaultProfile?.let { profile ->
if (profile.gatewayRunning) " \u2022 Running" else " \u2022 Idle"
}.orEmpty()
val defaultDisplay = if (selectedProfile == null && profileDisplayAlias != null) {
profileDisplayAlias
} else {
serverDefaultProfile?.let { profile ->
AgentDisplay.profileDisplayName(profile)
?: profile.name.replaceFirstChar { it.uppercase() }
}
}
val defaultSecondary = serverDefaultProfile?.let { profile ->
listOfNotNull(
defaultDisplay,
profile.model.takeIf { it.isNotBlank() }?.plus(defaultRunning),
).joinToString(" \u2022 ")
} ?: "Use this connection's default profile"
val soulBg = MaterialTheme.colorScheme.primaryContainer
val soulFg = MaterialTheme.colorScheme.onPrimaryContainer
val skillsBg = MaterialTheme.colorScheme.surfaceVariant
val skillsFg = MaterialTheme.colorScheme.onSurfaceVariant
// "Server default" is the single selectable state for
// the root Hermes config. If the relay advertises a
// synthetic profile named "default" (usually displayed
// as Victor), fold its metadata into this row instead of
// creating a second chat/voice/session scope.
ProfileRadioRow(
primary = "Server default",
secondary = "Use this connection's default profile",
secondary = defaultSecondary,
tertiary = "Uses this connection's default profile",
selected = selectedProfile == null,
enabled = !isStreaming,
leadingDotColor = defaultDotColor,
leadingDotContentDescription = defaultDotA11y,
secondaryTrailing = serverDefaultProfile?.let { profile ->
if (profile.hasSoul || profile.skillCount > 0) {
{
if (profile.skillCount > 0) {
ProfileMetadataBadge(
text = "${profile.skillCount} skills",
background = skillsBg,
contentColor = skillsFg,
)
}
if (profile.hasSoul) {
ProfileMetadataBadge(
text = "SOUL",
background = soulBg,
contentColor = soulFg,
)
}
}
} else {
null
}
},
onSelect = {
if (selectedProfile != null) {
connectionViewModel.selectProfile(null)
toast("Using default model")
// Gateway turns carry no per-request profile —
// hot-swap the live session server-side too.
chatViewModel.activateGatewayProfile(null)
toast("Using Server default")
}
},
)
agentProfiles.forEach { profile ->
selectableProfiles.forEach { profile ->
// v0.7.0 runtime metadata indicators:
// - leadingDotColor: green when this profile's
// gateway is the live one, grey otherwise.
@@ -741,10 +881,6 @@ fun AgentInfoSheet(
} else {
" \u2022 Idle"
}
val soulBg = MaterialTheme.colorScheme.primaryContainer
val soulFg = MaterialTheme.colorScheme.onPrimaryContainer
val skillsBg = MaterialTheme.colorScheme.surfaceVariant
val skillsFg = MaterialTheme.colorScheme.onSurfaceVariant
val isApparentActive =
apparentActiveProfile?.name == profile.name
// Emphasize the description as the primary label
@@ -753,8 +889,13 @@ fun AgentInfoSheet(
// to the capitalised profile name when description
// is blank.
val hasDescription = profile.description.isNotBlank()
val primaryLabel = if (hasDescription) {
profile.description
// Headline is the profile NAME (easy to scan); the
// description + model ride one subtitle line.
val primaryLabel = if (
selectedProfile?.name == profile.name &&
profileDisplayAlias != null
) {
profileDisplayAlias.orEmpty()
} else {
profile.name.replaceFirstChar { it.uppercase() }
}
@@ -763,32 +904,42 @@ fun AgentInfoSheet(
// colour; the text label is the a11y-visible
// complement so a screen reader user also gets
// the status without relying on colour.
val secondaryLine = profile.model + runningLabel
val secondaryLine = listOfNotNull(
profile.description.takeIf { hasDescription },
profile.model.takeIf { it.isNotBlank() },
).joinToString(" · ").takeIf { it.isNotBlank() }
// Tertiary caption: the profile identifier (when
// we promoted the description to primary) plus an
// "active on server" hint when this row matches
// the apparent default.
val tertiaryLine = buildString {
if (hasDescription) {
append("profile: ")
append(profile.name)
}
if (isApparentActive && selectedProfile == null) {
if (isNotEmpty()) append(" \u2022 ")
append("This is the server's active profile")
}
}.takeIf { it.isNotBlank() }
val tertiaryLine: String? = null
ProfileRadioRow(
primary = primaryLabel,
secondary = secondaryLine,
tertiary = tertiaryLine,
// Cleaner card: drop the verbose "profile: … ·
// compatibility overlay · active" caption now that
// the name is the headline.
tertiary = null,
selected = selectedProfile?.name == profile.name,
enabled = !isStreaming,
contentAlpha = 1f,
leadingDotColor = dotColor,
leadingDotContentDescription = dotA11y,
secondaryTrailing = if (profile.hasSoul || profile.skillCount > 0) {
secondaryTrailing = if (
profile.gatewayRunning || profile.hasSoul || profile.skillCount > 0
) {
{
// Prominent status chip — the running/active
// profile, so the dropped "· Running" text
// doesn't cost status visibility (the green
// leading dot still reinforces it).
if (profile.gatewayRunning) {
ProfileMetadataBadge(
text = "Active",
background = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
)
}
if (profile.skillCount > 0) {
ProfileMetadataBadge(
text = "${profile.skillCount} skills",
@@ -808,8 +959,14 @@ fun AgentInfoSheet(
onSelect = {
if (selectedProfile?.name != profile.name) {
connectionViewModel.selectProfile(profile)
// Gateway turns carry no per-request profile;
// hot-swap the live session server-side so the
// agent (SOUL+model+skills) changes in place.
chatViewModel.activateGatewayProfile(profile)
val display = primaryLabel
val suffix = if (profile.systemMessage?.isNotBlank() == true) {
val suffix = if (profile.hasIsolatedApi) {
" — profile API active"
} else if (profile.systemMessage?.isNotBlank() == true) {
" — model + SOUL applied"
} else {
" — model applied"
@@ -820,6 +977,21 @@ fun AgentInfoSheet(
)
}
val inspectorTarget = selectedProfile
?: serverDefaultProfile
?: selectableProfiles.firstOrNull()
inspectorTarget?.let { profile ->
TextButton(
onClick = {
onDismiss()
onNavigateToProfileInspector(profile.name)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Inspect ${AgentDisplay.profileDisplayName(profile) ?: profile.name}")
}
}
if (profileOverridesPersonality) {
Text(
text = "This profile's system message overrides the personality below.",
@@ -838,14 +1010,12 @@ fun AgentInfoSheet(
// row is still tappable because the user may want to queue the
// choice for after they clear the profile. No alpha on the entire
// Column because the section header would look broken.
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
CollapsiblePickerSection(
title = "Personality",
hint = "System-prompt preset on this agent",
currentValue = AgentDisplay.personalityLabel(selectedPersonality, defaultPersonality),
modifier = Modifier.alpha(if (profileOverridesPersonality) 0.55f else 1f),
) {
SectionLabel(
title = "Personality",
hint = "System-prompt preset on this agent",
)
// Default row — maps to selectedPersonality == "default" which
// the VM resolves to whatever server-side personality is
@@ -905,6 +1075,88 @@ fun AgentInfoSheet(
}
}
// ---- Model section (host-side provider model) ----
// Switches the model for THIS session. On the gateway this fires a
// `/model` dispatch (the rich model-info card lands in chat); on SSE
// the pick rides the next request body. Hidden when the server
// advertises no models. Locked mid-turn — the gateway rejects a
// switch while a turn runs, and SSE would race the in-flight request.
// SSE fallback model list — /v1/models plus the configured profiles'
// models (used only when the gateway model.options groups aren't
// available, e.g. on an SSE transport).
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
.distinct()
}
if (modelProviders.isNotEmpty() || sseModelOptions.isNotEmpty()) {
HorizontalDivider()
CollapsiblePickerSection(
title = "Model",
hint = "Provider model for this session",
currentValue = selectedModelOverride ?: "Server default",
) {
ProfileRadioRow(
primary = "Server default",
secondary = AgentDisplay.displayModelName(serverModelName),
selected = selectedModelOverride == null,
enabled = !isStreaming,
onSelect = {
if (selectedModelOverride != null) {
chatViewModel.selectModel(null)
toast("Using server default model")
}
},
)
if (modelProviders.isNotEmpty()) {
// Gateway: the curated provider→model groups the desktop
// picker uses (grok / kimi / gpt-5.5 …). Each provider's
// models are grouped under its name; the switch carries
// `--provider <slug>`.
modelProviders.forEach { provider ->
if (provider.models.isNotEmpty()) {
Text(
text = provider.name,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp, start = 4.dp),
)
provider.models.forEach { model ->
ProfileRadioRow(
primary = model,
secondary = null,
selected = selectedModelOverride == model,
enabled = !isStreaming,
onSelect = {
if (selectedModelOverride != model) {
chatViewModel.selectModel(model, provider.slug)
toast("Model: $model")
}
},
)
}
}
}
} else {
sseModelOptions.forEach { model ->
ProfileRadioRow(
primary = model,
secondary = null,
selected = selectedModelOverride == model,
enabled = !isStreaming,
onSelect = {
if (selectedModelOverride != model) {
chatViewModel.selectModel(model)
toast("Model: $model")
}
},
)
}
}
}
}
HorizontalDivider()
// ---- Session + stats section ----
@@ -970,7 +1222,7 @@ fun AgentInfoSheet(
val hostname = com.hermesandroid.relay.data.Connection
.extractDefaultLabel(connection.apiServerUrl)
val statusLine = when {
connection.pairedAt == null -> "$hostname • Not paired"
connection.pairedAt == null -> "$hostname • Standard"
else -> "$hostname • Paired"
}
ProfileRadioRow(
@@ -1126,6 +1378,112 @@ private fun SectionLabel(title: String, hint: String?) {
}
}
/**
* A space-saving picker section: a tappable header (the [SectionLabel] plus the
* current value and a chevron) that collapses its option rows by default and
* expands them on tap — a dropdown for the agent sheet's Profile / Personality
* / Model lists so the sheet doesn't render every option at once. Selecting an
* option (inside [content]) updates [currentValue] in the header; callers may
* collapse on select by toggling their own state if desired, but leaving it
* open lets the user see the new selection land.
*/
@Composable
private fun CollapsiblePickerSection(
title: String,
hint: String?,
currentValue: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable { expanded = !expanded }
.padding(vertical = 6.dp),
) {
Box(modifier = Modifier.weight(1f)) {
SectionLabel(title = title, hint = hint)
}
if (!expanded && currentValue.isNotBlank()) {
Text(
text = currentValue,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.widthIn(max = 150.dp)
.padding(end = 8.dp),
)
}
Icon(
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = if (expanded) "Collapse $title" else "Expand $title",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (expanded) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
content()
}
}
}
}
@Composable
private fun DisplayAliasSection(
currentAlias: String?,
fallbackName: String,
onSave: (String?) -> Unit,
) {
var draft by remember { mutableStateOf(currentAlias.orEmpty()) }
LaunchedEffect(currentAlias) {
draft = currentAlias.orEmpty()
}
val normalizedDraft = AgentDisplay.localDisplayAlias(draft)
val hasChange = normalizedDraft != currentAlias
CollapsiblePickerSection(
title = "Local display name",
hint = "Phone label",
currentValue = currentAlias ?: "Not set",
) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
label = { Text("Name") },
placeholder = { Text(fallbackName) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(
onClick = {
draft = ""
onSave(null)
},
enabled = currentAlias != null,
) {
Text("Clear")
}
OutlinedButton(
onClick = { onSave(normalizedDraft) },
enabled = hasChange,
) {
Text("Save")
}
}
}
}
/**
* Radio-style row used by both Profile and Personality sections. Whole row
* is a tap target (and selectable() for a11y). Disabled when [enabled] is
@@ -1269,20 +1627,24 @@ private fun ProfileMetadataBadge(
@Composable
private fun AgentSheetHeader(
selectedProfile: Profile?,
profile: Profile?,
selectedPersonality: String,
defaultPersonality: String,
localDisplayAlias: String?,
serverModelName: String,
apiServerReachable: Boolean,
chatMode: ChatMode,
isCustomized: Boolean,
) {
val agentName = when {
selectedProfile != null -> selectedProfile.name.replaceFirstChar { it.uppercase() }
selectedPersonality != "default" -> selectedPersonality.replaceFirstChar { it.uppercase() }
defaultPersonality.isNotBlank() -> defaultPersonality.replaceFirstChar { it.uppercase() }
else -> "Hermes"
}
val modelLabel = selectedProfile?.model ?: serverModelName
val agentName = AgentDisplay.agentName(
profile = profile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = null,
localDisplayAlias = localDisplayAlias,
)
val modelLabel = AgentDisplay.displayModelName(profile?.model)
?: AgentDisplay.displayModelName(serverModelName)
val isConnecting = !apiServerReachable && chatMode != ChatMode.DISCONNECTED
val statusText = when {
apiServerReachable -> "Connected"
@@ -1294,8 +1656,6 @@ private fun AgentSheetHeader(
isConnecting -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
val customized = selectedProfile != null || selectedPersonality != "default"
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -1304,7 +1664,7 @@ private fun AgentSheetHeader(
modifier = Modifier
.size(48.dp)
.then(
if (customized) {
if (isCustomized) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
@@ -1335,9 +1695,9 @@ private fun AgentSheetHeader(
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
)
if (modelLabel.isNotBlank()) {
modelLabel?.takeIf { it.isNotBlank() }?.let { label ->
Text(
text = modelLabel,
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -119,7 +119,7 @@ private fun ConnectionRow(
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val statusLine = if (connection.pairedAt == null) {
"$hostname • Not paired"
"$hostname • Standard"
} else {
"$hostname • Paired"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.RelayRefresh
import kotlin.math.roundToInt
/**
* Ambient context-window meter — a 2dp hairline strip seated at the seam
* between the TopAppBar and RelayModeStrip. The Telegram answer: zero-tap,
* invisible until it matters.
*
* Silent below 50% usage (composes to nothing — no reserved height, the
* seam simply stays a seam). From 50% the fill tracks
* [usedFraction] through Relay → Amber (≥75%) → Danger (≥90%), the same
* caution ladder the sudo countdown and voice badge use. Amber/Danger are
* deliberate direct RelayRefresh reads — identical in both schemes.
*
* Render only when the gateway usage carries a context_max (the compressor
* is present); pass null otherwise and the strip vanishes.
*/
@Composable
fun ContextMeterBar(usedFraction: Float?, modifier: Modifier = Modifier) {
if (usedFraction == null || usedFraction < 0.5f) return
val fill by animateFloatAsState(
targetValue = usedFraction.coerceIn(0f, 1f),
animationSpec = tween(600),
label = "ctxFill",
)
val color = when {
fill >= 0.9f -> RelayRefresh.Danger
fill >= 0.75f -> RelayRefresh.Amber
else -> RelayRefresh.Relay.copy(alpha = 0.8f)
}
val percent = (usedFraction.coerceIn(0f, 1f) * 100).roundToInt()
Box(
modifier = modifier
.fillMaxWidth()
.height(2.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
.semantics { contentDescription = "Context $percent% used" },
) {
Box(
modifier = Modifier
.fillMaxWidth(fill)
.fillMaxHeight()
.background(color),
)
}
}
@@ -0,0 +1,183 @@
package com.hermesandroid.relay.ui.components
import android.text.format.DateFormat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
@Composable
fun DiagnosticsLogPanel(
modifier: Modifier = Modifier,
title: String = "Recent activity",
categories: Set<DiagnosticCategory>? = null,
limit: Int = 8,
showCategory: Boolean = false,
showClear: Boolean = false,
) {
val entries by DiagnosticsLog.entries.collectAsState()
val visible = entries
.asReversed()
.filter { categories == null || it.category in categories }
.take(limit.coerceAtLeast(0))
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (showClear && entries.isNotEmpty()) {
TextButton(onClick = { DiagnosticsLog.clear() }) {
Text("Clear")
}
}
}
if (visible.isEmpty()) {
Text(
text = "No recent activity",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f),
) {
Column(modifier = Modifier.fillMaxWidth()) {
visible.forEachIndexed { index, entry ->
DiagnosticLogRow(
entry = entry,
showCategory = showCategory,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 9.dp),
)
if (index != visible.lastIndex) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
}
}
}
}
}
}
}
@Composable
private fun DiagnosticLogRow(
entry: DiagnosticLogEntry,
showCategory: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.padding(top = 5.dp)
.size(8.dp)
.clip(CircleShape)
.background(severityColor(entry.severity)),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (showCategory) {
"${entry.category.label} - ${entry.title}"
} else {
entry.title
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = DateFormat.format("HH:mm:ss", entry.timestampMs).toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
val detail = entry.detailLine()
if (detail != null) {
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
} else {
Spacer(modifier = Modifier.height(1.dp))
}
}
}
}
@Composable
private fun severityColor(severity: DiagnosticSeverity): Color = when (severity) {
DiagnosticSeverity.Info -> MaterialTheme.colorScheme.primary
DiagnosticSeverity.Warning -> MaterialTheme.colorScheme.tertiary
DiagnosticSeverity.Error -> MaterialTheme.colorScheme.error
}
private fun DiagnosticLogEntry.detailLine(): String? {
val pieces = listOfNotNull(
detail,
endpointRole?.let { "route=$it" },
url,
elapsedMs?.let { "${it}ms" },
)
return pieces.joinToString(" - ").takeIf { it.isNotBlank() }
}
@@ -19,15 +19,19 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.VpnKey
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -42,9 +46,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.data.isKnownRole
import com.hermesandroid.relay.network.RouteProbeOutcome
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
@@ -52,8 +58,14 @@ import kotlinx.coroutines.launch
* ADR 24 — per-endpoint visibility + override card for the Connection
* settings screen. Renders one row per [EndpointCandidate] stored for the
* active device, with a health chip, a 3-dot menu (Prefer / Probe now /
* View pin), and a bottom "Clear manual override" action when a preferred
* role is set.
* View pin), and bottom clear actions when a switch/preference is set.
*
* Two distinct route actions, deliberately separated:
* - "Use now" (row button) — one-time switch. Holds until the next
* disconnect, never persisted, cancelled by [onCancelUseNow].
* - "Prefer this route" (3-dot menu) — sticky policy. Persisted, restored
* on app start, tried first on every resolve; cleared by
* [onClearPreferred].
*
* Verbose by design — Bailey explicitly asked for per-row visibility, so we
* do NOT collapse into a master row. The card itself is wrapped in a
@@ -67,26 +79,70 @@ import kotlinx.coroutines.launch
fun EndpointsCard(
endpoints: List<EndpointCandidate>,
activeEndpoint: EndpointCandidate?,
/** Persisted sticky preference ([Connection.preferredRouteRole]). */
preferredRole: String?,
/**
* Live transient override installed by "Use now" (or by preference
* restoration — equal to [preferredRole] in that case). Drives the
* automatic / preferred / manual annotation on the Current line.
*/
manualOverrideRole: String?,
onUseNow: (EndpointCandidate) -> Unit,
onCancelUseNow: () -> Unit,
onPreferEndpoint: (EndpointCandidate) -> Unit,
onClearOverride: () -> Unit,
onClearPreferred: () -> Unit,
onProbeNow: () -> Unit,
onViewPin: suspend (EndpointCandidate) -> String?,
/** True while a user-triggered route probe is in flight. */
isProbing: Boolean = false,
/**
* Last probe verdict for a route, or null when it has never been
* probed. Lambda (not a map) so the card stays decoupled from the
* resolver's cache-key scheme.
*/
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
/**
* Route management — the standard path's manual equivalent of a v3 QR's
* `endpoints` array. Null callbacks hide the corresponding affordance.
* Edit/Remove only appear on fallback rows (priority > 0); the primary
* row mirrors the connection's API URL and is edited there.
*/
onAddRoute: (() -> Unit)? = null,
onEditRoute: ((EndpointCandidate) -> Unit)? = null,
onRemoveRoute: ((EndpointCandidate) -> Unit)? = null,
) {
if (endpoints.isEmpty()) {
Text(
text = "No route candidates stored for this device yet. " +
"Scan a v3 pairing QR (Hermes 0.4.2+) to enable multi-route " +
"switching — LAN + Tailscale + public URLs.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "No route candidates stored for this connection yet. " +
"Add a remote route (Tailscale, public URL) for automatic " +
"switching when the phone leaves this network — or scan a " +
"v3 pairing QR (Hermes 0.4.2+) if you use Relay.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onAddRoute != null) {
TextButton(onClick = onAddRoute) {
Text("Add route")
}
}
}
return
}
// A manual "Use now" switch is in effect when the live override differs
// from the sticky preference (preference restoration writes the same
// role into both, so equality means "preferred", not "manual").
val manualSwitchActive = manualOverrideRole != null &&
!manualOverrideRole.equals(preferredRole, ignoreCase = true)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}",
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}" + when {
manualSwitchActive -> " · manual (until disconnect)"
manualOverrideRole != null -> " · preferred"
else -> " · automatic"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -99,16 +155,39 @@ fun EndpointsCard(
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
activeEndpoint.api.port == candidate.api.port,
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
isProbing = isProbing,
outcome = outcomeFor(candidate),
onUseNow = { onUseNow(candidate) },
onPrefer = { onPreferEndpoint(candidate) },
onClearPrefer = onClearPreferred,
onProbeNow = onProbeNow,
onViewPin = onViewPin,
onEdit = onEditRoute?.takeIf { candidate.priority > 0 }
?.let { edit -> { edit(candidate) } },
onRemove = onRemoveRoute?.takeIf { candidate.priority > 0 }
?.let { remove -> { remove(candidate) } },
)
}
if (onAddRoute != null) {
HorizontalDivider()
TextButton(onClick = onAddRoute, modifier = Modifier.fillMaxWidth()) {
Text("Add route")
}
}
if (manualSwitchActive) {
HorizontalDivider()
TextButton(onClick = onCancelUseNow, modifier = Modifier.fillMaxWidth()) {
Text(
"Cancel manual switch (back to ${preferredRole ?: "automatic"})",
)
}
}
if (preferredRole != null) {
HorizontalDivider()
TextButton(onClick = onClearOverride, modifier = Modifier.fillMaxWidth()) {
Text("Clear manual override (preferring $preferredRole)")
TextButton(onClick = onClearPreferred, modifier = Modifier.fillMaxWidth()) {
Text("Stop preferring $preferredRole (back to automatic)")
}
}
}
@@ -122,12 +201,19 @@ private fun EndpointRow(
candidate: EndpointCandidate,
isActive: Boolean,
isPreferred: Boolean,
isProbing: Boolean = false,
outcome: RouteProbeOutcome? = null,
onUseNow: () -> Unit,
onPrefer: () -> Unit,
onClearPrefer: () -> Unit,
onProbeNow: () -> Unit,
onViewPin: suspend (EndpointCandidate) -> String?,
onEdit: (() -> Unit)? = null,
onRemove: (() -> Unit)? = null,
) {
var menuOpen by remember { mutableStateOf(false) }
var pinDialogText by remember { mutableStateOf<String?>(null) }
var confirmRemove by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxWidth()) {
@@ -174,21 +260,46 @@ private fun EndpointRow(
)
}
}
// Full URL, scheme included: http vs https decides whether
// the health probe TLS-handshakes, so two rows that both
// read "host:8642" can behave completely differently. The
// scheme must be visible to be debuggable.
Text(
text = "${candidate.api.host}:${candidate.api.port}" +
text = candidate.api.url +
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
when {
isProbing -> Text(
text = "Checking…",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
outcome == null -> Unit // never probed — say nothing
outcome.reachable -> Text(
text = "Reachable",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
else -> Text(
text = "Unreachable — ${outcome.detail ?: "no detail"}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
// 3-dot overflow menu — actions per-row so the card stays flat
// without needing to expand into a detail sheet. "View pin"
// suspends to read CertPinStore, so we resolve it into a dialog
// when the user taps it.
//
// "Use now" is the TRANSIENT switch (until disconnect); the
// sticky "Prefer this route" lives in the menu below.
if (!isActive) {
TextButton(onClick = onPrefer) {
TextButton(onClick = onUseNow) {
Text("Use now")
}
}
@@ -204,10 +315,25 @@ private fun EndpointRow(
onDismissRequest = { menuOpen = false },
) {
DropdownMenuItem(
text = { Text("Prefer this route") },
text = {
Column {
Text(
if (isPreferred) "Stop preferring" else "Prefer this route",
)
Text(
text = if (isPreferred) {
"Back to automatic choice"
} else {
"Always try this route first"
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
onClick = {
menuOpen = false
onPrefer()
if (isPreferred) onClearPrefer() else onPrefer()
},
)
DropdownMenuItem(
@@ -228,11 +354,54 @@ private fun EndpointRow(
}
},
)
if (onEdit != null) {
DropdownMenuItem(
text = { Text("Edit route") },
onClick = {
menuOpen = false
onEdit()
},
)
}
if (onRemove != null) {
DropdownMenuItem(
text = { Text("Remove route") },
onClick = {
menuOpen = false
confirmRemove = true
},
)
}
}
}
}
}
if (confirmRemove && onRemove != null) {
AlertDialog(
onDismissRequest = { confirmRemove = false },
title = { Text("Remove ${candidate.displayLabel()} route?") },
text = {
Text(
text = "${candidate.api.host}:${candidate.api.port} will no longer be " +
"probed as a fallback. You can add it back any time.",
style = MaterialTheme.typography.bodySmall,
)
},
confirmButton = {
TextButton(
onClick = {
confirmRemove = false
onRemove()
},
) { Text("Remove") }
},
dismissButton = {
TextButton(onClick = { confirmRemove = false }) { Text("Cancel") }
},
)
}
pinDialogText?.let { body ->
AlertDialog(
onDismissRequest = { pinDialogText = null },
@@ -328,3 +497,154 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
"public" -> Icons.Filled.Public
else -> Icons.Filled.Shield
}
/**
* Add/edit dialog for an extra fallback route — the manual counterpart of a
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
* set up LAN ↔ Tailscale roaming without the plugin.
*
* The relay URL is derived from the API URL (same `:8767` convention the
* wizard uses); routes that need a custom relay URL still come from a QR.
*
* @param original null = add a new route; non-null = edit (pre-fills role +
* URL, keeps the stored priority).
* @param onSave invoked with (role, apiUrl, resultCallback); the callback
* receives a user-facing error string to render inline, or null on
* success (the dialog then closes itself).
*/
@Composable
fun RouteEditorDialog(
original: EndpointCandidate?,
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
onDismiss: () -> Unit,
) {
val knownRoles = listOf("tailscale", "public")
var selectedRole by remember {
mutableStateOf(
when (original?.role?.lowercase()) {
null -> "tailscale"
in knownRoles -> original.role.lowercase()
else -> CUSTOM_ROLE
},
)
}
var customRole by remember {
mutableStateOf(
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
)
}
var url by remember { mutableStateOf(original?.api?.url.orEmpty()) }
var errorText by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
val effectiveRole = if (selectedRole == CUSTOM_ROLE) customRole else selectedRole
val saveEnabled = !saving &&
url.isNotBlank() &&
(selectedRole != CUSTOM_ROLE || customRole.isNotBlank())
AlertDialog(
onDismissRequest = { if (!saving) onDismiss() },
title = { Text(if (original == null) "Add route" else "Edit route") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "A fallback route the phone switches to when the " +
"primary stops answering — e.g. your server's " +
"Tailscale or public URL.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = selectedRole == "tailscale",
onClick = { selectedRole = "tailscale" },
label = { Text("Tailscale") },
)
FilterChip(
selected = selectedRole == "public",
onClick = { selectedRole = "public" },
label = { Text("Public") },
)
FilterChip(
selected = selectedRole == CUSTOM_ROLE,
onClick = { selectedRole = CUSTOM_ROLE },
label = { Text("Custom") },
)
}
if (selectedRole == CUSTOM_ROLE) {
OutlinedTextField(
value = customRole,
onValueChange = { customRole = it },
label = { Text("Route name") },
placeholder = { Text("wireguard-home") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
// Live preview of what will actually be saved — scheme and
// port defaults applied — so "what, which port, http or
// https?" is answered before Save, not after a failed probe.
val previewCandidate = remember(url, effectiveRole) {
url.takeIf { it.isNotBlank() }?.let {
Connection.endpointCandidateFromApiUrl(
role = effectiveRole.ifBlank { "custom" },
priority = original?.priority ?: 1,
apiServerUrl = Connection.normalizeApiUrlInput(it),
relayUrl = "",
)
}
}
OutlinedTextField(
value = url,
onValueChange = {
url = it
errorText = null
},
label = { Text("API server URL or host") },
placeholder = { Text("100.71.8.56 or http://host:8642") },
singleLine = true,
isError = errorText != null,
supportingText = {
Text(
text = errorText ?: when {
url.isBlank() ->
"Use the API server port (8642 by default) — " +
"not the dashboard's 9119. http:// is " +
"assumed unless you type https://."
previewCandidate != null ->
"Will save: ${previewCandidate.api.url} — relay " +
"and dashboard URLs are derived from the host"
else ->
"Enter a host/IP or an http(s):// URL " +
"(API port 8642, not dashboard 9119)"
},
)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = saveEnabled,
onClick = {
saving = true
onSave(effectiveRole, url) { error ->
saving = false
if (error == null) {
onDismiss()
} else {
errorText = error
}
}
},
) { Text(if (saving) "Saving…" else "Save") }
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") }
},
)
}
private const val CUSTOM_ROLE = "__custom__"

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