* fix: correct FilterChipRow and EmptyState API mismatches + ktlint fmt * fix: add missing Save import, fix verticalArrangement double-wrapping * fix: address Sourcery review — strings, enums, hubQuery, StatusColors • Hardcoded strings → stringResource (Installed, Browse Hub, Statuses, All sources) • Magic string filters → SkillFilter enum + CATEGORY_ALL constant • FilterChipRow made generic with chipLabel parameter • HubBrowseView hubQuery driven from ViewModel state (no dupe) • ViewModel toasts use getString(R.string.*) via AndroidViewModel • MaterialTheme.colorScheme.error → LocalHermesStatusColors.current.error * fix: OpenDrawer → Menu, listContentPadding is PaddingValues not ext NavIcon.OpenDrawer doesn't exist, use NavIcon.Menu. listContentPadding is a val PaddingValues, not a Modifier extension — use Modifier.padding(listContentPadding). * fix: NavIcon.Menu(onClick) -> NavIcon.Menu(onOpen) * fix: pass mockApp to SkillsViewModel in unit tests (now AndroidViewModel) * fix: mock getString in unit tests for AndroidViewModel string resources * fix: anyVararg<Any?>() → anyVararg<Any>() to satisfy type bound anyVararg<T> has bound T : Any, so Any? doesn't compile. Any is a subtype of Any? at the JVM level, so the matcher works correctly for vararg Any? parameters. * fix: use anyVararg<String>() for getString mock to match actual runtime types The previous anyVararg<Any>() did not match getString(Int, Object...) vararg calls at runtime — mockk's relaxed mock returned '' for all formatted string resources, causing all Skills error-path assertions to fail (9 tests). Root cause: mockk vararg matchers match by reified element type. The Java method Context.getString(int, Object...) passes format args as String at runtime (via Kotlin's spread). anyVararg<Any>() with Any (boxed) doesn't match Object/String vararg elements reliably, but anyVararg<String>() does, since String satisfies the T : Any bound and matches the actual runtime type. Also added getString(Int) stub for the no-arg overload used by enums. * fix: stub getString per-resource-ID to bypass mockk Java vararg bug mockk has a known bug matching Java vararg methods like Context.getString(int, Object...) with anyVararg<T>() — the matcher silently fails to match, and the relaxed mock returns '' for all formatted string resource calls. This caused 8 Skills error-path tests to fail (errorMessage/toastMessage was '' instead of containing 'HTTP 500'). Root cause: confirmed via test report artifacts — errorMessage was non-null but empty string, proving the vararg stub never matched. Fix: replace the generic anyVararg<String>() stub with explicit per- resource-ID stubs using eq() + any<String>() matchers. Each formatted string resource is stubbed individually, returning the format arg (the dynamic error message) so the test assertions find 'HTTP 500' and 'Timeout' substrings. References: - https://stackoverflow.com/questions/54326914/using-mockk-to-match-any-varargs - https://github.com/mockk/mockk/issues/224 * fix(skills): replace AndroidViewModel getString() with hardcoded strings to fix unit tests The SkillsViewModel was using getApplication<Application>().getString() for error/success messages. In unit tests, mockk cannot reliably stub Java vararg Context.getString(int, Object...), causing NullPointerException inside coroutine onError callbacks. All other ViewModels in the codebase use hardcoded strings; this change aligns SkillsViewModel with that convention. * test: add hub search/install/uninstall/preview coverage + rm vestigial stubs - Remove unused mockApp.getString() per-resource-ID stubs (SkillsViewModel no longer calls getApplication().getString() — hardcoded strings now). - Add 10 tests covering hub search (success/empty/error/emptyQuery), hub install (success/error), hub uninstall (success/error), and skill preview (success/error). Uses existing mockk + runTest patterns. * fix: correct import ordering for HubSkill (H < S) Move HubSkill import before KanbanBoard (H < K) and remove duplicate that was between SessionListResponse and Skill. * test: remove coVerify from emptyQuery test (cross-test mock state) * fix: remove double-stacked padding in SkillsScreen (HermesScaffold handles it) Per AGENTS.md — HermesScaffold already offsets for the top bar internally, so .padding(padding) on inner content creases double-stacked top padding. * refactor: merge preview + edit button in SkillCard into single adaptive action Per AGENTS.md — two nearly-identical icon buttons (Preview + Edit) side by side was confusing. Merged into one Edit button since both opened the same skill content dialog (editable for installed, read-only for hub is handled by the ViewModel). * fix: revert onCategoryChange to non-null String (FilterChipRow<T> needs it) Also removes leftover Preview icon import that ktlint flagged. * refactor: restructure SkillCard layout — name gets full width, actions below The old layout crammed icon, name, source badge, edit button, uninstall button, AND toggle switch all on one horizontal row. Long skill names got truncated to 2-3 characters. New layout: - Top row: icon | name (weight 1f) | source badge | toggle switch - Below: category (if present) - Below: description (if present) - Bottom row: edit + uninstall buttons (end-aligned, only if hub source) The name Text now has weight(1f) so it fills all available space. * fix: fix brace nesting + move icon to far left for extra name space * fix: add missing height import * refactor: remove Extension icon from SkillCard for cleaner layout * fix: remove double-stacked padding in SkillEditorDialog and SkillPreviewDialog Both dialogs had the AGENTS.md footgun: HermesScaffold already offsets the top bar internally, but the inner Box had .padding(padding) stacking an extra ~56dp gap between the header and the edit text field. * fix(skills-hub): unwrap SkillHubSearchResponse wrapper Backend changed /api/skills/hub/search from returning a raw JSON array to an object wrapper: { results: [...], source_counts: {...}, ... } - Added SkillHubSearchResponse, SkillHubInstalledEntry models - Extended HubSkill with identifier, trustLevel, repo, tags - Updated API endpoint return type to SkillHubSearchResponse - ViewModel unwraps data?.results.orEmpty() instead of data.orEmpty() * fix: unwrap SkillHubSearchResponse in ProfilesViewModel too + remove unused HubSkill import * fix: update test mocks to use SkillHubSearchResponse wrapper * fix: refactor HubSkillCard layout — remove icon, name gets full width, install below * fix: use unique key for hub results + weight(1f) to prevent scroll crash * fix(skills): resolve crash due to duplicate keys in lazy list * feat(skills): move search hub button into trailing icon of search bar --------- 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.
- 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
This project is licensed under the Apache License, Version 2.0. See the LICENSE file for details. renamed '/tmp/hermes-snap-800bd6e0cc89.sh.tmp.17855' -> '/tmp/hermes-snap-800bd6e0cc89.sh'