* fix(#589): show in-session model picker when user sends /model Typing a bare '/model' in the chat composer now opens the model picker so the user can hot-swap the CURRENT session's model via the existing slash-command path, instead of hand-typing the provider/model string. - ChatViewModel.preloadModelOptions() caches GET /api/model/options at GatewayReady so the picker opens instantly; falls back to on-demand refresh if opened before preload finishes. - sendMessage() intercepts a bare '/model' (no arg) -> showModelPicker=true instead of dispatching; a typed '/model provider/model' still dispatches via handleSlashCommand. - sendSlashModel() routes through command.dispatch with session_id, so the swap is session-scoped (not the global POST /api/model/set). - Extracted ModelPickerDialog into ui/model/components and reused by both ModelScreen and ChatScreen (adds loading + empty states). - Added a top-bar model chip (current session model label) that opens the same picker. - Added unit tests: bare /model opens picker, picker selection hot-swaps via slash dispatch with session_id, typed /model with arg dispatches directly. Note: CI shows 2 pre-existing test failures (ChatViewModelTest.testToggleSearch, HermesWsClientTest.testAutoReconnect) that also fail on a clean main full suite (MockK static-mock leakage between classes). Unrelated to this change; compile + ktlint + the 3 new tests pass. * fix(#589): use backend-valid session-scoped /model command syntax The model picker now dispatches the canonical backend command form '/model <model> --provider <slug> --session' instead of the unsupported 'provider/model' slash form. The '--session' flag keeps the switch scoped to the current chat only (per-session override) and never writes the global model config, which matches the backend model-switch contract. - sendSlashModel builds '/model $model --provider $provider --session'. - Updated unit tests to assert the corrected arg payload ('gpt-4o --provider openai --session') and the fully-typed dispatch path. * fix(#589): add pinned-models section to in-session model picker - ModelPickerDialog now renders a Pinned section (mirrors global model screen) - search filter matches model names too, not just provider name/slug - ChatUiState gains modelPickerPinned, fed from AuthManager.getPinnedModels() - top-bar model chip removed; picker opens on /model only - test mock for getPinnedModels() added * fix(#589): route /model via prompt.submit instead of command.dispatch (resolves 4018) /model is a real backend slash command handled in the backend's message pipeline (_handle_message -> _handle_model_command), so it must arrive as a normal prompt.submit message, not the command.dispatch RPC (which only knows quick/plugin/bundle/skill commands and 4018s on /model). - SlashCommandDispatcher: add SlashResult.ModelSwitch, map /model -> ModelSwitch - ChatViewModel: handle ModelSwitch -> handleModelSwitch() sends via WsMethods.PROMPT_SUBMIT with {session_id, text} - Tests: rewrite the two /model tests to assert prompt.submit (previously asserted the buggy command.dispatch path); add /model -> ModelSwitch test Independent agy review: APPROVE, no blocking issues. * fix(#589): route /model via slash.exec (correct transport; prompt.submit made LLM treat it as text) Earlier attempt routed /model through prompt.submit, but the TUI gateway's prompt.submit handler does NOT parse slash commands — it runs the text as a normal agent turn, so the LLM received '/model ...' as a user message (the exact reported bug). The correct RPC is slash.exec, which runs the command through the slash worker and _mirror_slash_side_effects -> _apply_model_switch to hot-swap the live session model. command.dispatch is also wrong (only knows quick/plugin/bundle/skill commands -> 4018). - WsMethods: add SLASH_EXEC = "slash.exec" - ChatViewModel.handleModelSwitch: send slash.exec with {command, session_id} (previously prompt.submit with {text}); corrected doc comment - SlashCommandDispatcher: corrected comments (slash.exec, not prompt.submit) - ChatViewModelTest: rewrite both /model tests to assert slash.exec with the /model <model> --provider <slug> --session command string - SlashCommandDispatcherTest: /model -> ModelSwitch already covered Verified against hermes-agent tui_gateway/server.py: slash.exec (L13190) -> worker.run + _mirror_slash_side_effects (L13299) -> _apply_model_switch (L13118); parse_model_flags accepts --provider/--session (hermes_cli/model_switch.py:355). Independent agy review of the prior attempt: APPROVE (the wrong-transport assumption was the author's, not agy's). * fix(#589): route /model via config.set key=model (slash.exec 4001'd; prompt.submit made LLM treat it as text) Third transport attempt, now verified against the gateway source: - prompt.submit: gateway runs text as a normal agent turn -> LLM sees '/model' as a message (the original reported bug). WRONG. - slash.exec: gateway returns 4001 'session not found'. The slash worker path resolves the session differently / requires agent-built state the mobile session isn't in at switch time. WRONG. - config.set key=model: gateway (server.py config.set L10253) routes key=='model' straight to _apply_model_switch using the SAME _sessions.get(session_id) lookup that the working command.dispatch uses. CORRECT. sendSlashModel still builds '/model <model> --provider <slug> --session'; parse_model_flags (hermes_cli/model_switch.py:355) extracts model/provider/ session from it. handleModelSwitch now sends config.set {key:model, value: command, session_id}. - WsMethods: add CONFIG_SET = 'config.set' - ChatViewModel.handleModelSwitch: config.set instead of slash.exec - SlashCommandDispatcher: comments updated (config.set, not slash.exec) - ChatViewModelTest: both /model tests assert config.set key=model + command - also fixes the MockK testToggleSearch flake by preloading byte-buddy agent via JAVA_TOOL_OPTIONS locally (container blocks JVM self-attach) * fix(#589): strip /model prefix before config.set key=model (5001 'model names can't have spaces') config.set key=model passes the value DIRECTLY to parse_model_flags — it does NOT strip a leading '/model' the way slash.exec / prompt.submit do. Sending '/model <model> ...' made parse_model_flags treat '/model <model>' as the model name (contains a space) -> 5001. handleModelSwitch now strips the '/model' prefix and sends the bare spec '<model> --provider <slug> --session', matching the TUI client's modelValueForConfigSet (ui-tui session.ts:23) used by useMainApp.ts:123. - ChatViewModel.handleModelSwitch: spec = command.removePrefix('/model').trim() - ChatViewModelTest: assert bare spec 'gpt-4o --provider openai --session' - comment updated * fix(#589): strip /model prefix case-insensitively before config.set SlashCommandDispatcher and isModelPickerCommand match /model case-insensitively, but handleModelSwitch used removePrefix("/model") (case-sensitive). A typed /MODEL ... forwarded the literal slash prefix to config.set and the backend's parse_model_flags wouldn't recognize it — silent hot-swap failure. Use startsWith("/model", ignoreCase=true) + substring(6) so any casing is stripped. Added a regression test asserting /MODEL does not forward a leading slash. * fix(test): inject searchDispatcher to fix MockKException in testToggleSearch --------- Co-authored-by: M57 <m57@hermes-control.app>
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.
- System Config: Manage active profiles, installed skills, plugins, and LLM model selections.
- Operations: Stream and filter live logs, manage cron jobs, edit environment keys, and test webhooks.
- Gateway Status: Monitor WebSocket connection, MCP servers, and messaging channel status.
- Productivity: View and manage tasks via integrated Kanban boards and track agent milestones.
- Modern UX: Native Material You design supporting dynamic color theming, scroll-aware TopBar, and pull-to-refresh.
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, EncryptedSharedPreferences) & Remote (Retrofit, OkHttp WS)
├── notification/ # Foreground service for message notifications
├── theme/ # Material You design system, status colors, spacing, and typography
└── ui/ # Compose screens (Chat, Settings, Profiles, Kanban, etc.) + Navigation
Tech Stack
- Language: Kotlin 2.4.0 with KSP 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, Gson 2.14.0
- Database: Room 2.7.1 with SQLCipher encryption
- Security:
EncryptedSharedPreferences(AES256-GCM) - Formatting:
ktlintstyle 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.