Initial commit of HermesControl Android app

This commit is contained in:
Hy4ri committed 2026-06-16 00:00:36 +03:00
commit d6ff3de594
92 files changed
+10004

No files matched your search

@@ -0,0 +1,106 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
/**
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
*
* The Hermes TUI gateway sends events as JSON-RPC **notifications** (no `id`
* field). The `method` is always `"event"` and the event type lives in
* `params.type`. The event payload is in `params.payload`.
*
* Regular RPC responses have an `id` and either a `result` or `error`.
*/
object EventParser {
private const val TAG = "EventParser"
fun parse(
response: JsonRpcResponse,
rawJson: String = "",
): WsEvent {
// ── RPC response (has id) ────────────────────────────────────────
val id = response.id
if (id != null) {
return if (response.error != null) {
WsEvent.RpcError(id, response.error)
} else {
WsEvent.RpcResult(id, response.result)
}
}
// ── Notification / event (no id, has method) ─────────────────────
val params = response.params ?: return WsEvent.Unknown(rawJson)
val eventType = params["type"] as? String ?: return WsEvent.Unknown(rawJson)
@Suppress("UNCHECKED_CAST")
val payload = params["payload"] as? Map<String, Any?>
val sessionId = payload?.get("session_id") as? String
return when (eventType) {
"gateway.ready" -> {
WsEvent.GatewayReady(payload)
}
"session.info" -> {
WsEvent.SessionInfo(payload)
}
"message.start" -> {
WsEvent.MessageStart(sessionId)
}
"message.token" -> {
val token = payload?.get("text") as? String ?: ""
WsEvent.MessageToken(token, sessionId)
}
"thinking.delta" -> {
val token = payload?.get("text") as? String ?: ""
WsEvent.ThinkingDelta(token, sessionId)
}
"message.complete" -> {
val text = payload?.get("text") as? String ?: ""
WsEvent.MessageComplete(text, sessionId)
}
"message.done" -> {
WsEvent.MessageDone(sessionId)
}
"tool.start" -> {
val name = payload?.get("name") as? String
WsEvent.ToolStart(name, payload)
}
"tool.complete" -> {
val name = payload?.get("name") as? String
WsEvent.ToolComplete(name, payload)
}
"clarify.request" -> {
val text = payload?.get("text") as? String
val rawOptions = payload?.get("options")
@Suppress("UNCHECKED_CAST")
val options = (rawOptions as? List<*>)?.filterIsInstance<String>()
WsEvent.ClarifyRequest(text, options)
}
"status.update" -> {
val status = payload?.get("status") as? String
WsEvent.StatusUpdate(status, payload)
}
"session.updated" -> {
WsEvent.SessionUpdated(payload)
}
else -> {
Log.w(TAG, "Unknown event type: $eventType")
WsEvent.Unknown(rawJson)
}
}
}
}
@@ -0,0 +1,224 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.google.gson.Gson
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* WebSocket client for the Hermes Dashboard JSON-RPC 2.0 interface.
*
* Connects to `ws://HOST:PORT/api/ws?token=TOKEN`, auto-reconnects with
* exponential backoff, and emits parsed [WsEvent]s via [events] SharedFlow
* as well as direct callbacks.
*/
object HermesWsClient {
private const val TAG = "HermesWsClient"
// ── Backoff settings ─────────────────────────────────────────────────
private const val INITIAL_BACKOFF_MS = 1_000L
private const val MAX_BACKOFF_MS = 30_000L
private const val BACKOFF_MULTIPLIER = 2.0
// ── Internal state (all access through synchronized / atomic) ────────
private val gson = Gson()
private val requestId = AtomicInteger(0)
private val connected = AtomicBoolean(false)
private val intentionalClose = AtomicBoolean(false)
@Volatile
private var webSocket: WebSocket? = null
@Volatile
private var currentBackoff = INITIAL_BACKOFF_MS
private val okHttpClient =
OkHttpClient
.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive forever
.pingInterval(30, TimeUnit.SECONDS)
.build()
// ── Public observable stream ─────────────────────────────────────────
private val _events = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
// ── Callbacks (legacy / convenience) ─────────────────────────────────
var onMessage: ((WsEvent) -> Unit)? = null
var onConnected: (() -> Unit)? = null
var onDisconnected: ((reason: String?) -> Unit)? = null
var onError: ((Throwable) -> Unit)? = null
// ── Connection helpers ────────────────────────────────────────────────
val isConnected: Boolean get() = connected.get()
/** Open a WebSocket connection using settings from [AuthManager]. */
fun connect() {
if (connected.get()) {
Log.d(TAG, "Already connected — skipping")
return
}
intentionalClose.set(false)
currentBackoff = INITIAL_BACKOFF_MS
openSocket()
}
/** Cleanly close the WebSocket and stop auto-reconnect. */
fun disconnect() {
intentionalClose.set(true)
webSocket?.close(1000, "Client closed")
webSocket = null
connected.set(false)
}
// ── Send helpers ─────────────────────────────────────────────────────
/**
* Send a JSON-RPC request with the given [method] and optional [params].
* @return the request id used (can be matched against [WsEvent.RpcResult]).
*/
fun send(
method: String,
params: Map<String, Any> = emptyMap(),
): String {
val id = requestId.incrementAndGet().toString()
val request = JsonRpcRequest(id = id, method = method, params = params)
val json = gson.toJson(request)
Log.d(TAG, "→ $json")
webSocket?.send(json) ?: Log.w(TAG, "send() called while disconnected")
return id
}
/** Convenience: submit a user prompt to an existing session. */
fun sendMessage(
sessionId: String,
text: String,
): String =
send(
method = WsMethods.PROMPT_SUBMIT,
params = mapOf("session_id" to sessionId, "text" to text),
)
// ── Internal ─────────────────────────────────────────────────────────
private fun openSocket() {
val url = AuthManager.wsUrl()
Log.d(TAG, "Connecting to $url")
val request = Request.Builder().url(url).build()
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl())
}
private fun scheduleReconnect() {
if (intentionalClose.get()) return
if (!AuthManager.isAutoReconnect()) {
Log.d(TAG, "Auto-reconnect disabled")
return
}
val delay = currentBackoff
currentBackoff =
(currentBackoff * BACKOFF_MULTIPLIER)
.toLong()
.coerceAtMost(MAX_BACKOFF_MS)
Log.d(TAG, "Reconnecting in ${delay}ms …")
// Simple thread-based scheduling; could be replaced with coroutine delay.
Thread {
try {
Thread.sleep(delay)
} catch (_: InterruptedException) {
return@Thread
}
if (!intentionalClose.get() && !connected.get()) {
openSocket()
}
}.apply {
isDaemon = true
name = "hermes-ws-reconnect"
start()
}
}
private fun emit(event: WsEvent) {
_events.tryEmit(event)
onMessage?.invoke(event)
}
// ── Listener ─────────────────────────────────────────────────────────
private class WsListenerImpl : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
response: Response,
) {
Log.i(TAG, "WebSocket opened")
connected.set(true)
currentBackoff = INITIAL_BACKOFF_MS
onConnected?.invoke()
}
override fun onMessage(
ws: WebSocket,
text: String,
) {
Log.d(TAG, "← $text")
try {
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
val event = EventParser.parse(rpc, text)
emit(event)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
emit(WsEvent.Unknown(text))
}
}
override fun onClosing(
ws: WebSocket,
code: Int,
reason: String,
) {
Log.d(TAG, "WebSocket closing: $code $reason")
ws.close(1000, null)
}
override fun onClosed(
ws: WebSocket,
code: Int,
reason: String,
) {
Log.i(TAG, "WebSocket closed: $code $reason")
connected.set(false)
onDisconnected?.invoke(reason)
scheduleReconnect()
}
override fun onFailure(
ws: WebSocket,
t: Throwable,
response: Response?,
) {
Log.e(TAG, "WebSocket failure: ${t.message}", t)
connected.set(false)
onError?.invoke(t)
onDisconnected?.invoke(t.message)
scheduleReconnect()
}
}
}
@@ -0,0 +1,32 @@
package com.m57.hermescontrol.data.ws
/**
* Outgoing JSON-RPC 2.0 request sent over the WebSocket.
*/
data class JsonRpcRequest(
val jsonrpc: String = "2.0",
val id: String,
val method: String,
val params: Map<String, Any> = emptyMap(),
)
/**
* Incoming JSON-RPC 2.0 response **or** server-pushed notification.
*
* - RPC result/error → [id] is non-null, [result] or [error] is populated.
* - Event notification → [id] is null, [method] is `"event"`, event data in [params].
*/
data class JsonRpcResponse(
val jsonrpc: String?,
val id: String?,
val result: Any?,
val error: JsonRpcError?,
val method: String?,
val params: Map<String, Any?>?,
)
data class JsonRpcError(
val code: Int,
val message: String,
val data: Any? = null,
)
@@ -0,0 +1,89 @@
package com.m57.hermescontrol.data.ws
/** Parsed WebSocket events emitted by [HermesWsClient]. */
sealed class WsEvent {
// ── Gateway lifecycle ────────────────────────────────────────────────
data class GatewayReady(
val data: Map<String, Any?>?,
) : WsEvent()
// ── Session information ──────────────────────────────────────────────
data class SessionInfo(
val data: Map<String, Any?>?,
) : WsEvent()
// ── Message streaming ────────────────────────────────────────────────
data class MessageStart(
val sessionId: String?,
) : WsEvent()
data class MessageToken(
val token: String,
val sessionId: String?,
) : WsEvent()
data class ThinkingDelta(
val token: String,
val sessionId: String?,
) : WsEvent()
data class MessageComplete(
val text: String,
val sessionId: String?,
) : WsEvent()
data class MessageDone(
val sessionId: String?,
) : WsEvent()
// ── Tool execution ───────────────────────────────────────────────────
data class ToolStart(
val name: String?,
val data: Map<String, Any?>?,
) : WsEvent()
data class ToolComplete(
val name: String?,
val data: Map<String, Any?>?,
) : WsEvent()
// ── Interactive ──────────────────────────────────────────────────────
data class ClarifyRequest(
val text: String?,
val options: List<String>?,
) : WsEvent()
// ── Status ───────────────────────────────────────────────────────────
data class StatusUpdate(
val status: String?,
val data: Map<String, Any?>?,
) : WsEvent()
data class SessionUpdated(
val data: Map<String, Any?>?,
) : WsEvent()
// ── RPC responses ────────────────────────────────────────────────────
data class RpcResult(
val id: String,
val result: Any?,
) : WsEvent()
data class RpcError(
val id: String,
val error: JsonRpcError,
) : WsEvent()
// ── Fallback ─────────────────────────────────────────────────────────
data class Unknown(
val raw: String,
) : WsEvent()
}
@@ -0,0 +1,17 @@
package com.m57.hermescontrol.data.ws
/** JSON-RPC method name constants used with [HermesWsClient.send]. */
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"
const val SESSION_DELETE = "session.delete"
const val SESSION_TITLE = "session.title"
const val CLARIFY_RESPOND = "clarify.respond"
const val APPROVAL_RESPOND = "approval.respond"
}