Compare 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
251 changed files with 23842 additions and 3284 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+4 -2
View File
@@ -5,12 +5,14 @@ on:
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"
@@ -49,8 +51,8 @@ jobs:
with:
python-version: "3.11"
- name: Verify server-owned version metadata
run: python scripts/check-server-version-sync.py
- 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
+2 -2
View File
@@ -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
@@ -1,12 +1,12 @@
# Hermes-Relay — Python Server CI Pipeline
# Hermes-Relay — Plugin CI Pipeline
#
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
# server-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 -> focused server tests
# Pipeline: syntax-check -> focused plugin tests
name: CI — Server
name: CI — Plugin
on:
push:
@@ -26,9 +26,11 @@ on:
- "relay_server/**"
- "hermes_relay_bootstrap/**"
- "pyproject.toml"
- "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-server.yml"
- ".github/workflows/ci-plugin.yml"
pull_request:
branches: [main, dev]
paths:
@@ -46,18 +48,20 @@ on:
- "relay_server/**"
- "hermes_relay_bootstrap/**"
- "pyproject.toml"
- "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-server.yml"
- ".github/workflows/ci-plugin.yml"
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
concurrency:
group: ci-server-${{ github.ref }}
group: ci-plugin-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
jobs:
# ──────────────────────────────────────────────
# Python Server — py_compile syntax sanity
# Python Plugin — py_compile syntax sanity
# ──────────────────────────────────────────────
syntax-check:
name: Syntax check (Python)
@@ -75,7 +79,7 @@ jobs:
- name: Install dependencies
run: pip install -r relay_server/requirements.txt
- name: Syntax check (server/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
@@ -87,18 +91,18 @@ jobs:
- name: Syntax check (relay_server shim)
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
- name: Validate Server version metadata
run: python scripts/check-server-version-sync.py
- name: Validate Plugin version metadata
run: python scripts/check-plugin-version-sync.py
# ──────────────────────────────────────────────
# Python Server — focused route/auth/session tests
# 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: Focused Server tests (Python)
name: Focused Plugin tests (Python)
needs: syntax-check
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -120,7 +124,7 @@ jobs:
pip install -r relay_server/requirements.txt
pip install pytest responses
- name: Run focused Server tests
- name: Run focused Plugin tests
run: |
python -m pytest \
plugin/tests/test_relay_security.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-server.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
+39 -4
View File
@@ -25,7 +25,16 @@ jobs:
issues: read
id-token: write
env:
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' && startsWith(github.event.pull_request.title, 'release:') }}
# 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
@@ -34,14 +43,40 @@ jobs:
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'
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'
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
+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
+33 -1
View File
@@ -3,7 +3,7 @@
# Triggered when an Android release tag (android-v*) is pushed.
# Validates the tag matches the app version in libs.versions.toml,
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
# GitHub Release. Server/Python package releases use server-v* tags.
# GitHub Release. Plugin/Python package releases use plugin-v* tags.
name: Release Android
@@ -151,6 +151,38 @@ 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 }}
@@ -1,8 +1,8 @@
name: Release Desktop
name: Release CLI
on:
push:
tags: ['desktop-v*']
tags: ['cli-v*']
permissions:
contents: write
@@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node.js (for npm ci + tsc)
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
@@ -89,7 +89,7 @@ jobs:
- name: Upload CLI release assets
uses: actions/upload-artifact@v4
with:
name: desktop-cli-release
name: cli-binaries
path: |
desktop/dist/bin/hermes-relay-win-x64.exe
desktop/dist/bin/hermes-relay-linux-x64
@@ -160,7 +160,7 @@ jobs:
- name: Upload Windows tray release asset
uses: actions/upload-artifact@v4
with:
name: desktop-windows-tray-release
name: cli-windows-tray-installer
path: desktop/dist/tray/hermes-relay-desktop-windows-x64-setup.exe
retention-days: 7
@@ -171,9 +171,13 @@ jobs:
- build-cli-binaries
- build-windows-tray-installer
steps:
- name: Extract desktop version
# 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#desktop-v}" >> "$GITHUB_OUTPUT"
run: echo "version=${GITHUB_REF_NAME#cli-v}" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v4
with:
@@ -188,54 +192,31 @@ jobs:
| 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-Desktop v${{ steps.version.outputs.version }}
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: |
# Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
**Experimental phase.** Assets are unsigned - Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows now ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
## 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=${{ github.ref_name }}`.
## 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.
body_path: cli_release_notes_rendered.md
files: |
release-assets/desktop-cli-release/hermes-relay-win-x64.exe
release-assets/desktop-cli-release/hermes-relay-linux-x64
release-assets/desktop-cli-release/hermes-relay-darwin-x64
release-assets/desktop-cli-release/hermes-relay-darwin-arm64
release-assets/desktop-windows-tray-release/hermes-relay-desktop-windows-x64-setup.exe
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
@@ -1,16 +1,16 @@
name: Release Server
name: Release Plugin
on:
push:
tags:
- "server-v*"
- "plugin-v*"
permissions:
contents: write
jobs:
validate:
name: Validate Server release
name: Validate Plugin release
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
@@ -20,15 +20,15 @@ jobs:
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
run: echo "version=${GITHUB_REF#refs/tags/plugin-v}" >> "$GITHUB_OUTPUT"
- name: Verify Server version sync
run: python scripts/check-server-version-sync.py --expect "$TAG_VERSION"
- 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 Server package
name: Test Plugin package
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -55,7 +55,7 @@ jobs:
python -m py_compile plugin/tools/desktop_tool.py
python -m py_compile relay_server/__init__.py relay_server/__main__.py
- name: Run focused Server tests
- name: Run focused Plugin tests
run: |
python -m pytest \
plugin/tests/test_relay_security.py \
@@ -63,7 +63,7 @@ jobs:
plugin/tests/test_session_grants.py
package:
name: Build and publish Server package
name: Build and publish Plugin package
needs: [validate, test]
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -86,32 +86,25 @@ jobs:
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-Server v${{ needs.validate.outputs.version }}
tag_name: server-v${{ needs.validate.outputs.version }}
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: |
# Hermes-Relay-Server v${{ needs.validate.outputs.version }}
This release contains the server/Python plugin package.
Android releases use `android-v*` tags. Desktop releases use
`desktop-v*` tags. Historical server releases before this lane
rename used `relay-v*` tags.
## Install
```bash
pip install hermes-relay==${{ needs.validate.outputs.version }}
```
## Verify
```bash
python -m relay_server --help
```
body_path: release_notes_rendered.md
files: |
dist/*.whl
dist/*.tar.gz
+1
View File
@@ -26,6 +26,7 @@ local.properties
/app/build/
/relay-core/build/
/relay-ui/build/
/ui-preview/build/
/quest/build/
/app/release/
*.apk
+44
View File
@@ -0,0 +1,44 @@
# 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.
+80 -86
View File
@@ -6,97 +6,89 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [1.1.0] - 2026-06-16
### Added
- **Persistent Realtime Agent conversation.** Realtime Agent voice now keeps one provider session/socket open across turns instead of creating a fresh session per utterance, so the provider retains the live conversation (follow-up references work) and turns skip session-setup latency. The relay needed no change — it already supported multiple turns on one socket. A **Voice Settings → Realtime Agent → Persistent session** toggle (default on) falls back to the legacy per-utterance path. See `docs/plans/2026-05-24-realtime-persistent-session.md`.
- **Background Hermes runs in Realtime Agent voice (ADR 33).** Long Hermes tasks no longer freeze the realtime conversation. A run that exceeds a grace window is promoted to a tracked background task: the provider speaks a short handoff ("I'm on it"), the conversation stays responsive, and the answer is spoken once the run finishes. `hermes_run_task(mode="background")` starts a durable run immediately. New relay events `hermes.run.promoted` and `hermes.run.background_completed`, plus `tier`/`floor` fields on `hermes.run.progress`.
- **Relay audio floor owner.** A single-owner audio floor (provider / relay-TTS / Android-filler) makes explicit the serialization that the old blocking design provided implicitly, so a completed background result never barges in and two voices never overlap.
- **Voice Settings → Realtime Agent → Background tasks.** New controls to enable/disable promotion, toggle the spoken handoff, and choose result delivery (speak when idle / notify / show only). A persistent "working on it" chip appears in the voice overlay while a background task runs.
- **Provider idle-tolerance probe.** `scripts/realtime-provider-idle-probe.py` records a per-provider verdict (hold-floor-ok / needs-keepalive / must-reopen) for holding a realtime socket quiescent during a background run; see `docs/realtime-voice-poc.md`.
- **Per-route reachability verdicts in the Routes card.** Every route row now shows the result of its last health probe — "Reachable", or "Unreachable" with the actual reason ("TLS failed — server may be http://, not https://", "Connection refused", "No answer (timed out)", "HTTP 404 from /health") — and "Re-check" shows a live checking state instead of doing invisible background work. Verdicts persist between probes so you can see what the network last said.
- **Manage parity with the hermes-desktop dashboard.** The Manage tab can now do what the desktop dashboard can: **Models** — change the main model from the full provider/model catalog (`/api/model/options` → `/api/model/set`), including the expensive-model confirmation round-trip; new **Keys** tab — view, set (write-only, masked), reveal (server rate-limited), and clear provider keys / env secrets; **Profiles** — create profiles (clone-from-default), edit descriptions, set per-profile models, and **edit SOUL.md** in a full-file editor; **Skills** — browse the multi-source skills hub with search, SKILL.md preview-before-install, install/uninstall (async server-side), and update-all.
- **Manage data survives app restarts.** The Manage payload cache now mirrors to a plain-JSON file in the app's private cache directory and hydrates at startup, so a cold app launch renders the last-seen dashboard data instantly while fresh data loads quietly behind it. Signing in or out wipes the disk mirror along with the in-memory cache. (Deliberately a flat file rather than encrypted prefs — the payload carries no credentials, and every encrypted-prefs build costs seconds under the Keystore's process-global lock.)
- **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
- **Standard (no-plugin) voice now rides the Hermes dashboard surface.** STT/TTS for the standard route uses the dashboard's `/api/audio/transcribe` + `/api/audio/speak` (the hermes-desktop voice contract) with the same cookie session Manage signs in with — a vanilla hermes-agent install needs no Relay plugin for voice. Previously the client targeted the API server, which has no audio routes, so standard-only voice always failed.
- **Auto STT/TTS route prefers Relay when paired.** Paired Relay voice is profile-aware and needs no dashboard sign-in; the standard dashboard route is the zero-plugin fallback. Voice Settings now shows live per-route status (ready / sign-in required / unreachable / unsupported build) with a "Sign in via Manage" shortcut, and the Realtime Agent engine is clearly marked as requiring a paired Relay.
- **Softened the active connection card.** The full-card Electric blue fill on the active connection was overpowering against body text; it now uses a muted indigo wash while small accents keep the vivid brand blue.
- **Connection wizard capability card now includes Voice.** Finishing setup shows Chat / Manage / Voice / Relay readiness in one card — voice availability (ready / unlocks with dashboard sign-in / build too old) is probed in the same pass, so the result is accurate the moment you connect.
- **No more relay warnings on standard-only connections.** Voice Settings no longer fetches Relay voice configs (and no longer shows "unavailable" rows or error snackbars) when no Relay is configured — relay-backed sections are replaced by a quiet note that speech uses the server's configured TTS/STT, with Relay pairing called out as the way to pick providers from the phone.
- **Skills hub opens with featured content.** The browse dialog lists the configured hub sources and the index's featured skills before the first search instead of starting blank.
- **Onboarding feature pages got real content.** Chat / Manage / Power tools pages now show three concrete feature rows each (streaming + profiles + voice; control + skills hub + one sign-in; terminal + bridge + realtime) instead of a single sentence.
- **Floating status pill.** The bottom status strip is now an inset rounded capsule floating above the gesture area instead of an edge-to-edge bordered bar that clashed with rounded display corners.
- **Ambient mode is now a gesture.** The top-bar sphere toggle is gone; long-press the conversation background to enter the fullscreen sphere, tap anywhere to return (a transient "tap to return to chat" pill teaches the exit on entry). Message long-press (copy) is unaffected.
- **Media settings labeled Relay-only.** The Media screen now states that its inbound-attachment controls apply to Relay-delivered files only, not to standard connections or images you attach in chat.
- **Quote in reply.** Long-pressing a message now offers Copy and "Quote in reply" — quoting drops the message into the input as a Markdown blockquote.
- **Share conversation.** A share icon in the chat top bar exports the visible conversation as Markdown through the system share sheet.
- **Manage cards declutter.** Cards with five or more actions (profiles) keep the three most-used buttons inline and fold the rest behind "More".
- **Ambient gesture is documented in Appearance.** Settings → Appearance now explains the long-press-to-enter / tap-to-return gesture, keeping it discoverable (including for screen-reader users) without a visible control.
- **User docs: Quick Start.** New two-minute Quick Start page leads the guide; the dashboard page documents the full phone Manage surface (skills hub, models, keys, profile + SOUL editing); voice docs lead with the standard no-Relay route.
- **Routes are now editable in Settings → Connections.** The Routes card gains "Add route" plus per-route Edit/Remove (the primary route mirrors the connection's API URL and stays protected) — the standard path's manual equivalent of the Relay QR's multi-endpoint provisioning. Add your server's Tailscale or public URL after the fact and the phone roams to it automatically; the wizard's optional Tailscale field remains the setup-time shortcut.
- **URL fields accept bare hosts and explain their ports.** Typing `100.71.8.56` (or any bare host/IP) into the API URL, wizard Tailscale, or route-editor fields now saves `http://100.71.8.56:8642` — scheme and API port defaulted, and the route editor previews exactly what will be saved ("Will save: http://100.71.8.56:8642") before you commit. Field copy now states which port is which (API `8642`, dashboard `9119`) and that `https://` should only be used when the server actually has TLS. Route rows display the full URL including the scheme, since an invisible `https` was the classic cause of a route that never won a probe.
- **Manage remembers its data and pre-warms it.** Dashboard payloads now live in a process-lifetime cache instead of screen state, so leaving and re-entering Manage shows the last data instantly (entries older than 30 s refresh quietly in the background — content stays put, only a thin progress bar shows). When a connection's saved dashboard status says it was reachable and signed in, the app pre-warms all Manage sections at startup (and again after a LAN↔Tailscale route handoff), so even the first open lands on real data. Signing in or out still clears the cache.
- **Manage's full load dropped from ~40 round trips to ~12.** Every section fetch used to re-run the dashboard auth preamble (status → providers → session → ws-ticket) before its payload — eight sections, strictly one after another, which over a Tailscale link read as 5–10 seconds of "still loading". The preamble is now fetched once per sweep and shared, and the section payloads download concurrently, so a full load costs roughly one preamble plus one payload's worth of latency.
- **Cold start no longer waits 15 seconds to learn there's no API key.** On devices with StrongBox secure hardware (recent Samsungs), every keystore operation takes ~half a second and they all run one at a time — a measured cold start spent 15 seconds decrypting the credential store before the app could even build its HTTP client, only to find the connection had no API key (the normal local setup). A plain non-sensitive "has API key?" hint now lets key-less connections build the client immediately — chat, health, and the conversation restore start within a couple of seconds — while keyed connections still wait for the real decrypt (a stale hint can only ever make startup slower, never strip auth). The startup checks also now count the route prober's successful health probe as "hermes online" instead of waiting for the client-based probe to repeat the same check.
- **The startup sphere is now the actual loading screen.** Cold starts used to flash a slideshow of half-ready states — the disconnected "connect" prompt, then the connected state, then the conversation, each revealing separately — because the splash gate released on the first health verdict (often a probe against the old route, moments before the resolver switched) and force-hid itself after 5.5 s no matter what. The sphere now holds until the app is presentable — server answering AND the last conversation restored — or until an unreachable verdict survives a settle window (then the normal UI takes over with its offline status), with a 12 s backstop. While it holds, terminal-style check lines narrate progress at the bottom (state restored · route · hermes online · conversation), so a longer wait reads as work instead of a hang.
- **Terminal and Settings headers gained back buttons.** Both are pushed destinations (reached from the Chat/Manage header chrome), but neither offered a way back except the system gesture; they now carry the same header back arrow as every other pushed screen. The footer status pill also hugs the bottom edge slightly tighter.
- **"Use now" no longer silently becomes a preference.** The Routes card's "Use now" is now a true one-time switch: it moves traffic immediately and holds only until the next disconnect, without touching the saved route preference. Making a route sticky is the explicit "Prefer this route" action in the row's ⋮ menu (now a toggle, with "Stop preferring" when set). The Current line says which mode picked the route — automatic, preferred, or "manual (until disconnect)" — and dedicated "Cancel manual switch" / "Stop preferring" actions undo each layer separately. Tailscale is intentionally not auto-preferred: automatic resolution already promotes it the moment the LAN route stops answering, and keeps the faster LAN path when you're home.
- **Manage loading and overview polish.** The cold-load skeleton is now one progress bar plus quiet content-shaped ghost cards — previously four stacked progress bars with fake narrative labels ("Checking dashboard session"…) that read like three different failures. The cryptic KPI glyphs (`ok / … / !`) are replaced by three cards: section count, a tone-colored dashboard state word (ready / sign-in / offline / error), and the server version (handy for confirming which host answered after a route handoff). The dashboard status banner is now two lines — state + identity with Sign out, then URL · route · checked time — so nothing truncates, and its duplicate "Connection" button is gone (the Connections tile sits directly below).
- **Manage names its dashboard target and explains per-route sign-in.** The Manage tab now shows exactly which dashboard URL it's talking to ("Dashboard: http://… · Tailscale route") above the content, and "Dashboard unavailable" errors name the URL that failed — the dashboard (`:9119`) is a separate server from the API (`:8642`), so "chat works" never proved Manage's target was reachable. When the resolver has moved Manage onto a different host (e.g. roamed to Tailscale), the sign-in card now explains that dashboard sign-ins are per host and a one-time sign-in on this route keeps both sessions — the same hint voice already had.
- **Remote access is discoverable, not an easter egg.** The standard setup form now shows a "Remote access — Tailscale URL (optional)" field in the main flow (previously buried under Advanced), with a hint when Tailscale is detected on the phone; the setup result card gains a "Remote" readiness line that calls out LAN-only connections; the "Hermes API unreachable" status now diagnoses the likely cause ("Away from the server's network? Add a Tailscale or public route") instead of just reporting; and the Connections card offers an "Add Tailscale route" shortcut when the phone is on Tailscale but the connection has no Tailscale route.
- **README + Play listing refresh.** Both rewritten around the standard-first story. The README quick start now mirrors the app's capability card (Chat / Manage / Voice / Remote / Relay), voice is no longer described as relay-only, Manage and remote access become headline features, the desktop CLI section is trimmed and clearly marked alpha (with its planned refocus into a remote "hands" connector), and the stale CI badge, broken in-page anchors, and version-pinned "What's new in v0.6.0" section are gone. The Play listing (`docs/play-store-listing.md`) gets an end-user-first short description, a quick-start beat, Manage/remote-access feature blocks, a corrected no-plugin voice story, and v0.8.1 release notes.
- **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
- **App-start UI freeze (frozen sphere) from Keystore lock contention.** Cold starts could freeze the UI for many seconds (logcat: `Skipped 1386 frames`, `Davey! duration=11596ms`): every `EncryptedDashboardCookieStore` eagerly built its Keystore-backed prefs in its constructor — a 1–4 s operation on StrongBox devices that serializes through a process-global Tink lock — and several code paths (Manage section loads, connection validation, the Manage pre-warm) each constructed their own instance, stacking multi-second lock holds that main-thread keystore users then queued behind. The store now builds lazily on first cookie access (always an I/O thread), all dashboard-surface consumers share one cached instance per connection, and the pre-warm uses a single client plus the shared store for its whole sweep instead of one of each per section.
- **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.
- **"Re-check" / "Use now" no longer fail silently.** When every saved route failed its probe, the user-triggered re-probe early-returned without publishing anything: the Routes card sat on "Current: Resolving" forever (showing the internal relay URL underneath, which read as "stuck on the internal route") with zero feedback. The probe now always publishes its outcome, the card states "No route reachable — using saved URL …" explicitly, and per-route rows show why each candidate failed. The old 100 ms post-probe delay — always shorter than a real resolve, leaving the follow-up health checks pointed at the stale route — is replaced by actually awaiting the resolve.
## [1.0.0] - 2026-06-14
- **Standard (no-Relay) connections now follow LAN ↔ Tailscale network changes.** The ADR 24 network-aware route switching only activated when a Relay socket was open: the connectivity callback registered inside `connect()` and bailed without a socket URL, so a standard connection that left home Wi-Fi kept probing the dead LAN route until the app was backgrounded and reopened. The callback now registers at construction and re-resolves routes (debounced) even with no socket — chat, Manage, and standard voice follow the resolved endpoint automatically.
### Added
- **Standard voice follows the resolved route.** The standard voice client and its availability probe targeted the connection's persisted dashboard URL instead of the resolver's active route, so voice stayed pinned to the LAN host (and gated off) while away from home even after chat had switched to Tailscale. Both now ride `effectiveDashboardUrl`.
- **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.
- **Stale probe cache can't pin a dead route.** App-resume and network-change revalidation now clear the endpoint resolver's probe cache, so a route that died moments ago can't win re-resolution for the remainder of its 60-second positive cache window. The periodic health check also escalates two consecutive unreachable probes into a full cache-cleared re-resolve — the safety net for handoffs Android never surfaces as connectivity changes (always-on VPN keeps "internet available" true throughout).
- **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.
- **Editing URLs no longer wipes fallback routes.** Saving an API or Relay URL rebuilt the connection's route-candidate list from just the edited URL, silently dropping the setup wizard's Tailscale route (or extra endpoints from a pairing payload). Edits now merge: the touched route is rebuilt, stored extras are preserved verbatim.
- **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.
- **Per-route sign-in is explained.** Dashboard sessions are cookie-based and per-host, so a Manage sign-in at home doesn't carry to the Tailscale host. When voice is gated on sign-in because the route moved, Voice Settings and the chat mic toast now say so ("sign in once in Manage on this route") instead of showing a bare sign-in nag that looks broken.
- **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.
- **A network change can no longer resurrect a deliberately disconnected relay socket.** The route-switch path force-reconnected whenever the resolved winner differed from the last URL, even after an explicit Disconnect; socket actions are now gated on reconnect intent while route publication for HTTP surfaces continues.
- **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
@@ -124,7 +116,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **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*`, server/Python releases use `server-v*`, and desktop continues on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Server vX.Y.Z`, and `Hermes-Relay-Desktop vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
- **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.
@@ -216,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
@@ -226,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`.
@@ -250,9 +242,9 @@ 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
@@ -302,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-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 (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.
@@ -685,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`).
@@ -1267,7 +1259,9 @@ MVP release — native Android companion app for Hermes agent with direct API ch
- **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/android-v0.8.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
+72 -20
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.8.0 (release-prep on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode (stable Hermes Chat + Voice Output plus opt-in provider-native Realtime Agent with reliable low-latency playback and a text/mic Voice Lab), bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, connection diagnostics, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative, Bridge Core without Device Control) 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
@@ -42,7 +44,7 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
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. **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** (`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.
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 |
@@ -65,7 +67,7 @@ The Android client probes per-endpoint capability via `HermesApiClient.probeCapa
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 Chat still uses the API-server bearer path until a dashboard `/api/ws` chat adapter is wired. Android Manage may consume this dashboard surface directly, but relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
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.
@@ -73,9 +75,10 @@ Current upstream supports two auth modes on this surface. Loopback dashboards st
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:** Retire `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.
- **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
@@ -92,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/
@@ -115,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 compatibility patch; retire per surface as upstream replaces it
├── 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
@@ -129,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.
@@ -155,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`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-server-version.sh` for `server-vX.Y.Z`, and `desktop/package.json` for `desktop-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`.
- **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-server.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
@@ -170,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 |
@@ -216,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 |
@@ -236,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 compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
| `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 |
@@ -284,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
@@ -354,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
@@ -372,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 (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` | 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` |
@@ -396,6 +446,8 @@ 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 | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
+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.
+10
View File
@@ -96,6 +96,16 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`,
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)
+346 -154
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.
+179 -161
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>Your self-hosted Hermes agent, native on your phone.</strong><br>
Chat, voice, and full agent management over your own infrastructure —<br>
plus an experimental desktop CLI that gives the agent hands on your computer.
<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%20%28alpha%29-orange.svg" alt="Desktop CLI (alpha)"></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://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://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,149 +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](#quick-start-android)** | Native phone client — streaming chat, hands-free voice, full agent management (models, keys, skills, profiles), and on sideload builds the agent can read your screen and act on it. | Available — Google Play (Internal testing) + sideload APK |
| **[Desktop CLI](#desktop-cli-alpha)** | The agent reaching back to **your machine** — local tool routing (files, terminal, screenshots, clipboard) plus a remote shell to the host. | **Alpha** — `desktop-v*` releases, expect heavy changes |
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 the same WSS relay and credentials store. **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 (Android)
Install → connect → talk, in about two minutes. A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**.
Install → connect → talk, in about two minutes.
### 1. Install the app
### 1 · Install the app
- **Google Play** — coming soon (currently on Internal testing)
- **APK** — 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). Full walkthrough — integrity verification, signing fingerprint, what's in each build — in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
- **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).
Sideload builds check GitHub for new releases and show a one-tap update banner when you're behind; Play builds update through the Play Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
### 2. Have Hermes running
### 2 · Have Hermes running
Run upstream Hermes with its API server and dashboard enabled:
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
hermes setup --portal
hermes setup --portal # install / log in / pick a provider — skip if already done
mkdir -p ~/.hermes
API_SERVER_KEY="$(openssl rand -hex 32)"
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"
echo "Android API key: $API_SERVER_KEY"
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
hermes gateway
```
Windows commands, dashboard auth notes, and upstream links: [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
`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**.
### 3. Connect and talk
> **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).
Open the app, choose **Standard Hermes**, and enter your server's address and API key. The wizard probes everything and finishes with a capability card:
### 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 also unlocks voice. That's the whole standard setup.
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).
> **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
### 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
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
hermes relay start --no-ssl
hermes pair
```
The installer clones to `~/.hermes/hermes-relay/`, registers the plugin/skill paths, and can install a systemd user service. Scan the QR from the phone's Connections screen; if you can't scan, use `hermes pair --register-code ABCD12` with the manual code from Android **Settings → Connections → Advanced**. (`/hermes-relay-pair` and the dashed `hermes-pair` shim remain for chat-surface and older builds.)
Use the legacy installer instead if you also want the systemd user service,
shell shims, and the full clone/update workflow:
- **Updating:** `hermes-relay-update` — idempotent; or re-run the install one-liner.
- **Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` — reverses every step, 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.
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
```
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**.
- **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.
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
**Requirements:** Android 8.0+ (SDK 26) · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server · macOS / Linux / Windows for the desktop CLI.
**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.
## Desktop CLI (alpha)
## Screenshots
> **Alpha — expect heavy changes.** With [hermes-desktop](https://hermes-agent.nousresearch.com) now covering chat and management on the desktop, this surface is being refocused into a pure remote **"hands" connector**: the agent reaching back through the relay to run tools on your machine (files, terminal, screenshots, clipboard, editor). The chat and shell features that overlap hermes-desktop will be removed in a future release. Binaries are unsigned during the experimental phase — SmartScreen/Gatekeeper warnings are expected.
<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>
The agent's brain stays on the host; the CLI lets it call `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, and more **on your machine** over the same WSS relay — with a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch. No Node required; installs are self-contained native binaries.
<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>
**Install** (Windows PowerShell / macOS / Linux):
## Features
### 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
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
```
```bash
hermes-relay pair --remote ws://<host>:8767 # once
hermes-relay daemon # headless tool router — agent reaches you anytime
hermes-relay # interactive Hermes TUI in tmux (legacy, being refocused)
hermes-relay update # self-update via GitHub Releases
```
- **Docs:** [Desktop 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)
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`
## Features
## How It Works
### Android
```
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]
```
- **Streaming chat** — direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, 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 provider keys (write-only, masked, server-rate-limited reveal), create and edit agent profiles including `SOUL.md`, and browse, install, and update skills from the hub. One dashboard sign-in covers it all
- **Voice mode** — talk hands-free 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 providers and an opt-in provider-native Realtime Agent with background task handoff
- **Works away from home** — add your server's Tailscale or public URL and the app roams automatically: LAN at home, fallback elsewhere. Routes are editable per connection, and an unreachable server gets a diagnosis ("away from the server's network? add a route"), not just a red dot
- **Multi-Connection + profiles** — pair with multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay an agent profile's model + `SOUL.md` per chat
- **Phone control (bridge)** — with the Relay plugin paired, the agent reads the screen and acts on it: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros, and event-driven waits. Guarded by safety rails: per-app blocklist (banking/payments/2FA default-blocked), destructive-verb confirmation, idle auto-disable, full activity log
- **Notification companion** — opt-in notification 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
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.
> 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).
## Documentation
### Desktop CLI
| | |
|---|---|
| **[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*`) |
- **Local tool routing** — `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` run on your machine; agent-proposed patches render as colored diffs with interactive approval
- **Daemon mode** — headless tool router; the agent can reach you with no shell open
- **Multi-endpoint pairing, reconnect-on-drop, TOFU cert pinning** — same model as the Android app
- **Self-update** — `hermes-relay update` verifies SHA256 and atomic-swaps the binary
<details>
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
## Install with an AI agent
<br>
If an AI assistant (Claude, GPT, etc.) 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:
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
@@ -175,131 +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`
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via the plugin-provided `hermes pair` or `/hermes-relay-pair` for power tools; 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 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.
## How It Works
```
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
Phone (HTTP) --> Hermes Dashboard (:9119) [manage + standard voice — cookie sign-in]
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
Desktop CLI (WSS) --> Relay (:8767) [desktop tools, tui, terminal]
```
Chat connects directly to the Hermes API Server with the API key — the same pattern used by Open WebUI and other Hermes frontends. The Manage tab 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, desktop tools, and relay-side voice providers (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, both surfaces, features, configuration — start here** |
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — 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 |
| [Upstream Integration Sync](docs/upstream-integration-sync.md) | Supported Hermes extension points vs server-owned compatibility layers |
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, 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 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 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-server / 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 |
| **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 |
### Server (optional — bridge, terminal, TUI, media, and relay 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](#4-optional-install-relay-for-power-tools) above. 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 the plugin-provided `hermes pair` to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. `/hermes-relay-pair` and the dashed `hermes-pair` shim remain available for chat-surface and older-build compatibility.
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
+199 -91
View File
@@ -13,20 +13,23 @@ 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:
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 |
|---|---|---|---|---|
| Android app | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
| Server / Python package | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-server-version.sh` | `.github/workflows/release-server.yml` |
| Desktop CLI | `desktop-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-desktop.yml` |
| 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 server now carries features for both Android
and desktop, so server fixes can ship without forcing an Android app
`versionCode` bump, and desktop CLI alphas can continue on their own cadence.
Historical Android releases before this naming split used bare `v*` tags, and
historical server releases used `relay-v*` tags. New releases use the explicit
surface prefixes above.
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
@@ -70,35 +73,47 @@ bash scripts/bump-android-version.sh 0.6.2
`scripts/bump-version.sh` remains as a backward-compatible alias for the
Android script.
### Server / Python package versioning
### Plugin / Python package versioning
Server version metadata lives in these server-owned files and must stay in
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` |
| `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 Server releases via:
Always bump Plugin releases via:
```bash
bash scripts/bump-server-version.sh 0.6.2
bash scripts/bump-plugin-version.sh 0.6.2
```
Check the current metadata with:
```bash
python scripts/check-server-version-sync.py
python scripts/check-plugin-version-sync.py
```
The `server-v*` release workflow validates the tag against the same metadata,
runs server tests, builds a wheel and sdist, generates checksums, and publishes
a GitHub Release with the package artifacts.
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
@@ -156,15 +171,15 @@ 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`,
server-owned version metadata, or `desktop/package.json`.
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).
Version-bump commits live on `dev` as the last commit of release-prep
work. Android commits use `release(android): android-vX.Y.Z`; server commits
use `release(server): server-vX.Y.Z`; desktop commits use the existing
`release: desktop-vX.Y.Z` convention. A release PR then merges `dev` →
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.
@@ -173,7 +188,7 @@ use `release(server): server-vX.Y.Z`; desktop commits use the existing
Light branch protection is enabled:
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
here. PR must pass CI (Android + Server) 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
@@ -275,22 +290,34 @@ 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
@@ -350,6 +377,12 @@ 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:
@@ -374,7 +407,34 @@ the new app version and a higher `appVersionCode`.
(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
@@ -425,64 +485,94 @@ Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.
which builds, signs, checksums, and creates a GitHub Release. Watch the
run under the **Actions** tab.
Server/Python version files are intentionally not part of an Android app
release unless the server package itself is also being released.
Plugin/Python version files are intentionally not part of an Android app
release unless the plugin package itself is also being released.
### Server / Python package release
### Plugin / Python package release
Use this when Server behavior changes independently of Android app
delivery, for example desktop channel support, bridge routes, pairing
server fixes, voice auth, or packaging changes.
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-server-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
git commit -m "release(server): server-v0.6.2"
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 server-v0.6.2
git push origin server-v0.6.2
git tag plugin-v0.6.2
git push origin plugin-v0.6.2
```
Pushing `server-v*` triggers `.github/workflows/release-server.yml`, which
validates all server-owned version metadata with
`scripts/check-server-version-sync.py`, runs server tests, builds a wheel and
sdist, generates `SHA256SUMS.txt`, and creates a GitHub Release for the server
package.
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:
@@ -490,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
@@ -522,9 +618,9 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
## CI Behavior
Android, Server, dashboard, and desktop now have separate CI/release lanes.
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
server changes from forcing an Android app `versionCode` bump.
plugin changes from forcing an Android app `versionCode` bump.
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
@@ -544,20 +640,25 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
that the artifacts are debug-signed and unsuitable for Play Store.
On every push of a tag matching `server-v*`,
`.github/workflows/release-server.yml`:
On every push of a tag matching `plugin-v*`,
`.github/workflows/release-plugin.yml`:
1. Validates the tag matches all server-owned version metadata checked by
`scripts/check-server-version-sync.py`.
2. Runs server syntax checks and the focused route/auth/session test slice.
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-Server v<version>` with the wheel,
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 `desktop-v*`,
`.github/workflows/release-desktop.yml` builds and publishes the desktop
CLI binaries. Dashboard-only changes are covered by
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.
@@ -570,6 +671,13 @@ in the built bundle.
| `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
@@ -595,9 +703,9 @@ For an Android app hotfix:
`dev`'s `appVersionCode` lags behind `main` and the next app release
bump collides.
For a Server hotfix, branch from the affected `server-v*` tag, apply
the fix, run `bash scripts/bump-server-version.sh <next-version>`, merge to
`main`, and tag `server-v<next-version>`. Do not touch
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
+36 -25
View File
@@ -1,44 +1,55 @@
# Unreleased
# Hermes-Relay-Android v1.1.0
## Changed
**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.
- Android now defaults to a standard Hermes layout with **Chat**, **Manage**, and **Settings** in bottom navigation. Terminal and Bridge remain available under **Settings → Power tools** and through existing routes.
- Added a native **Manage** surface backed by the Hermes dashboard/admin API for Skills, Cron, MCP servers/catalog, Profiles, Models, and Config. It supports dashboard sign-in, common management actions, cron run details, and read-only profile SOUL details without requiring relay pairing.
- Relay-only features now show a consistent **Requires pairing** / **Pair to unlock** gate when the active connection is not paired.
- Connections now model API auth, dashboard auth, and relay pairing separately. Dashboard URLs derive from the API host on port `9119` by default.
---
# Hermes-Relay-Android v0.8.1
**Release Date:** May 26, 2026
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
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.8.1 ships in two Android 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 |
|---|---|---|
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-0.8.1-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-0.8.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 APK install steps.
---
## Fixed
## Highlights
### Voice mode crash with barge-in on legacy TTS playback
### Settings screen overhaul
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
Settings was reorganized around what you actually touch and quieted down everywhere else:
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
- **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.
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
### Chat settings polish
- **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.
### 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.
---
## Upgrade notes
- 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**.
+3 -3
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 `android-v*` and Server `server-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`):**
@@ -47,11 +47,11 @@ Release tags: `desktop-v*` (separate cadence from Android `android-v*` and Serve
**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 the GitHub Releases API, filters to `desktop-v*`, 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.
+17
View File
@@ -106,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")
@@ -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)
+27
View File
@@ -6,6 +6,19 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- 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" />
<!-- 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" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
@@ -54,6 +67,20 @@
</service>
<!-- === END PHASE3-notif-listener === -->
<!-- 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=".network.GatewayKeepAliveService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
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>
</application>
</manifest>
+34 -5
View File
@@ -1,7 +1,36 @@
v0.8.1 - Voice mode crash fix
v1.0.0 - The 1.0 release
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.
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.
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
* Fixed a crash that could hit voice mode when barge-in was enabled on the
legacy text-to-speech path — the agent's first words no longer cut off
into a crash. Barge-in is opt-in; the Realtime Agent and Voice Output
paths were never affected.
* 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
@@ -19,6 +19,7 @@ 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
@@ -37,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
@@ -142,6 +143,9 @@ 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-
@@ -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
@@ -335,9 +335,21 @@ class AuthManager(
// 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, tokenPrefsName)
?: LegacyEncryptedPrefsTokenStore(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
@@ -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() }
}
@@ -10,28 +10,60 @@ package com.hermesandroid.relay.data
*/
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
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
// 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.description.isNotBlank() -> profile.description.trim()
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 (
@@ -61,6 +93,12 @@ object AgentDisplay {
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
@@ -78,6 +116,22 @@ object AgentDisplay {
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() }
}
@@ -238,7 +238,27 @@ data class ToolCall(
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 {
@@ -252,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
}
@@ -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
}
}
}
@@ -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"
}
}
@@ -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")
@@ -10,12 +10,62 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Per-connection, per-Hermes-profile last active chat session.
* 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>,
@@ -25,11 +75,18 @@ class ProfileSessionStore(
companion object {
private const val PREFIX = "profile_session__"
private fun keyName(connectionId: String, profileName: String?): String =
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
private fun keyName(
connectionId: String,
profileName: String?,
transport: SessionTransport,
): String =
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}__${transport.key}"
private fun keyFor(connectionId: String, profileName: String?) =
stringPreferencesKey(keyName(connectionId, profileName))
private fun keyFor(
connectionId: String,
profileName: String?,
transport: SessionTransport,
) = stringPreferencesKey(keyName(connectionId, profileName, transport))
private fun connectionPrefix(connectionId: String): String =
"$PREFIX${connectionId}__"
@@ -38,10 +95,11 @@ class ProfileSessionStore(
suspend fun setSessionId(
connectionId: String,
profileName: String?,
transport: SessionTransport,
sessionId: String?,
) {
dataStore.edit { prefs ->
val key = keyFor(connectionId, profileName)
val key = keyFor(connectionId, profileName, transport)
if (sessionId.isNullOrBlank()) {
prefs.remove(key)
} else {
@@ -50,8 +108,12 @@ class ProfileSessionStore(
}
}
fun sessionIdFlow(connectionId: String, profileName: String?): Flow<String?> {
val key = keyFor(connectionId, profileName)
fun sessionIdFlow(
connectionId: String,
profileName: String?,
transport: SessionTransport,
): Flow<String?> {
val key = keyFor(connectionId, profileName, transport)
return dataStore.data.map { prefs -> prefs[key] }
}
@@ -196,6 +196,18 @@ class ConnectionManager(
@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
@@ -214,6 +226,15 @@ class ConnectionManager(
// 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
@@ -540,6 +561,23 @@ class ConnectionManager(
}
return@launch
}
// 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
@@ -569,15 +607,33 @@ class ConnectionManager(
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
// 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")
endpointResolver?.clearCache()
markActiveEndpointUnreachable("network lost")
scheduleNetworkReResolve("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 {
@@ -594,6 +650,8 @@ class ConnectionManager(
}
private fun unregisterNetworkCallback() {
networkLossJob?.cancel()
networkLossJob = null
val ctx = context ?: return
val cb = networkCallback ?: return
try {
@@ -1,6 +1,11 @@
package com.hermesandroid.relay.network
import android.content.Context
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.models.MessageItem
import com.hermesandroid.relay.network.models.MessageListResponse
import com.hermesandroid.relay.network.models.SessionItem
import com.hermesandroid.relay.network.models.SessionListResponse
import com.hermesandroid.relay.auth.KeystoreTokenStore
import com.hermesandroid.relay.auth.LegacyEncryptedPrefsTokenStore
import com.hermesandroid.relay.auth.SessionTokenStore
@@ -71,6 +76,11 @@ data class DashboardWsTicket(
val ttlSeconds: Int? = null,
)
data class DashboardChatDisplaySettings(
val showReasoning: Boolean? = null,
val toolDisplay: String? = null,
)
/**
* Native client for the Hermes dashboard/admin server (:9119).
*
@@ -162,6 +172,9 @@ class DashboardApiClient(
// --- Models (dashboard parity with hermes-desktop Settings → Model) ---
suspend fun getChatDisplaySettings(): Result<DashboardChatDisplaySettings> =
getJsonObject("/api/config").mapCatching { root -> parseChatDisplaySettings(root) }
/** Full provider/model universe — REST twin of the TUI's `model.options` RPC. */
suspend fun getModelOptions(): Result<JsonObject> = getJsonObject("/api/model/options")
@@ -372,6 +385,90 @@ class DashboardApiClient(
suspend fun deleteProfile(name: String): Result<JsonObject> =
deleteJsonObject("/api/profiles/${pathSegment(name)}")
/**
* List the host's Hermes agent profiles (`GET /api/profiles`) — the same
* profiles the Manage tab and the official desktop expose — mapped into the
* shared [Profile] type so the chat agent sheet can offer them even on a
* dashboard-only (non-relay) connection, where the relay's `auth.ok`
* profile list is empty. Tolerates the array (`{profiles:[…]}` / `{items:[…]}`)
* and the object-map (`{profiles:{name:{…}}}`) shapes; an item missing a
* required field is skipped, not fatal.
*/
suspend fun listProfiles(): Result<List<Profile>> =
getJsonObject("/api/profiles").mapCatching { root -> parseProfiles(root) }
/**
* List a profile's chat sessions via the dashboard `GET /api/sessions?profile=`.
*
* This is the per-profile scoping the official desktop sidebar uses: upstream
* (`web_server.py` `_open_session_db_for_profile`) opens THAT profile's own
* `state.db` directly. The gateway `session.list` RPC can't do this — it reads
* one process-global DB bound to the launch profile, so over a single socket it
* always returns the launch profile's sessions regardless of the active profile.
*
* [profile] null/blank → the launch (default) profile's DB (param omitted). The
* returned ids are the same stored-session ids the gateway `session.resume`
* reads, so list-here / resume-on-gateway stays consistent. `min_messages=1`
* drops empty draft rows where supported; `order=recent` requests activity
* ordering where the host honors it. Android still sorts by decoded
* `last_active` locally because older hosts return started-time order.
*/
suspend fun listSessions(profile: String? = null, limit: Int = 200): Result<List<SessionItem>> =
withContext(Dispatchers.IO) {
val query = buildList {
add("limit=${limit.coerceIn(1, 200)}")
add("order=recent")
add("min_messages=1")
val name = profile?.trim().orEmpty()
if (name.isNotBlank()) add("profile=${pathSegment(name)}")
}.joinToString(prefix = "?", separator = "&")
getJson("/api/sessions$query").mapCatching { root ->
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
parsed.sessions ?: parsed.items ?: parsed.data ?: emptyList()
}
}
/**
* A session's message history, scoped to its owning profile via the dashboard
* `GET /api/sessions/{id}/messages?profile=`. Required twin of [listSessions]:
* a non-default profile's sessions live in that profile's own `state.db`, so
* loading their transcript through the api_server (one shared DB, no profile)
* returns nothing. [profile] null/blank → the launch profile's DB. Decodes the
* upstream `{session_id, messages:[…]}` envelope into the shared [MessageItem].
*/
suspend fun getSessionMessages(
sessionId: String,
profile: String? = null,
): Result<List<MessageItem>> = withContext(Dispatchers.IO) {
val name = profile?.trim().orEmpty()
val query = if (name.isNotBlank()) "?profile=${pathSegment(name)}" else ""
getJson("/api/sessions/${pathSegment(sessionId)}/messages$query").mapCatching { root ->
val parsed = json.decodeFromJsonElement(MessageListResponse.serializer(), root)
parsed.messages ?: parsed.data ?: parsed.items ?: emptyList()
}
}
private fun parseProfiles(root: JsonObject): List<Profile> {
fun decode(element: JsonElement, nameOverride: String?): Profile? = runCatching {
val obj = element as? JsonObject ?: return null
// Profile requires name + model; inject the map key as name and an
// empty model when the server omits them so a sparse row still maps.
val patched = buildJsonObject {
obj.forEach { (k, v) -> put(k, v) }
if (obj["name"] == null && !nameOverride.isNullOrBlank()) put("name", nameOverride)
if (obj["model"] == null) put("model", "")
}
json.decodeFromJsonElement(Profile.serializer(), patched)
}.getOrNull()
(root["profiles"] as? JsonArray)?.let { arr -> return arr.mapNotNull { decode(it, null) } }
(root["items"] as? JsonArray)?.let { arr -> return arr.mapNotNull { decode(it, null) } }
(root["profiles"] as? JsonObject)?.let { map ->
return map.entries.mapNotNull { (name, value) -> decode(value, name) }
}
return root.entries.mapNotNull { (name, value) -> decode(value, name) }
}
suspend fun loginPassword(
provider: String = "basic",
username: String,
@@ -658,6 +755,28 @@ class DashboardApiClient(
private fun isPasswordProvider(name: String): Boolean =
name.equals("basic", ignoreCase = true) ||
name.equals("password", ignoreCase = true)
fun parseChatDisplaySettings(root: JsonObject): DashboardChatDisplaySettings {
val config = root["config"] as? JsonObject
val display = (config?.get("display") as? JsonObject)
?: (root["display"] as? JsonObject)
return DashboardChatDisplaySettings(
showReasoning = display.booleanField("show_reasoning"),
toolDisplay = normalizeToolDisplay(
display.stringField("tool_progress")
?: display.stringField("tool_display")
?: display.stringField("toolProgress"),
),
)
}
private fun normalizeToolDisplay(value: String?): String? =
when (value?.trim()?.lowercase()) {
"off", "none", "false", "0", "hidden", "hide" -> "off"
"compact", "minimal", "summary", "brief" -> "compact"
"all", "detailed", "detail", "full", "true", "1", "on", "show" -> "detailed"
else -> null
}
}
}
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,
)
@@ -16,6 +16,7 @@ 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
@@ -287,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 ->
@@ -307,7 +308,7 @@ 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(
@@ -416,6 +417,32 @@ class HermesApiClient(
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/v1/models").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext 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 models: ${e.message}")
emptyList()
}
}
// --- Server personalities ---
/**
@@ -556,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) }
@@ -567,6 +596,7 @@ class HermesApiClient(
type: String?,
data: String
) {
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
@@ -577,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)) {
@@ -721,6 +759,7 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = when {
response != null && !response.isSuccessful ->
@@ -734,6 +773,7 @@ class HermesApiClient(
}
override fun onClosed(eventSource: EventSource) {
tracer.done()
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
@@ -796,6 +836,8 @@ class HermesApiClient(
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(
@@ -804,6 +846,7 @@ class HermesApiClient(
type: String?,
data: String
) {
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
@@ -832,12 +875,14 @@ class HermesApiClient(
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) }
}
}
@@ -856,6 +901,7 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = when {
response != null && !response.isSuccessful ->
@@ -869,6 +915,7 @@ class HermesApiClient(
}
override fun onClosed(eventSource: EventSource) {
tracer.done()
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
@@ -977,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(
@@ -985,6 +1034,7 @@ class HermesApiClient(
type: String?,
data: String
) {
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
@@ -995,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) }
@@ -1055,6 +1113,7 @@ class HermesApiClient(
"reasoning.available" -> {
val reasoningText = event.text
if (!reasoningText.isNullOrEmpty()) {
tracer.mark("ttft")
mainHandler.post { onThinkingDelta(reasoningText) }
}
}
@@ -1141,6 +1200,7 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = when {
response != null && !response.isSuccessful ->
@@ -1154,6 +1214,7 @@ class HermesApiClient(
}
override fun onClosed(eventSource: EventSource) {
tracer.done()
if (completeCalled.compareAndSet(false, true)) {
mainHandler.post { onComplete() }
}
@@ -538,7 +538,7 @@ class BridgeCommandHandler(
put(
"error",
"Device Control is not included in the Google Play build " +
"of Hermes Relay. This build keeps Hermes Bridge Core " +
"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, " +
@@ -562,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.",
@@ -570,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",
)
}
)
@@ -813,7 +813,7 @@ 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
@@ -1219,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")
}
@@ -1234,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")
}
@@ -1258,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")
}
@@ -1312,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")
}
@@ -1370,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")
}
@@ -1411,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) {
@@ -8,6 +8,7 @@ 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
@@ -33,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.
//
@@ -200,6 +209,58 @@ 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 ->
@@ -594,6 +655,7 @@ class ChatHandler {
activeAnnotationTools.clear()
cardLineBuffer.clear()
dispatchedCardMarkers.clear()
subagentLabels.clear()
}
/**
@@ -735,6 +797,14 @@ class ChatHandler {
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 {
""
},
)
}
@@ -755,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
@@ -933,18 +1010,34 @@ 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() {
@@ -1616,26 +1709,29 @@ class ChatHandler {
}
}
fun onToolCallStart(
messageId: String,
toolCallId: String,
toolName: String,
runId: String? = null,
provenance: String? = null,
) {
_isStreaming.value = true
/** Monotonic suffix for synthetic generating / subagent ToolCall ids. */
private var syntheticToolSeq = 0
val toolCall = ToolCall(
id = toolCallId,
name = toolName,
/**
* 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,
runId = runId,
provenance = provenance,
isGenerating = true,
)
_messages.update { messages ->
val target = messages.findLast {
it.id == messageId && it.role == MessageRole.ASSISTANT
@@ -1643,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
}
@@ -1655,13 +1751,214 @@ class ChatHandler {
content = "",
timestamp = System.currentTimeMillis(),
isStreaming = true,
toolCalls = listOf(toolCall),
toolCalls = listOf(placeholder),
agentName = activeAgentName
)
}
}
}
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,
@@ -1818,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) {
@@ -1831,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,6 +76,43 @@ 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
@@ -102,13 +140,32 @@ 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(
@@ -147,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) {
@@ -276,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
@@ -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
}
}
@@ -5,6 +5,8 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,6 +21,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.sp
@@ -64,7 +67,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.ConnectionStatusBanner
import com.hermesandroid.relay.ui.components.ConnectionStatusToast
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
@@ -113,6 +116,9 @@ import com.hermesandroid.relay.ui.screens.prewarmDashboardManage
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayGridTexture
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.RelayProfileInspectorClient
import com.hermesandroid.relay.network.AutoVoiceAudioClient
import com.hermesandroid.relay.network.DynamicDashboardCookieJar
@@ -367,6 +373,8 @@ fun RelayApp() {
val chatApiClient by connectionViewModel.chatApiClient.collectAsState()
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
val mediaContext = androidx.compose.ui.platform.LocalContext.current
@@ -538,46 +546,95 @@ fun RelayApp() {
}
}
chatViewModel.observeConnectionSwitches(connectionViewModel.connectionSwitchEvents)
}
LaunchedEffect(chatApiClient) {
chatApiClient?.let { client ->
chatViewModel.initialize(client, connectionViewModel.chatHandler)
chatViewModel.updateApiClient(client)
// Wire inbound-media dependencies. Safe to call on every reinit —
// idempotent rewire of the ChatHandler callbacks.
chatViewModel.initializeMedia(
context = mediaContext,
relayHttpClient = connectionViewModel.relayHttpClient,
mediaSettingsRepo = connectionViewModel.mediaSettingsRepo,
mediaCacheWriter = connectionViewModel.mediaCacheWriter
)
// Agent-profile pick provider (Pass 2). Lambda reads the latest
// StateFlow value on every send, so ChatViewModel never needs a
// direct reference to ConnectionViewModel. Safe to rewire on
// every API-client swap — the lambda captures the long-lived
// VM, not the (per-connection) apiClient.
chatViewModel.setSelectedProfileProvider {
connectionViewModel.selectedProfile.value
}
chatViewModel.setEffectiveProfileProvider {
AgentDisplay.effectiveProfile(
selectedProfile = connectionViewModel.selectedProfile.value,
profiles = connectionViewModel.agentProfiles.value,
)
}
// Wire session persistence callback
chatViewModel.onSessionChanged = { sessionId ->
connectionViewModel.saveLastSessionId(sessionId)
}
// Mirror chat streaming state so a mid-turn route change defers its
// client rebuild instead of cancelling the live turn (the gateway
// socket rides the blip via its own reconnect).
launch {
chatViewModel.isStreaming.collect { connectionViewModel.setChatStreaming(it) }
}
}
LaunchedEffect(chatApiClient, activeConnectionId, selectedProfile?.name, lastSessionId) {
if (chatApiClient == null) return@LaunchedEffect
LaunchedEffect(chatApiClient) {
val client = chatApiClient ?: return@LaunchedEffect
val handler = connectionViewModel.chatHandler
// A route handoff / reconnect rebuilds the API client (new instance)
// while the chat is unchanged — the bound handler is the same. Take the
// cheap path: swap the client reference only, no re-init. This is what
// keeps the chat surface from repainting/reloading on a LAN↔Tailscale
// switch or a reconnect. A genuine re-bind (different handler) falls
// through to the full one-time wiring below.
if (chatViewModel.boundHandler === handler) {
chatViewModel.updateApiClient(client)
return@LaunchedEffect
}
chatViewModel.initialize(client, handler)
// Wire inbound-media dependencies. Idempotent rewire of the
// ChatHandler callbacks.
chatViewModel.initializeMedia(
context = mediaContext,
relayHttpClient = connectionViewModel.relayHttpClient,
mediaSettingsRepo = connectionViewModel.mediaSettingsRepo,
mediaCacheWriter = connectionViewModel.mediaCacheWriter
)
// Agent-profile pick provider (Pass 2). Lambda reads the latest
// StateFlow value on every send, so ChatViewModel never needs a
// direct reference to ConnectionViewModel. The lambda captures the
// long-lived VM, not the (per-connection) apiClient.
chatViewModel.setSelectedProfileProvider {
connectionViewModel.selectedProfile.value
}
chatViewModel.setEffectiveProfileProvider {
AgentDisplay.effectiveProfile(
selectedProfile = connectionViewModel.selectedProfile.value,
profiles = connectionViewModel.agentProfiles.value,
)
}
chatViewModel.setDisplayProfileProvider {
AgentDisplay.effectiveDisplayProfile(
selectedProfile = connectionViewModel.selectedProfile.value,
profiles = connectionViewModel.agentProfiles.value,
)
}
chatViewModel.setDisplayAliasProvider {
connectionViewModel.profileDisplayAlias.value
}
// Drawer session list, scoped to the active profile on gateway
// connections (dashboard `/api/sessions?profile=`). Returns null off the
// dashboard surface so refreshSessions() falls back to the shared list.
chatViewModel.setProfileSessionLister {
connectionViewModel.listProfileScopedSessions()
}
// …and load a tapped session's transcript from that same profile's DB.
chatViewModel.setProfileMessageLoader { sessionId ->
connectionViewModel.loadProfileScopedMessages(sessionId)
}
// Wire session persistence callback
chatViewModel.onSessionChanged = { sessionId ->
connectionViewModel.saveLastSessionId(sessionId)
}
}
// Reload sessions / switch profile context only on a SEMANTIC change
// (connection, profile, or restored session) — NOT on every API-client
// instance swap. Keying on a readiness flag instead of the client instance
// means a route handoff (which churns the client) no longer triggers a
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
// already no-ops when the context key + session are unchanged.
val chatClientReady = chatApiClient != null
LaunchedEffect(chatClientReady, activeConnectionId, selectedProfile?.name, lastSessionId) {
if (!chatClientReady) return@LaunchedEffect
// Coalesce the rapid lastSessionId null→value churn a profile switch
// produces: selectProfile() nulls lastSessionId, then the persisted
// per-profile session resolves a tick later. This effect re-fires on that
// change, cancelling the delay below before it commits — so we skip
// painting the intermediate empty draft and land straight on the resolved
// session (or a genuine fresh draft when the profile has no history).
delay(160)
chatViewModel.switchProfileContext(
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnectionId,
@@ -594,6 +651,10 @@ fun RelayApp() {
)
}
LaunchedEffect(selectedProfile?.name, agentProfiles, profileDisplayAlias) {
chatViewModel.refreshAgentDisplayName(relabelGenericMessages = true)
}
// === PHASE3-status: sync granular phone-status settings to chat ===
val appContextEnabled by connectionViewModel.appContextEnabled.collectAsState()
val appContextBridgeState by connectionViewModel.appContextBridgeState.collectAsState()
@@ -617,6 +678,14 @@ fun RelayApp() {
}
// === END PHASE3-status ===
// Mirror the "Notify when Hermes finishes" setting into ChatViewModel —
// same pattern as appContextSettings; the VM reads the plain field at
// turn-complete time instead of holding a ConnectionViewModel reference.
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
LaunchedEffect(notifyTurnComplete) {
chatViewModel.notifyOnTurnComplete = notifyTurnComplete
}
// Sync tool annotation parsing toggle to ChatHandler
val parseAnnotations by connectionViewModel.parseToolAnnotations.collectAsState()
LaunchedEffect(parseAnnotations) {
@@ -626,11 +695,24 @@ fun RelayApp() {
// Sync streaming endpoint preference to chat. Resolves "auto" against the
// current server capabilities so vanilla upstream + bootstrap-injected
// sessions API picks /v1/chat/completions for portable SSE chat while
// still using /api/sessions/* for browse/rename/delete.
// still using /api/sessions/* for browse/rename/delete. Gateway
// availability is a key so a Manage sign-in mid-session re-resolves
// "auto" to the gateway transport (live thinking) without an app restart.
val streamingEndpoint by connectionViewModel.streamingEndpoint.collectAsState()
val serverCapabilities by connectionViewModel.serverCapabilities.collectAsState()
LaunchedEffect(streamingEndpoint, serverCapabilities) {
chatViewModel.streamingEndpoint = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
// The resolved API route is a key so a mid-turn route switch (LAN→Tailscale)
// re-runs activeGatewayChatClient(), which RETARGETS the in-flight gateway
// client to follow the new dashboard route instead of stranding the turn on
// the dead one.
val effectiveApiUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
LaunchedEffect(streamingEndpoint, serverCapabilities, gatewayAvailability, effectiveApiUrl) {
val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
chatViewModel.streamingEndpoint = resolved
chatViewModel.sseFallbackEndpoint = connectionViewModel.resolveSseStreamingEndpoint()
chatViewModel.updateGatewayClient(
if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null,
)
}
// What's New auto-show
@@ -690,6 +772,7 @@ fun RelayApp() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val isOnboarding = currentRoute == Screen.Onboarding.route
val suppressGlobalChrome = !onboardingCompleted || isOnboarding
var bridgePrimaryReturnRoute by remember { mutableStateOf<String?>(null) }
var bridgePrimaryReturnLabel by remember { mutableStateOf<String?>(null) }
@@ -748,8 +831,15 @@ fun RelayApp() {
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val appReady by connectionViewModel.isReady.collectAsState()
val initialChatSettled by chatViewModel.initialChatSettled.collectAsState()
// The SAME readiness signal ChatScreen renders its "Connect Standard
// Hermes" CTA from (chat client exists + reachable verdict). The gate
// must release on this — releasing on the resolver's earlier
// evidence alone left a window where the reveal showed the CTA for
// the few hundred ms until the client-based health verdict landed.
val chatReady by connectionViewModel.chatReady.collectAsState()
var startupGateMinElapsed by remember { mutableStateOf(false) }
var startupGateTimedOut by remember { mutableStateOf(false) }
var startupGateReleased by remember { mutableStateOf(false) }
@@ -800,12 +890,82 @@ fun RelayApp() {
}
}
// ---- Startup narration: real states the checklist verifies ----
val startupEndpoint = activeEndpoint
val startupCheckTargets = if (!hasStartupConnection) {
emptyList()
} else {
listOf(
if (appReady) {
StartupCheck(StartupCheckState.Done, "state restored")
} else {
StartupCheck(StartupCheckState.Active, "restoring state")
},
when {
startupEndpoint != null -> StartupCheck(
StartupCheckState.Done,
"route · ${startupEndpoint.displayLabel()}",
)
startupApiUp ->
StartupCheck(StartupCheckState.Done, "route · direct")
appReady ->
StartupCheck(StartupCheckState.Active, "resolving route")
else -> StartupCheck(StartupCheckState.Pending, "route")
},
when {
startupApiUp ->
StartupCheck(StartupCheckState.Done, "hermes online")
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
StartupCheck(StartupCheckState.Failed, "hermes unreachable")
appReady ->
StartupCheck(StartupCheckState.Active, "contacting hermes")
else -> StartupCheck(StartupCheckState.Pending, "hermes")
},
// Done is keyed on chatReady — the signal ChatScreen itself
// renders from — so this row can never tick while the chat
// surface would still show its connect CTA.
when {
chatReady && initialChatSettled ->
StartupCheck(StartupCheckState.Done, "conversation ready")
startupApiUp ->
StartupCheck(StartupCheckState.Active, "loading conversation")
else -> StartupCheck(StartupCheckState.Pending, "conversation")
},
)
}
// ---- Narration choreography ----
// With the key-less fast path every readiness signal can be
// satisfied before the sphere finishes fading in — an all-✓-at-once
// reveal reads as "nothing was actually checked". So rows resolve
// strictly top-to-bottom and each one holds a spinner beat before
// its verdict lands, even when the underlying state was already
// true. The gate's happy path waits for the narration to finish;
// error and timeout releases don't.
var startupNarrationStage by remember { mutableStateOf(0) }
LaunchedEffect(startupCheckTargets, startupNarrationStage, startupGateReleased) {
if (startupGateReleased) return@LaunchedEffect
if (startupNarrationStage >= startupCheckTargets.size) return@LaunchedEffect
val target = startupCheckTargets[startupNarrationStage].state
if (target == StartupCheckState.Done || target == StartupCheckState.Failed) {
delay(350L)
startupNarrationStage += 1
}
}
val startupNarrationComplete =
startupNarrationStage >= startupCheckTargets.size
val startupConnectionResolved = appReady && (
!hasStartupConnection ||
// Happy path: server answering AND the last conversation has
// been restored (or there was none) — the chat surface is
// real before it's revealed.
(startupApiUp && initialChatSettled) ||
// Happy path: the chat surface's OWN readiness signal is
// true (client built + reachable verdict — what its connect
// CTA renders from), the last conversation has been restored
// (or there was none), and the checklist has visibly
// finished ticking. Anything weaker (e.g. the resolver's
// earlier health evidence) reveals a chat screen that still
// shows "Connect Standard Hermes" for the few hundred ms
// until the client-based verdict catches up.
(chatReady && initialChatSettled && startupNarrationComplete) ||
// Error path: a settled unreachable reveals the normal UI,
// which owns offline presentation (status pill, retry).
startupUnreachableSettled ||
@@ -822,11 +982,35 @@ fun RelayApp() {
startupGateMinElapsed &&
startupConnectionResolved
) {
// When the 12s backstop (not readiness, not a settled error)
// is what opened the gate, leave a diagnostic naming the
// conditions still unmet — the demo-video session measured
// 6–28s launch variance against the same LAN server and had
// no way to see why from the device.
val happyPathReady =
chatReady && initialChatSettled && startupNarrationComplete
if (
hasStartupConnection &&
!happyPathReady &&
!startupUnreachableSettled &&
startupGateTimedOut
) {
DiagnosticsLog.record(
category = DiagnosticCategory.Api,
severity = DiagnosticSeverity.Warning,
title = "Startup gate released by timeout",
detail = "chatReady=$chatReady " +
"historySettled=$initialChatSettled " +
"narration=$startupNarrationStage/${startupCheckTargets.size} " +
"health=$apiHealth " +
"route=${activeEndpoint?.role ?: "unresolved"}",
)
}
startupGateReleased = true
}
}
val showStartupSphere =
onboardingCompleted &&
!suppressGlobalChrome &&
!startupGateReleased &&
!voiceUiState.voiceMode
@@ -927,12 +1111,25 @@ fun RelayApp() {
val showUnattendedBanner = BuildFlavor.isSideload &&
masterEnabled &&
unattendedEnabled &&
!isOnboarding &&
!suppressGlobalChrome &&
!showStartupSphere &&
!voiceUiState.voiceMode
val showConnectionStatusBanner =
// Sideload-only update availability (UpdateViewModel short-circuits on
// googlePlay). Hoisted to the outer scope so the update toast can render
// in the floating Box overlay below alongside the connection toast.
val updateBannerState by updateViewModel.bannerState.collectAsState()
val availableUpdate = (updateBannerState as? UpdateCheckResult.Available)?.update
// Content-identity key so a swipe-up dismiss sticks for THIS status but
// a genuinely new status (different title/tone/phase) re-shows.
var dismissedStatusKey by remember { mutableStateOf<String?>(null) }
val currentStatusKey = globalConnectionStatus?.let {
"${it.title}|${it.tone}|${it.active}|${it.success}|${it.route}"
}
val showConnectionStatusToast =
globalConnectionStatus != null &&
!isOnboarding &&
currentStatusKey != dismissedStatusKey &&
!suppressGlobalChrome &&
!showStartupSphere &&
!voiceUiState.voiceMode
val onConnectionStatusBannerClick: () -> Unit = {
@@ -988,36 +1185,10 @@ fun RelayApp() {
)
}
// Sideload-only update banner. UpdateViewModel short-circuits on
// googlePlay so this block is effectively dead on that flavor.
// bannerState hides the banner for versions the user has
// dismissed, re-appearing automatically on a newer release.
val updateBannerState by updateViewModel.bannerState.collectAsState()
val availableUpdate = (updateBannerState as? UpdateCheckResult.Available)?.update
AnimatedVisibility(
visible = availableUpdate != null && !isOnboarding,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(200)),
) {
availableUpdate?.let { upd ->
UpdateBanner(
update = upd,
onDismiss = { updateViewModel.dismiss(upd.latestVersion) },
)
}
}
AnimatedVisibility(
visible = showConnectionStatusBanner,
enter = fadeIn(tween(160)),
exit = fadeOut(tween(180)),
) {
ConnectionStatusBanner(
status = globalConnectionStatus,
includeStatusBarPadding = !showUnattendedBanner && availableUpdate == null,
onClick = onConnectionStatusBannerClick,
)
}
// The update banner AND the connection-status indicator now render as
// floating overlay TOASTS in the Box below (see the top-overlay Column
// after the Scaffold), so they slide down OVER the content instead of
// taking layout space — no UI resize/cut on update / handoff / reconnect.
// (The app-wide ConnectionChip row that used to live here has been
// removed. Multi-connection switching is now reachable from the
@@ -1048,7 +1219,10 @@ fun RelayApp() {
// would otherwise double-pad and render too far down.
// Consume the inset here so the Scaffold tree treats
// the top edge as already handled.
if (showUnattendedBanner || showConnectionStatusBanner || connectionChipVisible) {
// The connection-status toast is now a floating overlay and
// doesn't occupy space above the Scaffold, so it no longer
// participates in the top-inset accounting.
if (showUnattendedBanner || connectionChipVisible) {
Modifier.consumeWindowInsets(WindowInsets.statusBars)
} else {
Modifier
@@ -1057,7 +1231,7 @@ fun RelayApp() {
contentWindowInsets = WindowInsets(0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (!isOnboarding && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
val leading = when {
apiReachable -> "api online"
relayReady -> "relay connected"
@@ -1072,7 +1246,14 @@ fun RelayApp() {
?: activeConnection?.label
?: "no route"
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() } ?: "default"
val modelLabel = serverModelName.takeIf { it.isNotBlank() } ?: "model pending"
val displayProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
?: AgentDisplay.displayModelName(displayProfile?.model)
?: AgentDisplay.displayModelName(serverModelName)
?: "model pending"
val safetyLabel = if (BuildFlavor.isSideload && masterEnabled) {
"safety: ${if (unattendedEnabled) "unattended" else "on"}"
} else {
@@ -1855,6 +2036,45 @@ fun RelayApp() {
}
} // end Column (wraps banner + Scaffold)
// Floating overlay toasts (update + connection status). Rendered in the
// Box, stacked top-down in one status-bar-padded Column so they slide
// down OVER the content without resizing it — no UI cut/resize on update
// / handoff / reconnect. Both gated off during onboarding / startup
// sphere / voice mode. The Column self-pads the status bar once; the
// children don't (so two stacked toasts don't double-pad).
Column(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.statusBars),
) {
AnimatedVisibility(
visible = availableUpdate != null && !suppressGlobalChrome &&
!showStartupSphere && !voiceUiState.voiceMode,
enter = slideInVertically(tween(220)) { -it } + fadeIn(tween(180)),
exit = slideOutVertically(tween(200)) { -it } + fadeOut(tween(160)),
) {
availableUpdate?.let { upd ->
UpdateBanner(
update = upd,
onDismiss = { updateViewModel.dismiss(upd.latestVersion) },
)
}
}
AnimatedVisibility(
visible = showConnectionStatusToast,
enter = slideInVertically(tween(220)) { -it } + fadeIn(tween(180)),
exit = slideOutVertically(tween(200)) { -it } + fadeOut(tween(160)),
) {
ConnectionStatusToast(
status = globalConnectionStatus,
includeStatusBarPadding = false,
onClick = onConnectionStatusBannerClick,
onDismiss = { dismissedStatusKey = currentStatusKey },
)
}
}
// (The ConnectionSwitcherSheet modal that used to live here was
// driven by the removed top-bar ConnectionChip. Switching is now
// inline in AgentInfoSheet's Connection section — see
@@ -1904,46 +2124,28 @@ fun RelayApp() {
}
// Startup checks — all rows are always laid out (pending
// ones dimmed, lighting up as they activate) so the column
// never reflows and the branding above never shifts.
if (hasStartupConnection) {
val endpoint = activeEndpoint
val checks = listOf(
if (appReady) {
StartupCheck(StartupCheckState.Done, "state restored")
} else {
StartupCheck(StartupCheckState.Active, "restoring state…")
},
// ones dimmed) so the column never reflows and the branding
// above never shifts. What renders is the CHOREOGRAPHED view
// of startupCheckTargets: rows above the narration stage show
// their real verdict, the stage row spins (unless it already
// failed), rows below sit dimmed with their short labels.
if (startupCheckTargets.isNotEmpty()) {
val pendingLabels = listOf("state", "route", "hermes", "conversation")
val displayedChecks = startupCheckTargets.mapIndexed { index, check ->
when {
endpoint != null -> StartupCheck(
StartupCheckState.Done,
"route · ${endpoint.displayLabel()}",
index < startupNarrationStage -> check
index == startupNarrationStage ->
if (check.state == StartupCheckState.Failed) {
check
} else {
check.copy(state = StartupCheckState.Active)
}
else -> StartupCheck(
StartupCheckState.Pending,
pendingLabels.getOrElse(index) { check.label },
)
startupApiUp ->
StartupCheck(StartupCheckState.Done, "route · direct")
appReady ->
StartupCheck(StartupCheckState.Active, "resolving route…")
else -> StartupCheck(StartupCheckState.Pending, "route")
},
when {
startupApiUp ->
StartupCheck(StartupCheckState.Done, "hermes online")
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
StartupCheck(StartupCheckState.Failed, "hermes unreachable")
appReady ->
StartupCheck(StartupCheckState.Active, "contacting hermes…")
else -> StartupCheck(StartupCheckState.Pending, "hermes")
},
when {
startupApiUp && initialChatSettled ->
StartupCheck(StartupCheckState.Done, "conversation ready")
startupApiUp -> StartupCheck(
StartupCheckState.Active,
"loading conversation…",
)
else -> StartupCheck(StartupCheckState.Pending, "conversation")
},
)
}
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(3.dp),
@@ -1951,7 +2153,7 @@ fun RelayApp() {
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
) {
checks.forEach { check -> StartupCheckRow(check) }
displayedChecks.forEach { check -> StartupCheckRow(check) }
}
}
}
@@ -1966,6 +2168,8 @@ private data class StartupCheck(
val label: String,
)
private val STARTUP_SPINNER_FRAMES = listOf("|", "/", "-", "\\")
private enum class StartupCheckState { Pending, Active, Done, Failed }
/**
@@ -1986,9 +2190,21 @@ private fun StartupCheckRow(check: StartupCheck) {
animationSpec = tween(400),
label = "startup-check-alpha",
)
// Classic ASCII spinner for the active row — guaranteed glyphs in the
// platform monospace font (fancier braille spinners render as tofu on
// some devices) and on-theme for terminal-style narration.
var spinnerFrame by remember { mutableStateOf(0) }
if (check.state == StartupCheckState.Active) {
LaunchedEffect(Unit) {
while (true) {
delay(120L)
spinnerFrame = (spinnerFrame + 1) % STARTUP_SPINNER_FRAMES.size
}
}
}
val glyph = when (check.state) {
StartupCheckState.Pending -> "·"
StartupCheckState.Active -> "›"
StartupCheckState.Active -> STARTUP_SPINNER_FRAMES[spinnerFrame]
StartupCheckState.Done -> "✓"
StartupCheckState.Failed -> "✕"
}
@@ -138,6 +138,24 @@ fun ActiveCardStandardStatusSection(
onClick = onOpenDashboard,
modifier = Modifier.fillMaxWidth(),
)
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")
}
}
}
}
/**
@@ -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),
@@ -37,6 +37,11 @@ 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
@@ -206,6 +211,185 @@ fun ConnectionStatusBanner(
}
}
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")
@@ -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,6 +61,7 @@ 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
@@ -634,9 +638,20 @@ fun AgentInfoSheet(
// 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()
@@ -676,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()
@@ -707,16 +732,22 @@ fun AgentInfoSheet(
) {
// ---- Header: avatar + agent name + live status ----
AgentSheetHeader(
profile = AgentDisplay.effectiveProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
),
profile = effectiveDisplayProfile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
localDisplayAlias = profileDisplayAlias,
serverModelName = serverModelName,
apiServerReachable = apiServerReachable,
chatMode = chatMode,
isCustomized = selectedProfile != null || selectedPersonality != "default",
isCustomized = selectedProfile != null ||
selectedPersonality != "default" ||
profileDisplayAlias != null,
)
DisplayAliasSection(
currentAlias = profileDisplayAlias,
fallbackName = serverResolvedAgentName,
onSave = connectionViewModel::setProfileDisplayAlias,
)
HorizontalDivider()
@@ -739,11 +770,13 @@ fun AgentInfoSheet(
val apparentActiveProfile = agentProfiles
.firstOrNull { it.gatewayRunning }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
SectionLabel(
title = "Profile",
hint = "Host-side Hermes contexts",
)
CollapsiblePickerSection(
title = "Profile",
hint = "Host-side Hermes contexts",
currentValue = profileDisplayAlias
?: AgentDisplay.profileDisplayName(selectedProfile)
?: "Server default",
) {
val defaultDotColor = serverDefaultProfile?.let { profile ->
if (profile.gatewayRunning) {
@@ -758,9 +791,13 @@ fun AgentInfoSheet(
val defaultRunning = serverDefaultProfile?.let { profile ->
if (profile.gatewayRunning) " \u2022 Running" else " \u2022 Idle"
}.orEmpty()
val defaultDisplay = serverDefaultProfile?.let { profile ->
AgentDisplay.profileDisplayName(profile)
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(
@@ -811,6 +848,9 @@ fun AgentInfoSheet(
onSelect = {
if (selectedProfile != null) {
connectionViewModel.selectProfile(null)
// Gateway turns carry no per-request profile —
// hot-swap the live session server-side too.
chatViewModel.activateGatewayProfile(null)
toast("Using Server default")
}
},
@@ -849,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() }
}
@@ -859,52 +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 (profile.hasIsolatedApi) {
if (isNotEmpty()) append(" \u2022 ")
append("isolated API")
} else {
if (isNotEmpty()) append(" \u2022 ")
append("compatibility overlay")
}
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
) {
{
ProfileMetadataBadge(
text = if (profile.hasIsolatedApi) "API" else "Overlay",
background = if (profile.hasIsolatedApi) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (profile.hasIsolatedApi) {
MaterialTheme.colorScheme.onTertiaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
// 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",
@@ -924,6 +959,10 @@ 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.hasIsolatedApi) {
" — profile API active"
@@ -971,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
@@ -1038,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 ----
@@ -1259,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
@@ -1405,6 +1630,7 @@ private fun AgentSheetHeader(
profile: Profile?,
selectedPersonality: String,
defaultPersonality: String,
localDisplayAlias: String?,
serverModelName: String,
apiServerReachable: Boolean,
chatMode: ChatMode,
@@ -1415,8 +1641,10 @@ private fun AgentSheetHeader(
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = null,
localDisplayAlias = localDisplayAlias,
)
val modelLabel = profile?.model ?: serverModelName
val modelLabel = AgentDisplay.displayModelName(profile?.model)
?: AgentDisplay.displayModelName(serverModelName)
val isConnecting = !apiServerReachable && chatMode != ChatMode.DISCONNECTED
val statusText = when {
apiServerReachable -> "Connected"
@@ -1467,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,
@@ -537,7 +537,7 @@ fun ConnectionWizard(
reorderedPayload.serverUrl,
reorderedPayload.key,
"",
"",
reorderedPayload.dashboardUrl.orEmpty(),
reorderedPayload.endpoints,
)
} else {
@@ -626,6 +626,7 @@ fun ConnectionWizard(
if (payload.relay == null) {
standardApiUrl = payload.serverUrl
standardApiKey = payload.key
standardDashboardUrl = payload.dashboardUrl.orEmpty()
standardError = null
standardSuccess = null
}
@@ -705,7 +706,7 @@ fun ConnectionWizard(
payload.serverUrl,
payload.key,
"",
"",
payload.dashboardUrl.orEmpty(),
payload.endpoints,
)
} else {
@@ -1838,7 +1839,7 @@ private fun ManualEntryStep(
singleLine = true,
isError = relayError != null,
supportingText = {
Text(relayError ?: "Hermes Relay — Terminal, Bridge, relay sessions, and grants")
Text(relayError ?: "Hermes-Relay — Terminal, Bridge, relay sessions, and grants")
},
modifier = Modifier.fillMaxWidth(),
)
@@ -1938,7 +1939,7 @@ private fun ShowCodeStep(
singleLine = true,
isError = relayError != null,
supportingText = {
Text(relayError ?: "Hermes Relay — Terminal, Bridge, relay sessions, and grants")
Text(relayError ?: "Hermes-Relay — Terminal, Bridge, relay sessions, and grants")
},
modifier = Modifier.fillMaxWidth(),
)
@@ -2180,7 +2181,10 @@ private fun ConfirmStep(
if (relayUrl == null) {
LabeledLine(
label = "Dashboard",
value = Connection.deriveDefaultDashboardUrl(payload.serverUrl)
value = payload.dashboardUrl
?.trim()
?.takeIf { it.isNotBlank() }
?: Connection.deriveDefaultDashboardUrl(payload.serverUrl)
?: "Derived from API URL",
hint = "Manage",
)
@@ -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),
)
}
}
@@ -2,8 +2,13 @@ package com.hermesandroid.relay.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -20,37 +25,65 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.HourglassBottom
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
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.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
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.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.HermesCardField
import com.hermesandroid.relay.data.HermesCardInput
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Inline rich-card render for a [HermesCard] extracted from an assistant
@@ -69,11 +102,23 @@ import com.hermesandroid.relay.data.HermesCardField
* fields + actions — so a newer agent emitting a type the phone build
* doesn't recognize still gets a coherent card, not an empty bubble.
*
* Action dispatch is fully delegated to [onActionTap]. The bubble is
* stateless w.r.t. dispatch tracking — it reads [dispatches] (from the
* owning [com.hermesandroid.relay.data.ChatMessage.cardDispatches]) and
* renders a confirmation row instead of the action buttons once the user
* has chosen.
* Action dispatch is fully delegated to [onActionTap]; input-slot
* submissions (ask cards — clarify answer, secret value, sudo confirm)
* are delegated to [onInputSubmit] the same way. The bubble is stateless
* w.r.t. dispatch tracking — it reads [dispatches] (from the owning
* [com.hermesandroid.relay.data.ChatMessage.cardDispatches]) and renders
* a confirmation row instead of the answer surfaces once the user has
* chosen.
*
* Timed asks ([HermesCardInput.expiresAtMillis]) tick a countdown footer
* (Amber under 30s) and self-collapse to a muted "Expired — not granted"
* stamp past expiry — history reload past the deadline lands directly in
* the collapsed state, so a dead ask never re-prompts.
*
* Secrets: when [HermesCardInput.masked] is set, the submitted value goes
* only through [onInputSubmit]; the collapse stamp renders masked dots and
* the caller must record [HermesCardInput.SECRET_PROVIDED_STAMP] as the
* dispatch value, never the secret itself.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -82,6 +127,7 @@ fun HermesCardBubble(
cardKey: String,
dispatches: List<HermesCardDispatch>,
onActionTap: (cardKey: String, action: HermesCardAction) -> Unit,
onInputSubmit: (cardKey: String, value: String) -> Unit,
modifier: Modifier = Modifier,
maxWidth: Dp = 280.dp,
) {
@@ -89,6 +135,21 @@ fun HermesCardBubble(
val typeIcon = iconForType(card.type)
val alreadyChosen = dispatches.firstOrNull { it.cardKey == cardKey }
// Expiry clock for timed asks. Ticks once a second while the deadline
// is ahead; freezes after. Keyed on the deadline so a re-used card id
// with a fresh expiry restarts the loop.
val expiresAt = card.input?.expiresAtMillis
var nowMillis by remember(expiresAt) { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(expiresAt) {
if (expiresAt == null) return@LaunchedEffect
while (System.currentTimeMillis() < expiresAt) {
nowMillis = System.currentTimeMillis()
delay(1_000)
}
nowMillis = System.currentTimeMillis()
}
val expired = expiresAt != null && nowMillis >= expiresAt && alreadyChosen == null
Card(
modifier = modifier
.widthIn(max = maxWidth)
@@ -178,26 +239,66 @@ fun HermesCardBubble(
}
}
// Actions OR dispatch confirmation
if (card.actions.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
if (alreadyChosen != null) {
val chosen = card.actions.firstOrNull {
// Input slot + actions OR a single dispatch/expiry stamp.
// A card with an input slot owns ONE stamp for the whole
// card — answering via chip, field, hold-confirm, or an
// action button all collapse the same way.
val input = card.input
when {
alreadyChosen != null -> {
Spacer(Modifier.height(10.dp))
val chosenAction = card.actions.firstOrNull {
it.value == alreadyChosen.actionValue
}
ChoseRow(chosen?.label ?: alreadyChosen.actionValue)
} else {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
card.actions.forEach { action ->
ActionButton(
action = action,
onClick = { onActionTap(cardKey, action) },
)
when {
chosenAction != null -> ChoseRow("Chose: ${chosenAction.label}")
input?.masked == true -> ChoseRow("Secret provided · ••••")
input != null -> ChoseRow("Answered: ${alreadyChosen.actionValue}")
else -> ChoseRow("Chose: ${alreadyChosen.actionValue}")
}
}
expired -> {
Spacer(Modifier.height(10.dp))
ChoseRow(
text = "Expired — not granted",
icon = Icons.Filled.HourglassBottom,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> {
if (input != null) {
Spacer(Modifier.height(10.dp))
CardInputSlot(
input = input,
onSubmit = { value -> onInputSubmit(cardKey, value) },
)
}
if (card.actions.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
card.actions.forEach { action ->
ActionButton(
action = action,
onClick = { onActionTap(cardKey, action) },
)
}
}
}
// Countdown footer for timed asks — Amber when the
// deadline is inside 30s.
if (expiresAt != null) {
val remainingSec = ((expiresAt - nowMillis) / 1000).coerceAtLeast(0)
Spacer(Modifier.height(8.dp))
Text(
text = "expires in %d:%02d".format(remainingSec / 60, remainingSec % 60),
style = relayMetadataStyle(),
color = if (remainingSec < 30) RelayRefresh.Amber
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -250,7 +351,11 @@ private fun looksMonospaceWorthy(value: String): Boolean {
}
@Composable
private fun ChoseRow(label: String) {
private fun ChoseRow(
text: String,
icon: ImageVector = Icons.Filled.Check,
iconTint: Color = MaterialTheme.colorScheme.primary,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -259,20 +364,264 @@ private fun ChoseRow(label: String) {
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Icon(
imageVector = Icons.Filled.Check,
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
tint = iconTint,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = "Chose: $label",
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
/**
* The interactive answer surface for ask cards, composed from the
* [HermesCardInput] flags rather than the card type:
* - [HermesCardInput.choices] → AssistChip row, one tap dispatches.
* - [HermesCardInput.allowFreeText] → [InlineAnswerField] mini pill +
* 18dp send affordance.
* - [HermesCardInput.masked] → password-style OutlinedTextField with a
* reveal toggle and the "Not stored in chat history" assurance line.
* - [HermesCardInput.holdToConfirm] → [HoldToConfirmButton] replaces the
* plain submit (sudo). With [HermesCardInput.masked] it submits the
* typed value; bare, it submits [HermesCardInput.CONFIRM_VALUE].
*
* Unknown kinds degrade to the free-text field so a newer ask still gets
* an answer surface.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CardInputSlot(
input: HermesCardInput,
onSubmit: (String) -> Unit,
) {
// Deliberately remember, not rememberSaveable — a typed secret must
// never be written into the saved-instance-state Bundle.
var answerText by remember { mutableStateOf("") }
var reveal by remember { mutableStateOf(false) }
val showFreeText = !input.masked && (
input.allowFreeText ||
input.kind == HermesCardInput.Kinds.TEXT ||
// Unknown-kind fallback: with no other surface, still offer text.
(input.choices.isEmpty() && !input.holdToConfirm &&
input.kind != HermesCardInput.Kinds.CONFIRM)
)
Column(modifier = Modifier.fillMaxWidth()) {
// Choice chips
if (input.choices.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
input.choices.forEach { choice ->
AssistChip(
onClick = { onSubmit(choice) },
label = {
Text(choice, style = MaterialTheme.typography.labelMedium)
},
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
labelColor = MaterialTheme.colorScheme.onSurface,
),
)
}
}
}
// Masked secret field
if (input.masked) {
if (input.choices.isNotEmpty()) Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = answerText,
onValueChange = { answerText = it },
singleLine = true,
visualTransformation = if (reveal) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { reveal = !reveal }) {
Icon(
imageVector = if (reveal) Icons.Filled.VisibilityOff
else Icons.Filled.Visibility,
contentDescription = if (reveal) "Hide value" else "Reveal value",
modifier = Modifier.size(20.dp),
)
}
},
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(6.dp))
Text(
text = "Not stored in chat history",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
// Free-text mini field (clarify)
if (showFreeText) {
if (input.choices.isNotEmpty()) Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.Bottom) {
InlineAnswerField(
value = answerText,
onValueChange = { answerText = it },
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { onSubmit(answerText.trim()) },
enabled = answerText.isNotBlank(),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send answer",
tint = if (answerText.isNotBlank()) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
// Submit affordance for masked / hold-to-confirm inputs
when {
input.holdToConfirm -> {
Spacer(Modifier.height(10.dp))
HoldToConfirmButton(
label = "Hold to confirm",
enabled = !input.masked || answerText.isNotEmpty(),
onConfirmed = {
onSubmit(
if (input.masked) answerText
else HermesCardInput.CONFIRM_VALUE,
)
},
)
}
input.masked -> {
Spacer(Modifier.height(10.dp))
Button(
onClick = { onSubmit(answerText) },
enabled = answerText.isNotEmpty(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) { Text("Submit", style = MaterialTheme.typography.labelMedium) }
}
}
}
}
/**
* Mini pill answer field — Navy3 surface, hairline border, bodyMedium,
* single line growing to 3. The in-card sibling of the chat input pill.
*/
@Composable
private fun InlineAnswerField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(16.dp)
Box(
modifier = modifier
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
if (value.isEmpty()) {
Text(
text = "Type an answer…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
)
}
}
/** Sudo confirm hold duration — long enough to defeat a drive-by tap. */
private const val HOLD_TO_CONFIRM_MS = 650
/**
* Error-container button whose fill completes over [HOLD_TO_CONFIRM_MS] of
* sustained press (pointerInput onPress racing tryAwaitRelease). Releasing
* early snaps the fill back; holding to completion fires [onConfirmed]
* exactly once at the moment the fill lands.
*/
@Composable
private fun HoldToConfirmButton(
label: String,
enabled: Boolean,
onConfirmed: () -> Unit,
modifier: Modifier = Modifier,
) {
val fill = remember { Animatable(0f) }
val errorColor = MaterialTheme.colorScheme.error
val shape = RoundedCornerShape(20.dp)
Box(
modifier = modifier
.fillMaxWidth()
.height(40.dp)
.clip(shape)
.background(errorColor.copy(alpha = if (enabled) 0.18f else 0.08f))
.border(1.dp, errorColor.copy(alpha = if (enabled) 0.6f else 0.25f), shape)
.pointerInput(enabled) {
if (!enabled) return@pointerInput
detectTapGestures(
onPress = {
coroutineScope {
val ramp = launch {
fill.animateTo(
targetValue = 1f,
animationSpec = tween(HOLD_TO_CONFIRM_MS, easing = LinearEasing),
)
onConfirmed()
}
tryAwaitRelease()
ramp.cancel()
}
fill.snapTo(0f)
},
)
}
.semantics { contentDescription = "$label — press and hold" },
contentAlignment = Alignment.Center,
) {
// Press-fill layer grows left → right under the label.
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.fillMaxHeight()
.fillMaxWidth(fill.value.coerceIn(0f, 1f))
.background(errorColor.copy(alpha = 0.45f)),
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = if (enabled) errorColor else errorColor.copy(alpha = 0.5f),
)
}
}
@Composable
private fun ActionButton(
action: HermesCardAction,
@@ -320,6 +669,10 @@ private fun iconForType(type: String): ImageVector? = when (type) {
HermesCard.BuiltInTypes.CALENDAR_EVENT -> Icons.Filled.CalendarToday
HermesCard.BuiltInTypes.WEATHER -> Icons.Filled.WbSunny
HermesCard.BuiltInTypes.SKILL_RESULT -> Icons.Filled.AutoAwesome
HermesCard.BuiltInTypes.ASK_APPROVAL -> Icons.Filled.Shield
HermesCard.BuiltInTypes.ASK_SUDO -> Icons.Filled.Shield
HermesCard.BuiltInTypes.ASK_CLARIFY -> Icons.Filled.AutoAwesome
HermesCard.BuiltInTypes.ASK_SECRET -> Icons.Filled.Lock
else -> Icons.Filled.AutoAwesome
}
@@ -1,9 +1,13 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
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
@@ -13,11 +17,17 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.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.draw.clip
@@ -31,7 +41,11 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.AttachmentRenderMode
import com.hermesandroid.relay.data.AttachmentState
import com.hermesandroid.relay.util.MediaSaver
import com.hermesandroid.relay.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Discord-style inline render for any attachment — outbound (user-authored)
@@ -213,12 +227,25 @@ private fun ImageRender(
}
if (bitmap != null) {
var viewerOpen by remember { mutableStateOf(false) }
if (viewerOpen) {
ChatImageViewer(
source = ChatImageViewerSource.Bitmap(
bitmap = bitmap,
displayName = attachment.fileName ?: "image",
mime = attachment.contentType.ifBlank { "image/*" },
bytesProvider = { attachmentBytes(context, attachment) },
),
onDismiss = { viewerOpen = false },
)
}
Image(
bitmap = bitmap,
contentDescription = attachment.fileName,
modifier = modifier
.widthIn(max = maxWidth)
.clip(RoundedCornerShape(8.dp)),
.clip(RoundedCornerShape(8.dp))
.clickable { viewerOpen = true },
contentScale = ContentScale.FillWidth
)
} else {
@@ -232,6 +259,7 @@ private fun ImageRender(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FileCardRender(
attachment: Attachment,
@@ -239,32 +267,30 @@ private fun FileCardRender(
maxWidth: Dp
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val (emoji, typeLabel) = emojiAndLabelFor(attachment.renderMode, attachment.contentType)
var menuExpanded by remember { mutableStateOf(false) }
val openExternal = {
val uriStr = attachment.cachedUri
if (!uriStr.isNullOrBlank()) {
MediaSaver.open(context, Uri.parse(uriStr), attachment.contentType)
}
}
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = modifier
.widthIn(max = maxWidth)
.clickable {
val uriStr = attachment.cachedUri
if (!uriStr.isNullOrBlank()) {
try {
val uri = Uri.parse(uriStr)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, attachment.contentType)
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_ACTIVITY_NEW_TASK
)
}
context.startActivity(intent)
} catch (_: Exception) {
// No viewer installed or malformed URI — silently ignore.
}
}
}
// Tap opens externally (unchanged); long-press surfaces the
// Open / Share / Save menu — only when there are bytes to act on.
.combinedClickable(
onClick = openExternal,
onLongClick = { if (!attachment.cachedUri.isNullOrBlank()) menuExpanded = true },
)
) {
Box {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -298,9 +324,80 @@ private fun FileCardRender(
}
}
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text("Open") },
onClick = {
menuExpanded = false
openExternal()
},
)
DropdownMenuItem(
text = { Text("Share") },
onClick = {
menuExpanded = false
scope.launch {
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
attachmentToast(context, "Couldn't read this file")
return@launch
}
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
},
)
DropdownMenuItem(
text = { Text("Save to device") },
onClick = {
menuExpanded = false
scope.launch {
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
attachmentToast(context, "Couldn't read this file")
return@launch
}
when (val result = MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)) {
is MediaSaver.SaveResult.Saved ->
attachmentToast(context, "Saved to ${result.location}")
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
attachmentToast(context, "Save failed: ${result.message}")
}
}
},
)
}
} // end Box
}
}
/**
* Original bytes behind an attachment — read from the cached `content://` URI
* when present (inbound), else base64-decoded from the inline content
* (outbound). Off the main thread; null when neither source is available.
*/
private suspend fun attachmentBytes(context: Context, attachment: Attachment): ByteArray? {
val uriStr = attachment.cachedUri
return when {
!uriStr.isNullOrBlank() -> MediaSaver.readUriBytes(context, Uri.parse(uriStr))
attachment.content.isNotBlank() -> withContext(Dispatchers.IO) {
runCatching {
android.util.Base64.decode(attachment.content, android.util.Base64.DEFAULT)
}.getOrNull()
}
else -> null
}
}
private fun attachmentToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
private fun emojiAndLabelFor(
mode: AttachmentRenderMode,
contentType: String
@@ -1,34 +1,26 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
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.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
import com.mikepenz.markdown.compose.extendedspans.ExtendedSpans
import com.mikepenz.markdown.compose.extendedspans.RoundedCornerSpanPainter
import com.mikepenz.markdown.m3.Markdown
@@ -37,7 +29,6 @@ import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.markdownExtendedSpans
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import kotlinx.coroutines.delay
@Composable
fun MarkdownContent(
@@ -55,12 +46,11 @@ fun MarkdownContent(
modifier = modifier,
colors = markdownColor(
text = textColor,
codeText = MaterialTheme.colorScheme.onSurfaceVariant,
codeBackground = MaterialTheme.colorScheme.surfaceVariant,
linkText = MaterialTheme.colorScheme.primary
inlineCodeBackground = MaterialTheme.colorScheme.surfaceVariant
),
typography = markdownTypography(
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
paragraph = MaterialTheme.typography.bodyMedium.copy(color = textColor),
code = MaterialTheme.typography.bodySmall.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -68,14 +58,20 @@ fun MarkdownContent(
),
components = markdownComponents(
codeBlock = {
MarkdownCodeBlock(it.content, it.node) { code, language ->
CodeBlockWithCopyButton(code, language, highlightsBuilder)
}
MarkdownHighlightedCodeBlock(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
showHeader = true
)
},
codeFence = {
MarkdownCodeFence(it.content, it.node) { code, language ->
CodeBlockWithCopyButton(code, language, highlightsBuilder)
}
MarkdownHighlightedCodeFence(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
showHeader = true
)
}
),
extendedSpans = markdownExtendedSpans {
@@ -85,46 +81,147 @@ fun MarkdownContent(
}
@Composable
private fun CodeBlockWithCopyButton(
code: String,
language: String?,
highlightsBuilder: Highlights.Builder
fun StreamingMarkdownContent(
content: String,
textColor: Color,
modifier: Modifier = Modifier
) {
val clipboardManager = LocalClipboardManager.current
val haptic = LocalHapticFeedback.current
var copied by remember { mutableStateOf(false) }
val blocks = remember(content) { parseStreamingMarkdownBlocks(content) }
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
blocks.forEach { block ->
when (block) {
is StreamingMarkdownBlock.Text -> Text(
text = block.text,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
)
is StreamingMarkdownBlock.Code -> StreamingCodeBlock(block)
}
}
}
}
Box {
MarkdownHighlightedCode(code, language, highlightsBuilder)
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(code))
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
copied = true
},
modifier = Modifier
.size(28.dp)
.align(Alignment.TopEnd)
.padding(4.dp),
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
)
@Composable
private fun StreamingCodeBlock(block: StreamingMarkdownBlock.Code) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f),
) {
Column(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy code",
modifier = Modifier.size(14.dp),
tint = if (copied) Color(0xFF4CAF50)
else MaterialTheme.colorScheme.onSurfaceVariant
if (block.language.isNotBlank()) {
Text(
text = block.language,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.68f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = block.code.ifEmpty { " " },
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
),
softWrap = false,
)
}
}
}
private sealed interface StreamingMarkdownBlock {
data class Text(val text: String) : StreamingMarkdownBlock
data class Code(val language: String, val code: String) : StreamingMarkdownBlock
}
private fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
if (content.isBlank()) return emptyList()
val blocks = mutableListOf<StreamingMarkdownBlock>()
val paragraph = StringBuilder()
val code = StringBuilder()
var inFence = false
var activeFence = ""
var language = ""
fun flushParagraph() {
val text = paragraph.toString().trimEnd()
if (text.isNotBlank()) {
blocks += StreamingMarkdownBlock.Text(text)
}
paragraph.clear()
}
fun flushCode() {
blocks += StreamingMarkdownBlock.Code(
language = language,
code = code.toString().trimEnd('\n'),
)
code.clear()
}
val lines = content
.replace("\r\n", "\n")
.replace('\r', '\n')
.split('\n')
lines.forEachIndexed { index, line ->
val lineWithBreak = if (index == lines.lastIndex) line else "$line\n"
if (!inFence) {
val fence = streamingFenceMarker(line)
if (fence != null) {
flushParagraph()
inFence = true
activeFence = fence
language = streamingFenceLanguage(line, fence)
} else {
paragraph.append(lineWithBreak)
}
} else if (streamingFenceMarker(line) == activeFence) {
flushCode()
inFence = false
activeFence = ""
language = ""
} else {
code.append(lineWithBreak)
}
}
if (inFence) {
flushCode()
} else {
flushParagraph()
}
return blocks
}
private fun streamingFenceMarker(line: String): String? {
val trimmed = line.trimStart()
return when {
trimmed.startsWith("```") -> "```"
trimmed.startsWith("~~~") -> "~~~"
else -> null
}
}
private fun streamingFenceLanguage(line: String, marker: String): String {
val tail = line.trimStart().removePrefix(marker).trim()
return tail
.takeWhile { !it.isWhitespace() && it != '`' && it != '~' }
.take(32)
}
@@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
@@ -49,7 +50,6 @@ import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.ui.theme.leftEdgeGlow
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -85,7 +85,20 @@ fun MessageBubble(
* card collapses) and forwards the action value per its mode.
* Defaults to no-op so legacy callers / tests don't have to wire it.
*/
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit = { _, _, _ -> }
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit = { _, _, _ -> },
/**
* Invoked when the user submits a card's interactive input slot (the
* gateway ask cards — clarify answer, secret value, sudo confirm).
* Routed to [com.hermesandroid.relay.viewmodel.ChatViewModel.answerAsk]
* by ChatScreen; defaults to no-op for legacy callers.
*/
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit = { _, _, _ -> },
/**
* "Edit & resend" entry in the USER-bubble long-press menu — gateway
* transport only (the only path that supports rewinding the server
* conversation). Null hides the entry.
*/
onEditMessage: ((ChatMessage) -> Unit)? = null,
) {
val isUser = message.role == MessageRole.USER
val isSystem = message.role == MessageRole.SYSTEM
@@ -137,10 +150,23 @@ fun MessageBubble(
}
val alignment = if (isUser) Alignment.End else Alignment.Start
val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
val locale = LocalLocale.current.platformLocale
val timeFormat = remember(locale) { SimpleDateFormat("h:mm a", locale) }
val a11yDescription = "${message.role.name.lowercase()} message: ${message.content.take(100)}"
val isDarkTheme = isSystemInDarkTheme()
// Pull generated/inline image links (`![alt](src)`) out of assistant
// content so they render as real images (remote URLs via Coil) or a
// graceful inline notice — not the blank element the markdown renderer
// emits for an image link. User/system bubbles keep their raw content.
val (markdownBody, inlineImages) = remember(message.content, isUser, isSystem) {
if (isUser || isSystem) {
message.content to emptyList()
} else {
extractChatInlineImages(message.content)
}
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = alignment
@@ -174,6 +200,7 @@ fun MessageBubble(
ThinkingBlock(
thinkingContent = message.thinkingContent,
isStreaming = message.isThinkingStreaming,
timestamp = message.timestamp,
modifier = Modifier
.widthIn(max = maxBubbleWidth)
.padding(bottom = 4.dp)
@@ -187,6 +214,21 @@ fun MessageBubble(
// is rendered as a separate Box so it hugs the bubble's left edge
// regardless of content height (tall bubbles with multi-line
// markdown stretch the bar via fillMaxHeight + IntrinsicSize).
//
// Suppress an otherwise-empty assistant bubble: a message that
// carries only thinking and/or tool calls (both rendered OUTSIDE
// this Surface — the ThinkingBlock above, the tool pills as separate
// rows) would otherwise paint a bare timestamp-only chip between the
// Thought-process block and the tool pill. Keep the bubble while
// streaming (StreamingDots is the live "working" indicator) and
// whenever there are cards/attachments to render inside it.
val showBubble = isUser || isSystem ||
message.content.isNotBlank() ||
message.isStreaming ||
message.cards.isNotEmpty() ||
message.attachments.isNotEmpty() ||
inlineImages.isNotEmpty()
if (showBubble) {
Row(
modifier = Modifier.widthIn(max = maxBubbleWidth),
verticalAlignment = Alignment.Top,
@@ -205,7 +247,8 @@ fun MessageBubble(
// wired; with copy as the only action it stays a direct copy so the
// one-action case doesn't pay a menu tap.
var showMessageActions by remember { mutableStateOf(false) }
if (onQuoteMessage != null) {
val showEditAction = onEditMessage != null && isUser
if (onQuoteMessage != null || showEditAction) {
DropdownMenu(
expanded = showMessageActions,
onDismissRequest = { showMessageActions = false },
@@ -217,13 +260,24 @@ fun MessageBubble(
onCopyMessage(message.content)
},
)
DropdownMenuItem(
text = { Text("Quote in reply") },
onClick = {
showMessageActions = false
onQuoteMessage(message.content)
},
)
if (onQuoteMessage != null) {
DropdownMenuItem(
text = { Text("Quote in reply") },
onClick = {
showMessageActions = false
onQuoteMessage(message.content)
},
)
}
if (showEditAction) {
DropdownMenuItem(
text = { Text("Edit & resend") },
onClick = {
showMessageActions = false
onEditMessage(message)
},
)
}
}
}
Surface(
@@ -242,7 +296,7 @@ fun MessageBubble(
.combinedClickable(
onClick = {},
onLongClick = {
if (onQuoteMessage != null) {
if (onQuoteMessage != null || showEditAction) {
showMessageActions = true
} else {
onCopyMessage(message.content)
@@ -261,16 +315,38 @@ fun MessageBubble(
color = textColor
)
} else {
// Markdown for assistant messages
if (message.content.isNotEmpty()) {
MarkdownContent(
content = message.content,
textColor = textColor
)
// Use a stable lightweight renderer while streaming.
// The full parser/highlighter can rebuild block shapes
// on every partial fence/token and visibly flicker.
if (markdownBody.isNotEmpty()) {
if (message.isStreaming) {
StreamingMarkdownContent(
content = markdownBody,
textColor = textColor
)
} else {
MarkdownContent(
content = markdownBody,
textColor = textColor
)
}
}
}
}
// Inline generated images (assistant only) — rendered OUTSIDE
// the SelectionContainer (they're not selectable text). Remote
// http(s) URLs load via Coil; server-local paths and load
// failures degrade to a notice that says why, instead of a
// blank space.
if (!isUser && !isSystem && inlineImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
ChatInlineImages(
images = inlineImages,
maxWidth = maxBubbleWidth - 24.dp,
)
}
// Rich cards — rendered between the markdown body and
// attachments so the reading order stays: narration → card
// → attached file. Each card gets a stable key built from
@@ -288,6 +364,9 @@ fun MessageBubble(
onActionTap = { key, action ->
onCardAction(message.id, key, action)
},
onInputSubmit = { key, value ->
onCardInput(message.id, key, value)
},
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
@@ -340,6 +419,7 @@ fun MessageBubble(
}
}
} // end Row (bubble + optional leading accent bar)
} // end if (showBubble)
}
}
@@ -163,6 +163,8 @@ data class HermesPairingPayload(
val port: Int = 8642,
val key: String = "",
val tls: Boolean = false,
@SerialName("dashboard_url")
val dashboardUrl: String? = null,
val relay: RelayPairing? = null,
val sig: String? = null,
/**
@@ -228,7 +230,8 @@ private val json = Json {
* For standard Hermes setup, also accepts generic API-only QRs:
* - a plain `http://host:8642` or `https://host:8642` URL
* - JSON with `api_url`, `apiUrl`, `server_url`, `serverUrl`, or `url`, plus
* optional `api_key`, `apiKey`, or `key`
* optional `api_key`, `apiKey`, or `key`, and optional `dashboard_url` or
* `dashboardUrl`
*
* **Endpoint synthesis (ADR 24):** when the payload has no `endpoints`
* array (v1/v2 QRs), a single priority-0 [EndpointCandidate] is materialized
@@ -257,6 +260,13 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
if (version < 1) return null
val decoded = json.decodeFromString<HermesPairingPayload>(raw)
if (decoded.host.isBlank()) return null
val dashboardAlias = firstString(obj, "dashboardUrl")
val decodedWithAliases =
if (decoded.dashboardUrl.isNullOrBlank() && dashboardAlias != null) {
decoded.copy(dashboardUrl = dashboardAlias)
} else {
decoded
}
// TODO(security): verify `decoded.sig` against the server's HMAC
// secret once the pairing protocol exposes a public verification
@@ -267,10 +277,12 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
// Synthesize a single priority-0 candidate from the top-level fields
// when the wire payload didn't carry an explicit `endpoints` array.
// v3+ payloads with an explicit array pass through untouched.
if (decoded.endpoints.isNullOrEmpty()) {
decoded.copy(endpoints = listOf(synthesizeLegacyEndpoint(decoded)))
if (decodedWithAliases.endpoints.isNullOrEmpty()) {
decodedWithAliases.copy(
endpoints = listOf(synthesizeLegacyEndpoint(decodedWithAliases)),
)
} else {
decoded
decodedWithAliases
}
} catch (_: Exception) {
null
@@ -289,7 +301,8 @@ private fun parseGenericApiJsonQr(raw: String): HermesPairingPayload? {
"url",
) ?: return null
val apiKey = firstString(obj, "api_key", "apiKey", "key").orEmpty()
payloadFromApiUrl(apiUrl, apiKey)
val dashboardUrl = firstString(obj, "dashboard_url", "dashboardUrl")
payloadFromApiUrl(apiUrl, apiKey, dashboardUrl)
} catch (_: Exception) {
null
}
@@ -305,7 +318,11 @@ private fun firstString(obj: JsonObject, vararg names: String): String? {
}
}
private fun payloadFromApiUrl(apiUrl: String, apiKey: String): HermesPairingPayload? {
private fun payloadFromApiUrl(
apiUrl: String,
apiKey: String,
dashboardUrl: String? = null,
): HermesPairingPayload? {
val uri = runCatching { URI(apiUrl.trim().trimEnd('/')) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase()
val tls = when (scheme) {
@@ -319,6 +336,7 @@ private fun payloadFromApiUrl(apiUrl: String, apiKey: String): HermesPairingPayl
port = if (uri.port > 0) uri.port else 8642,
key = apiKey.trim(),
tls = tls,
dashboardUrl = dashboardUrl?.trim()?.takeIf { it.isNotBlank() },
relay = null,
)
return payload.copy(endpoints = listOf(synthesizeGenericEndpoint(payload)))
@@ -3,24 +3,31 @@ package com.hermesandroid.relay.ui.components
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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
@@ -31,12 +38,14 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.platform.LocalLocale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ChatSession
@@ -58,6 +67,8 @@ fun SessionDrawerContent(
currentSessionId: String?,
scopeTitle: String = "Sessions",
scopeSubtitle: String? = null,
isLoading: Boolean = false,
isOpen: Boolean = true,
onNewChat: () -> Unit,
onSelectSession: (String) -> Unit,
onDeleteSession: (String) -> Unit,
@@ -69,6 +80,9 @@ fun SessionDrawerContent(
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
var pinnedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var archivedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
val listState = rememberLazyListState()
var scrollToTopPending by remember { mutableStateOf(false) }
val trimmedQuery = query.trim()
val visibleSessions = sessions
.asSequence()
.filter { session ->
@@ -81,13 +95,44 @@ fun SessionDrawerContent(
}
}
.filter { session ->
val needle = query.trim()
val needle = trimmedQuery
needle.isBlank() ||
session.sessionId.contains(needle, ignoreCase = true) ||
session.title.orEmpty().contains(needle, ignoreCase = true) ||
session.model.orEmpty().contains(needle, ignoreCase = true)
}
.sortedWith(
compareByDescending<ChatSession> { it.sessionId in pinnedSessionIds }
.thenByDescending { it.activityTimestamp }
.thenByDescending { it.startTimestamp }
.thenBy { it.title.orEmpty().lowercase(locale = Locale.ROOT) }
)
.toList()
val topVisibleSessionId = visibleSessions.firstOrNull()?.sessionId
LaunchedEffect(isOpen) {
scrollToTopPending = isOpen
if (isOpen && visibleSessions.isNotEmpty()) {
listState.scrollToItem(0)
scrollToTopPending = false
}
}
LaunchedEffect(filter, trimmedQuery) {
if (isOpen && visibleSessions.isNotEmpty()) {
listState.scrollToItem(0)
scrollToTopPending = false
} else if (isOpen) {
scrollToTopPending = true
}
}
LaunchedEffect(isOpen, topVisibleSessionId, visibleSessions.size) {
if (isOpen && scrollToTopPending && visibleSessions.isNotEmpty()) {
listState.scrollToItem(0)
scrollToTopPending = false
}
}
ModalDrawerSheet(
modifier = Modifier.width(320.dp),
@@ -157,7 +202,27 @@ fun SessionDrawerContent(
Spacer(modifier = Modifier.height(8.dp))
}
if (visibleSessions.isEmpty()) {
if (isLoading && sessions.isEmpty()) {
// First load (or a profile switch) — show a quiet spinner instead of
// flashing "No sessions yet" before the list arrives.
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
Text(
text = "Loading sessions…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else if (visibleSessions.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -176,7 +241,7 @@ fun SessionDrawerContent(
)
}
} else {
LazyColumn {
LazyColumn(state = listState) {
items(visibleSessions, key = { it.sessionId }) { session ->
SessionItem(
session = session,
@@ -276,6 +341,8 @@ private fun SessionItem(
onRename: () -> Unit,
onDelete: () -> Unit
) {
var menuOpen by remember { mutableStateOf(false) }
val locale = LocalLocale.current.platformLocale
val backgroundColor = if (isActive) {
MaterialTheme.colorScheme.secondaryContainer
} else {
@@ -290,7 +357,11 @@ private fun SessionItem(
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
) {
Text(
text = session.title ?: "Untitled",
style = MaterialTheme.typography.bodyMedium,
@@ -303,63 +374,129 @@ private fun SessionItem(
}
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (session.updatedAt > 0) {
sessionTimestampText(session, locale)?.let { timestamp ->
Text(
text = formatTimestamp(session.updatedAt),
text = timestamp,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
if (session.messageCount > 0) {
Text(
text = "${session.messageCount} msgs",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Clip,
)
}
}
}
IconButton(onClick = onTogglePinned, modifier = Modifier.padding(0.dp)) {
IconButton(
onClick = onTogglePinned,
modifier = Modifier.size(36.dp),
) {
Icon(
Icons.Filled.Star,
contentDescription = if (pinned) "Unpin session" else "Pin session",
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(19.dp),
)
}
IconButton(onClick = onToggleArchived, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Filled.Archive,
contentDescription = if (archived) "Restore session" else "Archive session",
tint = if (archived) RelayRefresh.Relay else MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onRename, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Filled.Edit,
contentDescription = "Rename",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onDelete, modifier = Modifier.padding(0.dp)) {
Icon(
Icons.Filled.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
)
Box {
IconButton(
onClick = { menuOpen = true },
modifier = Modifier.size(36.dp),
) {
Icon(
Icons.Filled.MoreVert,
contentDescription = "Session actions",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(19.dp),
)
}
DropdownMenu(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
) {
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = {
Icon(Icons.Filled.Edit, contentDescription = null)
},
onClick = {
menuOpen = false
onRename()
},
)
DropdownMenuItem(
text = { Text(if (archived) "Restore" else "Archive") },
leadingIcon = {
Icon(
Icons.Filled.Archive,
contentDescription = null,
tint = if (archived) {
RelayRefresh.Relay
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
onClick = {
menuOpen = false
onToggleArchived()
},
)
DropdownMenuItem(
text = {
Text(
text = "Delete",
color = MaterialTheme.colorScheme.error,
)
},
leadingIcon = {
Icon(
Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.75f),
)
},
onClick = {
menuOpen = false
onDelete()
},
)
}
}
}
}
private fun formatTimestamp(millis: Long): String {
private fun sessionTimestampText(session: ChatSession, locale: Locale): String? {
val timestamp = session.activityTimestamp
if (timestamp <= 0L) return null
val hasDistinctActivity =
session.lastActivityAt > 0L &&
session.startTimestamp > 0L &&
session.lastActivityAt != session.startTimestamp
val prefix = if (hasDistinctActivity) "Active" else "Started"
return "$prefix ${formatTimestamp(timestamp, locale)}"
}
private fun formatTimestamp(millis: Long, locale: Locale): String {
val now = System.currentTimeMillis()
val diff = now - millis
return when {
diff < 60_000 -> "Just now"
diff < 3_600_000 -> "${diff / 60_000}m ago"
diff < 86_400_000 -> SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(millis))
else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(Date(millis))
diff < 86_400_000 -> SimpleDateFormat("h:mm a", locale).format(Date(millis))
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
}
}
@@ -0,0 +1,198 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
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.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
/**
* One subagent lane — the grouped render for every [ToolCall] sharing a
* non-null [ToolCall.taskIndex], shown after top-level tool cards under the
* streaming bubble. Grouping is a pure derivation in the caller
* (`message.toolCalls.groupBy { it.taskIndex }`); the null group renders
* exactly as before (flat ToolProgressCard / CompactToolCall).
*
* Anatomy mirrors the ToolProgressCard header (icon, 6dp gap, labelMedium
* title, weight spacer, mono meta, expand chevron) with a 2dp guide rail
* down the leading edge — tertiary while any child runs, hairline once done.
* Children are ALWAYS [CompactToolCall] regardless of the toolDisplay pref:
* full cards nested in a lane are visual noise on a phone width.
*
* Collapse behavior copies ToolProgressCard's auto-collapse: expanded while
* any child is running, folds to the one-line summary header when the last
* child completes ([LaunchedEffect] keyed on allComplete). A failed child
* stamps the folded header with Close in the error tint instead of Check.
*
* Max depth is 1 — nested subagent-of-subagent calls arrive flattened into
* their parent lane by the mapper (taskLabel prefixed "parent / child"), so
* this component never indents twice.
*/
@Composable
fun SubagentLane(
taskIndex: Int,
calls: List<ToolCall>,
modifier: Modifier = Modifier,
) {
val anyRunning = calls.any { !it.isComplete }
val allComplete = calls.isNotEmpty() && calls.all { it.isComplete }
val anyFailed = calls.any { it.isComplete && it.success == false }
val runningCount = calls.count { !it.isComplete }
val laneLabel = calls.firstNotNullOfOrNull { call ->
call.taskLabel?.takeIf { it.isNotBlank() }
} ?: "Agent ${taskIndex + 1}"
// Lane duration: first child start → last child completion.
val duration = run {
val startedAt = calls.minOfOrNull { it.startedAt }
val completedAt = calls.mapNotNull { it.completedAt }.maxOrNull()
if (allComplete && startedAt != null && completedAt != null && completedAt >= startedAt) {
String.format("%.1fs", (completedAt - startedAt) / 1000.0)
} else null
}
val toolCountLabel = "${calls.size} tool${if (calls.size == 1) "" else "s"}"
val statusMeta = when {
anyRunning -> "$runningCount running"
duration != null -> "$toolCountLabel · $duration"
else -> toolCountLabel
}
var expanded by remember { mutableStateOf(!allComplete) }
// Auto-collapse when the last child completes — same pattern as
// ToolProgressCard's LaunchedEffect(toolCall.isComplete).
LaunchedEffect(allComplete) {
if (allComplete) expanded = false
}
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = 8.dp)
.height(IntrinsicSize.Min)
.semantics {
contentDescription = "Subagent $laneLabel, $statusMeta" +
if (anyFailed) ", failed" else ""
},
) {
// Guide rail
Box(
modifier = Modifier
.width(2.dp)
.fillMaxHeight()
.clip(CircleShape)
.background(
if (anyRunning) MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f)
else RelayRefresh.Line
),
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
// Lane header
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.AccountTree,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = laneLabel,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = statusMeta,
style = relayMetadataStyle(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (allComplete) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = if (anyFailed) Icons.Filled.Close else Icons.Filled.Check,
contentDescription = if (anyFailed) "Failed" else "Completed",
modifier = Modifier.size(14.dp),
tint = if (anyFailed) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically(),
) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
calls.forEach { call ->
CompactToolCall(toolCall = call)
}
}
}
}
}
}
@@ -27,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -35,9 +36,18 @@ import androidx.compose.ui.unit.dp
fun ThinkingBlock(
thinkingContent: String,
isStreaming: Boolean,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
/** Message timestamp shown right-aligned in the header (null hides it). */
timestamp: Long? = null,
) {
var expanded by remember { mutableStateOf(isStreaming) }
val locale = LocalLocale.current.platformLocale
val timeLabel = timestamp?.let {
remember(it, locale) {
java.text.SimpleDateFormat("h:mm a", locale)
.format(java.util.Date(it))
}
}
Card(
modifier = modifier.fillMaxWidth(),
@@ -66,6 +76,14 @@ fun ThinkingBlock(
color = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.weight(1f))
if (!isStreaming && timeLabel != null) {
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(6.dp))
}
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
@@ -1,6 +1,12 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
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.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
@@ -22,6 +28,7 @@ import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.HourglassTop
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.TouchApp
@@ -40,18 +47,37 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.data.ToolCall
@Composable
fun ToolProgressCard(
toolCall: ToolCall,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
/**
* Fallback wall-clock for the right-aligned header time when the call
* has no completion stamp (history-restored calls) — usually the parent
* message timestamp. Null hides the time.
*/
messageTimestamp: Long? = null,
) {
var expanded by remember { mutableStateOf(!toolCall.isComplete) }
// Preparing (tool.generating) starts collapsed — the args preview line
// under the header is the whole story until tool.start lands.
var expanded by remember { mutableStateOf(!toolCall.isComplete && !toolCall.isGenerating) }
val isPreparing = toolCall.isGenerating && !toolCall.isComplete
val timeMillis = toolCall.completedAt ?: messageTimestamp
val locale = LocalLocale.current.platformLocale
val timeLabel = timeMillis?.takeIf { toolCall.isComplete }?.let {
remember(it, locale) {
java.text.SimpleDateFormat("h:mm a", locale)
.format(java.util.Date(it))
}
}
// Auto-collapse when tool completes
LaunchedEffect(toolCall.isComplete) {
@@ -68,6 +94,13 @@ fun ToolProgressCard(
statusIcon = Icons.Filled.Close
MaterialTheme.colorScheme.error
}
// Args still streaming — "preparing" must read as LESS active than
// running: Muted instead of tertiary (Cyan stays reserved for
// actually-executing tools).
isPreparing -> {
statusIcon = Icons.Filled.MoreHoriz
MaterialTheme.colorScheme.onSurfaceVariant
}
else -> {
statusIcon = Icons.Filled.HourglassTop
MaterialTheme.colorScheme.tertiary
@@ -78,9 +111,22 @@ fun ToolProgressCard(
val statusText = when {
toolCall.isComplete && toolCall.success == true -> "completed"
toolCall.isComplete && toolCall.success == false -> "failed"
isPreparing -> "preparing"
else -> "running"
}
// Slow alpha breathe on the tool icon while preparing — an indeterminate
// bar promises imminent work; a breathe says "being written".
val toolIconAlpha = if (isPreparing) {
val breathe = rememberInfiniteTransition(label = "toolGenerating")
breathe.animateFloat(
initialValue = 0.35f,
targetValue = 0.9f,
animationSpec = infiniteRepeatable(tween(900), repeatMode = RepeatMode.Reverse),
label = "toolGeneratingAlpha",
).value
} else 1f
val duration = if (toolCall.completedAt != null && toolCall.completedAt >= toolCall.startedAt) {
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
String.format("%.1fs", seconds)
@@ -109,35 +155,41 @@ fun ToolProgressCard(
imageVector = toolIcon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = toolIconAlpha)
)
Spacer(modifier = Modifier.width(6.dp))
// Tool name
// Tool name — tool.generating may arrive nameless
Text(
text = toolCall.name,
text = if (isPreparing) toolCall.name.ifBlank { "Preparing tool…" } else toolCall.name,
style = MaterialTheme.typography.labelMedium,
color = if (isPreparing) MaterialTheme.colorScheme.onSurfaceVariant
else androidx.compose.ui.graphics.Color.Unspecified,
modifier = Modifier.weight(1f)
)
// Duration
if (duration != null) {
// Duration + completion time ("3.1s · 5:32 PM")
val metaLabel = listOfNotNull(duration, timeLabel).joinToString(" · ")
if (metaLabel.isNotEmpty()) {
Text(
text = duration,
text = metaLabel,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
}
// Status icon
Icon(
imageVector = statusIcon,
contentDescription = statusText,
tint = statusColor,
modifier = Modifier.size(16.dp)
)
// Status icon — crossfade so MoreHoriz→HourglassTop on
// tool.start reads as a state change, not a card swap.
Crossfade(targetState = statusIcon, label = "toolStatusIcon") { icon ->
Icon(
imageVector = icon,
contentDescription = statusText,
tint = statusColor,
modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.width(4.dp))
@@ -150,8 +202,21 @@ fun ToolProgressCard(
)
}
// Progress bar while running
if (!toolCall.isComplete) {
// One-line faded mono args preview while preparing — partial
// JSON grows per delta; no expand needed, the card stays folded.
if (isPreparing && !toolCall.args.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = compactToolDetail(toolCall.args, 80),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// Progress bar while running (not while args are still streaming)
if (!toolCall.isComplete && !isPreparing) {
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
@@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SystemUpdate
@@ -21,7 +24,7 @@ import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -48,14 +51,22 @@ fun UpdateBanner(
update: AvailableUpdate,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
includeStatusBarPadding: Boolean = false,
) {
val context = LocalContext.current
Row(
modifier = modifier
.then(
if (includeStatusBarPadding) {
Modifier.windowInsetsPadding(WindowInsets.statusBars)
} else {
Modifier
}
)
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.shadow(8.dp, RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(14.dp))
.padding(start = 12.dp, end = 6.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -1134,6 +1134,7 @@ private fun CompactTranscriptRow(
ThinkingBlock(
thinkingContent = message.thinkingContent,
isStreaming = message.isThinkingStreaming,
timestamp = message.timestamp,
modifier = Modifier.padding(bottom = 6.dp),
)
}
@@ -1,8 +1,12 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
@@ -13,30 +17,80 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* "What's New" sheet, shown automatically on a version bump (RelayApp) and from
* the About screen. Parses [whats_new.txt]'s tiny markup — a version line,
* blank-separated sections with a plain-text header, `*` bullets with indented
* continuation lines — into styled Compose instead of pasting the raw text
* (which showed literal `*` and gave headers no emphasis).
*/
@Composable
fun WhatsNewDialog(
onDismiss: () -> Unit
) {
val context = LocalContext.current
val changelogText = remember { loadWhatsNew(context) }
val notes = remember { parseWhatsNew(loadWhatsNew(context)) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("What's New") },
title = {
Column {
Text("What's New")
notes.version?.let { version ->
Text(
text = version,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
},
text = {
Text(
text = changelogText,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Default
),
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState())
)
.heightIn(max = 420.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (notes.groups.isEmpty()) {
Text(
text = notes.fallback ?: "No release notes available.",
style = MaterialTheme.typography.bodyMedium,
)
}
notes.groups.forEachIndexed { index, group ->
group.header?.let { header ->
Text(
text = header,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = if (index == 0) 0.dp else 6.dp),
)
}
group.bullets.forEach { bullet ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "•",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = bullet,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
@@ -46,6 +100,74 @@ fun WhatsNewDialog(
)
}
/** One section: an optional header plus its bullets. */
private data class WhatsNewGroup(val header: String?, val bullets: List<String>)
/** Parsed release notes: the leading version line plus styled sections. */
private data class WhatsNewNotes(
val version: String?,
val groups: List<WhatsNewGroup>,
val fallback: String?,
)
/**
* Classify each line of the [whats_new.txt] format:
* - line 0 (non-bullet) → version subtitle (`-` upgraded to an em dash),
* - blank line → ends the current bullet,
* - `* ` prefix → a new bullet,
* - leading whitespace → continuation appended to the current bullet,
* - anything else → a section header.
*/
private fun parseWhatsNew(raw: String): WhatsNewNotes {
if (raw.isBlank()) return WhatsNewNotes(null, emptyList(), raw.ifBlank { null })
val lines = raw.lines()
var version: String? = null
val groups = mutableListOf<WhatsNewGroup>()
var currentHeader: String? = null
val currentBullets = mutableListOf<String>()
val pending = StringBuilder()
fun flushBullet() {
if (pending.isNotEmpty()) {
currentBullets += pending.toString().trim()
pending.clear()
}
}
fun flushGroup() {
flushBullet()
if (currentHeader != null || currentBullets.isNotEmpty()) {
groups += WhatsNewGroup(currentHeader, currentBullets.toList())
}
currentHeader = null
currentBullets.clear()
}
lines.forEachIndexed { index, line ->
val trimmed = line.trim()
when {
index == 0 && trimmed.isNotEmpty() && !trimmed.startsWith("*") ->
version = trimmed.replace(" - ", " — ")
trimmed.isEmpty() -> flushBullet()
trimmed.startsWith("* ") -> {
flushBullet()
pending.append(trimmed.removePrefix("* "))
}
line.startsWith(" ") || line.startsWith("\t") -> {
if (pending.isNotEmpty()) pending.append(' ')
pending.append(trimmed)
}
else -> {
flushGroup()
currentHeader = trimmed
}
}
}
flushGroup()
return WhatsNewNotes(version, groups, fallback = null)
}
private fun loadWhatsNew(context: Context): String {
return try {
context.assets.open("whats_new.txt").bufferedReader().readText()
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,8 @@
package com.hermesandroid.relay.ui.screens
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -42,14 +45,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.GatewayAvailability
import com.hermesandroid.relay.ui.theme.gradientBorder
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
/**
* Dedicated Chat settings screen. Hosts message length, attachment size,
* tool display mode, chat endpoint preference, show-reasoning toggle,
* smooth auto-scroll, and related chat behavior knobs.
* chat endpoint preference, smooth auto-scroll, and related chat behavior knobs.
* Reasoning and tool-progress visibility are server display settings managed
* through the Manage tab so Android matches desktop.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -57,8 +63,6 @@ fun ChatSettingsScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
) {
val showThinkingSetting by connectionViewModel.showThinking.collectAsState()
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
val appContextEnabled by connectionViewModel.appContextEnabled.collectAsState()
// === PHASE3-status: granular phone-status sub-toggles ===
val appContextBridgeState by connectionViewModel.appContextBridgeState.collectAsState()
@@ -114,31 +118,6 @@ fun ChatSettingsScreen(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Show reasoning toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Show reasoning",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Display the AI's thinking process above responses",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = showThinkingSetting,
onCheckedChange = { connectionViewModel.setShowThinking(it) }
)
}
HorizontalDivider()
// Smooth auto-scroll toggle
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
Row(
@@ -165,36 +144,101 @@ fun ChatSettingsScreen(
HorizontalDivider()
// Tool call display mode
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Tool call display",
style = MaterialTheme.typography.bodyMedium
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Close sessions on send",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Return to the conversation after sending from the session drawer. Off keeps the drawer open for session triage.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = closeDrawerOnSend,
onCheckedChange = { connectionViewModel.setCloseDrawerOnSend(it) }
)
Text(
text = "How tool calls (file reads, searches, etc.) appear in chat",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
}
HorizontalDivider()
val keepComposerFocusedOnSend by
connectionViewModel.keepComposerFocusedOnSend.collectAsState()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Keep keyboard open on send",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = keepComposerFocusedOnSend,
onCheckedChange = {
connectionViewModel.setKeepComposerFocusedOnSend(it)
}
)
}
val toolDisplayOptions = listOf("off", "compact", "detailed")
val toolDisplayLabels = listOf("Off", "Compact", "Detailed")
val selectedToolIndex = toolDisplayOptions.indexOf(toolDisplay).coerceAtLeast(0)
HorizontalDivider()
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
toolDisplayOptions.forEachIndexed { index, option ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = toolDisplayOptions.size
),
onClick = { connectionViewModel.setToolDisplay(option) },
selected = index == selectedToolIndex
// Turn-complete notification toggle. First enable on
// API 33+ runs the POST_NOTIFICATIONS request (the
// BridgeScreen master-toggle precedent); if the user
// denies, the notifier silently no-ops at post time.
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
val settingsContext = LocalContext.current
val notifyPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* Notifier re-checks the grant at post time. */ }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Notify when Hermes finishes",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Post a notification when a reply completes while the app is in the background",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = notifyTurnComplete,
onCheckedChange = { enabled ->
connectionViewModel.setNotifyTurnComplete(enabled)
if (enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
androidx.core.content.ContextCompat.checkSelfPermission(
settingsContext,
android.Manifest.permission.POST_NOTIFICATIONS,
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
Text(toolDisplayLabels[index])
notifyPermissionLauncher.launch(
android.Manifest.permission.POST_NOTIFICATIONS
)
}
}
}
)
}
HorizontalDivider()
@@ -335,10 +379,18 @@ fun ChatSettingsScreen(
battery = appContextBattery,
safetyStatus = appContextSafetyStatus,
),
// Preview uses a neutral "nothing bound" snapshot so the
// user sees the shape without leaking their current
// phone state into the settings screen.
snapshot = com.hermesandroid.relay.util.PhoneSnapshot(),
// Preview uses representative placeholder values (NOT the
// user's real phone state) so each enabled toggle visibly
// contributes its line — an empty snapshot left the
// Foreground app / Battery / Safety rails toggles looking
// inert because their lines guard on snapshot data.
snapshot = com.hermesandroid.relay.util.PhoneSnapshot(
currentApp = "com.android.chrome",
batteryPercent = 82,
blocklistCount = 3,
destructiveVerbCount = 5,
autoDisableMinutes = 15,
),
)
}
Card(
@@ -373,11 +425,9 @@ fun ChatSettingsScreen(
HorizontalDivider()
val serverCaps by connectionViewModel.serverCapabilities.collectAsState()
val resolvedStreamingEndpoint = if (streamingEndpoint == "auto") {
serverCaps.preferredChatEndpoint()
} else {
streamingEndpoint
}
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
val resolvedStreamingEndpoint =
connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
// Parse tool annotations toggle (text-stream endpoints only)
val isTextAnnotationMode = resolvedStreamingEndpoint == "sessions" ||
@@ -440,6 +490,8 @@ fun ChatSettingsScreen(
"Auto: picks the best path based on what your server exposes. " +
"Currently using: $resolvedStreamingEndpoint" +
when {
resolvedStreamingEndpoint == "gateway" ->
" (live thinking via the dashboard WebSocket)"
!serverCaps.sessionsChatStream && serverCaps.portable ->
" (chat via /v1/chat/completions)"
!serverCaps.sessionsChatStream && serverCaps.runs ->
@@ -447,6 +499,9 @@ fun ChatSettingsScreen(
else -> ""
}
}
"gateway" -> "Gateway: live thinking + rich tool events over the " +
"dashboard WebSocket (/api/ws) — what the desktop app uses. " +
"Requires Manage sign-in; falls back to SSE per turn when unavailable."
"sessions" -> "Sessions: Hermes-native /api/sessions/{id}/chat/stream."
"completions" -> "Chat: OpenAI-compatible SSE via /v1/chat/completions."
"runs" -> "Runs: use only when your server streams /v1/runs directly."
@@ -457,9 +512,19 @@ fun ChatSettingsScreen(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (gatewayAvailability == GatewayAvailability.SignInRequired &&
(streamingEndpoint == "gateway" || streamingEndpoint == "auto")
) {
Text(
text = "Sign in via the Manage tab to enable Gateway streaming " +
"(live thinking).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
val endpointOptions = listOf("auto", "sessions", "completions", "runs")
val endpointLabels = listOf("Auto", "Sessions", "Chat", "Runs")
val endpointOptions = listOf("auto", "gateway", "sessions", "completions", "runs")
val endpointLabels = listOf("Auto", "Gateway", "Sessions", "Chat", "Runs")
val selectedEndpointIndex = endpointOptions.indexOf(streamingEndpoint).coerceAtLeast(0)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
@@ -470,9 +535,18 @@ fun ChatSettingsScreen(
count = endpointOptions.size
),
onClick = { connectionViewModel.setStreamingEndpoint(option) },
selected = index == selectedEndpointIndex
selected = index == selectedEndpointIndex,
// Drop the default check icon — with 5 segments its
// reserved width pushed "Gateway"/"Sessions" onto a
// second line. Selection still reads via the fill.
icon = {},
) {
Text(endpointLabels[index])
Text(
text = endpointLabels[index],
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
softWrap = false,
)
}
}
}
@@ -480,6 +554,36 @@ fun ChatSettingsScreen(
HorizontalDivider()
// Keep connected in background — opt-in, both flavors.
run {
val gatewayKeepAlive by connectionViewModel.gatewayKeepAlive.collectAsState()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Keep connected in background",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Hold the chat connection open while the app is in the " +
"background via a persistent notification, so replies stay " +
"instant. Uses more battery; off by default.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = gatewayKeepAlive,
onCheckedChange = { connectionViewModel.setGatewayKeepAlive(it) }
)
}
HorizontalDivider()
}
// Limits — expandable
var limitsExpanded by remember { mutableStateOf(false) }
Row(
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -454,6 +455,7 @@ private fun ConnectionCard(
liveState = liveState,
activeConnectionViewModel = activeConnectionViewModel,
relayConfigured = relayConfigured,
onOpenDashboard = onOpenDashboard,
)
// ── Single-endpoint nudge (active only) ──────────────────────
@@ -518,8 +520,10 @@ private fun ConnectionCard(
HorizontalDivider()
// ── Standard section ─────────────────────────────────────
SectionHeader(text = "Standard")
SectionCaption(text = "API and dashboard setup for Chat and Manage.")
SectionHeader(text = "API + Dashboard")
SectionCaption(
text = "Standard Hermes setup for Chat, Manage, sessions, and dashboard voice.",
)
ActiveCardStandardStatusSection(
connectionViewModel = activeConnectionViewModel,
@@ -529,9 +533,10 @@ private fun ConnectionCard(
if (relayEnabled) {
HorizontalDivider()
SectionHeader(text = "Relay")
SectionHeader(text = "Relay pairing")
SectionCaption(
text = "Optional power tools: Terminal, Bridge, relay sessions, and grants.",
text = "Optional power tools: Terminal, Bridge, relay sessions, " +
"media, notifications, and grants.",
)
ActiveCardRelayStatusSection(
connectionViewModel = activeConnectionViewModel,
@@ -950,6 +955,7 @@ private fun ConnectionSurfaceSummary(
liveState: RelayUiState?,
activeConnectionViewModel: ConnectionViewModel?,
relayConfigured: Boolean,
onOpenDashboard: () -> Unit,
) {
val activeApiReachable: Boolean? = if (activeConnectionViewModel != null) {
val reachable by activeConnectionViewModel.apiServerReachable.collectAsState()
@@ -1030,6 +1036,7 @@ private fun ConnectionSurfaceSummary(
value = dashboardText,
tone = dashboardTone,
modifier = Modifier.weight(1f),
onClick = if (dashboardSignInRequired) onOpenDashboard else null,
)
ConnectionSurfacePill(
label = "Relay",
@@ -1048,6 +1055,7 @@ private fun ConnectionSurfacePill(
value: String,
tone: SummaryTone,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
val container = when (tone) {
SummaryTone.Good -> MaterialTheme.colorScheme.primaryContainer
@@ -1062,7 +1070,13 @@ private fun ConnectionSurfacePill(
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
modifier = modifier,
modifier = modifier.then(
if (onClick != null) {
Modifier.clickable(onClick = onClick)
} else {
Modifier
},
),
color = container,
shape = RoundedCornerShape(8.dp),
) {
@@ -766,12 +766,24 @@ fun DashboardManagementScreen(
}
pendingAction?.let { pending ->
val isActivateProfile = pending.action.kind == DashboardActionKind.ActivateProfile
AlertDialog(
onDismissRequest = { pendingAction = null },
title = { Text("${pending.action.label} ${pending.item.title}?") },
title = {
Text(
if (isActivateProfile) "Make ${pending.item.title} the server default?"
else "${pending.action.label} ${pending.item.title}?",
)
},
text = {
Text(
text = "This changes server-side dashboard state for ${pending.item.title}.",
text = if (isActivateProfile) {
"Sets ${pending.item.title} as the server's active agent for every " +
"client — the persistent “hermes use” default. Switching agents in " +
"chat is per-conversation and doesn't change this."
} else {
"This changes server-side dashboard state for ${pending.item.title}."
},
style = MaterialTheme.typography.bodyMedium,
)
},
@@ -781,7 +793,7 @@ fun DashboardManagementScreen(
pendingAction = null
runAction(pending.item, pending.action)
},
) { Text(pending.action.label) }
) { Text(if (isActivateProfile) "Set default" else pending.action.label) }
},
dismissButton = {
TextButton(onClick = { pendingAction = null }) {
@@ -1154,6 +1166,12 @@ fun DashboardManagementScreen(
)
DashboardActionKind.EditProfileSoul ->
openSoulEditor(item)
// Always confirm: this flips the
// server's persistent active agent for
// every client, unlike the ephemeral
// per-conversation switch in chat.
DashboardActionKind.ActivateProfile ->
pendingAction = PendingDashboardAction(item, action)
else -> if (action.destructive) {
pendingAction = PendingDashboardAction(item, action)
} else {
@@ -1188,7 +1206,11 @@ fun DashboardManagementScreen(
session = dashboardSession,
authenticated = dashboardAuthenticated,
lastCheckedAtMillis = activeConnection?.dashboardLastStatus?.checkedAtMillis,
actionInFlight = actionInFlight,
actionMessage = actionMessage,
onClearSession = { confirmClearDashboardSession = true },
onSignIn = ::submitDashboardSignIn,
onOAuthSignIn = { provider -> oauthProvider = provider },
onNavigateToConnections = onNavigateToConnections,
onSelectSection = { label ->
managementSections.indexOfFirst { it.label == label }
@@ -1270,7 +1292,11 @@ private fun ManageOverviewBody(
session: DashboardAuthSession?,
authenticated: Boolean?,
lastCheckedAtMillis: Long?,
actionInFlight: Boolean,
actionMessage: String?,
onClearSession: () -> Unit,
onSignIn: (String, String, String) -> Unit,
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
onNavigateToConnections: () -> Unit,
onSelectSection: (String) -> Unit,
) {
@@ -1339,6 +1365,20 @@ private fun ManageOverviewBody(
onClearSession = onClearSession,
)
}
val signInStatus = status
if (signInStatus?.authRequired == true && authenticated != true) {
item {
DashboardSignInCard(
dashboardUrl = dashboardUrl,
routeHint = routeHint,
providers = signInStatus.authProviderDetails,
actionInFlight = actionInFlight,
actionMessage = actionMessage,
onSignIn = onSignIn,
onOAuthSignIn = onOAuthSignIn,
)
}
}
item {
RelayNavTile(
icon = Icons.Filled.Link,
@@ -126,7 +126,7 @@ fun NotificationCompanionSettingsScreen(
"notifications. When enabled, your phone " +
"forwards each notification's app, title, " +
"and text to your paired Hermes server " +
"over the same secure connection chat uses."
"through the Relay pairing used by phone tools."
),
style = MaterialTheme.typography.bodyMedium,
)
@@ -1,11 +1,14 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -39,7 +42,6 @@ import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -61,24 +63,28 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
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.BuildFlavor
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.ui.components.AgentInfoSheet
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.gradientBorder
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
/**
* Root Settings destination. After the 2026-04-11 split, Settings is a
* lightweight category list — the heavy lifting for each category lives in
* a dedicated sub-screen reached by navigation. The top card shows live
* API / Relay / Session status and taps through to the Connection
* sub-screen for pairing and manual configuration.
* API / Dashboard / Relay status and opens the agent info sheet for the
* active connection, profile, and personality.
*
* The old mega-file version of this screen (≈2609 lines) carried every
* setting inline in a single scrolling Column. That was painful to navigate
@@ -87,7 +93,7 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
* the content went. The split follows the `VoiceSettingsScreen` pattern
* that was already in the repo.
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SettingsScreen(
connectionViewModel: ConnectionViewModel,
@@ -145,10 +151,86 @@ fun SettingsScreen(
// ring-accent, and subtitle.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiServerHealth by connectionViewModel.apiServerHealth.collectAsState()
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
val relayPaired = authState is AuthState.Paired
val dashboardStatus = activeConnection?.dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
// Status pills are exception-only: a pill appears only when the surface
// needs attention (missing / checking / offline / sign-in). When it's
// healthy the pill is null so the card + agent summary stay clean.
val apiPill: SettingsStatusPillModel? = when {
activeConnection?.apiServerUrl.isNullOrBlank() -> SettingsStatusPillModel(
label = "API missing",
tone = SettingsStatusTone.Warning,
)
apiServerHealth == ConnectionViewModel.HealthStatus.Probing -> SettingsStatusPillModel(
label = "API checking",
tone = SettingsStatusTone.Info,
)
apiServerReachable -> null
apiServerHealth == ConnectionViewModel.HealthStatus.Unreachable -> SettingsStatusPillModel(
label = "API offline",
tone = SettingsStatusTone.Warning,
)
else -> null
}
val dashboardPill: SettingsStatusPillModel? = when {
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> SettingsStatusPillModel(
label = "Dashboard missing",
tone = SettingsStatusTone.Warning,
)
dashboardStatus == null -> null
!dashboardStatus.reachable -> SettingsStatusPillModel(
label = "Dashboard offline",
tone = SettingsStatusTone.Warning,
)
dashboardSignInRequired -> SettingsStatusPillModel(
label = "Dashboard sign in",
tone = SettingsStatusTone.Info,
)
dashboardStatus.authenticated == true -> null
else -> null
}
val relayPill: SettingsStatusPillModel? = when (relayUiState) {
RelayUiState.Connected -> null
RelayUiState.Connecting -> SettingsStatusPillModel(
label = "Relay reconnecting",
tone = SettingsStatusTone.Info,
)
RelayUiState.Stale -> SettingsStatusPillModel(
label = "Relay stale",
tone = SettingsStatusTone.Warning,
)
RelayUiState.Disconnected -> SettingsStatusPillModel(
label = if (relayPaired) "Relay offline" else "Requires pairing",
tone = SettingsStatusTone.Warning,
)
RelayUiState.NotConfigured -> null
}
// The Power tools below all ride the relay plugin. Rather than stamp an
// identical badge on every card (noise, not signal), the dependency is
// surfaced ONCE on the section header as a single plugin-state badge.
val pluginBadge = when {
!relayPaired ->
SettingsStatusPillModel(label = "Plugin required", tone = SettingsStatusTone.Info)
relayUiState == RelayUiState.Disconnected ->
SettingsStatusPillModel(label = "Plugin offline", tone = SettingsStatusTone.Warning)
relayUiState == RelayUiState.Stale ->
SettingsStatusPillModel(label = "Plugin stale", tone = SettingsStatusTone.Warning)
relayUiState == RelayUiState.Connecting ->
SettingsStatusPillModel(label = "Plugin connecting", tone = SettingsStatusTone.Info)
else ->
SettingsStatusPillModel(label = "Plugin active", tone = SettingsStatusTone.Good)
}
// Kick a WSS reconnect when Settings first composes so the Connections
// subpage's active-card relay row doesn't flash Disconnected on cold
@@ -202,11 +284,9 @@ fun SettingsScreen(
// ── Active Agent summary ───────────────────────────────────
// Mirrors the ChatScreen TopAppBar title block (avatar + name
// + one-line `connection · model · personality` subtitle).
// Tapping jumps to Chat AND auto-opens AgentInfoSheet so
// users can change Connection / Profile / Personality without
// having to navigate to Chat first and then hunt for the
// agent-name header.
val effectiveProfile = AgentDisplay.effectiveProfile(
// Tapping opens AgentInfoSheet inline so users can change
// Connection / Profile / Personality without leaving Settings.
val effectiveProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
@@ -216,6 +296,7 @@ fun SettingsScreen(
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = activeConnection?.label,
localDisplayAlias = profileDisplayAlias,
),
connectionLabel = activeConnection?.label ?: "No connection",
model = effectiveProfile?.model ?: "default",
@@ -224,6 +305,7 @@ fun SettingsScreen(
defaultPersonality = defaultPersonality,
),
isCustomized = selectedProfile != null || selectedPersonality != "default",
statusPills = listOfNotNull(apiPill, dashboardPill, relayPill),
onClick = { showAgentSheet = true },
isDarkTheme = isDarkTheme,
)
@@ -259,24 +341,24 @@ fun SettingsScreen(
// connection-settings unification.)
// ── Category list ──────────────────────────────────────────
// Multi-connection: Connections sits at the very top of the
// list so users who just want to switch server connections
// don't have to hunt for it. `Icons.Filled.Devices` is already
// imported for the "Paired devices" row below — reusing it
// here is visually fine since both rows cover server / device
// relationships.
// Connections sits ABOVE the Hermes section: it's the foundational
// layer everything else points at (standard + plugin), not a Hermes
// feature — and the home for multi-connection.
SettingsCategoryRow(
icon = Icons.Filled.Devices,
title = "Connections",
subtitle = "Switch or manage server connections",
subtitle = "API, dashboard, relay pairing, and routes",
onClick = onNavigateToConnections,
isDarkTheme = isDarkTheme,
)
SettingsSectionHeader("Hermes")
SettingsCategoryRow(
icon = Icons.Filled.Link,
title = "Hermes management",
subtitle = "Skills, cron, MCP, profiles, models, config",
subtitle = "Dashboard features: skills, cron, MCP, profiles, models",
badge = dashboardPill,
onClick = onNavigateToManage,
isDarkTheme = isDarkTheme,
)
@@ -284,7 +366,8 @@ fun SettingsScreen(
SettingsCategoryRow(
icon = Icons.AutoMirrored.Filled.Chat,
title = "Chat",
subtitle = "Reasoning, tool display, endpoints, message length",
subtitle = "API chat behavior, endpoints, tool display, message length",
badge = apiPill,
onClick = onNavigateToChatSettings,
isDarkTheme = isDarkTheme,
)
@@ -292,17 +375,43 @@ fun SettingsScreen(
SettingsCategoryRow(
icon = Icons.Filled.GraphicEq,
title = "Voice mode",
subtitle = "Interaction mode, silence threshold, providers",
subtitle = "Dashboard voice, realtime relay options, providers",
onClick = onNavigateToVoiceSettings,
isDarkTheme = isDarkTheme,
)
SettingsSectionHeader("Power tools", trailing = pluginBadge)
SettingsCategoryRow(
icon = Icons.Filled.Code,
title = "Terminal",
subtitle = "Server shell access through a paired relay session", onClick = onNavigateToTerminal,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.PhoneAndroid,
title = if (BuildFlavor.isSideload) "Bridge" else "Bridge Core",
subtitle = if (BuildFlavor.isSideload) {
"Relay-granted phone bridge controls"
} else {
"Relay features without sideload device control"
}, onClick = onNavigateToBridge,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Devices,
title = "Relay sessions",
subtitle = "Phones paired with this server, revoke, extend", onClick = onNavigateToPairedDevices,
isDarkTheme = isDarkTheme,
)
// === PHASE3-notif-listener-followup: notification companion entry-point ===
SettingsCategoryRow(
icon = Icons.Filled.Notifications,
title = "Notification companion",
subtitle = "Let your assistant triage notifications you've shared",
onClick = onNavigateToNotificationCompanion,
subtitle = "Shared phone notifications for paired relay tools", onClick = onNavigateToNotificationCompanion,
isDarkTheme = isDarkTheme,
)
// === END PHASE3-notif-listener-followup ===
@@ -310,42 +419,7 @@ fun SettingsScreen(
SettingsCategoryRow(
icon = Icons.Filled.Image,
title = "Media",
subtitle = "Inbound attachment size, auto-fetch, cache cap",
onClick = onNavigateToMediaSettings,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Palette,
title = "Appearance",
subtitle = "Theme, font size, animations",
onClick = onNavigateToAppearanceSettings,
isDarkTheme = isDarkTheme,
)
SettingsSectionHeader("Power tools")
SettingsCategoryRow(
icon = Icons.Filled.Code,
title = "Terminal",
subtitle = "Server shell access through a paired relay session",
onClick = onNavigateToTerminal,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.PhoneAndroid,
title = "Bridge",
subtitle = "Relay-granted phone bridge controls",
onClick = onNavigateToBridge,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Devices,
title = "Relay sessions",
subtitle = "Phones paired with this server — revoke, extend",
onClick = onNavigateToPairedDevices,
subtitle = "Relay inbound attachments, auto-fetch, cache cap", onClick = onNavigateToMediaSettings,
isDarkTheme = isDarkTheme,
)
@@ -355,16 +429,30 @@ fun SettingsScreen(
icon = Icons.Filled.Security,
title = "Bridge safety",
subtitle = "Blocklist, destructive-verb confirmation, auto-disable",
badge = SettingsStatusPillModel(
label = "Sideload",
tone = SettingsStatusTone.Info,
),
onClick = onNavigateToBridgeSafety,
isDarkTheme = isDarkTheme,
)
// === END PHASE3-safety-rails ===
}
SettingsSectionHeader("App")
SettingsCategoryRow(
icon = Icons.Filled.Palette,
title = "Appearance",
subtitle = "Theme, font size, animations",
onClick = onNavigateToAppearanceSettings,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Analytics,
title = "Analytics",
subtitle = "Stats for nerds — TTFT, tokens, health",
subtitle = "Usage stats, TTFT, tokens, health",
onClick = onNavigateToAnalytics,
isDarkTheme = isDarkTheme,
)
@@ -447,15 +535,16 @@ fun SettingsScreen(
/**
* Compact summary card of the currently active agent (Connection + Profile
* + Personality) rendered at the very top of SettingsScreen. Tapping
* navigates to the Chat tab with the AgentInfoSheet pre-opened — that sheet
* is the canonical place to actually change any of these three dimensions.
* + Personality) rendered at the very top of SettingsScreen. Tapping opens
* AgentInfoSheet inline — that sheet is the canonical place to actually
* change any of these three dimensions.
*
* Visual parity with the ChatScreen TopAppBar title block: 32dp avatar with
* an optional 1.5dp primary-color accent ring when the user has overridden
* either the profile or the personality; single-line subtitle joining the
* three tokens with a middle-dot separator.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ActiveAgentCard(
agentName: String,
@@ -465,6 +554,7 @@ private fun ActiveAgentCard(
isCustomized: Boolean,
onClick: () -> Unit,
isDarkTheme: Boolean,
statusPills: List<SettingsStatusPillModel>,
) {
val subtitle = "$connectionLabel \u00B7 $model \u00B7 $personalityLabel"
Card(
@@ -524,7 +614,10 @@ private fun ActiveAgentCard(
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = agentName.ifBlank { "Hermes" },
style = MaterialTheme.typography.bodyLarge,
@@ -538,6 +631,14 @@ private fun ActiveAgentCard(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
statusPills.forEach { pill ->
SettingsStatusPill(pill)
}
}
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
@@ -548,16 +649,72 @@ private fun ActiveAgentCard(
}
}
private data class SettingsStatusPillModel(
val label: String,
val tone: SettingsStatusTone = SettingsStatusTone.Neutral,
)
private enum class SettingsStatusTone {
Neutral,
Good,
Info,
Warning,
}
@Composable
private fun SettingsSectionHeader(label: String) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
private fun SettingsStatusPill(pill: SettingsStatusPillModel) {
// Soft "chip" treatment that matches the translucent, bordered language
// of the chat/manage/bridge mode strip (relaySelectedPanel) instead of a
// solid full-strength fill — a tinted wash + hairline accent border + cream
// label reads cleaner and stays in-theme across tones.
val hue = when (pill.tone) {
SettingsStatusTone.Good -> RelayRefresh.Electric
SettingsStatusTone.Info -> RelayRefresh.Purple
SettingsStatusTone.Warning -> RelayRefresh.Amber
SettingsStatusTone.Neutral -> RelayRefresh.Muted
}
val contentColor = when (pill.tone) {
SettingsStatusTone.Good, SettingsStatusTone.Info -> RelayRefresh.Paper
SettingsStatusTone.Warning -> RelayRefresh.Amber
SettingsStatusTone.Neutral -> RelayRefresh.Muted
}
Surface(
color = hue.copy(alpha = 0.18f),
contentColor = contentColor,
shape = RoundedCornerShape(7.dp),
border = BorderStroke(1.dp, hue.copy(alpha = 0.55f)),
) {
Text(
text = pill.label,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
)
}
}
@Composable
private fun SettingsSectionHeader(
label: String,
trailing: SettingsStatusPillModel? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 2.dp),
)
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
if (trailing != null) {
SettingsStatusPill(trailing)
}
}
}
/**
@@ -572,6 +729,7 @@ private fun SettingsCategoryRow(
subtitle: String,
onClick: () -> Unit,
isDarkTheme: Boolean,
badge: SettingsStatusPillModel? = null,
) {
Card(
modifier = Modifier
@@ -598,16 +756,26 @@ private fun SettingsCategoryRow(
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (badge != null) {
SettingsStatusPill(badge)
}
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
@@ -33,7 +33,11 @@ object RelayRefresh {
val Navy3 = Color(0xFF22243C)
val Relay = Color(0xFFAEBFFF)
val Purple = Color(0xFF8C5CFF)
val Electric = Color(0xFF111DFF)
// Brand blue. Deepened from the original neon 0xFF111DFF (2026-06-16
// feedback: the default blue read too bright next to the calmer tab
// chrome) to a richer cobalt that sits closer to the chat/manage/bridge
// mode-strip accent.
val Electric = Color(0xFF0E18D6)
/**
* Softened Electric for large filled surfaces (e.g. the active
@@ -15,7 +15,7 @@ import java.util.concurrent.TimeUnit
* Fetches the latest Android GitHub release for this repo and returns an
* [UpdateCheckResult]. Callers on `googlePlay` builds should treat this
* as a no-op — the Play Store handles update delivery and the repo's
* GitHub Releases also include desktop and server artifacts.
* GitHub Releases also include CLI and plugin artifacts.
*
* Network + JSON runs on [Dispatchers.IO].
*
@@ -0,0 +1,248 @@
package com.hermesandroid.relay.util
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Save / share / open helpers for chat images and attachments — the byte
* plumbing behind the image viewer's Save/Share controls and the attachment
* card's long-press menu.
*
* Save destinations are deliberately permission-free:
* - **Android 10+ (API 29, Scoped Storage):** [MediaStore] — images land in
* `Pictures/Hermes-Relay`, other files in `Download/Hermes-Relay`. No
* runtime permission required.
* - **Android 9 and below:** the app declares no `WRITE_EXTERNAL_STORAGE`
* permission (and shouldn't prompt for one for this niche path), so save
* returns [SaveResult.UseShareInstead] and the caller routes to the system
* share sheet — which offers "Save to Files"/Drive/etc.
*
* Sharing always works regardless of version: bytes are staged into the
* FileProvider-backed `hermes-media/` cache (the same path `file_provider_paths`
* already maps) and handed to `ACTION_SEND` as a `content://` URI, since you
* cannot share a remote `http(s)` URL as a stream.
*/
object MediaSaver {
private const val SAVE_SUBDIR = "Hermes-Relay"
private const val SHARE_CACHE_DIR = "hermes-media"
private val httpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.callTimeout(30, TimeUnit.SECONDS)
.build()
}
/** Outcome of a save attempt. */
sealed interface SaveResult {
/** Persisted; [location] is a human-readable folder for the toast. */
data class Saved(val uri: Uri, val location: String) : SaveResult
/** Pre-Q with no storage permission — the caller should share instead. */
data object UseShareInstead : SaveResult
data class Failed(val message: String) : SaveResult
}
// --- Remote bytes -------------------------------------------------------
/**
* GET [url] and return its bytes plus the best-effort `Content-Type`. Runs
* on IO. Throws on a non-2xx response or empty body so callers can fall
* back to a notice.
*/
suspend fun fetchRemoteBytes(url: String): Pair<ByteArray, String?> =
withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).get().build()
httpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) error("HTTP ${resp.code}")
val contentType = resp.header("Content-Type")?.substringBefore(';')?.trim()
val body = resp.body.bytes()
body to contentType
}
}
/** Read the bytes behind a `content://` (or `file://`) [uri]. */
suspend fun readUriBytes(context: Context, uri: Uri): ByteArray? =
withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
}
// --- Save ---------------------------------------------------------------
suspend fun saveImage(
context: Context,
bytes: ByteArray,
displayName: String?,
mime: String,
): SaveResult {
// Prefer a magic-byte-sniffed type so the extension is correct even
// when the caller only had a generic `image/*` guess (remote URLs).
val effectiveMime = sniffImageMime(bytes) ?: mime.ifBlank { "image/png" }
return saveTo(context, bytes, ensureNamed(displayName, effectiveMime), effectiveMime, isImage = true)
}
suspend fun saveFile(
context: Context,
bytes: ByteArray,
displayName: String?,
mime: String,
): SaveResult = saveTo(
context,
bytes,
ensureNamed(displayName, mime),
mime.ifBlank { "application/octet-stream" },
isImage = false,
)
private suspend fun saveTo(
context: Context,
bytes: ByteArray,
fileName: String,
mime: String,
isImage: Boolean,
): SaveResult = withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
runCatching { saveViaMediaStore(context, bytes, fileName, mime, isImage) }
.getOrElse { SaveResult.Failed(it.message ?: "save failed") }
} else {
// Pre-Q public-dir writes need WRITE_EXTERNAL_STORAGE, which we
// don't request — route the caller to the share sheet instead.
SaveResult.UseShareInstead
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveViaMediaStore(
context: Context,
bytes: ByteArray,
fileName: String,
mime: String,
isImage: Boolean,
): SaveResult {
val resolver = context.contentResolver
val collection = if (isImage) {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Downloads.EXTERNAL_CONTENT_URI
}
val baseFolder = if (isImage) Environment.DIRECTORY_PICTURES else Environment.DIRECTORY_DOWNLOADS
val relativePath = "$baseFolder/$SAVE_SUBDIR"
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
if (mime.isNotBlank()) put(MediaStore.MediaColumns.MIME_TYPE, mime)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = resolver.insert(collection, values) ?: error("MediaStore insert returned null")
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("openOutputStream returned null")
resolver.update(uri, ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, null, null)
return SaveResult.Saved(uri, relativePath)
}
// --- Share / open -------------------------------------------------------
/**
* Stage [bytes] in the FileProvider cache; return a shareable `content://`
* URI. When [mime] is blank or a wildcard (e.g. an `image/…` wildcard from
* an inline image whose real type we only learn from the bytes), the type is
* magic-byte-sniffed so the staged file gets a correct extension —
* FileProvider derives the shared `content://` type from that extension, so
* a wrong `.bin` would otherwise make the receiver see octet-stream.
*/
suspend fun stageForShare(context: Context, bytes: ByteArray, displayName: String?, mime: String): Uri =
withContext(Dispatchers.IO) {
val effectiveMime = if (mime.isBlank() || mime.endsWith("/*")) {
sniffImageMime(bytes) ?: mime
} else {
mime
}
val dir = File(context.cacheDir, SHARE_CACHE_DIR).apply { if (!exists()) mkdirs() }
val file = File(dir, ensureNamed(displayName, effectiveMime))
file.writeBytes(bytes)
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
fun share(context: Context, uri: Uri, mime: String) {
val send = Intent(Intent.ACTION_SEND).apply {
type = mime.ifBlank { "application/octet-stream" }
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(send, "Share").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
runCatching { context.startActivity(chooser) }
}
fun open(context: Context, uri: Uri, mime: String) {
val view = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mime.ifBlank { "*/*" })
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(view) }
}
// --- Naming / sniffing --------------------------------------------------
/** Sanitize [base] and guarantee a file extension derived from [mime]. */
fun ensureNamed(base: String?, mime: String): String {
val cleaned = base
?.substringAfterLast('/')
?.substringAfterLast('\\')
?.replace(Regex("[^A-Za-z0-9._-]"), "_")
?.take(80)
?.ifBlank { null }
val stem = cleaned ?: "hermes-${System.currentTimeMillis()}"
// Keep an existing, plausible extension; otherwise append the derived one.
val hasExt = stem.substringAfterLast('.', "").let { it.isNotBlank() && it.length <= 5 }
return if (hasExt) stem else "$stem.${extensionFor(mime)}"
}
private fun extensionFor(mime: String): String = when (mime.lowercase().substringBefore(';').trim()) {
"image/jpeg", "image/jpg" -> "jpg"
"image/png" -> "png"
"image/webp" -> "webp"
"image/gif" -> "gif"
"image/bmp" -> "bmp"
"image/heic" -> "heic"
"image/heif" -> "heif"
"image/svg+xml" -> "svg"
"video/mp4" -> "mp4"
"audio/mpeg" -> "mp3"
"application/pdf" -> "pdf"
"text/plain" -> "txt"
"application/json" -> "json"
else -> "bin"
}
/** Identify a common image type from its magic bytes; null if unknown. */
private fun sniffImageMime(b: ByteArray): String? {
if (b.size < 12) return null
fun u(i: Int) = b[i].toInt() and 0xFF
return when {
u(0) == 0x89 && u(1) == 0x50 && u(2) == 0x4E && u(3) == 0x47 -> "image/png"
u(0) == 0xFF && u(1) == 0xD8 && u(2) == 0xFF -> "image/jpeg"
u(0) == 0x47 && u(1) == 0x49 && u(2) == 0x46 -> "image/gif"
u(0) == 0x42 && u(1) == 0x4D -> "image/bmp"
// RIFF....WEBP
u(0) == 0x52 && u(1) == 0x49 && u(2) == 0x46 && u(3) == 0x46 &&
u(8) == 0x57 && u(9) == 0x45 && u(10) == 0x42 && u(11) == 0x50 -> "image/webp"
else -> null
}
}
}
@@ -137,6 +137,15 @@ fun buildPromptBlock(settings: AppContextSettings, snapshot: PhoneSnapshot): Str
return lines.joinToString("\n\n")
}
// Note: the gateway path intentionally carries NO phone-context preamble.
// `prompt.submit` is bare text with no system-message slot, and prepending
// the preamble to the user's text persists it into the session transcript
// (ugly on history reload + visible from desktop). The gateway's only
// call-time system overlay (`ephemeral_system_prompt`) is the personality
// slot, so it can't carry phone context without clobbering the user's persona.
// Phone context therefore rides ONLY the SSE `systemMessage` (invisible) and
// the on-demand `android_phone_status` tool.
/**
* Bridge-channel summary. Always emitted when `bridgeState` is on, even if
* the bridge isn't bound — "Phone bridge: not installed" is itself useful
@@ -0,0 +1,75 @@
package com.hermesandroid.relay.util
import android.util.Log
/**
* Per-turn latency tracer for the chat transports.
*
* Stamps monotonic ([System.nanoTime]) marks across a single
* send → first-token → done turn and emits ONE INFO line on [done] so the
* gateway and SSE paths are directly comparable in logcat — the whole point
* being to answer "where did the wait go?" against the official hermes-desktop
* client, which always speaks the gateway.
*
* All marks are cumulative milliseconds since construction (t0 = the moment
* the send began), tagged with `@` so adjacent marks can be diffed to read a
* phase duration. Durations only — this NEVER logs message content.
*
* Gateway turns set `connect`/`session`/`submit` (the connection-establish
* phases, skipped on a warm socket) plus `ttfe`/`ttft`. SSE turns are a single
* POST, so they set only `ttfe`/`ttft` (the connection phases show as absent).
*
* - `ttfe` — time to first event (server acknowledged and started producing).
* - `ttft` — time to first *visible* streamed token (reasoning OR text). This
* is the perceptual "it finally responded" metric and the one that exposes
* the SSE reasoning dead-air: on the gateway reasoning streams live so `ttft`
* is small; on SSE the reasoning phase is invisible until done so `ttft`
* balloons.
*
* Every mutator is idempotent on first-wins ([mark]) or single-shot ([done]),
* so terminal paths can call [done] from more than one place safely.
*/
class TurnLatencyTracer(private val transport: String) {
private val t0 = System.nanoTime()
private val marks = LinkedHashMap<String, Long>()
@Volatile
private var warmTag: String? = null
@Volatile
private var finished = false
private fun nowMs(): Long = (System.nanoTime() - t0) / 1_000_000
/** Record whether the socket+session were already warm (gateway only). */
@Synchronized
fun warm(isWarm: Boolean) {
warmTag = if (isWarm) "warm" else "cold"
}
/** Record cumulative elapsed for [name]; first call wins (later calls ignored). */
@Synchronized
fun mark(name: String) {
if (!marks.containsKey(name)) marks[name] = nowMs()
}
/** Emit the consolidated timing line. Single-shot; safe to call from multiple terminals. */
@Synchronized
fun done(outcome: String = "") {
if (finished) return
finished = true
val total = nowMs()
val line = buildString {
append("turn[").append(transport).append(']')
warmTag?.let { append(' ').append(it) }
marks.forEach { (k, v) -> append(' ').append(k).append('@').append(v).append("ms") }
append(" done@").append(total).append("ms")
if (outcome.isNotEmpty()) append(' ').append(outcome)
}
Log.i(TAG, line)
}
companion object {
private const val TAG = "TurnLatency"
}
}
@@ -73,6 +73,17 @@ object CardDispatchSyncBuilder {
/** Prefix for synthetic tool-call IDs. Stable so tests can match. */
internal const val CALL_ID_PREFIX = "call_carddispatch_"
/**
* Gateway ask cards (clarify/approval/sudo/secret) are EXCLUDED from
* sync entirely: the server already absorbed the answer through the
* blocking ask RPC, and for secrets the value (even its sanitized
* stamp) must not be replayed into session memory. Ask cards live only
* on locally-built messages with this id prefix (see
* ChatViewModel.presentInteractionAsk), and carry `ask.*` card types.
*/
internal const val ASK_MESSAGE_ID_PREFIX = "ask-"
private const val ASK_CARD_TYPE_PREFIX = "ask."
/** Synthetic tool name. Intentionally namespaced so the upstream tool
* dispatcher has no chance of mistaking it for a real executor. */
internal const val TOOL_NAME = "hermes_card_action"
@@ -90,6 +101,7 @@ object CardDispatchSyncBuilder {
// audit records (regression caught by
// CardDispatchSyncBuilderTest.buildSyntheticMessages_unknownCardKey_stillEmitsBareEnvelope).
if (msg.cardDispatches.isEmpty()) continue
if (msg.id.startsWith(ASK_MESSAGE_ID_PREFIX)) continue
// Index cards by their resolved cardKey so the dispatch
// lookup is O(1). Mirrors the key formula in MessageBubble.kt
@@ -101,6 +113,8 @@ object CardDispatchSyncBuilder {
if (dispatch.syncedToServer) continue
val card = cardByKey[dispatch.cardKey]
// Belt for ask cards that somehow live on a non-ask message.
if (card?.type?.startsWith(ASK_CARD_TYPE_PREFIX) == true) continue
// Dispatches whose card is gone from the message (trimmed
// by the rolling MAX_MESSAGES buffer, for instance) still
// get synced, just with less context — the key + value
@@ -143,7 +157,8 @@ object CardDispatchSyncBuilder {
fun hasUnsynced(history: List<ChatMessage>): Boolean =
history.any { msg ->
msg.cardDispatches.any { !it.syncedToServer }
!msg.id.startsWith(ASK_MESSAGE_ID_PREFIX) &&
msg.cardDispatches.any { !it.syncedToServer }
}
// === helpers ===
File diff suppressed because it is too large Load Diff
@@ -25,8 +25,10 @@ import com.hermesandroid.relay.data.ConnectionValidation
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProfileDisplayAliasStore
import com.hermesandroid.relay.data.ProfileSessionStore
import com.hermesandroid.relay.data.ProfileSelectionStore
import com.hermesandroid.relay.data.SessionTransport
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -38,12 +40,20 @@ import com.hermesandroid.relay.network.ChatMode
import com.hermesandroid.relay.network.ConnectionManager
import com.hermesandroid.relay.network.ConnectionState
import com.hermesandroid.relay.network.DashboardApiClient
import com.hermesandroid.relay.network.DashboardChatDisplaySettings
import com.hermesandroid.relay.network.models.MessageItem
import com.hermesandroid.relay.network.models.SessionItem
import com.hermesandroid.relay.network.DashboardAuthSession
import com.hermesandroid.relay.network.DashboardCookieStore
import com.hermesandroid.relay.network.DashboardStatus
import com.hermesandroid.relay.network.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.EndpointResolver
import com.hermesandroid.relay.network.GatewayAvailability
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
import com.hermesandroid.relay.network.GatewayChatClient
import com.hermesandroid.relay.network.GatewayKeepAliveService
import com.hermesandroid.relay.network.HermesApiClient
import com.hermesandroid.relay.network.resolveStreamingEndpointPreference
import com.hermesandroid.relay.network.RouteProbeOutcome
import com.hermesandroid.relay.network.ProfileApiUrlResolver
import com.hermesandroid.relay.network.ServerCapabilities
@@ -75,6 +85,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
@@ -114,6 +125,23 @@ enum class StandardVoiceAvailability {
Unsupported,
}
/**
* Coarse connection state for the chat empty-state, derived in
* [ConnectionViewModel.chatConnectState]. Lets the UI hold a neutral
* "Connecting…" placeholder during cold-start hydration instead of flashing
* the "Connect to Hermes" CTA before we know whether anything is configured.
*/
enum class ChatConnectState {
/** Store not hydrated yet, or an active connection is still coming up. */
Connecting,
/** Chat client built and the API server is reachable. */
Ready,
/** Hydration complete and no connection is configured — show the CTA. */
NeedsConnection,
}
class ConnectionViewModel(application: Application) : AndroidViewModel(application) {
companion object {
@@ -173,6 +201,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// Chat scroll behavior
private val KEY_SMOOTH_AUTO_SCROLL = booleanPreferencesKey("smooth_auto_scroll")
private val KEY_CLOSE_DRAWER_ON_SEND = booleanPreferencesKey("close_drawer_on_send")
private val KEY_KEEP_COMPOSER_FOCUSED_ON_SEND =
booleanPreferencesKey("keep_composer_focused_on_send")
// Turn-complete notification ("Notify when Hermes finishes")
private val KEY_NOTIFY_TURN_COMPLETE = booleanPreferencesKey("notify_turn_complete")
// One-shot "Live voice conversation" hint on the input bar's voice slot
private val KEY_VOICE_HINT_SEEN = booleanPreferencesKey("voice_mode_hint_seen")
}
// --- Core networking components ---
@@ -341,6 +378,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val connectionHandoffStatus: StateFlow<ConnectionHandoffStatus?> =
_connectionHandoffStatus.asStateFlow()
private var connectionHandoffClearJob: Job? = null
private val _serverChatDisplaySettings =
MutableStateFlow<DashboardChatDisplaySettings?>(null)
/**
* ADR 24 — [relayUiState] bundled with the currently-active endpoint
@@ -533,6 +572,90 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private val _standardAudioApiReachable = MutableStateFlow(false)
val standardAudioApiReachable: StateFlow<Boolean> = _standardAudioApiReachable.asStateFlow()
/**
* Gateway chat transport (tui_gateway over the dashboard's `/api/ws`)
* availability. Piggybacks on [probeStandardVoice] — same surface, same
* `/api/status` + `/api/auth/me` checks — minus the audio-route HEAD:
* `/api/ws` ships with every embedded-chat dashboard build, so route
* absence is only discovered (and made sticky) at WS-upgrade time via
* [markGatewayUnsupported].
*/
private val _gatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
val gatewayAvailability: StateFlow<GatewayAvailability> = _gatewayAvailability.asStateFlow()
/**
* Sticky downgrade fired by [GatewayChatClient] when the WS upgrade is
* rejected outright (404/403 — dashboard build without `/api/ws`). Stops
* auto-resolution from re-picking gateway until a connection switch
* resets it.
*/
fun markGatewayUnsupported() {
_gatewayAvailability.value = GatewayAvailability.Unsupported
}
/** Probe-driven update that respects the sticky [markGatewayUnsupported] verdict. */
private fun updateGatewayAvailability(probed: GatewayAvailability) {
val current = _gatewayAvailability.value
if (current == GatewayAvailability.Unsupported && probed == GatewayAvailability.Ready) return
_gatewayAvailability.value = probed
}
/** Cached gateway client, keyed by connection + resolved dashboard URL. */
private var gatewayClientCache: Triple<String, String, GatewayChatClient>? = null
/**
* Gateway chat client for the active connection — built lazily, rebuilt
* when the connection or its resolved dashboard URL changes (LAN ↔
* Tailscale handoff), sharing the Manage tab's encrypted cookie store so
* a dashboard sign-in there authenticates chat here.
*/
@Synchronized
fun activeGatewayChatClient(): GatewayChatClient? {
val connectionId = connectionStore.activeConnectionId.value ?: return null
val dashboardUrl = activeDashboardUrl() ?: return null
gatewayClientCache?.let { (cachedConnection, cachedUrl, client) ->
if (cachedConnection == connectionId && cachedUrl == dashboardUrl) return client
// Same connection, the resolved dashboard URL moved (a LAN⇄Tailscale
// route change) WHILE a turn is in flight: RETARGET the live client
// to the new route so the turn FOLLOWS it (reconnect + keep the live
// session id — the session is server-side and the same shared
// gateway sits behind both routes), instead of tearing the client
// down (which would call activeTurn.cancel()) or stranding the turn
// on the dead route until the watchdog.
if (cachedConnection == connectionId && client.hasActiveTurn()) {
android.util.Log.i(
"ConnectionViewModel",
"gateway route changed mid-turn — retargeting active client to follow the route",
)
client.retarget(
DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(
cookieStore = dashboardCookieStoreFor(connectionId),
),
),
)
gatewayClientCache = Triple(connectionId, dashboardUrl, client)
return client
}
}
gatewayClientCache?.third?.shutdown()
val client = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(
cookieStore = dashboardCookieStoreFor(connectionId),
),
),
onGatewayUnsupported = { markGatewayUnsupported() },
)
// Carry the current keep-alive preference onto the fresh client so a
// connection/route switch doesn't lose the no-background-close flag.
client.setKeepAliveInBackground(gatewayKeepAlive.value)
gatewayClientCache = Triple(connectionId, dashboardUrl, client)
return client
}
/** Per-connection encrypted cookie stores, cached to avoid Keystore churn. */
private val dashboardCookieStores =
java.util.concurrent.ConcurrentHashMap<String, EncryptedDashboardCookieStore>()
@@ -580,6 +703,34 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val chatReady: StateFlow<Boolean> = combine(_chatApiClient, _apiServerReachable) { client, reachable ->
client != null && reachable
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/**
* Three-way gate for the chat empty-state, so a cold start doesn't flash
* the loud "Connect to Hermes" CTA while DataStore is still hydrating.
*
* - [ChatConnectState.Ready] — chat client built + server reachable.
* - [ChatConnectState.Connecting] — either the connection store hasn't
* hydrated yet (we don't yet know if anything is configured), OR an
* active connection exists but chat isn't reachable yet (cold connect /
* route resolve). Show a quiet spinner, never the connect button.
* - [ChatConnectState.NeedsConnection] — hydration finished and there is
* genuinely no connection to use. The only state that shows the CTA.
*
* Seeds [ChatConnectState.Connecting] so the very first composed frame —
* before any flow emits — is the neutral state, not the CTA.
*/
val chatConnectState: StateFlow<ChatConnectState> = combine(
connectionStore.isHydrated,
activeConnection,
chatReady,
) { hydrated, active, ready ->
when {
ready -> ChatConnectState.Ready
!hydrated -> ChatConnectState.Connecting
active != null -> ChatConnectState.Connecting
else -> ChatConnectState.NeedsConnection
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, ChatConnectState.Connecting)
// NOTE: [relayReady] / [voiceReady] are declared below the [_relayUrl]
// MutableStateFlow,
// further down this file, because Kotlin class-body initializers run
@@ -604,6 +755,25 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
endpoint?.api?.url ?: savedUrl
}.stateIn(viewModelScope, SharingStarted.Eagerly, DEFAULT_API_URL)
/**
* Whether a chat turn is currently streaming — mirrored from
* [com.hermesandroid.relay.viewmodel.ChatViewModel.isStreaming] by RelayApp.
* While true, an [effectiveApiServerUrl] route change DEFERS its chat-client
* rebuild: rebuilding mid-turn replaces the client and cancels the in-flight
* turn, whereas the gateway socket rides a transient route blip via its own
* reconnect (keeping the live session). The deferred rebuild applies once
* the turn ends.
*/
private val _chatStreaming = MutableStateFlow(false)
fun setChatStreaming(streaming: Boolean) {
_chatStreaming.value = streaming
}
/** A route change arrived mid-turn and its chat-client rebuild was deferred. */
@Volatile
private var pendingApiClientRebuild = false
/**
* Runtime route for relay HTTP calls and WSS-adjacent helpers. Relay
* control still requires the paired relay session token; this only
@@ -806,10 +976,91 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// the same reason as [authState] / [pairingCode] — after a connection
// switch the underlying [AuthManager] instance is replaced and the
// public flow needs to repoint at the new manager's backing state.
/**
* Host agent profiles loaded from the dashboard `GET /api/profiles` — the
* same profiles Manage and the official desktop expose. Populates
* [agentProfiles] on a dashboard-only (non-relay) connection, where the
* relay's `auth.ok` profile list is empty. Refreshed by
* [refreshDashboardProfiles] when the agent sheet opens.
*/
private val _dashboardProfiles = MutableStateFlow<List<Profile>>(emptyList())
@OptIn(ExperimentalCoroutinesApi::class)
val agentProfiles: StateFlow<List<Profile>> = _authManagerFlow
.flatMapLatest { it.agentProfiles }
.stateIn(viewModelScope, SharingStarted.Eagerly, authManager.agentProfiles.value)
val agentProfiles: StateFlow<List<Profile>> = combine(
_authManagerFlow.flatMapLatest { it.agentProfiles },
_dashboardProfiles,
) { relay, dashboard ->
// Prefer the relay's list when it has entries (richer runtime metadata);
// fall back to the dashboard list so a dashboard-only connection still
// sees its server profiles in the chat picker.
relay.ifEmpty { dashboard }
}.stateIn(viewModelScope, SharingStarted.Eagerly, authManager.agentProfiles.value)
/**
* Load the host's agent profiles from the dashboard `/api/profiles` into
* [agentProfiles] (merged in the combine above). Lets the chat agent sheet
* offer server profiles on a dashboard-only connection. Best-effort: leaves
* the current list untouched on failure (e.g. dashboard not signed in).
*/
fun refreshDashboardProfiles() {
val connectionId = connectionStore.activeConnectionId.value ?: return
val dashboardUrl = activeDashboardUrl() ?: return
viewModelScope.launch {
DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(
cookieStore = dashboardCookieStoreFor(connectionId),
),
).listProfiles().onSuccess { profiles ->
_dashboardProfiles.value = profiles
}
}
}
/**
* The ACTIVE profile's chat sessions, scoped server-side via the dashboard
* `GET /api/sessions?profile=` surface — upstream opens that profile's own
* `state.db` directly, the same per-profile scoping the official desktop
* sidebar uses. Returns `null` when there's no dashboard URL (an api_server-
* only connection with no Manage session), so the caller falls back to the
* shared api_server session list.
*
* The gateway `session.list` RPC can't substitute here: it reads one process-
* global DB pinned to the launch profile, so it never re-scopes on a profile
* switch. The default/`null` selection omits the param → the launch profile's
* DB (the server's configured default), matching [selectProfile]'s semantics.
*/
suspend fun listProfileScopedSessions(limit: Int = 200): Result<List<SessionItem>>? {
val connectionId = connectionStore.activeConnectionId.value ?: return null
val dashboardUrl = activeDashboardUrl() ?: return null
val profileName = AgentDisplay.profileRequestName(_selectedProfile.value?.name)
return DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(
cookieStore = dashboardCookieStoreFor(connectionId),
),
).listSessions(profile = profileName, limit = limit)
}
/**
* A session's transcript, scoped to the active profile via the dashboard
* `/api/sessions/{id}/messages?profile=`. The twin of [listProfileScopedSessions]:
* once the drawer lists a non-default profile's sessions, opening one must read
* that profile's own `state.db` (the api_server's shared DB has no such rows).
* Returns `null` off the dashboard surface so the caller falls back to the
* api_server transcript.
*/
suspend fun loadProfileScopedMessages(sessionId: String): Result<List<MessageItem>>? {
val connectionId = connectionStore.activeConnectionId.value ?: return null
val dashboardUrl = activeDashboardUrl() ?: return null
val profileName = AgentDisplay.profileRequestName(_selectedProfile.value?.name)
return DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(
cookieStore = dashboardCookieStoreFor(connectionId),
),
).getSessionMessages(sessionId, profileName)
}
/**
* User's current profile pick for the chat send pipeline. `null` means
@@ -842,6 +1093,31 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
ProfileSelectionStore(application)
private val profileSessionStore: ProfileSessionStore =
ProfileSessionStore(application)
private val profileDisplayAliasStore: ProfileDisplayAliasStore =
ProfileDisplayAliasStore(application)
@OptIn(ExperimentalCoroutinesApi::class)
val profileDisplayAlias: StateFlow<String?> = combine(
activeConnectionId,
selectedProfile,
) { connectionId, profile ->
connectionId to AgentDisplay.profileRequestName(profile?.name)
}.flatMapLatest { (connectionId, profileName) ->
if (connectionId == null) {
flowOf(null)
} else {
profileDisplayAliasStore.aliasFlow(connectionId, profileName)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
fun setProfileDisplayAlias(alias: String?) {
val connectionId = activeConnectionId.value ?: return
val profileName = AgentDisplay.profileRequestName(_selectedProfile.value?.name)
val normalizedAlias = AgentDisplay.localDisplayAlias(alias)
viewModelScope.launch {
profileDisplayAliasStore.setAlias(connectionId, profileName, normalizedAlias)
}
}
/**
* Set (or clear, with `null`) the active profile pick. Writes through
@@ -900,16 +1176,42 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
return false
}
/**
* Which transport's session slot to restore right now — or `null` when the
* decision is still pending (the gateway probe hasn't landed). A manual
* streaming-endpoint override resolves immediately; under `"auto"` the slot
* follows the gateway probe, and we deliberately DEFER while it's [Unknown]
* rather than guess SSE — otherwise a gateway connection would momentarily
* restore the wrong (or empty) slot before the probe confirms it. The
* gateway-availability collector re-runs the restore once it settles.
*/
private fun activeSessionTransport(): SessionTransport? {
val preference = streamingEndpoint.value
if (preference != "auto") return SessionTransport.forEndpoint(preference)
return when (_gatewayAvailability.value) {
GatewayAvailability.Ready -> SessionTransport.GATEWAY
GatewayAvailability.Unknown -> null
else -> SessionTransport.SSE
}
}
private fun refreshLastSessionForProfile(
connectionId: String?,
profileName: String?,
) {
_lastSessionId.value = null
if (connectionId == null) return
// Defer until the active transport is known — restoring an id the
// current transport can't resume is exactly what forks a session
// mid-conversation on a non-default profile.
val transport = activeSessionTransport() ?: return
viewModelScope.launch {
val profileScoped = profileSessionStore
.sessionIdFlow(connectionId, profileName)
.sessionIdFlow(connectionId, profileName, transport)
.first()
// Default profile shares the launch DB across both transports, so a
// pre-transport (untransported) pointer is still resumable — surface
// it as the fallback only for the server-default context.
val legacyDefault = if (profileName == null) {
getApplication<Application>().relayDataStore.data
.first()[KEY_LAST_SESSION_ID]
@@ -918,7 +1220,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
if (
activeConnectionId.value == connectionId &&
_selectedProfile.value?.name == profileName
_selectedProfile.value?.name == profileName &&
activeSessionTransport() == transport
) {
_lastSessionId.value = profileScoped ?: legacyDefault
}
@@ -991,9 +1294,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
// Show thinking toggle
val showThinking: StateFlow<Boolean> = application.relayDataStore.data
private val localShowThinking = application.relayDataStore.data
.map { it[KEY_SHOW_THINKING] ?: true }
// Reasoning visibility follows the Hermes dashboard config when available.
// The local DataStore value remains an offline/legacy fallback.
val showThinking: StateFlow<Boolean> = combine(
localShowThinking,
_serverChatDisplaySettings,
) { local, server ->
server?.showReasoning ?: local
}
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setShowThinking(enabled: Boolean) {
@@ -1004,19 +1315,33 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
// Tool call display mode: "off", "compact", "detailed"
val toolDisplay: StateFlow<String> = application.relayDataStore.data
.map { it[KEY_TOOL_DISPLAY] ?: "detailed" }
private val localToolDisplay = application.relayDataStore.data
.map { normalizeToolDisplayMode(it[KEY_TOOL_DISPLAY]) ?: "detailed" }
// Tool call display mode: "off", "compact", "detailed". Prefer the
// server display.tool_progress config so Android matches desktop.
val toolDisplay: StateFlow<String> = combine(
localToolDisplay,
_serverChatDisplaySettings,
) { local, server ->
server?.toolDisplay ?: local
}
.stateIn(viewModelScope, SharingStarted.Eagerly, "detailed")
fun setToolDisplay(mode: String) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_TOOL_DISPLAY] = mode
prefs[KEY_TOOL_DISPLAY] = normalizeToolDisplayMode(mode) ?: "detailed"
}
}
}
private fun normalizeToolDisplayMode(mode: String?): String? =
when (mode?.trim()?.lowercase()) {
"off", "compact", "detailed" -> mode.trim().lowercase()
else -> null
}
// App context prompt toggle
val appContextEnabled: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_APP_CONTEXT] ?: true }
@@ -1108,6 +1433,41 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
// --- Opt-in "keep gateway connected in background" (sideload) ---
/**
* Off by default. When on, the gateway socket stays open in the background
* (the process is held up by [GatewayKeepAliveService]) until the app is
* killed, so replies stay instant instead of paying a cold rejoin.
*/
val gatewayKeepAlive: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_GATEWAY_KEEP_ALIVE] ?: false }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
fun setGatewayKeepAlive(enabled: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_GATEWAY_KEEP_ALIVE] = enabled
}
}
}
init {
// Drive the keep-alive: flip the active client's no-background-close
// flag and start/stop the foreground service. Both flavors — the
// GatewayKeepAliveService is declared in the main manifest (Play permits
// this Home-Assistant-class persistent-connection use case). Mirrors
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
viewModelScope.launch {
gatewayKeepAlive.collect { enabled ->
gatewayClientCache?.third?.setKeepAliveInBackground(enabled)
val ctx = getApplication<Application>()
if (enabled) runCatching { GatewayKeepAliveService.start(ctx) }
else runCatching { GatewayKeepAliveService.stop(ctx) }
}
}
}
/**
* Resolve the user's `streamingEndpoint` preference to a concrete value
* based on the latest capability probe. Returns a concrete endpoint
@@ -1116,10 +1476,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
* - "sessions" / "completions" / "runs" pass through unchanged (manual override wins).
* - "auto" → reads `serverCapabilities.value.preferredChatEndpoint()`.
*/
fun resolveStreamingEndpoint(preference: String): String = when (preference) {
"sessions", "completions", "runs" -> preference
else -> _serverCapabilities.value.preferredChatEndpoint()
}
fun resolveStreamingEndpoint(preference: String): String =
resolveStreamingEndpointPreference(
preference = preference,
gateway = _gatewayAvailability.value,
capabilities = _serverCapabilities.value,
)
/**
* Capability-resolved SSE endpoint, ignoring the gateway tier — wired to
* [ChatViewModel.sseFallbackEndpoint] for per-turn gateway fallbacks.
*/
fun resolveSseStreamingEndpoint(): String =
_serverCapabilities.value.preferredChatEndpoint()
// Parse tool annotations from text markers toggle
val parseToolAnnotations: StateFlow<Boolean> = application.relayDataStore.data
@@ -1176,6 +1545,67 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
// Close the session drawer after a successful send. Default ON because
// sending should return focus to the live conversation; users who use the
// drawer as a pinned session navigator can keep it open.
val closeDrawerOnSend: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_CLOSE_DRAWER_ON_SEND] ?: true }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setCloseDrawerOnSend(enabled: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_CLOSE_DRAWER_ON_SEND] = enabled
}
}
}
// Keep the text composer focused after send. Default ON matches mobile
// chat convention: quick follow-up messages should not require retapping
// the input. Turning it off drops the keyboard after a successful send.
val keepComposerFocusedOnSend: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_KEEP_COMPOSER_FOCUSED_ON_SEND] ?: true }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setKeepComposerFocusedOnSend(enabled: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_KEEP_COMPOSER_FOCUSED_ON_SEND] = enabled
}
}
}
// Turn-complete notification (default ON). RelayApp mirrors this into
// ChatViewModel.notifyOnTurnComplete; ChatSettingsScreen owns the toggle
// + the POST_NOTIFICATIONS runtime request on first enable.
val notifyTurnComplete: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_NOTIFY_TURN_COMPLETE] ?: true }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setNotifyTurnComplete(enabled: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_NOTIFY_TURN_COMPLETE] = enabled
}
}
}
// One-shot voice hint on the input bar. Initial stateIn value is TRUE
// (treated as already-seen) so returning users never get a flash of the
// hint while DataStore hydrates; fresh installs flip to false once the
// (absent) preference loads and the hint shows exactly once.
val voiceHintSeen: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_VOICE_HINT_SEEN] ?: false }
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setVoiceHintSeen(seen: Boolean) {
viewModelScope.launch {
getApplication<Application>().relayDataStore.edit { prefs ->
prefs[KEY_VOICE_HINT_SEEN] = seen
}
}
}
// Max attachment size in MB (default 10)
val maxAttachmentMb: StateFlow<Int> = application.relayDataStore.data
.map { it[KEY_MAX_ATTACHMENT_MB] ?: 10 }
@@ -2050,6 +2480,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// ConnectionStore's EncryptedSharedPrefs.
profileSelectionStore.clear(connectionId)
profileSessionStore.clearConnection(connectionId)
profileDisplayAliasStore.clearConnection(connectionId)
}
private suspend fun readStoredDeviceIdForRemoval(
@@ -2692,6 +3123,39 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
// Fast-retry burst on an Unreachable verdict. The periodic loop
// above ticks every 30s — a single transient miss (cold-start race
// with the route resolver, Wi-Fi still settling, mid-route-swap)
// used to park "offline" for a full tick, which the startup gate
// (and the demo-video camera) measured as a 6–28s wildly variable
// launch against the same healthy LAN server. One bounded burst per
// failure episode: three quick re-probes, re-armed only by a
// Reachable verdict — a genuinely down server fails one burst and
// settles back to the 30s cadence (where the 2-consecutive-failures
// escalation still owns route re-resolution). StateFlow dedup means
// repeat Unreachable verdicts can't re-trigger the burst.
viewModelScope.launch {
var burstArmed = true
_apiServerHealth.collect { verdict ->
when (verdict) {
HealthStatus.Reachable -> burstArmed = true
HealthStatus.Unreachable -> {
if (!burstArmed) return@collect
burstArmed = false
for (retryDelayMs in listOf(2_500L, 5_000L, 7_500L)) {
delay(retryDelayMs)
if (_apiServerHealth.value != HealthStatus.Unreachable) {
return@collect
}
if (_apiClient.value == null) return@collect
probeApiHealth()
}
}
else -> Unit
}
}
}
// Periodic relay health check — same cadence. Only fires when a relay
// URL is configured. Does NOT touch the WSS channel; this is a pure
// /health probe via RelayHttpClient (3s timeout, no auth needed).
@@ -2716,7 +3180,35 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
.drop(1)
.distinctUntilChanged()
.collect {
rebuildApiClient()
if (_chatStreaming.value) {
// Rebuilding the chat client mid-turn replaces it and
// CANCELS the in-flight turn. The gateway socket rides a
// transient route blip via its own reconnect (keeping the
// live session), so defer the rebuild until the turn ends.
pendingApiClientRebuild = true
android.util.Log.i(
"ConnectionViewModel",
"route changed mid-turn — deferring chat client rebuild",
)
} else {
rebuildApiClient()
}
}
}
// Apply a route change that was deferred because a turn was streaming.
// (StateFlow already conflates/dedups, so no distinctUntilChanged.)
viewModelScope.launch {
_chatStreaming
.collect { streaming ->
if (!streaming && pendingApiClientRebuild) {
pendingApiClientRebuild = false
android.util.Log.i(
"ConnectionViewModel",
"turn ended — applying deferred chat client rebuild",
)
rebuildApiClient()
}
}
}
@@ -2769,6 +3261,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
_selectedProfile.value = null
_pendingSelectedProfileConnectionId.value = null
_pendingSelectedProfileName.value = null
// Dashboard profile lists are per-connection — drop the old one so
// the pending persisted name can't resolve against the previous
// connection's profiles before the new connection's list arrives.
_dashboardProfiles.value = emptyList()
// Gateway state is per-connection: drop the sticky
// Unsupported verdict and tear down the old socket so the
// next probe/send evaluates the new connection fresh.
_gatewayAvailability.value = GatewayAvailability.Unknown
synchronized(this@ConnectionViewModel) {
gatewayClientCache?.third?.shutdown()
gatewayClientCache = null
}
}
}
@@ -2795,6 +3299,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
rebuildApiClient()
}
rebuildChatApiClient()
// Hydrate the host's agent profiles eagerly (not lazily on
// agent-sheet open). On a dashboard/gateway connection the relay
// `auth.ok` profile list is empty, so without this the persisted
// profile selection can't resolve until the user opens the picker
// — the header shows the default agent on cold start, then visibly
// snaps to the real profile (and re-scopes the chat) the moment the
// sheet fetches the list. Best-effort; the agentProfiles collector
// resolves the pending name once the list lands.
refreshDashboardProfiles()
}
}
@@ -2812,6 +3325,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
}
// Cold-start restore timing: the gateway probe is async, so the first
// refreshLastSessionForProfile at connection-activate can run while
// availability is still Unknown — [activeSessionTransport] defers, leaving
// no session restored. Re-run once the probe settles, but only when
// nothing has been restored or started yet, so we never yank a session
// the user is already in.
viewModelScope.launch {
_gatewayAvailability.collect { availability ->
if (availability == GatewayAvailability.Unknown) return@collect
if (_lastSessionId.value != null) return@collect
val connectionId = activeConnectionId.value ?: return@collect
refreshLastSessionForProfile(connectionId, _selectedProfile.value?.name)
}
}
}
// --- Revalidation ----------------------------------------------------
@@ -2913,6 +3441,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
if (connectionId == null || dashboardUrl.isNullOrBlank()) {
_standardVoiceAvailability.value = StandardVoiceAvailability.Unknown
_standardAudioApiReachable.value = false
_serverChatDisplaySettings.value = null
updateGatewayAvailability(GatewayAvailability.Unknown)
return
}
val client = DashboardApiClient(
@@ -2927,12 +3457,20 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
if (status == null) {
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
_standardAudioApiReachable.value = false
_serverChatDisplaySettings.value = null
updateGatewayAvailability(GatewayAvailability.Unreachable)
recordDashboardStatusIfChanged(connectionId, status = null, session = null)
return
}
val session = if (status.authRequired) client.currentSession().getOrNull() else null
val authed = !status.authRequired || session?.authenticated == true
recordDashboardStatusIfChanged(connectionId, status, session)
refreshChatDisplaySettings(client, authed)
// Gateway chat shares the voice probe's dashboard checks; it has
// no audio-route requirement.
updateGatewayAvailability(
if (authed) GatewayAvailability.Ready else GatewayAvailability.SignInRequired,
)
val availability = when {
!authed -> StandardVoiceAvailability.SignInRequired
client.audioRoutesPresent() -> StandardVoiceAvailability.Ready
@@ -2955,6 +3493,20 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
private suspend fun refreshChatDisplaySettings(
client: DashboardApiClient,
authenticated: Boolean,
) {
if (!authenticated) {
_serverChatDisplaySettings.value = null
return
}
client.getChatDisplaySettings().fold(
onSuccess = { _serverChatDisplaySettings.value = it },
onFailure = { _serverChatDisplaySettings.value = null },
)
}
/**
* [recordDashboardStatus] persists to the ConnectionStore; the voice probe
* runs on every health cycle, so gate the write on a material change to
@@ -3193,13 +3745,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
.firstOrNull { it.id == activeId }
if (current != null) {
val newRelayUrl = payload.relay?.url ?: current.relayUrl
val newDashboardUrl = if (
Connection.isAutoManagedDashboardUrl(current.dashboardUrl, current.apiServerUrl)
) {
Connection.deriveDefaultDashboardUrl(payload.serverUrl)
} else {
current.dashboardUrl
}
val payloadDashboardUrl = payload.dashboardUrl
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
val newDashboardUrl = payloadDashboardUrl
?: if (
Connection.isAutoManagedDashboardUrl(current.dashboardUrl, current.apiServerUrl)
) {
Connection.deriveDefaultDashboardUrl(payload.serverUrl)
} else {
current.dashboardUrl
}
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
current.relayUrl != newRelayUrl ||
current.dashboardUrl != newDashboardUrl ||
@@ -4561,7 +5118,34 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val profileName = _selectedProfile.value?.name
viewModelScope.launch {
if (connectionId != null) {
profileSessionStore.setSessionId(connectionId, profileName, sessionId)
if (sessionId != null) {
// Bucket by the id's own namespace: the prefix is the server's
// ground truth about which transport can resume it, robust to a
// turn that fell back from gateway to SSE.
val transport = SessionTransport.forSessionId(sessionId)
profileSessionStore.setSessionId(
connectionId,
profileName,
transport,
sessionId,
)
} else {
// A null clears only the ACTIVE transport's slot, and only when
// that transport is known. A null while the gateway probe is
// still pending (transport == null) — or right after a switch,
// when availability is reset to Unknown — is a deferred-restore
// transient forwarded by switchProfileContext, NOT a user clear;
// clearing then would wipe a still-valid session before the
// availability collector restores it.
activeSessionTransport()?.let { transport ->
profileSessionStore.setSessionId(
connectionId,
profileName,
transport,
null,
)
}
}
}
if (profileName == null) {
getApplication<Application>().relayDataStore.edit { preferences ->
@@ -4384,14 +4384,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
errorCode == "permission_missing_sms" -> buildString {
append("**Send SMS — permission needed**")
append('\n')
append("Grant SMS permission in Settings › Apps › Hermes Relay › Permissions.")
append("Grant SMS permission in Settings › Apps › Hermes-Relay › Permissions.")
}
errorCode == "permission_missing_contacts" -> buildString {
append("**Send SMS — permission needed**")
append('\n')
append("Grant Contacts permission to look up '")
append(contact ?: "?")
append("' in Settings › Apps › Hermes Relay › Permissions.")
append("' in Settings › Apps › Hermes-Relay › Permissions.")
}
errorCode == "service_missing" -> buildString {
append("**Send SMS — bridge offline**")
+9 -2
View File
@@ -1,10 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Blank/transparent splash icon — system splash shows as plain dark screen,
then our Compose sphere intro takes over seamlessly. -->
then our Compose sphere intro takes over seamlessly.
The path is a REAL (fully transparent) rect, not an empty vector: some
OEM splash implementations (observed on OneUI / S25 Ultra) treat a
pathless vector as an invalid icon and fall back to drawing the
launcher mark instead. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- intentionally empty -->
<path
android:fillColor="#00000000"
android:pathData="M0,0h108v108h-108z" />
</vector>
+5 -1
View File
@@ -2,6 +2,10 @@
<resources>
<color name="purple_primary">#6B35E8</color>
<color name="purple_light">#9B6BF0</color>
<color name="dark_background">#1A1A2E</color>
<!-- Splash window background — MUST equal RelayRefresh.Background
(#08090D) so the system splash and the sphere screen read as one
continuous surface; the old #1A1A2E made the splash a visibly
different navy that "switched" to the app. -->
<color name="dark_background">#08090D</color>
<color name="splash_icon_bg">#252540</color>
</resources>
+3
View File
@@ -98,6 +98,9 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains the sideload Device Control bridge while the user has explicitly enabled agent control." />
</service>
<!-- Note: GatewayKeepAliveService ("Keep connected in background") is
declared in the MAIN manifest so both flavors ship it. -->
</application>
</manifest>
@@ -29,21 +29,34 @@ class AgentDisplayTest {
}
@Test
fun effectiveProfile_fallsBackToAdvertisedDefaultProfile() {
fun effectiveProfile_isNullWithoutAnExplicitPick() {
// No fallback to the advertised "default" profile - its verbose SOUL
// summary must not replace the personality-derived agent name.
val effective = AgentDisplay.effectiveProfile(
selectedProfile = null,
profiles = listOf(mizu, defaultProfile),
)
assertEquals(null, effective)
}
@Test
fun effectiveDisplayProfile_usesDefaultProfileForDisplayOnly() {
val effective = AgentDisplay.effectiveDisplayProfile(
selectedProfile = null,
profiles = listOf(mizu, defaultProfile),
)
assertEquals(defaultProfile, effective)
}
@Test
fun agentName_prefersProfileDescriptionThenProfileName() {
fun agentName_usesProfileNameNotVerboseDescription() {
// The name slot shows the NAME, even when a (verbose) description exists.
assertEquals(
"Mizu",
AgentDisplay.agentName(
profile = mizu,
profile = mizu.copy(description = "Builds and maintains the codebase"),
selectedPersonality = "friendly",
defaultPersonality = "default-persona",
connectionLabel = "Lab",
@@ -61,6 +74,54 @@ class AgentDisplayTest {
)
}
@Test
fun agentName_usesConciseDefaultDescriptionNotVerboseSummary() {
assertEquals(
"Victor",
AgentDisplay.agentName(
profile = defaultProfile.copy(description = "victor"),
selectedPersonality = "default",
defaultPersonality = "",
connectionLabel = "Lab",
),
)
assertEquals(
"Lab",
AgentDisplay.agentName(
profile = defaultProfile.copy(description = "Builds and maintains the codebase."),
selectedPersonality = "default",
defaultPersonality = "",
connectionLabel = "Lab",
),
)
}
@Test
fun agentName_usesLocalAliasForDisplayOnly() {
assertEquals(
"House",
AgentDisplay.agentName(
profile = defaultProfile.copy(description = "Builds and maintains the codebase."),
selectedPersonality = "default",
defaultPersonality = "",
connectionLabel = "Lab",
localDisplayAlias = " House ",
),
)
assertEquals(
"Code Guide",
AgentDisplay.agentName(
profile = mizu,
selectedPersonality = "default",
defaultPersonality = "",
connectionLabel = "Lab",
localDisplayAlias = "Code\nGuide",
),
)
}
@Test
fun agentName_fallsBackToPersonalityConnectionThenHermes() {
assertEquals(
@@ -109,6 +170,13 @@ class AgentDisplayTest {
assertEquals("mizu", AgentDisplay.profileRequestName("mizu"))
}
@Test
fun displayModelName_hidesGenericApiAlias() {
assertNull(AgentDisplay.displayModelName("hermes-agent"))
assertNull(AgentDisplay.displayModelName(" Hermes Agent "))
assertEquals("gpt-5.5", AgentDisplay.displayModelName(" gpt-5.5 "))
}
@Test
fun normalizeSelection_collapsesSyntheticDefaultProfile() {
assertNull(AgentDisplay.normalizeSelection(defaultProfile))
@@ -281,14 +281,20 @@ class ChatMessageTest {
title = "My Session",
model = "gpt-4",
messageCount = 10,
updatedAt = 1700000000L
updatedAt = 1700000300L,
startedAt = 1700000000L,
lastActivityAt = 1700000300L
)
assertEquals("sess-1", session.sessionId)
assertEquals("My Session", session.title)
assertEquals("gpt-4", session.model)
assertEquals(10, session.messageCount)
assertEquals(1700000000L, session.updatedAt)
assertEquals(1700000300L, session.updatedAt)
assertEquals(1700000000L, session.startedAt)
assertEquals(1700000300L, session.lastActivityAt)
assertEquals(1700000300L, session.activityTimestamp)
assertEquals(1700000000L, session.startTimestamp)
}
@Test
@@ -303,6 +309,10 @@ class ChatMessageTest {
assertNull(session.model)
assertEquals(0, session.messageCount)
assertEquals(0L, session.updatedAt)
assertEquals(0L, session.startedAt)
assertEquals(0L, session.lastActivityAt)
assertEquals(0L, session.activityTimestamp)
assertEquals(0L, session.startTimestamp)
}
@Test
@@ -0,0 +1,70 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class ProfileDisplayAliasStoreTest {
private lateinit var store: ProfileDisplayAliasStore
@Before
fun setUp() {
store = ProfileDisplayAliasStore(InMemoryPreferencesDataStore())
}
@Test
fun aliasesAreScopedByConnectionAndProfile() = runBlocking {
store.setAlias("conn-1", null, "Victor")
store.setAlias("conn-1", "mizu", "Mizu")
store.setAlias("conn-2", null, "House")
assertEquals("Victor", store.aliasFlow("conn-1", null).first())
assertEquals("Mizu", store.aliasFlow("conn-1", "mizu").first())
assertEquals("House", store.aliasFlow("conn-2", null).first())
}
@Test
fun nullAliasClearsOnlyThatContext() = runBlocking {
store.setAlias("conn-1", null, "Victor")
store.setAlias("conn-1", "mizu", "Mizu")
store.setAlias("conn-1", null, null)
assertNull(store.aliasFlow("conn-1", null).first())
assertEquals("Mizu", store.aliasFlow("conn-1", "mizu").first())
}
@Test
fun clearConnectionRemovesEveryAliasForThatConnectionOnly() = runBlocking {
store.setAlias("conn-1", null, "Victor")
store.setAlias("conn-1", "mizu", "Mizu")
store.setAlias("conn-2", null, "House")
store.clearConnection("conn-1")
assertNull(store.aliasFlow("conn-1", null).first())
assertNull(store.aliasFlow("conn-1", "mizu").first())
assertEquals("House", store.aliasFlow("conn-2", null).first())
}
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
val next = transform(state.value)
state.value = next
return next
}
}
}
@@ -3,6 +3,8 @@ package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.hermesandroid.relay.data.SessionTransport.GATEWAY
import com.hermesandroid.relay.data.SessionTransport.SSE
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -46,48 +48,82 @@ class ProfileSessionStoreTest {
@Test
fun setAndGet_defaultProfileSession() = runBlocking {
store.setSessionId("conn-1", null, "session-default")
store.setSessionId("conn-1", null, GATEWAY, "session-default")
assertEquals(
"session-default",
store.sessionIdFlow("conn-1", null).first(),
store.sessionIdFlow("conn-1", null, GATEWAY).first(),
)
}
@Test
fun profileSessionsAreIndependentFromDefaultAndEachOther() = runBlocking {
store.setSessionId("conn-1", null, "session-default")
store.setSessionId("conn-1", "mizu", "session-mizu")
store.setSessionId("conn-1", "coder", "session-coder")
store.setSessionId("conn-2", "mizu", "session-other")
store.setSessionId("conn-1", null, GATEWAY, "session-default")
store.setSessionId("conn-1", "mizu", GATEWAY, "session-mizu")
store.setSessionId("conn-1", "coder", GATEWAY, "session-coder")
store.setSessionId("conn-2", "mizu", GATEWAY, "session-other")
assertEquals("session-default", store.sessionIdFlow("conn-1", null).first())
assertEquals("session-mizu", store.sessionIdFlow("conn-1", "mizu").first())
assertEquals("session-coder", store.sessionIdFlow("conn-1", "coder").first())
assertEquals("session-other", store.sessionIdFlow("conn-2", "mizu").first())
assertEquals("session-default", store.sessionIdFlow("conn-1", null, GATEWAY).first())
assertEquals("session-mizu", store.sessionIdFlow("conn-1", "mizu", GATEWAY).first())
assertEquals("session-coder", store.sessionIdFlow("conn-1", "coder", GATEWAY).first())
assertEquals("session-other", store.sessionIdFlow("conn-2", "mizu", GATEWAY).first())
}
@Test
fun nullSessionClearsOnlyThatProfileSlot() = runBlocking {
store.setSessionId("conn-1", "mizu", "session-mizu")
store.setSessionId("conn-1", "coder", "session-coder")
fun gatewayAndSseSlotsAreIndependentForSameProfile() = runBlocking {
// The core of the continuity fix: a profile's gateway session and its
// api_server (SSE) session must not clobber one another.
store.setSessionId("conn-1", "mizu", GATEWAY, "20260614_192846_4bf8d3")
store.setSessionId("conn-1", "mizu", SSE, "api_1781479723_f60ca534")
store.setSessionId("conn-1", "mizu", null)
assertNull(store.sessionIdFlow("conn-1", "mizu").first())
assertEquals("session-coder", store.sessionIdFlow("conn-1", "coder").first())
assertEquals(
"20260614_192846_4bf8d3",
store.sessionIdFlow("conn-1", "mizu", GATEWAY).first(),
)
assertEquals(
"api_1781479723_f60ca534",
store.sessionIdFlow("conn-1", "mizu", SSE).first(),
)
}
@Test
fun clearConnectionRemovesAllProfilesForThatConnectionOnly() = runBlocking {
store.setSessionId("conn-1", null, "session-default")
store.setSessionId("conn-1", "mizu", "session-mizu")
store.setSessionId("conn-2", "mizu", "session-other")
fun nullSessionClearsOnlyThatTransportSlot() = runBlocking {
store.setSessionId("conn-1", "mizu", GATEWAY, "session-gw")
store.setSessionId("conn-1", "mizu", SSE, "session-sse")
store.setSessionId("conn-1", "mizu", GATEWAY, null)
assertNull(store.sessionIdFlow("conn-1", "mizu", GATEWAY).first())
assertEquals("session-sse", store.sessionIdFlow("conn-1", "mizu", SSE).first())
}
@Test
fun clearConnectionRemovesAllProfilesAndTransportsForThatConnectionOnly() = runBlocking {
store.setSessionId("conn-1", null, GATEWAY, "session-default")
store.setSessionId("conn-1", "mizu", GATEWAY, "session-mizu")
store.setSessionId("conn-1", "mizu", SSE, "session-mizu-sse")
store.setSessionId("conn-2", "mizu", GATEWAY, "session-other")
store.clearConnection("conn-1")
assertNull(store.sessionIdFlow("conn-1", null).first())
assertNull(store.sessionIdFlow("conn-1", "mizu").first())
assertEquals("session-other", store.sessionIdFlow("conn-2", "mizu").first())
assertNull(store.sessionIdFlow("conn-1", null, GATEWAY).first())
assertNull(store.sessionIdFlow("conn-1", "mizu", GATEWAY).first())
assertNull(store.sessionIdFlow("conn-1", "mizu", SSE).first())
assertEquals("session-other", store.sessionIdFlow("conn-2", "mizu", GATEWAY).first())
}
@Test
fun forSessionId_bucketsByNamespace() {
// api_ ids are api_server (launch DB / SSE path); everything else is gateway.
assertEquals(SSE, SessionTransport.forSessionId("api_1781479723_f60ca534"))
assertEquals(GATEWAY, SessionTransport.forSessionId("20260614_192846_4bf8d3"))
}
@Test
fun forEndpoint_onlyGatewayMapsToGateway() {
assertEquals(GATEWAY, SessionTransport.forEndpoint("gateway"))
assertEquals(SSE, SessionTransport.forEndpoint("sessions"))
assertEquals(SSE, SessionTransport.forEndpoint("completions"))
assertEquals(SSE, SessionTransport.forEndpoint("runs"))
}
}
@@ -2,6 +2,8 @@ package com.hermesandroid.relay.network
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -428,4 +430,163 @@ class DashboardApiClientTest {
assertEquals("DELETE", delete.method)
assertEquals("/api/profiles/old%20profile", delete.path)
}
@Test
fun listProfiles_parsesArrayShapeIntoProfiles() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""
{"profiles":[
{"name":"default","model":"gpt-5.5","description":"Victor","gateway_running":true,"skill_count":3,"is_default":true},
{"name":"mizu","model":"claude-opus-4-8","description":"Code assistant","gateway_running":false}
]}
""".trimIndent(),
),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val profiles = client.listProfiles().getOrThrow()
val request = server.takeRequest()
assertEquals("GET", request.method)
assertEquals("/api/profiles", request.path)
assertEquals(2, profiles.size)
assertEquals("default", profiles[0].name)
assertEquals("gpt-5.5", profiles[0].model)
assertEquals("Victor", profiles[0].description)
assertTrue(profiles[0].gatewayRunning)
assertEquals(3, profiles[0].skillCount)
assertEquals("mizu", profiles[1].name)
assertEquals("claude-opus-4-8", profiles[1].model)
}
@Test
fun listProfiles_parsesObjectMapShapeWithInjectedName() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("""{"profiles":{"default":{"model":"gpt-5.5"},"mizu":{"model":"claude-opus-4-8","description":"Coder"}}}"""),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val profiles = client.listProfiles().getOrThrow().sortedBy { it.name }
assertEquals(2, profiles.size)
// The map key is injected as the profile name when the object omits it.
assertEquals("default", profiles[0].name)
assertEquals("mizu", profiles[1].name)
assertEquals("Coder", profiles[1].description)
}
@Test
fun listSessions_scopesToProfileAndParsesUpstreamEnvelope() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""
{"sessions":[
{"id":"sess-a","title":"Refactor","model":"claude-opus-4-8","message_count":4,"started_at":1234.5,"last_active":1250.5,"source":"tui","profile":"mizu"},
{"id":"sess-b","title":"Notes","message_count":2,"started_at":1200.0,"source":"tui","profile":"mizu"}
],"total":2,"limit":50,"offset":0}
""".trimIndent(),
),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val sessions = client.listSessions(profile = "mizu").getOrThrow()
val request = server.takeRequest()
assertEquals("GET", request.method)
// Server-side per-profile scoping is the whole point — the request must
// carry profile=mizu (the desktop's `_open_session_db_for_profile` path).
val url = request.requestUrl!!
assertEquals("/api/sessions", url.encodedPath)
assertEquals("mizu", url.queryParameter("profile"))
assertEquals("1", url.queryParameter("min_messages"))
assertEquals(2, sessions.size)
assertEquals("sess-a", sessions[0].id)
assertEquals("Refactor", sessions[0].title)
assertEquals("claude-opus-4-8", sessions[0].model)
assertEquals(4, sessions[0].messageCount)
assertEquals(1250.5, sessions[0].lastActive!!, 0.001)
}
@Test
fun listSessions_omitsProfileParamForTheDefaultSelection() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("""{"sessions":[],"total":0,"limit":50,"offset":0}"""),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
client.listSessions(profile = null).getOrThrow()
val request = server.takeRequest()
// No profile → omit the param so upstream reads the launch (default) DB.
assertEquals(null, request.requestUrl!!.queryParameter("profile"))
}
@Test
fun getSessionMessages_scopesToProfileAndParsesUpstreamEnvelope() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""{"session_id":"sess-a","messages":[
{"id":"m1","role":"user","content":"hi"},
{"id":"m2","role":"assistant","content":"hello"}
]}""".trimIndent(),
),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
val messages = client.getSessionMessages("sess-a", profile = "mizu").getOrThrow()
val request = server.takeRequest()
val url = request.requestUrl!!
assertEquals("/api/sessions/sess-a/messages", url.encodedPath)
assertEquals("mizu", url.queryParameter("profile"))
assertEquals(2, messages.size)
assertEquals("user", messages[0].role)
assertEquals("assistant", messages[1].role)
}
@Test
fun parseChatDisplaySettings_readsNestedDisplayConfig() {
val root = Json.parseToJsonElement(
"""
{
"config": {
"display": {
"show_reasoning": false,
"tool_progress": "all"
}
}
}
""".trimIndent(),
).jsonObject
val settings = DashboardApiClient.parseChatDisplaySettings(root)
assertEquals(false, settings.showReasoning)
assertEquals("detailed", settings.toolDisplay)
}
@Test
fun parseChatDisplaySettings_mapsToolProgressNoneToOff() {
val root = Json.parseToJsonElement(
"""
{
"display": {
"show_reasoning": true,
"tool_progress": "none"
}
}
""".trimIndent(),
).jsonObject
val settings = DashboardApiClient.parseChatDisplaySettings(root)
assertEquals(true, settings.showReasoning)
assertEquals("off", settings.toolDisplay)
}
}
@@ -0,0 +1,876 @@
package com.hermesandroid.relay.network
import com.hermesandroid.relay.network.models.UsageInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* [GatewayChatClient] wire tests against a scripted fake tui_gateway:
* MockWebServer serves POST /api/auth/ws-ticket and upgrades /api/ws,
* auto-answering session/prompt RPCs like upstream does.
*/
class GatewayClientHarness(
autoRespond: Boolean = true,
) {
val json = Json { ignoreUnknownKeys = true }
val server = MockWebServer()
val ticketMints = AtomicInteger(0)
val serverSockets = LinkedBlockingQueue<WebSocket>()
private val allServerSockets = ConcurrentLinkedQueue<WebSocket>()
val rpcLog = ConcurrentLinkedQueue<Pair<String, JsonObject>>()
var failTicketMint = false
var resumeFails = false
@Volatile
var steerStatus = "queued"
@Volatile
var reasoningEffort = "medium"
@Volatile
var reasoningDisplay = "hide"
/** Methods answered with JSON-RPC -32601 — exercises the legacy-name fallback. */
val methodNotFound: MutableSet<String> = ConcurrentHashMap.newKeySet()
private val wsListener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
serverSockets.add(webSocket)
allServerSockets.add(webSocket)
webSocket.send(eventFrame("gateway.ready", null, null))
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text) as JsonObject
val method = (frame["method"] as? JsonPrimitive)?.contentOrNull ?: return
val id = (frame["id"] as? JsonPrimitive)?.contentOrNull ?: return
val params = frame["params"] as? JsonObject ?: JsonObject(emptyMap())
rpcLog.add(method to params)
if (!autoRespondEnabled) return
if (method in methodNotFound) {
webSocket.send(
buildJsonObject {
put("jsonrpc", "2.0")
put("id", id.toLong())
put("error", buildJsonObject {
put("code", -32601)
put("message", "Method not found: $method")
})
}.toString(),
)
return
}
val result: JsonObject? = when (method) {
"session.create" -> buildJsonObject {
put("session_id", "live-1")
put("stored_session_id", "20260612_120000_abc123")
}
"session.resume" ->
if (resumeFails) null
else buildJsonObject { put("session_id", "live-resumed") }
"prompt.submit" -> buildJsonObject { put("ok", true) }
"session.interrupt" -> buildJsonObject { put("ok", true) }
"session.steer" -> buildJsonObject {
put("status", steerStatus)
put("text", (params["text"] as? JsonPrimitive)?.contentOrNull ?: "")
}
"image.attach_bytes", "image.attach.bytes" -> buildJsonObject {
put("attached", true)
put("count", 1)
}
"pdf.attach" -> buildJsonObject {
put("attached", true)
put("pages", 1)
}
"file.attach" -> buildJsonObject {
put("attached", true)
put("ref_text", "@file:notes.txt")
}
"clarify.respond", "sudo.respond", "secret.respond" ->
buildJsonObject { put("status", "ok") }
"approval.respond" -> buildJsonObject { put("resolved", true) }
"commands.catalog" -> buildJsonObject {
put(
"pairs",
json.parseToJsonElement("""[["/help","Show help"],["/model","Pick model"]]"""),
)
}
"config.get" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
"reasoning" -> buildJsonObject {
put("value", reasoningEffort)
put("display", reasoningDisplay)
}
else -> JsonObject(emptyMap())
}
"config.set" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
"reasoning" -> {
reasoningEffort = (params["value"] as? JsonPrimitive)?.contentOrNull ?: reasoningEffort
buildJsonObject {
put("key", "reasoning")
put("value", reasoningEffort)
}
}
else -> JsonObject(emptyMap())
}
else -> JsonObject(emptyMap())
}
val reply = if (result != null) {
buildJsonObject {
put("jsonrpc", "2.0")
put("id", id.toLong())
put("result", result)
}
} else {
buildJsonObject {
put("jsonrpc", "2.0")
put("id", id.toLong())
put("error", buildJsonObject { put("message", "$method refused") })
}
}
webSocket.send(reply.toString())
}
}
private val autoRespondEnabled = autoRespond
init {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path ?: ""
return when {
path.startsWith("/api/auth/ws-ticket") -> {
ticketMints.incrementAndGet()
if (failTicketMint) {
MockResponse().setResponseCode(401).setBody("""{"error":"no session"}""")
} else {
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"ticket":"tkt-${ticketMints.get()}","ttl_seconds":30}""")
}
}
path.startsWith("/api/ws") -> MockResponse().withWebSocketUpgrade(wsListener)
else -> MockResponse().setResponseCode(404)
}
}
}
server.start()
}
fun eventFrame(type: String, payload: JsonObject?, sessionId: String?): String =
buildJsonObject {
put("jsonrpc", "2.0")
put("method", "event")
put("params", buildJsonObject {
put("type", type)
if (payload != null) put("payload", payload)
if (sessionId != null) put("session_id", sessionId)
})
}.toString()
fun awaitServerSocket(): WebSocket =
serverSockets.poll(5, TimeUnit.SECONDS) ?: error("server socket never opened")
fun awaitRpc(method: String): JsonObject {
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
rpcLog.firstOrNull { it.first == method }?.let { return it.second }
Thread.sleep(20)
}
error("rpc $method never arrived; saw ${rpcLog.map { it.first }}")
}
/** Waits until [method] has been seen at least [count] times; returns the params in arrival order. */
fun awaitRpcCount(method: String, count: Int): List<JsonObject> {
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
val seen = rpcLog.filter { it.first == method }
if (seen.size >= count) return seen.map { it.second }
Thread.sleep(20)
}
error("rpc $method x$count never arrived; saw ${rpcLog.map { it.first }}")
}
fun shutdown() {
// Close any still-open server-side sockets first — an upgraded WS
// connection otherwise occupies a MockWebServer dispatcher thread
// and shutdown() gives up waiting for its queue. close(), not
// cancel(): mockwebserver's server-side RealWebSocket has no `call`
// and cancel() NPEs on it.
allServerSockets.forEach { runCatching { it.close(1001, "teardown") } }
try {
server.shutdown()
} catch (e: Throwable) {
// Known mockwebserver limitation: shutdown can give up waiting
// when a WS upgrade was served this test. Behaviour is asserted
// in test bodies; teardown noise must not fail the suite.
println("MockWebServer shutdown tolerated: ${e.message}")
}
}
}
class GatewayChatClientTest {
private lateinit var harness: GatewayClientHarness
private lateinit var scope: CoroutineScope
private lateinit var client: GatewayChatClient
private var unsupportedMarked = false
private class Recorder {
val textDeltas = ConcurrentLinkedQueue<String>()
val thinkingDeltas = ConcurrentLinkedQueue<String>()
val sessionIds = ConcurrentLinkedQueue<String>()
val errors = ConcurrentLinkedQueue<String>()
val interactions = ConcurrentLinkedQueue<GatewayAsk>()
// ConcurrentLinkedQueue rejects nulls — unnamed generating events store "".
val toolGenerating = ConcurrentLinkedQueue<String>()
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
val usages = ConcurrentLinkedQueue<UsageInfo>()
val completeLatch = CountDownLatch(1)
val preflightFailures = ConcurrentLinkedQueue<String>()
val callbacks = GatewayTurnCallbacks(
onSessionId = { sessionIds += it },
onTextDelta = { textDeltas += it },
onThinkingDelta = { thinkingDeltas += it },
onToolCallStart = { _, _ -> },
onToolCallDone = { _, _ -> },
onToolCallFailed = { _, _ -> },
onTurnComplete = { },
onComplete = { completeLatch.countDown() },
onUsage = { it?.let(usages::add) },
onError = { errors += it; completeLatch.countDown() },
onToolGenerating = { toolGenerating += it ?: "" },
onSubagentEvent = { subagentEvents += it },
onInteractionRequest = { interactions += it },
)
}
@Before
fun setUp() {
harness = GatewayClientHarness()
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
unsupportedMarked = false
client = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = harness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
okHttpClient = OkHttpClient(),
callbackDispatcher = { it() },
onGatewayUnsupported = { unsupportedMarked = true },
scope = scope,
// Keep the mid-turn reconnect window short so `failed rejoin`
// surfaces its error well within the test's await budget.
midTurnRejoinWindowMs = 3_000L,
)
}
@After
fun tearDown() {
client.shutdown()
scope.cancel()
harness.shutdown()
}
@Test
fun `happy path - ticket, ready, create, submit, stream, complete`() {
val r = Recorder()
client.sendTurn(
sessionId = null,
text = "hello",
newSessionTitle = "hello",
callbacks = r.callbacks,
onPreflightFailure = { r.preflightFailures += it },
)
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
// Verify the create carried the title and the stored id was reported.
val create = harness.awaitRpc("session.create")
assertEquals("hello", (create["title"] as? JsonPrimitive)?.contentOrNull)
serverWs.send(harness.eventFrame("message.start", null, "live-1"))
serverWs.send(
harness.eventFrame(
"reasoning.delta",
buildJsonObject { put("text", "thinking hard") },
"live-1",
),
)
serverWs.send(
harness.eventFrame("message.delta", buildJsonObject { put("text", "Hi!") }, "live-1"),
)
serverWs.send(
harness.eventFrame(
"message.complete",
buildJsonObject {
put("text", "Hi!")
put("status", "complete")
put("usage", buildJsonObject { put("input", 5); put("output", 2); put("total", 7) })
},
"live-1",
),
)
assertTrue("turn never completed", r.completeLatch.await(5, TimeUnit.SECONDS))
assertEquals(listOf("thinking hard"), r.thinkingDeltas.toList())
assertEquals(listOf("Hi!"), r.textDeltas.toList())
assertEquals(listOf("20260612_120000_abc123"), r.sessionIds.toList())
assertEquals(5, r.usages.firstOrNull()?.resolvedInputTokens)
assertTrue(r.errors.isEmpty())
assertTrue(r.preflightFailures.isEmpty())
}
@Test
fun `foreign session events are dropped`() {
val r = Recorder()
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
serverWs.send(
harness.eventFrame("message.delta", buildJsonObject { put("text", "not yours") }, "someone-else"),
)
serverWs.send(
harness.eventFrame("message.complete", buildJsonObject { put("text", "yours") }, "live-1"),
)
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
assertEquals(listOf("yours"), r.textDeltas.toList())
}
@Test
fun `existing session id is resumed not recreated`() {
val r = Recorder()
client.sendTurn("20260101_010101_aaaaaa", "again", null, r.callbacks) {
r.preflightFailures += it
}
harness.awaitRpc("prompt.submit")
val resume = harness.awaitRpc("session.resume")
assertEquals(
"20260101_010101_aaaaaa",
(resume["session_id"] as? JsonPrimitive)?.contentOrNull,
)
assertTrue(harness.rpcLog.none { it.first == "session.create" })
// Resumed sessions keep their stored id — no onSessionId rotation.
assertTrue(r.sessionIds.isEmpty())
}
@Test
fun `failed resume falls back to fresh create`() {
harness.resumeFails = true
val r = Recorder()
client.sendTurn("api_123_dead", "hi", "hi", r.callbacks) { r.preflightFailures += it }
harness.awaitRpc("session.resume")
harness.awaitRpc("session.create")
harness.awaitRpc("prompt.submit")
val deadline = System.currentTimeMillis() + 5_000
while (r.sessionIds.isEmpty() && System.currentTimeMillis() < deadline) Thread.sleep(20)
assertEquals(listOf("20260612_120000_abc123"), r.sessionIds.toList())
}
@Test
fun `ticket mint failure triggers preflight fallback not error`() {
harness.failTicketMint = true
val r = Recorder()
client.sendTurn(null, "hello", null, r.callbacks) {
r.preflightFailures += it
r.completeLatch.countDown()
}
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
assertTrue(r.preflightFailures.isNotEmpty())
assertTrue(r.errors.isEmpty())
assertTrue(r.textDeltas.isEmpty())
}
@Test
fun `each connect attempt mints a fresh ticket`() {
val r1 = Recorder()
client.sendTurn(null, "one", null, r1.callbacks) { r1.preflightFailures += it }
val ws1 = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
ws1.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "ok") }, "live-1"))
assertTrue(r1.completeLatch.await(5, TimeUnit.SECONDS))
assertEquals(1, harness.ticketMints.get())
// Kill the socket server-side; next send must reconnect with a NEW ticket.
ws1.close(1001, "server restart")
Thread.sleep(200)
harness.rpcLog.clear()
val r2 = Recorder()
client.sendTurn(null, "two", null, r2.callbacks) { r2.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
// ≥2: the reconnect minted at least one fresh ticket (a retried
// attempt may mint a third — what matters is no reuse).
assertTrue("expected a fresh ticket on reconnect", harness.ticketMints.get() >= 2)
}
@Test
fun `cancel sends session interrupt`() {
val r = Recorder()
val handle = client.sendTurn(null, "long task", null, r.callbacks) {
r.preflightFailures += it
}
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
handle.cancel()
val interrupt = harness.awaitRpc("session.interrupt")
assertEquals("live-1", (interrupt["session_id"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `socket loss mid-turn reconnects without resume and completes on the original session`() {
val r = Recorder()
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
val ws1 = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
// Server connection dies mid-turn (Wi-Fi roam analogue). The client
// must reconnect with a FRESH ticket but WITHOUT session.resume —
// upstream resume would mint a new id + fresh agent and orphan the
// running turn — then keep consuming the still-running turn's events,
// which arrive tagged with the ORIGINAL live session id ("live-1")
// on the shared gateway stream.
harness.rpcLog.clear()
ws1.close(1011, "server crashed")
val ws2 = harness.awaitServerSocket()
assertTrue("reconnect must mint a fresh ticket", harness.ticketMints.get() >= 2)
ws2.send(
harness.eventFrame("message.delta", buildJsonObject { put("text", "after rejoin") }, "live-1"),
)
ws2.send(
harness.eventFrame("message.complete", buildJsonObject { put("text", "after rejoin") }, "live-1"),
)
assertTrue("turn never completed after rejoin", r.completeLatch.await(10, TimeUnit.SECONDS))
assertEquals(listOf("after rejoin"), r.textDeltas.toList())
assertTrue("rejoined turn must not error, got ${r.errors}", r.errors.isEmpty())
// The fix's core invariant: a mid-turn rejoin must NEVER session.resume.
assertTrue(
"mid-turn rejoin must not call session.resume",
harness.rpcLog.none { it.first == "session.resume" },
)
assertTrue(r.preflightFailures.isEmpty())
}
@Test
fun `failed rejoin surfaces stream error`() {
val r = Recorder()
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
// Reconnect is impossible (ticket mint rejected) — the turn must
// surface a stream error rather than hang.
harness.failTicketMint = true
serverWs.close(1011, "server crashed")
assertTrue(r.completeLatch.await(10, TimeUnit.SECONDS))
assertTrue("expected stream error, got ${r.errors}", r.errors.isNotEmpty())
assertTrue(r.preflightFailures.isEmpty())
}
@Test
fun `interactive ask surfaces as structured GatewayAsk`() {
val r = Recorder()
client.sendTurn(null, "do something risky", null, r.callbacks) { r.preflightFailures += it }
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
serverWs.send(
harness.eventFrame(
"approval.request",
buildJsonObject { put("command", "rm -rf /tmp/x"); put("description", "cleanup") },
"live-1",
),
)
serverWs.send(
harness.eventFrame("message.complete", buildJsonObject { put("text", "done") }, "live-1"),
)
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
val ask = r.interactions.single()
assertEquals(GatewayAsk.Kind.APPROVAL, ask.kind)
assertEquals(null, ask.requestId)
assertEquals("rm -rf /tmp/x — cleanup", ask.text)
}
// --- Steer ---
@Test
fun `steer queued when the server accepts`() {
val r = Recorder()
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
harness.steerStatus = "queued"
assertEquals(SteerResult.Queued, runBlocking { client.steer("focus on tests") })
val steer = harness.awaitRpc("session.steer")
assertEquals("live-1", (steer["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("focus on tests", (steer["text"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `steer rejected propagates to the caller`() {
val r = Recorder()
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
harness.steerStatus = "rejected"
assertEquals(SteerResult.Rejected, runBlocking { client.steer("too late") })
}
@Test
fun `steer with no live session fails without touching the wire`() {
assertEquals(SteerResult.Failed, runBlocking { client.steer("nothing running") })
assertTrue(harness.rpcLog.none { it.first == "session.steer" })
}
// --- Profile-bound sessions (upstream tui_gateway: session.create/resume
// take a `profile` arg; a session's agent is built from it) ---
@Test
fun `session create binds the selected profile`() {
val r = Recorder()
client.sessionProfileProvider = { "mizu" }
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val create = harness.awaitRpc("session.create")
assertEquals("mizu", (create["profile"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `session create omits profile when none is selected`() {
val r = Recorder()
// Default provider returns null → no profile bound (launch profile).
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val create = harness.awaitRpc("session.create")
assertEquals(null, create["profile"])
}
// --- Attachments (image / pdf / file routing) ---
@Test
fun `image attachments upload between session establish and prompt submit`() {
val r = Recorder()
client.sendTurn(
sessionId = null,
text = "describe this",
newSessionTitle = null,
callbacks = r.callbacks,
attachments = listOf(GatewayAttachment(name = "shot.png", base64 = "aGVsbG8=", ext = "png", contentType = "image/png")),
onPreflightFailure = { r.preflightFailures += it },
)
harness.awaitRpc("prompt.submit")
val methods = harness.rpcLog.map { it.first }
val createIdx = methods.indexOf("session.create")
val attachIdx = methods.indexOf("image.attach_bytes")
val submitIdx = methods.indexOf("prompt.submit")
assertTrue("expected create < attach < submit, got $methods", createIdx in 0 until attachIdx)
assertTrue("expected attach before submit, got $methods", attachIdx < submitIdx)
val attach = harness.awaitRpc("image.attach_bytes")
assertEquals("live-1", (attach["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("aGVsbG8=", (attach["content_base64"] as? JsonPrimitive)?.contentOrNull)
assertEquals("shot.png", (attach["filename"] as? JsonPrimitive)?.contentOrNull)
assertEquals("png", (attach["ext"] as? JsonPrimitive)?.contentOrNull)
assertTrue(r.preflightFailures.isEmpty())
}
@Test
fun `attach falls back to the legacy name on method-not-found and remembers it`() {
harness.methodNotFound.add("image.attach_bytes")
val r1 = Recorder()
client.sendTurn(
sessionId = null,
text = "one",
newSessionTitle = null,
callbacks = r1.callbacks,
attachments = listOf(GatewayAttachment("a.png", "QQ==", "png", "image/png")),
onPreflightFailure = { r1.preflightFailures += it },
)
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val legacy = harness.awaitRpc("image.attach.bytes")
assertEquals("live-1", (legacy["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("QQ==", (legacy["bytes_base64"] as? JsonPrimitive)?.contentOrNull)
assertEquals("png", (legacy["format"] as? JsonPrimitive)?.contentOrNull)
assertEquals("a.png", (legacy["filename_hint"] as? JsonPrimitive)?.contentOrNull)
assertTrue(r1.preflightFailures.isEmpty())
serverWs.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "ok") }, "live-1"))
assertTrue(r1.completeLatch.await(5, TimeUnit.SECONDS))
// Same socket: the second upload must go straight to the legacy name —
// no second probe of the upstream name.
val r2 = Recorder()
client.sendTurn(
sessionId = "20260612_120000_abc123",
text = "two",
newSessionTitle = null,
callbacks = r2.callbacks,
attachments = listOf(GatewayAttachment("b.png", "Qg==", "png", "image/png")),
onPreflightFailure = { r2.preflightFailures += it },
)
harness.awaitRpcCount("image.attach.bytes", 2)
assertEquals(1, harness.rpcLog.count { it.first == "image.attach_bytes" })
assertTrue(r2.preflightFailures.isEmpty())
}
@Test
fun `attach failing on both names surfaces as preflight fallback`() {
harness.methodNotFound.add("image.attach_bytes")
harness.methodNotFound.add("image.attach.bytes")
val r = Recorder()
client.sendTurn(
sessionId = null,
text = "img",
newSessionTitle = null,
callbacks = r.callbacks,
attachments = listOf(GatewayAttachment("a.png", "QQ==", "png", "image/png")),
onPreflightFailure = {
r.preflightFailures += it
r.completeLatch.countDown()
},
)
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
assertTrue(r.preflightFailures.isNotEmpty())
// Nothing started server-side — the prompt was never submitted.
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
assertTrue(r.errors.isEmpty())
}
@Test
fun `pdf attachments route to pdf attach with content_base64`() {
val r = Recorder()
client.sendTurn(
sessionId = null,
text = "summarize this",
newSessionTitle = null,
callbacks = r.callbacks,
attachments = listOf(
GatewayAttachment(name = "report.pdf", base64 = "JVBERi0=", ext = "pdf", contentType = "application/pdf"),
),
onPreflightFailure = { r.preflightFailures += it },
)
harness.awaitRpc("prompt.submit")
val attach = harness.awaitRpc("pdf.attach")
assertEquals("live-1", (attach["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("JVBERi0=", (attach["content_base64"] as? JsonPrimitive)?.contentOrNull)
// No image RPC should have fired for a PDF.
assertTrue(harness.rpcLog.none { it.first == "image.attach_bytes" })
assertTrue(r.preflightFailures.isEmpty())
}
@Test
fun `non-image non-pdf attachments route to file attach with a data url`() {
val r = Recorder()
client.sendTurn(
sessionId = null,
text = "read this",
newSessionTitle = null,
callbacks = r.callbacks,
attachments = listOf(
GatewayAttachment(name = "notes.txt", base64 = "aGk=", ext = "txt", contentType = "text/plain"),
),
onPreflightFailure = { r.preflightFailures += it },
)
harness.awaitRpc("prompt.submit")
val attach = harness.awaitRpc("file.attach")
assertEquals("live-1", (attach["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals(
"data:text/plain;base64,aGk=",
(attach["data_url"] as? JsonPrimitive)?.contentOrNull,
)
assertEquals("notes.txt", (attach["name"] as? JsonPrimitive)?.contentOrNull)
assertTrue(harness.rpcLog.none { it.first == "image.attach_bytes" })
assertTrue(harness.rpcLog.none { it.first == "pdf.attach" })
assertTrue(r.preflightFailures.isEmpty())
}
// --- Ask responders ---
@Test
fun `clarify respond carries request id and answer`() {
val r = Recorder()
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
assertTrue(runBlocking { client.respondClarify("r1", "use a.txt") }.isSuccess)
val respond = harness.awaitRpc("clarify.respond")
assertEquals("r1", (respond["request_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("use a.txt", (respond["answer"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `sudo and secret responds carry request id under their key names`() {
val r = Recorder()
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
assertTrue(runBlocking { client.respondSudo("r2", "hunter2") }.isSuccess)
val sudo = harness.awaitRpc("sudo.respond")
assertEquals("r2", (sudo["request_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("hunter2", (sudo["password"] as? JsonPrimitive)?.contentOrNull)
assertTrue(runBlocking { client.respondSecret("r3", "sk-123") }.isSuccess)
val secret = harness.awaitRpc("secret.respond")
assertEquals("r3", (secret["request_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("sk-123", (secret["value"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `approval respond targets the live session`() {
val r = Recorder()
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
assertTrue(runBlocking { client.respondApproval("approve") }.isSuccess)
val respond = harness.awaitRpc("approval.respond")
assertEquals("live-1", (respond["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("approve", (respond["choice"] as? JsonPrimitive)?.contentOrNull)
assertEquals(false, (respond["all"] as? JsonPrimitive)?.booleanOrNull)
}
// --- Commands catalog ---
@Test
fun `commands catalog is fetched once and cached per socket`() {
val r = Recorder()
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val first = runBlocking { client.commandsCatalog() }
assertTrue(first.isSuccess)
assertTrue(first.getOrThrow().containsKey("pairs"))
val second = runBlocking { client.commandsCatalog() }
assertTrue(second.isSuccess)
assertEquals(1, harness.rpcLog.count { it.first == "commands.catalog" })
}
@Test
fun `commands catalog without a ready socket fails fast and mints no ticket`() {
val result = runBlocking { client.commandsCatalog() }
assertTrue(result.isFailure)
assertEquals(0, harness.ticketMints.get())
}
@Test
fun `commands catalog connects on demand only when asked`() {
val result = runBlocking { client.commandsCatalog(connectIfNeeded = true) }
assertTrue(result.isSuccess)
assertTrue(harness.ticketMints.get() >= 1)
}
// --- Reasoning config ---
@Test
fun `reasoning settings fetch uses config get`() {
harness.reasoningEffort = "high"
harness.reasoningDisplay = "show"
val result = runBlocking { client.getReasoningSettings() }
assertTrue(result.isSuccess)
assertEquals("high", result.getOrThrow().effort)
assertEquals("show", result.getOrThrow().display)
val rpc = harness.awaitRpc("config.get")
assertEquals("reasoning", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `reasoning settings update targets live session when present`() {
val r = Recorder()
client.sendTurn("stored-1", "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
val result = runBlocking { client.setReasoning("low") }
assertTrue(result.isSuccess)
val rpc = harness.awaitRpc("config.set")
assertEquals("reasoning", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
assertEquals("low", (rpc["value"] as? JsonPrimitive)?.contentOrNull)
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
}
// --- Edit & regenerate ---
@Test
fun `truncate ordinal rides prompt submit`() {
val r = Recorder()
client.sendTurn(
sessionId = null,
text = "edited message",
newSessionTitle = null,
callbacks = r.callbacks,
truncateBeforeUserOrdinal = 2,
onPreflightFailure = { r.preflightFailures += it },
)
val submit = harness.awaitRpc("prompt.submit")
assertEquals(2, (submit["truncate_before_user_ordinal"] as? JsonPrimitive)?.intOrNull)
}
@Test
fun `truncate ordinal is absent from plain sends`() {
val r = Recorder()
client.sendTurn(null, "plain", null, r.callbacks) { r.preflightFailures += it }
val submit = harness.awaitRpc("prompt.submit")
assertFalse(submit.containsKey("truncate_before_user_ordinal"))
}
}
@@ -0,0 +1,78 @@
package com.hermesandroid.relay.network
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Resolution matrix for [resolveStreamingEndpointPreference] — the gateway
* tier sits above the capability-preferred SSE endpoint, but only for "auto"
* and only when the dashboard probe reports Ready.
*/
class GatewayEndpointResolutionTest {
private val fullCaps = ServerCapabilities(
sessionsApi = true,
sessionsChatStream = true,
runs = true,
portable = true,
healthy = true,
)
private val portableOnlyCaps = ServerCapabilities(
sessionsApi = false,
sessionsChatStream = false,
runs = false,
portable = true,
healthy = true,
)
@Test
fun `auto prefers gateway when ready`() {
assertEquals(
"gateway",
resolveStreamingEndpointPreference("auto", GatewayAvailability.Ready, fullCaps),
)
}
@Test
fun `auto falls back to capability preference for every non-ready state`() {
listOf(
GatewayAvailability.Unknown,
GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported,
).forEach { availability ->
assertEquals(
"expected SSE fallback for $availability",
"sessions",
resolveStreamingEndpointPreference("auto", availability, fullCaps),
)
}
}
@Test
fun `auto fallback respects capability ordering`() {
assertEquals(
"completions",
resolveStreamingEndpointPreference(
"auto",
GatewayAvailability.SignInRequired,
portableOnlyCaps,
),
)
}
@Test
fun `manual picks pass through regardless of gateway state`() {
listOf("gateway", "sessions", "completions", "runs").forEach { pick ->
assertEquals(
pick,
resolveStreamingEndpointPreference(pick, GatewayAvailability.Unreachable, fullCaps),
)
assertEquals(
pick,
resolveStreamingEndpointPreference(pick, GatewayAvailability.Ready, fullCaps),
)
}
}
}
@@ -0,0 +1,469 @@
package com.hermesandroid.relay.network
import com.hermesandroid.relay.network.models.UsageInfo
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Mapping-table tests for [GatewayEventMapper] — one fixture per row of the
* event→callback contract, plus the backfill, synthetic-id, and
* forward-compat (unknown event type) behaviours.
*/
class GatewayEventMapperTest {
private class Recorder {
val textDeltas = mutableListOf<String>()
val thinkingDeltas = mutableListOf<String>()
val toolStarts = mutableListOf<Pair<String, String>>()
val toolDones = mutableListOf<Pair<String, String?>>()
val toolFails = mutableListOf<Pair<String, String?>>()
val toolGenerating = mutableListOf<String?>()
val subagentEvents = mutableListOf<GatewaySubagentEvent>()
val interactions = mutableListOf<GatewayAsk>()
val sessionIds = mutableListOf<String>()
var turnCompletes = 0
var completes = 0
var usage: UsageInfo? = null
var usageCalls = 0
val errors = mutableListOf<String>()
val callbacks = GatewayTurnCallbacks(
onSessionId = { sessionIds += it },
onTextDelta = { textDeltas += it },
onThinkingDelta = { thinkingDeltas += it },
onToolCallStart = { id, name -> toolStarts += id to name },
onToolCallDone = { id, preview -> toolDones += id to preview },
onToolCallFailed = { id, err -> toolFails += id to err },
onTurnComplete = { turnCompletes++ },
onComplete = { completes++ },
onUsage = { usage = it; usageCalls++ },
onError = { errors += it },
onToolGenerating = { toolGenerating += it },
onSubagentEvent = { subagentEvents += it },
onInteractionRequest = { interactions += it },
)
}
private fun obj(jsonText: String): JsonObject =
Json.parseToJsonElement(jsonText) as JsonObject
private fun mapperWith(recorder: Recorder) = GatewayEventMapper(recorder.callbacks)
// --- The feature: live thinking ---
@Test
fun `reasoning delta streams to onThinkingDelta`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("reasoning.delta", obj("""{"text":"pondering"}"""))
mapper.onEvent("thinking.delta", obj("""{"text":" more"}"""))
assertEquals(listOf("pondering", " more"), r.thinkingDeltas)
assertFalse(mapper.turnEnded)
}
@Test
fun `reasoning available backfills only when nothing streamed`() {
val streamed = Recorder()
val m1 = mapperWith(streamed)
m1.onEvent("reasoning.delta", obj("""{"text":"live"}"""))
m1.onEvent("reasoning.available", obj("""{"text":"post-hoc"}"""))
assertEquals(listOf("live"), streamed.thinkingDeltas)
val quiet = Recorder()
val m2 = mapperWith(quiet)
m2.onEvent("reasoning.available", obj("""{"text":"post-hoc"}"""))
assertEquals(listOf("post-hoc"), quiet.thinkingDeltas)
}
// --- Text + turn lifecycle ---
@Test
fun `message delta streams text and complete ends turn`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("message.start", null)
mapper.onEvent("message.delta", obj("""{"text":"Hel"}"""))
mapper.onEvent("message.delta", obj("""{"text":"lo"}"""))
mapper.onEvent("message.complete", obj("""{"text":"Hello","status":"complete"}"""))
assertEquals(listOf("Hel", "lo"), r.textDeltas)
assertEquals(1, r.completes)
assertTrue(mapper.turnEnded)
// Text already streamed — complete must NOT re-append it.
assertEquals(2, r.textDeltas.size)
}
@Test
fun `message complete backfills text and reasoning when nothing streamed`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent(
"message.complete",
obj("""{"text":"Full answer","reasoning":"the hidden why","status":"complete"}"""),
)
assertEquals(listOf("Full answer"), r.textDeltas)
assertEquals(listOf("the hidden why"), r.thinkingDeltas)
assertEquals(1, r.completes)
}
@Test
fun `second message start signals turn boundary`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("message.start", null)
assertEquals(0, r.turnCompletes)
mapper.onEvent("message.start", null)
assertEquals(1, r.turnCompletes)
}
@Test
fun `events after turn end are ignored`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("message.complete", obj("""{"text":"done"}"""))
mapper.onEvent("message.delta", obj("""{"text":"straggler"}"""))
mapper.onEvent("error", obj("""{"message":"late"}"""))
assertEquals(listOf("done"), r.textDeltas)
assertEquals(1, r.completes)
assertTrue(r.errors.isEmpty())
}
@Test
fun `error event surfaces message and ends turn`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("error", obj("""{"message":"model exploded"}"""))
assertEquals(listOf("model exploded"), r.errors)
assertTrue(mapper.turnEnded)
assertEquals(0, r.completes)
}
// --- Tools ---
@Test
fun `tool start and complete route by tool_id`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("tool.start", obj("""{"tool_id":"t1","name":"execute_code"}"""))
mapper.onEvent("tool.complete", obj("""{"tool_id":"t1","name":"execute_code","summary":"ran fine"}"""))
assertEquals(listOf("t1" to "execute_code"), r.toolStarts)
assertEquals(listOf("t1" to "ran fine"), r.toolDones)
}
@Test
fun `tool complete with error routes to failed`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("tool.start", obj("""{"tool_id":"t2","name":"web_search"}"""))
mapper.onEvent("tool.complete", obj("""{"tool_id":"t2","name":"web_search","error":"timeout"}"""))
assertEquals(listOf("t2" to "timeout"), r.toolFails)
assertTrue(r.toolDones.isEmpty())
}
@Test
fun `missing tool ids get synthesized and matched FIFO by name`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("tool.start", obj("""{"name":"read_file"}"""))
mapper.onEvent("tool.start", obj("""{"name":"read_file"}"""))
mapper.onEvent("tool.complete", obj("""{"name":"read_file","summary":"first"}"""))
mapper.onEvent("tool.complete", obj("""{"name":"read_file","summary":"second"}"""))
assertEquals(2, r.toolStarts.size)
val (firstId, secondId) = r.toolStarts.map { it.first }
assertEquals(listOf(firstId to "first", secondId to "second"), r.toolDones)
}
@Test
fun `tool complete with no id and no open start is dropped`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("tool.complete", obj("""{"name":"mystery"}"""))
assertTrue(r.toolDones.isEmpty())
assertTrue(r.toolFails.isEmpty())
}
// --- tool.generating (args still being written) ---
@Test
fun `tool generating fires callback and its id is adopted through to complete`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("tool.generating", obj("""{"name":"write_file"}"""))
assertEquals(listOf<String?>("write_file"), r.toolGenerating)
assertTrue(r.toolStarts.isEmpty())
mapper.onEvent("tool.start", obj("""{"name":"write_file"}"""))
mapper.onEvent("tool.complete", obj("""{"name":"write_file","summary":"wrote"}"""))
val startId = r.toolStarts.single().first
assertEquals(listOf(startId to "wrote"), r.toolDones)
}
@Test
fun `generating pre-registrations are adopted FIFO per name`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("tool.generating", obj("""{"name":"read_file"}"""))
mapper.onEvent("tool.generating", obj("""{"name":"read_file"}"""))
mapper.onEvent("tool.start", obj("""{"name":"read_file"}"""))
mapper.onEvent("tool.start", obj("""{"name":"read_file"}"""))
assertEquals(2, r.toolStarts.size)
assertEquals(2, r.toolStarts.map { it.first }.distinct().size)
mapper.onEvent("tool.complete", obj("""{"name":"read_file","summary":"first"}"""))
mapper.onEvent("tool.complete", obj("""{"name":"read_file","summary":"second"}"""))
// Completes match the started ids in start order — FIFO held end to end.
assertEquals(r.toolStarts.map { it.first }, r.toolDones.map { it.first })
assertEquals(listOf("first", "second"), r.toolDones.map { it.second })
}
@Test
fun `server tool id wins over a pending generating pre-registration`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("tool.generating", obj("""{"name":"web_search"}"""))
mapper.onEvent("tool.start", obj("""{"tool_id":"t9","name":"web_search"}"""))
assertEquals(listOf("t9" to "web_search"), r.toolStarts)
// The pre-registration was consumed — a later id-less start mints fresh.
mapper.onEvent("tool.start", obj("""{"name":"web_search"}"""))
assertTrue(r.toolStarts[1].first != "t9")
}
@Test
fun `tool generating without a name still fires the callback`() {
val r = Recorder()
mapperWith(r).onEvent("tool.generating", obj("""{}"""))
assertEquals(listOf<String?>(null), r.toolGenerating)
}
// --- Subagent lifecycle ---
@Test
fun `subagent lifecycle maps phases and fields`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("subagent.start", obj("""{"goal":"research topic","task_index":1,"task_count":3}"""))
mapper.onEvent("subagent.thinking", obj("""{"goal":"research topic","task_index":1,"task_count":3,"text":"hmm"}"""))
mapper.onEvent(
"subagent.tool",
obj("""{"goal":"research topic","task_index":1,"task_count":3,"tool_name":"web_search","tool_preview":"searching docs","text":"searching docs"}"""),
)
mapper.onEvent("subagent.progress", obj("""{"goal":"research topic","task_index":1,"task_count":3,"text":"halfway"}"""))
mapper.onEvent(
"subagent.complete",
obj("""{"goal":"research topic","task_index":1,"task_count":3,"status":"completed","summary":"found it","duration_seconds":12.5}"""),
)
assertEquals(
listOf(
GatewaySubagentEvent.Phase.START,
GatewaySubagentEvent.Phase.THINKING,
GatewaySubagentEvent.Phase.TOOL,
GatewaySubagentEvent.Phase.PROGRESS,
GatewaySubagentEvent.Phase.COMPLETE,
),
r.subagentEvents.map { it.phase },
)
val start = r.subagentEvents[0]
assertEquals(1, start.taskIndex)
assertEquals(3, start.taskCount)
assertEquals("research topic", start.goal)
assertEquals("hmm", r.subagentEvents[1].preview)
val tool = r.subagentEvents[2]
assertEquals("web_search", tool.toolName)
assertEquals("searching docs", tool.preview)
assertEquals("halfway", r.subagentEvents[3].preview)
val complete = r.subagentEvents[4]
assertEquals("completed", complete.status)
assertEquals("found it", complete.summary)
assertEquals(12.5, complete.durationSeconds!!, 0.001)
assertFalse(mapper.turnEnded)
}
@Test
fun `subagent payload defaults when older emitters omit fields`() {
val r = Recorder()
mapperWith(r).onEvent("subagent.start", obj("""{}"""))
val event = r.subagentEvents.single()
assertEquals(0, event.taskIndex)
assertEquals(1, event.taskCount)
assertEquals("", event.goal)
assertNull(event.status)
assertNull(event.toolName)
assertNull(event.durationSeconds)
}
// --- Usage translation (tui_gateway key names, not SSE names) ---
@Test
fun `gateway usage keys translate to UsageInfo`() {
val usage = GatewayEventMapper.parseGatewayUsage(
obj("""{"input":120,"output":45,"total":165,"model":"hermes-4"}"""),
)
assertEquals(120, usage?.resolvedInputTokens)
assertEquals(45, usage?.resolvedOutputTokens)
assertEquals(165, usage?.resolvedTotalTokens)
}
@Test
fun `gateway usage falls back to prompt and completion keys`() {
val usage = GatewayEventMapper.parseGatewayUsage(
obj("""{"prompt":10,"completion":5}"""),
)
assertEquals(10, usage?.resolvedInputTokens)
assertEquals(5, usage?.resolvedOutputTokens)
}
@Test
fun `empty or missing usage maps to null`() {
assertNull(GatewayEventMapper.parseGatewayUsage(null))
assertNull(GatewayEventMapper.parseGatewayUsage(obj("""{"model":"x"}""")))
}
@Test
fun `gateway usage lifts context window fields`() {
val usage = GatewayEventMapper.parseGatewayUsage(
obj("""{"input":120,"output":45,"total":165,"context_used":41200,"context_max":48000,"context_percent":86}"""),
)
assertEquals(41200, usage?.contextUsed)
assertEquals(48000, usage?.contextMax)
assertEquals(86, usage?.contextPercent)
assertEquals(120, usage?.resolvedInputTokens)
}
@Test
fun `context fields stay null when the compressor block is absent`() {
val usage = GatewayEventMapper.parseGatewayUsage(obj("""{"input":1,"output":1,"total":2}"""))
assertNull(usage?.contextUsed)
assertNull(usage?.contextMax)
assertNull(usage?.contextPercent)
}
@Test
fun `message complete forwards usage`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent(
"message.complete",
obj("""{"text":"hi","usage":{"input":7,"output":3,"total":10}}"""),
)
assertEquals(7, r.usage?.resolvedInputTokens)
assertEquals(3, r.usage?.resolvedOutputTokens)
}
// --- Interactive asks (structured GatewayAsk) ---
@Test
fun `clarify request maps to structured ask with request id and choices`() {
val r = Recorder()
mapperWith(r).onEvent(
"clarify.request",
obj("""{"request_id":"r1","question":"Which file?","choices":["a.txt","b.txt"]}"""),
)
val ask = r.interactions.single()
assertEquals(GatewayAsk.Kind.CLARIFY, ask.kind)
assertEquals("r1", ask.requestId)
assertEquals("Which file?", ask.text)
assertEquals(listOf("a.txt", "b.txt"), ask.choices)
assertNull(ask.envVar)
assertEquals(300, ask.timeoutSeconds)
}
@Test
fun `clarify request without choices maps null choices`() {
val r = Recorder()
mapperWith(r).onEvent(
"clarify.request",
obj("""{"request_id":"r1","question":"Why?","choices":null}"""),
)
assertNull(r.interactions.single().choices)
}
@Test
fun `approval request has no request id by contract`() {
val r = Recorder()
mapperWith(r).onEvent(
"approval.request",
obj("""{"command":"rm -rf build","description":"clean the build tree"}"""),
)
val ask = r.interactions.single()
assertEquals(GatewayAsk.Kind.APPROVAL, ask.kind)
assertNull(ask.requestId)
assertEquals("rm -rf build — clean the build tree", ask.text)
// Session-scoped — no countdown.
assertEquals(0, ask.timeoutSeconds)
}
@Test
fun `approval request ignores a stray request id`() {
// Upstream approvals correlate per-session; even if some build sends
// a request_id it must not be adopted (approval.respond has no slot for it).
val r = Recorder()
mapperWith(r).onEvent(
"approval.request",
obj("""{"command":"ls","request_id":"bogus"}"""),
)
assertNull(r.interactions.single().requestId)
}
@Test
fun `sudo and secret requests carry request ids and timeouts`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("sudo.request", obj("""{"request_id":"r2"}"""))
mapper.onEvent("secret.request", obj("""{"request_id":"r3","env_var":"API_KEY","prompt":"Enter API key"}"""))
assertEquals(2, r.interactions.size)
val sudo = r.interactions[0]
assertEquals(GatewayAsk.Kind.SUDO, sudo.kind)
assertEquals("r2", sudo.requestId)
assertEquals(120, sudo.timeoutSeconds)
val secret = r.interactions[1]
assertEquals(GatewayAsk.Kind.SECRET, secret.kind)
assertEquals("r3", secret.requestId)
assertEquals("Enter API key", secret.text)
assertEquals("API_KEY", secret.envVar)
assertEquals(300, secret.timeoutSeconds)
}
// --- Forward compat ---
@Test
fun `unknown event types are silently ignored`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("hologram.delta", obj("""{"text":"future"}"""))
mapper.onEvent("session.info", obj("""{"model":"x"}"""))
mapper.onEvent("status.update", obj("""{"kind":"busy"}"""))
mapper.onEvent("tool.progress", obj("""{"name":"write_file","preview":"..."}"""))
assertTrue(r.textDeltas.isEmpty())
assertTrue(r.thinkingDeltas.isEmpty())
assertTrue(r.errors.isEmpty())
assertTrue(r.subagentEvents.isEmpty())
assertTrue(r.toolGenerating.isEmpty())
assertFalse(mapper.turnEnded)
}
@Test
fun `null payloads do not crash`() {
val r = Recorder()
val mapper = mapperWith(r)
listOf(
"reasoning.delta", "thinking.delta", "message.delta", "message.start",
"message.complete", "error", "clarify.request", "approval.request",
"sudo.request", "secret.request", "reasoning.available",
"tool.generating", "subagent.start", "subagent.thinking",
"subagent.tool", "subagent.progress", "subagent.complete",
).forEach { type ->
// message.complete/error end the turn; use a fresh mapper for each
mapperWith(Recorder()).onEvent(type, null)
}
// tool events with null payload on a shared mapper
mapper.onEvent("tool.start", null)
mapper.onEvent("tool.complete", null)
}
}
@@ -228,8 +228,8 @@ class HermesApiClientTest {
@Test
fun urlConstruction_sessionsEndpoint() {
val baseUrl = "http://localhost:8642"
val url = "$baseUrl/api/sessions?limit=50"
assertEquals("http://localhost:8642/api/sessions?limit=50", url)
val url = "$baseUrl/api/sessions?limit=200"
assertEquals("http://localhost:8642/api/sessions?limit=200", url)
}
@Test
@@ -363,6 +363,33 @@ class ChatHandlerTest {
assertEquals(0, handler.sessions.value[0].messageCount)
}
@Test
fun updateSessions_sortsByLastActivityAndKeepsStartedAt() {
handler.updateSessions(
listOf(
SessionItem(
id = "started-later",
title = "Started later",
startedAt = 2_000.0,
lastActive = 2_000.0,
),
SessionItem(
id = "active-later",
title = "Active later",
startedAt = 1_000.0,
lastActive = 3_000.0,
),
),
)
val sessions = handler.sessions.value
assertEquals("active-later", sessions[0].sessionId)
assertEquals(1_000_000L, sessions[0].startedAt)
assertEquals(3_000_000L, sessions[0].lastActivityAt)
assertEquals(3_000_000L, sessions[0].updatedAt)
assertEquals("started-later", sessions[1].sessionId)
}
// --- removeSession ---
@Test
@@ -35,6 +35,7 @@ class SessionModelsTest {
source = "api",
startedAt = 1700000000.0,
endedAt = 1700001000.0,
lastActive = 1700000950.0,
messageCount = 10,
toolCallCount = 3,
inputTokens = 500,
@@ -50,6 +51,8 @@ class SessionModelsTest {
assertEquals("api", restored.source)
assertEquals(1700000000.0, restored.startedAt!!, 0.001)
assertEquals(1700001000.0, restored.endedAt!!, 0.001)
assertEquals(1700000950.0, restored.lastActive!!, 0.001)
assertEquals(1700000950.0, restored.resolvedLastActivity!!, 0.001)
assertEquals(10, restored.messageCount)
assertEquals(3, restored.toolCallCount)
assertEquals(500, restored.inputTokens)
@@ -64,6 +67,7 @@ class SessionModelsTest {
"title": "Test",
"started_at": 1700000000.0,
"ended_at": 1700001000.0,
"last_active": 1700000900.0,
"message_count": 5,
"tool_call_count": 2,
"input_tokens": 100,
@@ -74,10 +78,29 @@ class SessionModelsTest {
val item = json.decodeFromString<SessionItem>(jsonStr)
assertEquals("s1", item.id)
assertEquals(1700000000.0, item.startedAt!!, 0.001)
assertEquals(1700000900.0, item.lastActive!!, 0.001)
assertEquals(1700000900.0, item.resolvedLastActivity!!, 0.001)
assertEquals(5, item.messageCount)
assertEquals(2, item.toolCallCount)
}
@Test
fun sessionItem_deserialization_acceptsIsoUpdatedAtFallback() {
val jsonStr = """
{
"id": "s1",
"started_at": 1700000000.0,
"updated_at": "2026-04-05T12:30:00Z"
}
""".trimIndent()
val item = json.decodeFromString<SessionItem>(jsonStr)
assertEquals("s1", item.id)
assertEquals(1775392200.0, item.updatedAt!!, 0.001)
assertEquals(1775392200.0, item.resolvedLastActivity!!, 0.001)
}
@Test
fun sessionItem_nullableFields_defaultToNull() {
val jsonStr = """{"id": "s1"}"""
@@ -89,6 +112,11 @@ class SessionModelsTest {
assertNull(item.source)
assertNull(item.startedAt)
assertNull(item.endedAt)
assertNull(item.lastActive)
assertNull(item.lastActivity)
assertNull(item.lastActivityAt)
assertNull(item.updatedAt)
assertNull(item.resolvedLastActivity)
assertNull(item.messageCount)
assertNull(item.toolCallCount)
assertNull(item.inputTokens)

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