* fix(chat): preserve reasoning across tool calls + REST reload (#771) A reasoning-model turn streams its thinking BEFORE the tool starts. The reducer wiped the streaming state at tool.start (returned a fresh StreamingState()), so the reasoning bubble vanished mid-turn and the finalized answer had no reasoning card. Real gateway capture for `run echo hi` (reasoning model): message.start reasoning.delta xN (thinking before the tool) tool.generating/start/complete (terminal) message.delta xN (answer after the tool) reasoning.available (FULL trace in payload text) message.complete (text + reasoning field) Fixes: - onToolStart keeps the streaming message + reasoningText alive across the tool call (only buffers are cleared via clearStreamingBuffers); an interim-text orphan still seals, but reasoning survives for the post-tool answer - EventParser now reads reasoning.available text and message.complete reasoning (the authoritative full trace the gateway sends) - onReasoningAvailable fills the streaming message with the full trace - onMessageComplete prefers the payload reasoning over delta leftovers - mapServerMessages folds reasoning-only empty assistant rows into the next real answer bubble (no more standalone reasoning ghost bubble) and carries tool names over from the live WS list by position (no more generic 'tool' title after a REST reload) Regression tests replay the captured sequence end-to-end. * fix(chat): dedupe WS + REST tool bubbles by result content (#771) The REST transcript tool rows carry no tool name, and WS messages (UUID ids) + REST rows (rest- ids) are BOTH persisted to Room — the cache paint and reloads could render the same tool call twice, with the second bubble falling back to the generic 'tool' title. Real captured pair for 'echo meow': terminal {tool_id, name, args, result:{...}} (WS) Tool {output, exit_code, error} (REST, no name) The previous positional carry-over misaligned whenever the REST page and the live tool list didn't line up 1:1 (multiple tool calls in history → newest call got null name). Fixes: - canonicalToolResultKey(): content-based matching between the WS tool.complete payload and the REST result row — int/float agnostic (0 vs 0.0), prefers the payload's 'result' field - mapServerMessages(): when a REST tool row's result matches a live WS tool message, REUSE the live message wholesale (same id + toolName + rich payload) so Room upserts instead of accumulating a rest- copy - dedupeCachedMessages(): cache paint drops rest- copies whenever a WS copy of the same logical message exists (tools matched by result, other roles by content) 11 new unit tests with the real captured payloads. * fix(chat): merge transcript reload with live WS bubbles (#771) The gateway persists a tool row only when the tool COMPLETES server-side. A REST transcript reload landing mid-turn (app background/foreground, reconnect re-resume, pull-refresh) therefore returned a page WITHOUT the in-flight tool row — replacing the message list outright made the tool bubble vanish and left tool.complete with no RUNNING message to update. - mergeTranscriptWithLive(): reload now merges instead of replacing — any current message the REST page doesn't cover (by id or logical content) survives, chronological order preserved - loadSessionMessages applies the merge before publishing state 4 new unit tests: in-flight RUNNING tool survives a mid-turn reload, reused-id and canonical-match cases don't duplicate, order preserved. * test(chat): end-to-end tool-bubble survival through mid-turn reload Chains the real user scenario at the state level: WS turn (message.start → reasoning → tool.start → tool.complete) + a transcript reload landing while the tool is still running server-side (REST page lacks the tool row) + stream finish. Asserts the tool bubble survives to the final list with its name and status intact. * debug(chat): add Logcat tracing for tool-bubble vanish (#771) Temporary diagnostic logging to catch the exact moment and code path where the tool bubble disappears. Logs to tag 'ChatReducer771' and 'ChatVM771' at every state mutation that touches messages: - onMessageComplete: message count + roles before the assistant append - loadSessionMessages: rest vs current vs merged counts and roles - syncCurrentSession: trigger + current tool count - sync merge: before/after merge counts and roles To reproduce: install this debug APK, run 'echo meow', watch the tool bubble vanish, then capture logcat: adb logcat -d -s ChatReducer771:D ChatVM771:D Will remove the logging once the root cause is pinned. * fix(chat): drop debug logging + harden sync merge (#771) The debug commit added android.util.Log calls in the reducer's onMessageComplete path, which is a Stub! in JVM unit tests — every test touching that path died with RuntimeException, and the long log lines broke ktlint (max-line-length). Remove them. Sync merge fix: the 5s sync poll could drop the newest WS tool bubble when the REST page lagged one row behind. Route the merge through sameLogicalMessage (canonical result-key match) and keep any WS message that has no REST counterpart. Adds 2 regression tests for the sync-merge scenario. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Hermes Mobile
Native Android companion app for your Hermes AI agent.
Overview
Hermes Mobile is the native Android client for Hermes Agent. It connects securely to your local Hermes gateway (REST API and WebSocket TUI Gateway) over LAN, giving you pocket control over your AI assistant.
Features
- Real-Time Chat: Message your agent with Room-backed local database history and inline reply notifications.
- System Config: Manage active profiles, installed skills, plugins, toolsets, and LLM model/provider selections.
- Operations: Stream and filter live logs, manage cron jobs, edit environment keys, test webhooks, and monitor processes.
- Gateway Status: Monitor WebSocket connection, MCP servers, messaging channels, and OAuth providers.
- Productivity: View and manage tasks via integrated Kanban boards, track agent milestones, and browse session history.
- Analytics & Billing: Usage analytics dashboard and billing/subscription management.
- Theming: 6 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) plus Material You dynamic colors on supported devices.
- Modern UX: Native Material 3 design with pull-to-refresh, scroll-aware TopBar, and customizable bottom navigation.
Quick Start
Prerequisites
- JDK 21+ (required for Kotlin compilation and the Gradle toolchain)
- Android Studio (Ladybug+) or a Nix development environment
Build & Deploy
- Clone the repository:
git clone https://github.com/Hy4ri/hermes-mobile.git cd hermes-mobile - Build the debug APK:
./gradlew assembleDebug - Install on your emulator/device:
adb install app/build/outputs/apk/debug/app-debug.apk
Note: For release builds, ensure keystore environment variables (KEYSTORE_PATH, KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD) are configured, or let the GitHub Actions release workflow handle it on tag push (v*).
Authentication
Once the app is installed, you need to point it at your Hermes gateway. The app auto-detects which auth mode the dashboard is using — just fill in the fields it shows.
1. Start the dashboard
On your host machine, start the dashboard:
hermes dashboard # loopback (127.0.0.1:9119) — no auth needed
hermes dashboard --host 0.0.0.0 # LAN — requires auth
For LAN access, configure credentials in ~/.hermes/config.yaml:
dashboard:
basic_auth:
username: admin # pick your own
password: hermes # pick your own
2. Connect the app
Tap Sign in on the landing screen and enter the dashboard host and port. The app probes the dashboard and reveals the fields you need:
| Auth mode | When | What you fill |
|---|---|---|
| Token only | Dashboard on same machine (loopback) | Token — grab from ~/.hermes/dashboard-token.txt or ~/.hermes/.env (HERMES_DASHBOARD_SESSION_TOKEN). The app can also auto-extract it from the dashboard page |
| Basic auth | Dashboard on LAN with password gate | Username + Password (default admin / hermes). The app logs in, gets a session cookie, and mints a WebSocket ticket automatically |
The app communicates over plain HTTP — it's designed for trusted local networks only. Do not expose your Hermes gateway to untrusted networks.
Connection profiles
Have multiple gateways? Switch between them in Settings → Connection profiles. Each profile stores its own host, port, and token — just tap to swap.
Pairing (admin)
The Pairing screen lets you approve or revoke agents and services that are trying to connect to your gateway, such as Telegram or Discord sessions.
Project Structure
app/src/main/java/com/m57/hermescontrol/
├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models
├── notification/ # Foreground service + inline reply for chat notifications
├── theme/ # Preset-based design system (6 themes), status colors, spacing, typography
├── ui/ # 28 Compose feature screens + common components (HermesScaffold, StateViews)
├── util/ # CronExpressionFormatter, LocaleContextWrapper
└── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller
Tech Stack
- Language: Kotlin 2.4.10 with KSP 2.3.10 compiler plugin
- UI & Layout: Jetpack Compose (BOM 2026.03.01) & Material 3 / Material You
- Navigation: Navigation3 (Compose-first Routing)
- Networking: Retrofit 3.0.0, OkHttp 5.4.0, Kotlinx Serialization 1.11.0
- Database: Room 2.7.1 with SQLCipher 4.17.0 encryption
- Security:
EncryptedSharedPreferences(AES256-GCM), DataStore - Theming: 6 built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) + Material You dynamic colors
- Image Loading: Coil 2.7.0
- Testing: JUnit 5, MockK, Turbine, Espresso, Compose UI testing
- Formatting:
ktlint1.2.1 style rules (checked automatically in CI)
Contributing
Contributions are welcome! Please read CONTRIBUTING.md for our branch workflow, code style guidelines, and PR checklist.
For developer-specific details, code conventions, and project architecture notes, refer to AGENTS.md.
License
Copyright © 2026 M57 (Hy4ri).
This project is licensed under the Apache License, Version 2.0. See the LICENSE file for details.