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,96 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Singleton that manages encrypted storage of the Hermes dashboard token
* and connection settings.
*
* Must call [init] with a Context before any other method.
*/
object AuthManager {
private const val PREFS_FILE = "hermes_secure_prefs"
private const val KEY_TOKEN = "auth_token"
private const val KEY_HOST = "host"
private const val KEY_PORT = "port"
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
private const val DEFAULT_HOST = "127.0.0.1"
private const val DEFAULT_PORT = 9119
private const val DEFAULT_AUTO_RECONNECT = true
@Volatile
private var prefs: SharedPreferences? = null
/**
* Initialise the encrypted preferences.
* Call this once from Application.onCreate() or MainActivity.onCreate().
*/
fun init(context: Context) {
if (prefs != null) return
synchronized(this) {
if (prefs != null) return
val masterKey =
MasterKey
.Builder(context.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
prefs =
EncryptedSharedPreferences.create(
context.applicationContext,
PREFS_FILE,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
}
private fun requirePrefs(): SharedPreferences =
prefs ?: throw IllegalStateException(
"AuthManager not initialised – call AuthManager.init(context) first",
)
// ── Token ────────────────────────────────────────────────────────────
fun getToken(): String? = requirePrefs().getString(KEY_TOKEN, null)
fun setToken(token: String?) {
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
}
// ── Host ─────────────────────────────────────────────────────────────
fun getHost(): String = requirePrefs().getString(KEY_HOST, DEFAULT_HOST) ?: DEFAULT_HOST
fun setHost(host: String) {
requirePrefs().edit().putString(KEY_HOST, host).apply()
}
// ── Port ─────────────────────────────────────────────────────────────
fun getPort(): Int = requirePrefs().getInt(KEY_PORT, DEFAULT_PORT)
fun setPort(port: Int) {
requirePrefs().edit().putInt(KEY_PORT, port).apply()
}
// ── Auto-reconnect ───────────────────────────────────────────────────
fun isAutoReconnect(): Boolean = requirePrefs().getBoolean(KEY_AUTO_RECONNECT, DEFAULT_AUTO_RECONNECT)
fun setAutoReconnect(enabled: Boolean) {
requirePrefs().edit().putBoolean(KEY_AUTO_RECONNECT, enabled).apply()
}
/** Convenience: build the base URL from current host + port. */
fun baseUrl(): String = "http://${getHost()}:${getPort()}/"
/** Convenience: build the WebSocket URL with token query param. */
fun wsUrl(): String = "ws://${getHost()}:${getPort()}/api/ws?token=${getToken().orEmpty()}"
}
@@ -0,0 +1,22 @@
package com.m57.hermescontrol.data.model
data class AchievementsResponse(
val achievements: List<Achievement>,
)
data class Achievement(
val id: String,
val name: String,
val description: String?,
val category: String?,
val kind: String?,
val icon: String?,
val unlocked: Boolean,
val discovered: Boolean,
val state: String?,
val tier: String?,
val progress: Double?,
val next_tier: String?,
val next_threshold: Double?,
val progress_pct: Double?,
)
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.data.model
data class CronJob(
val id: String,
val name: String,
val schedule: String?,
val state: String?,
val last_run_status: String?,
val next_run: String?,
)
@@ -0,0 +1,43 @@
package com.m57.hermescontrol.data.model
data class ProfilesResponse(
val profiles: List<ProfileInfo>,
)
data class ProfileInfo(
val name: String,
val path: String?,
val is_default: Boolean?,
val model: String?,
val provider: String?,
val has_env: Boolean?,
val skill_count: Int?,
val gateway_running: Boolean?,
val description: String?,
)
data class ActiveProfileResponse(
val active: String,
val current: String?,
)
data class SetActiveProfileRequest(
val name: String,
)
data class ProfileSoulResponse(
val content: String,
)
data class UpdateProfileSoulRequest(
val content: String,
)
data class UpdateProfileModelRequest(
val provider: String,
val model: String,
)
data class UpdateProfileDescriptionRequest(
val description: String,
)
@@ -0,0 +1,13 @@
package com.m57.hermescontrol.data.model
data class SessionListResponse(
val sessions: List<SessionInfo>,
)
data class SessionInfo(
val id: String,
val title: String?,
val created_at: String?,
val message_count: Int?,
val status: String?,
)
@@ -0,0 +1,12 @@
package com.m57.hermescontrol.data.model
data class SessionMessagesResponse(
val messages: List<SessionMessage>,
)
data class SessionMessage(
val role: String?,
val content: String?,
val timestamp: String?,
val type: String?,
)
@@ -0,0 +1,8 @@
package com.m57.hermescontrol.data.model
data class Skill(
val name: String,
val description: String?,
val category: String?,
val enabled: Boolean,
)
@@ -0,0 +1,14 @@
package com.m57.hermescontrol.data.model
data class StatusResponse(
val version: String?,
val gateway_running: Boolean?,
val active_sessions: Int?,
val auth_required: Boolean?,
val gateway_platforms: Map<String, PlatformStatus>?,
)
data class PlatformStatus(
val state: String?,
val error_code: String? = null,
)
@@ -0,0 +1,24 @@
package com.m57.hermescontrol.data.model
/**
* Wrapper around the /api/system/stats response.
*
* The payload shape can change across Hermes versions, so we keep it
* flexible with a Map and provide convenience accessors for the
* most commonly used fields.
*/
data class SystemStatsResponse(
val cpu: Map<String, Any?>? = null,
val memory: Map<String, Any?>? = null,
val disk: Map<String, Any?>? = null,
val uptime: Any? = null,
val extra: Map<String, Any?>? = null,
) {
/** CPU usage percentage, if available. */
val cpuPercent: Double?
get() = (cpu?.get("percent") as? Number)?.toDouble()
/** Memory usage percentage, if available. */
val memoryPercent: Double?
get() = (memory?.get("percent") as? Number)?.toDouble()
}
@@ -0,0 +1,6 @@
package com.m57.hermescontrol.data.model
data class ToggleSkillRequest(
val name: String,
val enabled: Boolean,
)
@@ -0,0 +1,16 @@
package com.m57.hermescontrol.data.model
data class Toolset(
val name: String,
val label: String?,
val description: String?,
val enabled: Boolean,
val available: Boolean?,
val configured: Boolean?,
val tools: List<String>?,
)
data class ToolsetToggleRequest(
val enabled: Boolean,
val profile: String? = null,
)
@@ -0,0 +1,86 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Provides a Retrofit-backed [HermesApiService].
*
* The client is lazily built using the current [AuthManager] settings and
* can be rebuilt at any time via [rebuild] (e.g. after the user changes
* host / port / token).
*/
object ApiClient {
@Volatile
private var retrofit: Retrofit? = null
@Volatile
private var service: HermesApiService? = null
/** The current [HermesApiService] instance. Lazily created on first access. */
val hermesApi: HermesApiService
get() {
return service ?: synchronized(this) {
service ?: buildService().also { service = it }
}
}
/** Force-rebuild the Retrofit client (e.g. after settings change). */
fun rebuild() {
synchronized(this) {
retrofit = null
service = null
}
}
// ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService {
val logging =
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val authInterceptor =
Interceptor { chain ->
val token = AuthManager.getToken()
val request =
if (!token.isNullOrBlank()) {
chain
.request()
.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
chain.proceed(request)
}
val okHttp =
OkHttpClient
.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
val rf =
Retrofit
.Builder()
.baseUrl(AuthManager.baseUrl())
.client(okHttp)
.addConverterFactory(GsonConverterFactory.create())
.build()
.also { retrofit = it }
return rf.create(HermesApiService::class.java)
}
}
@@ -0,0 +1,131 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.AchievementsResponse
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.model.ProfileSoulResponse
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
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>
@GET("api/config")
suspend fun getConfig(): Response<Map<String, Any?>>
@GET("api/skills")
suspend fun getSkills(): Response<List<Skill>>
@PUT("api/skills/toggle")
suspend fun toggleSkill(
@Body body: ToggleSkillRequest,
): Response<Unit>
@GET("api/cron/jobs")
suspend fun getCronJobs(): Response<List<CronJob>>
@POST("api/cron/jobs/{id}/pause")
suspend fun pauseCronJob(
@Path("id") id: String,
): Response<Unit>
@POST("api/cron/jobs/{id}/resume")
suspend fun resumeCronJob(
@Path("id") id: String,
): Response<Unit>
@POST("api/cron/jobs/{id}/trigger")
suspend fun triggerCronJob(
@Path("id") id: String,
): Response<Unit>
@DELETE("api/cron/jobs/{id}")
suspend fun deleteCronJob(
@Path("id") id: String,
): Response<Unit>
@POST("api/gateway/start")
suspend fun startGateway(): Response<Unit>
@POST("api/gateway/stop")
suspend fun stopGateway(): Response<Unit>
@POST("api/gateway/restart")
suspend fun restartGateway(): Response<Unit>
@GET("api/profiles")
suspend fun getProfiles(): Response<ProfilesResponse>
@GET("api/profiles/active")
suspend fun getActiveProfile(): Response<ActiveProfileResponse>
@POST("api/profiles/active")
suspend fun setActiveProfile(
@Body body: SetActiveProfileRequest,
): Response<Unit>
@GET("api/profiles/{name}/soul")
suspend fun getProfileSoul(
@Path("name") name: String,
): Response<ProfileSoulResponse>
@PUT("api/profiles/{name}/soul")
suspend fun updateProfileSoul(
@Path("name") name: String,
@Body body: UpdateProfileSoulRequest,
): Response<Unit>
@PUT("api/profiles/{name}/model")
suspend fun updateProfileModel(
@Path("name") name: String,
@Body body: UpdateProfileModelRequest,
): Response<Unit>
@PUT("api/profiles/{name}/description")
suspend fun updateProfileDescription(
@Path("name") name: String,
@Body body: UpdateProfileDescriptionRequest,
): Response<Unit>
@GET("api/tools/toolsets")
suspend fun getToolsets(): Response<List<Toolset>>
@PUT("api/tools/toolsets/{name}")
suspend fun toggleToolset(
@Path("name") name: String,
@Body body: ToolsetToggleRequest,
): Response<Unit>
@GET("api/plugins/hermes-achievements/achievements")
suspend fun getAchievements(): Response<AchievementsResponse>
}
@@ -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"
}