style: modernize UI aesthetics and replace app launcher icons
No files matched your search
@@ -1,594 +0,0 @@
|
||||
# Android App for Hermes — Chat & Control
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build an Android app that lets m57 chat with his Hermes agent instance and send remote control commands from anywhere, directly from his phone.
|
||||
|
||||
**Architecture:** The app talks directly to Hermes' built-in **Dashboard REST API** (port 9119) for management and **Dashboard WebSocket** (`/api/ws`) for real-time chat via JSON-RPC. **No custom bridge needed.**
|
||||
|
||||
**Tech Stack:** Android (Kotlin/Jetpack Compose), OkHttp (WebSocket + Retrofit), JSON-RPC protocol, Hermes Dashboard API.
|
||||
|
||||
---
|
||||
|
||||
## Current State (What Already Works)
|
||||
|
||||
| Component | Status | Endpoint |
|
||||
|-----------|--------|----------|
|
||||
| **Dashboard REST API** | ✅ Running on `127.0.0.1:9119` | `GET /api/status`, sessions, config, etc. |
|
||||
| **TUI Gateway WebSocket** | ✅ Running at `/api/ws?token=<TOKEN>` | JSON-RPC over WebSocket |
|
||||
| **Auth (loopback mode)** | ✅ `Authorization: Bearer <TOKEN>` or `X-Hermes-Session-Token: <TOKEN>` | For REST API |
|
||||
| **WebSocket Auth** | ✅ `?token=<TOKEN>` query param | For WS upgrade |
|
||||
| **Dashboard Token** | ✅ Stored in `~/.hermes/.env` | `HERMES_DASHBOARD_SESSION_TOKEN=y9oAS3...iTZ4` |
|
||||
|
||||
**Available JSON-RPC Methods (for Chat):**
|
||||
- `session.list` — List all sessions
|
||||
- `session.active_list` — List active sessions
|
||||
- `session.status` — Get session status
|
||||
- `session.history` — Get session message history
|
||||
- `session.resume` — Resume an existing session
|
||||
- `session.create` — Create a new session
|
||||
- `prompt.submit` — **Send a message to Hermes!** (key endpoint)
|
||||
- `session.interrupt` — Interrupt a response
|
||||
- `session.steer` — Steer the agent
|
||||
- `clarify.respond` — Respond to clarifications
|
||||
- `approval.respond` — Approve/reject commands
|
||||
- `session.delete` — Delete sessions
|
||||
- `session.title` — Set session title
|
||||
- `config.get` / `config.set` — Manage config
|
||||
- `image.attach`, `file.attach` — Attach files
|
||||
- And many more...
|
||||
|
||||
**Available REST Endpoints (for Management):**
|
||||
- `GET /api/status` — Hermes version, gateway status, active sessions
|
||||
- `GET /api/sessions` — List sessions with metadata
|
||||
- `GET /api/sessions/{id}/messages` — Get messages for a session
|
||||
- `GET /api/system/stats` — System stats
|
||||
- `GET /api/config` — Full config
|
||||
- `GET /api/logs` — Logs
|
||||
- `GET /api/cron/jobs` — Cron jobs
|
||||
- And many more (check /api/docs)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Android Device │
|
||||
│ ┌─────────────────┐ ┌────────────────────────┐ │
|
||||
│ │ Android App │ │ Hermes (Termux/Ubuntu)│ │
|
||||
│ │ (Kotlin/Compose)│────▶│ Dashboard (127.0.0.1) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • REST client │────▶│ REST API :9119 │ │
|
||||
│ │ • WS client │────▶│ WS /api/ws?token=... │ │
|
||||
│ │ • Local cache │ │ TUI Gateway (JSON-RPC)│ │
|
||||
│ └─────────────────┘ └────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Since Hermes runs on the same Android device in Termux, the app connects to **localhost** — no network issues, no CORS, no latency.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Android Project Setup
|
||||
|
||||
### Task 1.1: Scaffold Android Project
|
||||
|
||||
**Objective:** Create a new Android project with Jetpack Compose and the right SDK.
|
||||
|
||||
**Actions:**
|
||||
1. Open Android Studio → New Project → Empty Compose Activity
|
||||
2. Name: `HermesControl`, Package: `com.m57.hermescontrol`
|
||||
3. Language: **Kotlin**, Minimum SDK: **26**
|
||||
4. Build system: Gradle with Kotlin DSL
|
||||
|
||||
**Files:**
|
||||
- `app/build.gradle.kts` — Dependencies
|
||||
- `AndroidManifest.xml` — Internet permission
|
||||
|
||||
**Verify:** App builds and runs on device/emulator.
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2: Add Dependencies
|
||||
|
||||
**Objective:** Add OkHttp (WebSocket + HTTP), Gson for JSON-RPC.
|
||||
|
||||
**File:** `app/build.gradle.kts`
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
// Core
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
|
||||
// Compose BOM
|
||||
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
|
||||
// JSON
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
|
||||
// Local storage
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** Gradle sync succeeds.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: REST API Client (Management)
|
||||
|
||||
### Task 2.1: Data Models
|
||||
|
||||
**Objective:** Create Kotlin data classes for REST API responses.
|
||||
|
||||
**Files:**
|
||||
- `data/model/StatusResponse.kt`
|
||||
- `data/model/SessionListResponse.kt`
|
||||
- `data/model/SessionMessagesResponse.kt`
|
||||
|
||||
```kotlin
|
||||
// data/model/StatusResponse.kt
|
||||
data class StatusResponse(
|
||||
val version: String,
|
||||
val gateway_running: Boolean,
|
||||
val active_sessions: Int,
|
||||
val gateway_platforms: Map<String, PlatformStatus>
|
||||
)
|
||||
|
||||
data class PlatformStatus(
|
||||
val state: String,
|
||||
val error_code: String? = null
|
||||
)
|
||||
```
|
||||
|
||||
**Verify:** Compiles cleanly.
|
||||
|
||||
---
|
||||
|
||||
### Task 2.2: REST API Interface
|
||||
|
||||
**Objective:** Create Retrofit service for Hermes dashboard REST API.
|
||||
|
||||
**Files:**
|
||||
- `data/remote/HermesApiService.kt`
|
||||
- `data/remote/ApiClient.kt`
|
||||
|
||||
```kotlin
|
||||
// data/remote/HermesApiService.kt
|
||||
interface HermesApiService {
|
||||
@GET("api/status")
|
||||
suspend fun getStatus(): Response<StatusResponse>
|
||||
|
||||
@GET("api/sessions")
|
||||
suspend fun getSessions(): Response<SessionListResponse>
|
||||
|
||||
@GET("api/sessions/{id}/messages")
|
||||
suspend fun getSessionMessages(@Path("id") sessionId: String): Response<SessionMessagesResponse>
|
||||
|
||||
@GET("api/system/stats")
|
||||
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
||||
}
|
||||
|
||||
// data/remote/ApiClient.kt
|
||||
object ApiClient {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val original = chain.request()
|
||||
val request = original.newBuilder()
|
||||
.header("Authorization", "Bearer ${AuthManager.getToken()}")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
.build()
|
||||
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl("http://127.0.0.1:9119/")
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
val hermesApi: HermesApiService = retrofit.create(HermesApiService::class.java)
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** API calls return valid data.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: WebSocket Client (Chat)
|
||||
|
||||
### Task 3.1: JSON-RPC Models
|
||||
|
||||
**Objective:** Create models for JSON-RPC over WebSocket.
|
||||
|
||||
**Files:**
|
||||
- `data/ws/JsonRpcRequest.kt`
|
||||
- `data/ws/JsonRpcResponse.kt`
|
||||
- `data/ws/WsMethods.kt`
|
||||
|
||||
```kotlin
|
||||
// data/ws/JsonRpcRequest.kt
|
||||
data class JsonRpcRequest(
|
||||
val jsonrpc: String = "2.0",
|
||||
val id: String,
|
||||
val method: String,
|
||||
val params: Map<String, Any> = emptyMap()
|
||||
)
|
||||
|
||||
// data/ws/WsMethods.kt
|
||||
object WsMethods {
|
||||
const val SESSION_LIST = "session.list"
|
||||
const val SESSION_ACTIVE_LIST = "session.active_list"
|
||||
const val SESSION_STATUS = "session.status"
|
||||
const val SESSION_HISTORY = "session.history"
|
||||
const val SESSION_RESUME = "session.resume"
|
||||
const val SESSION_CREATE = "session.create"
|
||||
const val PROMPT_SUBMIT = "prompt.submit"
|
||||
const val SESSION_INTERRUPT = "session.interrupt"
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** Data classes serialize/deserialize correctly with Gson.
|
||||
|
||||
---
|
||||
|
||||
### Task 3.2: WebSocket Client
|
||||
|
||||
**Objective:** Implement WebSocket connection to `/api/ws?token=<TOKEN>` with reconnection support.
|
||||
|
||||
**Files:**
|
||||
- `data/ws/HermesWsClient.kt`
|
||||
|
||||
```kotlin
|
||||
// data/ws/HermesWsClient.kt
|
||||
class HermesWsClient(
|
||||
private val token: String,
|
||||
private val onMessage: (JsonRpcResponse) -> Unit,
|
||||
private val onConnected: () -> Unit,
|
||||
private val onDisconnected: (String) -> Unit
|
||||
) {
|
||||
private val client = OkHttpClient()
|
||||
private var ws: WebSocket? = null
|
||||
private val requestId = AtomicInteger(0)
|
||||
|
||||
fun connect() {
|
||||
val url = "ws://127.0.0.1:9119/api/ws?token=$token"
|
||||
val request = Request.Builder().url(url).build()
|
||||
ws = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(ws: WebSocket, response: Response) {
|
||||
onConnected()
|
||||
}
|
||||
|
||||
override fun onMessage(ws: WebSocket, text: String) {
|
||||
val response = Gson().fromJson(text, JsonRpcResponse::class.java)
|
||||
onMessage(response)
|
||||
}
|
||||
|
||||
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
|
||||
onDisconnected(t.message ?: "Unknown error")
|
||||
}
|
||||
|
||||
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
|
||||
onDisconnected(reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
val request = JsonRpcRequest(
|
||||
id = requestId.incrementAndGet().toString(),
|
||||
method = WsMethods.PROMPT_SUBMIT,
|
||||
params = mapOf("text" to text)
|
||||
)
|
||||
ws?.send(Gson().toJson(request))
|
||||
}
|
||||
|
||||
fun resumeSession(sessionId: String) {
|
||||
val request = JsonRpcRequest(
|
||||
id = requestId.incrementAndGet().toString(),
|
||||
method = WsMethods.SESSION_RESUME,
|
||||
params = mapOf("id" to sessionId)
|
||||
)
|
||||
ws?.send(Gson().toJson(request))
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
ws?.close(1000, "App closing")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** App connects to WebSocket, receives JSON-RPC responses.
|
||||
|
||||
---
|
||||
|
||||
### Task 3.3: WebSocket Event Parser
|
||||
|
||||
**Objective:** Handle the event-based responses from Hermes (responses come as events, not just RPC replies).
|
||||
|
||||
**Files:**
|
||||
- `data/ws/EventParser.kt`
|
||||
|
||||
The TUI Gateway sends:
|
||||
- **RPC responses** (to our requests) with `id` matching
|
||||
- **Events** (tool calls, streamed tokens, clarify requests) with `event` field
|
||||
|
||||
```kotlin
|
||||
data class JsonRpcResponse(
|
||||
val jsonrpc: String?,
|
||||
val id: String?,
|
||||
val result: Any?,
|
||||
val error: JsonRpcError?,
|
||||
val event: String?, // Event type (for push events)
|
||||
val data: Map<String, Any>? // Event payload
|
||||
)
|
||||
|
||||
data class JsonRpcError(
|
||||
val code: Int,
|
||||
val message: String
|
||||
)
|
||||
```
|
||||
|
||||
**Event types to handle:**
|
||||
- `message.complete` — Agent finished responding
|
||||
- `message.token` — Streaming token
|
||||
- `clarify.request` — Agent needs clarification
|
||||
- `tool.start` / `tool.complete` — Tool calls
|
||||
- `session.updated` — Session state changed
|
||||
|
||||
**Verify:** Parse events correctly from Gson.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: UI Screens
|
||||
|
||||
### Task 4.1: Connection Screen
|
||||
|
||||
**Objective:** Enter/display connection info (token auto-loaded or configurable).
|
||||
|
||||
**Files:**
|
||||
- `ui/connect/ConnectScreen.kt`
|
||||
- `ui/connect/ConnectViewModel.kt`
|
||||
|
||||
**Features:**
|
||||
- Text field for token (or load from SharedPreferences)
|
||||
- "Connect" button
|
||||
- Connection status indicator
|
||||
|
||||
---
|
||||
|
||||
### Task 4.2: Chat Screen
|
||||
|
||||
**Objective:** Full chat UI with real-time messaging.
|
||||
|
||||
**Files:**
|
||||
- `ui/chat/ChatScreen.kt`
|
||||
- `ui/chat/ChatViewModel.kt`
|
||||
- `ui/chat/ChatBubble.kt`
|
||||
|
||||
**Features:**
|
||||
- `LazyColumn` for message list (user + agent bubbles)
|
||||
- Bottom input bar with send button
|
||||
- Streaming text (update bubble as tokens arrive)
|
||||
- Image/file attachment support
|
||||
- Loading / typing indicator
|
||||
- Session selector (pick which session to resume)
|
||||
|
||||
**Message types:**
|
||||
- User messages (right-aligned, primary color)
|
||||
- Agent messages (left-aligned, surface color)
|
||||
- Tool calls (collapsible, monospace)
|
||||
- System notifications (centered, muted)
|
||||
|
||||
---
|
||||
|
||||
### Task 4.3: Command Panel
|
||||
|
||||
**Objective:** Quick-action buttons for common Hermes commands.
|
||||
|
||||
**Files:**
|
||||
- `ui/commands/CommandPanel.kt`
|
||||
- `ui/commands/CommandViewModel.kt`
|
||||
|
||||
**Commands (can be customized):**
|
||||
- `/status` — Check system status
|
||||
- `/skills` — List skills
|
||||
- `/memory` — Show recent memories
|
||||
- `/sessions` — List active sessions
|
||||
- `🔄 Restart Gateway` — Trigger gateway restart
|
||||
- `📊 System Stats` — Show CPU/memory
|
||||
- `⚡ Quick Prompt` — Pre-set prompts
|
||||
|
||||
---
|
||||
|
||||
### Task 4.4: Settings Screen
|
||||
|
||||
**Objective:** Configure connection parameters.
|
||||
|
||||
**Files:**
|
||||
- `ui/settings/SettingsScreen.kt`
|
||||
- `ui/settings/SettingsViewModel.kt`
|
||||
|
||||
**Settings:**
|
||||
- Host/IP (default: `127.0.0.1`)
|
||||
- Port (default: `9119`)
|
||||
- Auth token (loaded from encrypted storage)
|
||||
- Theme (Light/Dark/System)
|
||||
- Auto-reconnect toggle
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Polish & Security
|
||||
|
||||
### Task 5.1: Encrypted Token Storage
|
||||
|
||||
**Objective:** Store the dashboard token securely.
|
||||
|
||||
**Files:**
|
||||
- `data/local/AuthManager.kt`
|
||||
|
||||
Use Android `EncryptedSharedPreferences` to store the `HERMES_DASHBOARD_SESSION_TOKEN`.
|
||||
|
||||
**Verify:** Token persists across app restarts; is encrypted at rest.
|
||||
|
||||
---
|
||||
|
||||
### Task 5.2: Auto-Reconnect
|
||||
|
||||
**Objective:** Reconnect WebSocket if connection drops.
|
||||
|
||||
**Files:**
|
||||
- Update `HermesWsClient.kt` — Exponential backoff reconnection.
|
||||
|
||||
**Verify:** Kill dashboard process → app shows "disconnected" → restart dashboard → app reconnects.
|
||||
|
||||
---
|
||||
|
||||
### Task 5.3: Error Handling & Offline Mode
|
||||
|
||||
**Objective:** Graceful handling of network errors.
|
||||
|
||||
**Files:**
|
||||
- Update `ChatViewModel.kt` — Show offline banner, cache last N messages locally.
|
||||
|
||||
**Verify:** Turn off network → app shows offline state, cached messages visible.
|
||||
|
||||
---
|
||||
|
||||
## Task-by-Task Summary
|
||||
|
||||
| # | Task | Phase | Est. Time | Status |
|
||||
|---|------|-------|-----------|--------|
|
||||
| 1.1 | Scaffold Android Project | 1 | 10 min | ⬜ |
|
||||
| 1.2 | Add Dependencies | 1 | 10 min | ⬜ |
|
||||
| 2.1 | REST Data Models | 2 | 15 min | ⬜ |
|
||||
| 2.2 | REST API Client | 2 | 20 min | ⬜ |
|
||||
| 3.1 | JSON-RPC Models | 3 | 10 min | ⬜ |
|
||||
| 3.2 | WebSocket Client | 3 | 30 min | ⬜ |
|
||||
| 3.3 | Event Parser | 3 | 20 min | ⬜ |
|
||||
| 4.1 | Connection Screen | 4 | 20 min | ⬜ |
|
||||
| 4.2 | Chat Screen | 4 | 45 min | ⬜ |
|
||||
| 4.3 | Command Panel | 4 | 25 min | ⬜ |
|
||||
| 4.4 | Settings Screen | 4 | 20 min | ⬜ |
|
||||
| 5.1 | Encrypted Token Storage | 5 | 15 min | ⬜ |
|
||||
| 5.2 | Auto-Reconnect | 5 | 20 min | ⬜ |
|
||||
| 5.3 | Error Handling & Offline | 5 | 15 min | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## What You DON'T Need Anymore (vs Original Plan)
|
||||
|
||||
| Originally Planned | Now? | Reason |
|
||||
|--------------------|------|--------|
|
||||
| Custom Python Flask bridge | ❌ Removed | Dashboard IS the bridge |
|
||||
| Custom SQLite reads | ❌ Removed | Dashboard API handles sessions |
|
||||
| Hermes DB schema analysis | ❌ Removed | REST API abstracts the DB |
|
||||
| Queue-based message passing | ❌ Removed | WebSocket JSON-RPC handles it |
|
||||
|
||||
---
|
||||
|
||||
## App Connection Details
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| **REST API Base** | `http://127.0.0.1:9119/` |
|
||||
| **WS Endpoint** | `ws://127.0.0.1:9119/api/ws?token=<TOKEN>` |
|
||||
| **REST Auth** | `Authorization: Bearer *** or `X-Hermes-Session-Token: <TOKEN>` |
|
||||
| **WS Auth** | `?token=<TOKEN>` query parameter (NOT header-based for WS) |
|
||||
| **Current Token** | `y9oAS3frCosCSaynhmAofMkeCsuiNuF2ad2XB35iTZ4` (in `~/.hermes/.env`) |
|
||||
| **Dashboard Binding** | `0.0.0.0:9119` (LAN accessible via `--insecure`) |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Verified Protocol Details (E2E Tested)
|
||||
|
||||
### REST API
|
||||
| Endpoint | Auth | Returns |
|
||||
|----------|------|---------|
|
||||
| `GET /api/status` | ❌ Public | `version`, `gateway_running`, `active_sessions`, `auth_required` |
|
||||
| `GET /api/sessions` | ✅ Token | `{"sessions": [...]}` — full session list with message counts |
|
||||
| `GET /api/config` | ✅ Token | Full config as flat JSON dict |
|
||||
| `GET /api/system/stats` | ✅ Token | System statistics |
|
||||
|
||||
**Auth methods tested:**
|
||||
- `Authorization: Bearer <token>` ✅ works
|
||||
- `X-Hermes-Session-Token: <token>` ✅ works
|
||||
- No header → `401` ✅
|
||||
- Bad token → `401` ✅
|
||||
|
||||
### WebSocket JSON-RPC Protocol
|
||||
|
||||
**Connection:** `ws://HOST:9119/api/ws?token=<TOKEN>`
|
||||
|
||||
**First message (always):** `gateway.ready` event — server pushes this immediately on connect.
|
||||
|
||||
**RPC methods tested:**
|
||||
| Method | Params | Response | Status |
|
||||
|--------|--------|----------|--------|
|
||||
| `session.list` | `{}` | `{"sessions": [...]}` — all TUI sessions | ✅ |
|
||||
| `session.active_list` | `{}` | `{"sessions": [...]}` — active TUI sessions | ✅ |
|
||||
| `session.status` | `{}` | `{"sessions": [...]}` — session statuses | ✅ |
|
||||
| `session.create` | `{}` | `{"session_id": "xxx", "stored_session_id": "xxx", ...}` | ✅ |
|
||||
| `session.resume` | `{"id": "session_id"}` | Session data | ✅ (when valid session) |
|
||||
| `prompt.submit` | `{"session_id": "xxx", "text": "message"}` | `{"status": "streaming"}` then events | ✅ |
|
||||
| `nonexistent.method` | `{}` | `{"error": {"code": -32601, "message": "..."}}` | ✅ |
|
||||
|
||||
### Chat Flow (Event Sequence)
|
||||
|
||||
After `prompt.submit`, events arrive on the same WebSocket in this order:
|
||||
|
||||
1. `session.info` — Session metadata, model info, tool list
|
||||
2. `message.start` — Processing began
|
||||
3. `thinking.delta` (0+) — Reasoning tokens (model-dependent)
|
||||
4. `message.token` (0+) — Streaming text tokens (model-dependent)
|
||||
5. `status.update` — Status changes
|
||||
6. `tool.start` / `tool.complete` — Tool execution (when applicable)
|
||||
7. `clarify.request` (optional) — Agent needs input
|
||||
8. **`message.complete`** — **Final response!** `payload.text` contains the full response text.
|
||||
9. `message.done` — Processing fully finished
|
||||
|
||||
**Key finding:** The actual response text is in `message.complete.payload.text`, NOT nested under `payload.message.content`. Use:
|
||||
```kotlin
|
||||
val responseText = event.payload["text"] as? String
|
||||
```
|
||||
|
||||
### Important Implementation Notes
|
||||
|
||||
1. **Agent build time:** After `session.create`, the AI agent needs ~15-20s to initialize (tool discovery, model loading, skill loading). The Android app should:
|
||||
- Show a loading/connecting state during this time
|
||||
- Listen for events but ignore them until `message.complete` or set a timer
|
||||
- OR create a session on app startup and keep it alive for instant responses
|
||||
|
||||
2. **Session reuse:** Multiple `prompt.submit` calls work on the same session_id. Create one session at startup and reuse it.
|
||||
|
||||
3. **No heartbeat needed:** The WS connection stays alive without pings.
|
||||
|
||||
4. **Error codes:**
|
||||
- `4001` — session not found (invalid/missing session_id)
|
||||
- `4009` — session busy (agent still processing previous prompt)
|
||||
- `-32601` — unknown method
|
||||
- `-32600` — invalid JSON-RPC format
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Chat flow**: Do you want the app to resume the current active session, or start fresh each time?
|
||||
2. **Deep linking**: Want to add a way to share messages/commands from other apps?
|
||||
3. **Notifications**: Want push notifications when Hermes completes a task? (Would need a server component for push)
|
||||
|
||||
---
|
||||
|
||||
*Plan updated: 2026-06-15. Simplified architecture using Hermes Dashboard + TUI Gateway. Ready to build!* 💋
|
||||
@@ -1,44 +0,0 @@
|
||||
# Progress Status: HermesControl Android App
|
||||
|
||||
This document tracks the completed features and the pending backlog for the HermesControl Jetpack Compose Android application.
|
||||
|
||||
## 👥 Done (Implemented & Verified)
|
||||
|
||||
### 1. Base Infrastructure & Environment
|
||||
* **Nix Flake Dev Environment (`flake.nix`)**: Fully configured with JDK 17, Android SDK (platforms 34-36), `ktlint`, and Gradle.
|
||||
* **Scaffolding & Build Pipeline**: Android Jetpack Compose project compiles cleanly using `./gradlew assembleDebug`.
|
||||
* **Code Formatting**: Code formatting checks integrated using `ktlint` (`./gradlew ktlintCheck`).
|
||||
* **Unit Testing Suite**: Verified with `./gradlew test` (AuthManager, EventParser, ConnectViewModel, ChatViewModel, and new viewmodels/integration tests).
|
||||
* **Network Capabilities**: Cleartext traffic enabled in `AndroidManifest.xml` to allow connection to local networks (e.g., `192.168.1.18` to `192.168.1.48`).
|
||||
* **Git Repository**: Initialized and linked with GitHub (`https://github.com/Hy4ri/hermes-mobile.git`).
|
||||
|
||||
### 2. Implemented Features
|
||||
* **Connection Screen (`ConnectScreen`)**: Saves Host, Port, and Auth Token to `AuthManager` (EncryptedSharedPreferences), with live verification via `GET /api/status`.
|
||||
* **Chat Screen (`ChatScreen`)**: Live chat over WebSocket (JSON-RPC) message token streaming, thinking delta, tool call indicators, and clarifying inputs.
|
||||
* **Skills Screen (`SkillsScreen`)**: Lists skills (`GET /api/skills`) and toggles them with optimistic state reversion.
|
||||
* **Cron Jobs Screen (`CronJobsScreen`)**: Lists cron jobs (`GET /api/cron/jobs`) with actions (Trigger, Pause, Resume, and Delete) with optimistic updates.
|
||||
* **Gateway Control Screen (`GatewayScreen`)**: Start/Stop/Restart with platform state reporting.
|
||||
* **Profiles Management Screen (`ProfilesScreen`)**: Switch active profile, update soul personality description, and switch AI model providers.
|
||||
* **Toolsets Screen (`ToolsetsScreen`)**: List and toggle toolsets.
|
||||
* **Achievements Screen (`AchievementsScreen`)**: Displays gamified achievements list.
|
||||
* **Device Pairing Screen (`PairingScreen`)**: List pairing approvals, approve/revoke access.
|
||||
* **Raw Config Editor (`ConfigScreen`)**: Live edit `/api/config/raw`.
|
||||
* **MCP Servers Management (`McpServersScreen`)**: List MCP servers, toggle, test connection, and delete.
|
||||
* **Webhooks Management (`WebhooksScreen`)**: List webhook callback subscriptions and globally toggle webhooks.
|
||||
* **Model Configuration (`ModelScreen`)**: Inspect model providers list.
|
||||
* **Settings Screen (`SettingsScreen`)**: Change Host, Port, Token, Theme, and Auto-reconnect.
|
||||
* **Logs View (`LogsScreen`)**: Live scrolling platform/stdout logs (`GET /api/logs`) with console-style layout.
|
||||
* **Plugins View (`PluginsScreen`)**: Hub rescan, install, update, disable, enable with optimistic state updates.
|
||||
* **Channels / Messaging (`ChannelsScreen`)**: Platform configurations and credentials manager (e.g. Telegram bot details).
|
||||
* **Keys (`KeysScreen`)**: Live environment variables and credentials manager (`GET/PUT /api/env`, reveal option).
|
||||
* **System View (`SystemScreen`)**: Diagnostics doctor reports (`GET /api/ops/doctor`) and backups trigger (`POST /api/ops/backup`).
|
||||
* **Kanban View (`KanbanScreen`)**: Full task boards, task columns (To Do, In Progress, Done), new tasks dialog, and task movement with optimistic updates.
|
||||
* **Navigation Drawer**: Material3 navigation drawer using Navigation3 keys to switch between all screens.
|
||||
* **Chat Slash Commands**: Local interception and execution of custom commands (`/help`, `/status`, `/sessions`, `/stats` / `/system`, `/new`, `/stop` / `/interrupt`) displaying results inline.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Left (To Be Implemented)
|
||||
|
||||
* Other minor backlogs include Skills Hub and Advanced Session management.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Project: HermesControl Jetpack Compose Android App
|
||||
|
||||
## Architecture
|
||||
The HermesControl Jetpack Compose Android application communicates with the local Hermes agent instance via two communication channels:
|
||||
1. **REST API (Port 9119)**: For status queries, listing sessions, and system configuration.
|
||||
2. **WebSocket Gateway (`/api/ws?token=<TOKEN>`)**: For real-time JSON-RPC messaging (chat, prompt submission, stream events, tool execution updates, etc.).
|
||||
|
||||
### Package Boundaries
|
||||
- `com.m57.hermescontrol.data.model` — REST API data classes.
|
||||
- `com.m57.hermescontrol.data.remote` — Retrofit ApiClient and HermesApiService.
|
||||
- `com.m57.hermescontrol.data.ws` — JSON-RPC models, HermesWsClient, and EventParser.
|
||||
- `com.m57.hermescontrol.data.local` — AuthManager (EncryptedSharedPreferences).
|
||||
- `com.m57.hermescontrol.ui.connect` — ConnectScreen and ConnectViewModel.
|
||||
- `com.m57.hermescontrol.ui.chat` — ChatScreen, ChatViewModel, and ChatBubble.
|
||||
- `com.m57.hermescontrol.ui.settings` — SettingsScreen and SettingsViewModel.
|
||||
- `com.m57.hermescontrol.theme` — Color, Theme, and Type.
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Name | Scope | Dependencies | Status |
|
||||
|---|---|---|---|---|
|
||||
| M1 | Exploration & Setup | Explore codebase, identify dependencies and structure | None | DONE |
|
||||
| M2 | Networking & Data Layer | Implement/Fix AuthManager, api clients, WS clients, EventParser | M1 | DONE |
|
||||
| M3 | UI Screen Implementation | Implement ConnectScreen, ChatScreen, SettingsScreen, and Nav integration | M2 | DONE |
|
||||
| M4 | Unit Testing | Implement unit tests for AuthManager, EventParser, Connect/Chat ViewModels | M2, M3 | DONE |
|
||||
| M5 | Styling & Audit Validation | Perform ktlint formatting fixes, compiler error fixes, and run Forensic Audit | M4 | DONE |
|
||||
| M6 | E2E Testing Track | Design and build comprehensive opaque-box integration tests (Tiers 1-4) | M5 | PLANNED |
|
||||
| M7 | API Integration & Models | Add Retrofit endpoints and JSON serialization models for Skills & Cron Jobs | M5 | PLANNED |
|
||||
| M8 | Skills Management UI | Implement Viewmodel and UI Screen for skills management | M7 | PLANNED |
|
||||
| M9 | Cron Jobs Management UI | Implement Viewmodel and UI Screen for cron jobs control | M7 | PLANNED |
|
||||
| M10 | Navigation Drawer | Integrate Material3 navigation drawer using Navigation3 keys | M8, M9 | PLANNED |
|
||||
| M11 | E2E & Hardening Phase | Verify all E2E tests, perform adversarial coverage hardening (Tier 5) | M6, M10 | PLANNED |
|
||||
|
||||
---
|
||||
|
||||
## Interface Contracts
|
||||
|
||||
### REST API Service
|
||||
- `getStatus()`: Returns `StatusResponse` (version, gateway status, active sessions)
|
||||
- `getSessions()`: Returns `SessionListResponse` (all sessions and metadata)
|
||||
- `getSessionMessages(sessionId: String)`: Returns `SessionMessagesResponse` (message history)
|
||||
- `getSystemStats()`: Returns `SystemStatsResponse` (CPU, memory, etc.)
|
||||
- `getSkills()`: Returns list of skills (`GET api/skills`)
|
||||
- `toggleSkill(payload)`: Toggles a skill's state (`PUT api/skills/toggle`)
|
||||
- `getCronJobs()`: Returns list of cron jobs (`GET api/cron/jobs`)
|
||||
- `pauseCronJob(id)`: Pauses a job (`POST api/cron/jobs/{id}/pause`)
|
||||
- `resumeCronJob(id)`: Resumes a job (`POST api/cron/jobs/{id}/resume`)
|
||||
- `triggerCronJob(id)`: Triggers a job immediately (`POST api/cron/jobs/{id}/trigger`)
|
||||
- `deleteCronJob(id)`: Deletes a job (`DELETE api/cron/jobs/{id}`)
|
||||
|
||||
### WebSocket Client (`HermesWsClient`)
|
||||
- `connect()`: Establish connection to `/api/ws?token=<TOKEN>`
|
||||
- `disconnect()`: Terminate connection
|
||||
- `sendMessage(text: String)`: Submit prompt (`prompt.submit` JSON-RPC method)
|
||||
- `resumeSession(sessionId: String)`: Resume a session (`session.resume` JSON-RPC method)
|
||||
- Callbacks: `onConnected()`, `onDisconnected(reason: String)`, `onMessage(JsonRpcResponse)`
|
||||
|
||||
### Event Parser (`EventParser`)
|
||||
- Parses incoming JSON messages from WebSocket and extracts event fields (`event`, `data`, `id`, `result`, `error`).
|
||||
- Categorizes events: `message.complete`, `message.token`, `tool.start`, `tool.complete`, `clarify.request`, etc.
|
||||
|
||||
## Code Layout
|
||||
- `app/src/main/java/com/m57/hermescontrol/` (Main Source)
|
||||
- `app/src/test/java/com/m57/hermescontrol/` (Unit Tests)
|
||||
@@ -21,6 +21,7 @@ android {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -79,6 +80,7 @@ dependencies {
|
||||
|
||||
// Encrypted storage
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation("androidx.startup:startup-runtime:1.1.1")
|
||||
|
||||
// Local tests: jUnit, coroutines, Android runner
|
||||
testImplementation(libs.junit)
|
||||
|
||||
@@ -6,6 +6,8 @@ import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -35,6 +37,7 @@ 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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -96,6 +99,14 @@ private fun UserBubble(
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
val gradientBrush =
|
||||
Brush.linearGradient(
|
||||
colors =
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.secondary,
|
||||
),
|
||||
)
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -107,8 +118,8 @@ private fun UserBubble(
|
||||
bottomStart = 16.dp,
|
||||
bottomEnd = 4.dp,
|
||||
),
|
||||
),
|
||||
color = UserBubble,
|
||||
).background(brush = gradientBrush),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
@@ -164,6 +175,11 @@ private fun AssistantBubble(
|
||||
),
|
||||
),
|
||||
color = bubbleColor,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f),
|
||||
),
|
||||
tonalElevation = 1.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -74,6 +75,7 @@ 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.Brush
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -121,6 +123,16 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
val backgroundGradient =
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.background,
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.05f),
|
||||
),
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
@@ -262,6 +274,7 @@ fun ChatScreen(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.background(backgroundGradient)
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
) {
|
||||
@@ -460,13 +473,21 @@ private fun ChatInputBar(
|
||||
exit = slideOutVertically(targetOffsetY = { it }),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(8.dp),
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
|
||||
) {
|
||||
Column {
|
||||
val commands =
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.m57.hermescontrol.ui.connect
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -38,6 +40,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.graphics.Brush
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
@@ -64,10 +67,21 @@ fun ConnectScreen(
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
val backgroundGradient =
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.background,
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundGradient)
|
||||
.imePadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -110,9 +124,14 @@ fun ConnectScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 40 KiB |