diff --git a/README.md b/README.md
index 393f0875..d2a72800 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,12 @@
---
+## Support the project
+
+If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7).
+
+---
+
## Screenshots
diff --git a/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/7.json b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/7.json
new file mode 100644
index 00000000..3f6766e7
--- /dev/null
+++ b/app/schemas/com.m57.hermescontrol.data.local.HermesDatabase/7.json
@@ -0,0 +1,110 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 7,
+ "identityHash": "3deb0f3f202de9316222a02a0b5ae80e",
+ "entities": [
+ {
+ "tableName": "chat_messages",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, `token_count` INTEGER, `tps` REAL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionId",
+ "columnName": "session_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "role",
+ "columnName": "role",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reasoningText",
+ "columnName": "reasoning_text",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "toolName",
+ "columnName": "tool_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "toolCallId",
+ "columnName": "tool_call_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "toolStatus",
+ "columnName": "tool_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isStreaming",
+ "columnName": "is_streaming",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayKind",
+ "columnName": "display_kind",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "tokenCount",
+ "columnName": "token_count",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tps",
+ "columnName": "tps",
+ "affinity": "REAL"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_chat_messages_session_id_timestamp",
+ "unique": false,
+ "columnNames": [
+ "session_id",
+ "timestamp"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)"
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3deb0f3f202de9316222a02a0b5ae80e')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheetTest.kt
new file mode 100644
index 00000000..69c0072c
--- /dev/null
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SessionIntegrationsSheetTest.kt
@@ -0,0 +1,413 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.m57.hermescontrol.data.model.ConnectorError
+import com.m57.hermescontrol.data.model.ConnectorStatus
+import com.m57.hermescontrol.ui.chat.ChatConnectorsUiState
+import com.m57.hermescontrol.ui.chat.ConnectorActionState
+import com.m57.hermescontrol.ui.chat.ConnectorUiItem
+import com.m57.hermescontrol.ui.chat.ConnectorsLoadPhase
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class SessionIntegrationsSheetTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun testLoadingState_displaysLoadingIndicator() {
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Loading,
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_loading").assertIsDisplayed()
+ }
+
+ @Test
+ fun testConnectedItem_displaysConnectedStatusAndReconnectAction() {
+ var clickedSlug: String? = null
+ var clickedReconnect: Boolean? = null
+
+ val item =
+ ConnectorUiItem(
+ slug = "github",
+ name = "GitHub",
+ isConnected = true,
+ isEnabled = true,
+ status = ConnectorStatus.CONNECTED,
+ connectionStatus = "connected",
+ )
+
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Loaded(available = true),
+ items = listOf(item),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { slug, reconnect ->
+ clickedSlug = slug
+ clickedReconnect = reconnect
+ },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed()
+ composeTestRule.onNodeWithText("GitHub").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed()
+
+ composeTestRule.onNodeWithTag("session_integrations_action_github").performClick()
+ assertEquals("github", clickedSlug)
+ assertEquals(true, clickedReconnect)
+ }
+
+ @Test
+ fun testDisconnectedItem_displaysConnectActionAndTriggersCallback() {
+ var clickedSlug: String? = null
+ var clickedReconnect: Boolean? = null
+
+ val item =
+ ConnectorUiItem(
+ slug = "slack",
+ name = "Slack",
+ isConnected = false,
+ isEnabled = true,
+ status = ConnectorStatus.DISCONNECTED,
+ connectionStatus = "disconnected",
+ )
+
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Loaded(available = true),
+ items = listOf(item),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { slug, reconnect ->
+ clickedSlug = slug
+ clickedReconnect = reconnect
+ },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_item_slack").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("session_integrations_action_slack").assertIsDisplayed()
+
+ composeTestRule.onNodeWithTag("session_integrations_action_slack").performClick()
+ assertEquals("slack", clickedSlug)
+ assertEquals(false, clickedReconnect)
+ }
+
+ @Test
+ fun testConnectingState_displaysSpinnerAndDisablesActions() {
+ val item =
+ ConnectorUiItem(
+ slug = "google",
+ name = "Google Drive",
+ isConnected = false,
+ isEnabled = true,
+ status = ConnectorStatus.DISCONNECTED,
+ connectionStatus = "disconnected",
+ )
+
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Loaded(available = true),
+ items = listOf(item),
+ actionState = ConnectorActionState.Connecting(slug = "google", isReconnect = false),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_spinner_google").assertIsDisplayed()
+ }
+
+ @Test
+ fun testAwaitingAuthorizationState_displaysBannerAndRefreshStatus() {
+ var refreshed = false
+ val item =
+ ConnectorUiItem(
+ slug = "jira",
+ name = "Jira",
+ isConnected = false,
+ isEnabled = true,
+ status = ConnectorStatus.INITIATED,
+ connectionStatus = "initiated",
+ )
+
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Loaded(available = true),
+ items = listOf(item),
+ actionState = ConnectorActionState.AwaitingAuthorization(slug = "jira"),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = { refreshed = true },
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_awaiting_banner").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("session_integrations_refresh_status_button").performClick()
+ assertTrue(refreshed)
+ }
+
+ @Test
+ fun testUnavailablePhase_displaysErrorMessage() {
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Unavailable("Connectors unavailable"),
+ items = emptyList(),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
+ }
+
+ @Test
+ fun testErrorPhase_displaysRetryButtonAndTriggersRefresh() {
+ var retryTriggered = false
+
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Error(ConnectorError.NetworkError(), canRetry = true),
+ items = emptyList(),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = { retryTriggered = true },
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("session_integrations_retry_button").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("session_integrations_retry_button").performClick()
+ assertTrue(retryTriggered)
+ }
+
+ @Test
+ fun testEmptyState_displaysNoIntegrationsMessage() {
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Loaded(available = true),
+ items = emptyList(),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_empty").assertIsDisplayed()
+ }
+
+ @Test
+ fun testNoSessionState_displaysNoActiveSession() {
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Initial,
+ items = emptyList(),
+ ),
+ sessionId = null,
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_no_session_state").assertIsDisplayed()
+ }
+
+ @Test
+ fun testNoStorageButRuntimeSession_usesAuthoritativeRuntimeSessionId() {
+ val item =
+ ConnectorUiItem(
+ slug = "linear",
+ name = "Linear",
+ isConnected = false,
+ isEnabled = true,
+ status = ConnectorStatus.DISCONNECTED,
+ connectionStatus = "disconnected",
+ )
+
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Loaded(available = true),
+ sessionId = "sess-runtime-999",
+ items = listOf(item),
+ ),
+ sessionId = null,
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_item_linear").assertIsDisplayed()
+ }
+
+ @Test
+ fun testUnsupportedBackend_displaysErrorMessage() {
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.UnsupportedBackend("Older server does not support connectors"),
+ items = emptyList(),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
+ }
+
+ @Test
+ fun testUnsupportedRuntime_displaysErrorMessage() {
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.UnsupportedRuntime("Runtime incompatible"),
+ items = emptyList(),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
+ }
+
+ @Test
+ fun testNotOwner_displaysErrorMessage() {
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.NotOwner("Session owned by another user"),
+ items = emptyList(),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_error").assertIsDisplayed()
+ }
+
+ @Test
+ fun testExpiredAndRevokedItems_displayStatusAndAction() {
+ val expiredItem =
+ ConnectorUiItem(
+ slug = "github",
+ name = "GitHub",
+ isConnected = false,
+ isEnabled = true,
+ status = ConnectorStatus.EXPIRED,
+ connectionStatus = "expired",
+ )
+ val revokedItem =
+ ConnectorUiItem(
+ slug = "jira",
+ name = "Jira",
+ isConnected = false,
+ isEnabled = true,
+ status = ConnectorStatus.REVOKED,
+ connectionStatus = "revoked",
+ )
+
+ composeTestRule.setContent {
+ SessionIntegrationsContent(
+ uiState =
+ ChatConnectorsUiState(
+ loadPhase = ConnectorsLoadPhase.Loaded(available = true),
+ items = listOf(expiredItem, revokedItem),
+ ),
+ sessionId = "session-12345",
+ onDismiss = {},
+ onRefresh = {},
+ onConnect = { _, _ -> },
+ onClearError = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("session_integrations_item_github").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("session_integrations_action_github").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("session_integrations_item_jira").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("session_integrations_action_jira").assertIsDisplayed()
+ }
+}
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheetTest.kt
new file mode 100644
index 00000000..2acea0b8
--- /dev/null
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheetTest.kt
@@ -0,0 +1,121 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.m57.hermescontrol.ui.chat.SubagentIndicator
+import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class SubagentInspectionSheetTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun testSubagentInspectionSheet_displaysSubagentAndTogglesTranscript() {
+ var toggledSubagentId = ""
+ val indicator =
+ SubagentIndicator(
+ type = "subagent.progress",
+ subagentId = "sub-1089",
+ goal = "Analyze server logs",
+ status = "running",
+ model = "claude-sonnet",
+ durationSeconds = 12.4,
+ )
+
+ composeTestRule.setContent {
+ SubagentInspectionSheet(
+ indicators = listOf(indicator),
+ inspectingSubagentId = null,
+ subagentTranscript = null,
+ onToggleTranscript = { toggledSubagentId = it },
+ onDismiss = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("subagent_inspection_sheet").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Analyze server logs").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("subagent_transcript_toggle_sub-1089").assertIsDisplayed()
+
+ composeTestRule.onNodeWithTag("subagent_transcript_toggle_sub-1089").performClick()
+ assertEquals("sub-1089", toggledSubagentId)
+ }
+
+ @Test
+ fun testSubagentInspectionSheet_displaysTranscriptContentAndTruncation() {
+ val indicator =
+ SubagentIndicator(
+ type = "subagent.progress",
+ subagentId = "sub-1089",
+ goal = "Analyze server logs",
+ status = "running",
+ )
+ val transcript =
+ SubagentTranscriptUiState(
+ subagentId = "sub-1089",
+ text = "Fetched 100 log lines successfully.",
+ isTruncated = true,
+ isLoading = false,
+ )
+
+ composeTestRule.setContent {
+ SubagentInspectionSheet(
+ indicators = listOf(indicator),
+ inspectingSubagentId = "sub-1089",
+ subagentTranscript = transcript,
+ onToggleTranscript = {},
+ onDismiss = {},
+ )
+ }
+
+ composeTestRule.onNodeWithTag("subagent_transcript_container").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("subagent_transcript_text").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Fetched 100 log lines successfully.").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Rolling 16KB tail (older output truncated)").assertIsDisplayed()
+ }
+
+ @Test
+ fun testSubagentInspectionSheet_displaysErrorAndTriggersRetry() {
+ var retried = false
+ val indicator =
+ SubagentIndicator(
+ type = "subagent.progress",
+ subagentId = "sub-1089",
+ goal = "Analyze server logs",
+ status = "running",
+ )
+ val transcript =
+ SubagentTranscriptUiState(
+ subagentId = "sub-1089",
+ text = "",
+ error = "Connection timeout",
+ isLoading = false,
+ )
+
+ composeTestRule.setContent {
+ SubagentInspectionSheet(
+ indicators = listOf(indicator),
+ inspectingSubagentId = "sub-1089",
+ subagentTranscript = transcript,
+ onToggleTranscript = {},
+ onRetryTranscript = { retried = true },
+ onDismiss = {},
+ )
+ }
+
+ composeTestRule.onNodeWithText("Connection timeout").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("subagent_transcript_retry").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("subagent_transcript_retry").performClick()
+ assertEquals(true, retried)
+ }
+}
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt
new file mode 100644
index 00000000..044a7a04
--- /dev/null
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt
@@ -0,0 +1,120 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
+import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
+import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class VaultPromptCardsTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun testVaultUnlockCard_interaction() {
+ var confirmedPassword = ""
+ var dismissed = false
+
+ composeTestRule.setContent {
+ VaultUnlockCard(
+ prompt =
+ VaultUnlockPromptUi(
+ requestId = "req-1",
+ sessionId = "sess-1",
+ backend = "onepassword",
+ displayName = "1Password",
+ ),
+ onConfirm = { confirmedPassword = it },
+ onDismiss = { dismissed = true },
+ )
+ }
+
+ composeTestRule.onNodeWithTag("vault_unlock_card").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Unlock 1Password").assertIsDisplayed()
+
+ composeTestRule.onNodeWithTag("vault_unlock_password_input").performTextInput("mypass123")
+ composeTestRule.onNodeWithTag("vault_unlock_confirm").performClick()
+
+ assertEquals("mypass123", confirmedPassword)
+
+ composeTestRule.onNodeWithTag("vault_unlock_dismiss").performClick()
+ assertEquals(true, dismissed)
+ }
+
+ @Test
+ fun testVaultSaveLoginCard_interaction() {
+ var savedIdentifier = ""
+ var savedPassword = ""
+ var dismissed = false
+
+ composeTestRule.setContent {
+ VaultSaveLoginCard(
+ prompt =
+ VaultSaveLoginPromptUi(
+ requestId = "req-save",
+ sessionId = "sess-1",
+ origin = "https://github.com/login",
+ site = "GitHub",
+ ),
+ onConfirm = { id, pass ->
+ savedIdentifier = id
+ savedPassword = pass
+ },
+ onDismiss = { dismissed = true },
+ )
+ }
+
+ composeTestRule.onNodeWithTag("vault_save_login_card").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("vault_save_login_identifier_input").performTextInput("testuser@example.com")
+ composeTestRule.onNodeWithTag("vault_save_login_password_input").performTextInput("supersecret")
+
+ composeTestRule.onNodeWithTag("vault_save_login_confirm").performClick()
+ assertEquals("testuser@example.com", savedIdentifier)
+ assertEquals("supersecret", savedPassword)
+
+ composeTestRule.onNodeWithTag("vault_save_login_dismiss").performClick()
+ assertEquals(true, dismissed)
+ }
+
+ @Test
+ fun testVaultCodeCard_interaction() {
+ var submittedCode = ""
+ var dismissed = false
+
+ composeTestRule.setContent {
+ VaultCodeCard(
+ prompt =
+ VaultCodePromptUi(
+ requestId = "req-code",
+ sessionId = "sess-1",
+ site = "GitHub",
+ hint = "SMS verification to ***1234",
+ ),
+ onConfirm = { submittedCode = it },
+ onDismiss = { dismissed = true },
+ )
+ }
+
+ composeTestRule.onNodeWithTag("vault_code_card").assertIsDisplayed()
+ composeTestRule.onNodeWithText("SMS verification to ***1234").assertIsDisplayed()
+
+ composeTestRule.onNodeWithTag("vault_code_input").performTextInput("654321")
+ composeTestRule.onNodeWithTag("vault_code_confirm").performClick()
+ assertEquals("654321", submittedCode)
+
+ composeTestRule.onNodeWithTag("vault_code_dismiss").performClick()
+ assertEquals(true, dismissed)
+ }
+}
diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusIndicatorTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusIndicatorTest.kt
new file mode 100644
index 00000000..2a10a610
--- /dev/null
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionLiveStatusIndicatorTest.kt
@@ -0,0 +1,180 @@
+package com.m57.hermescontrol.ui.sessions
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.m57.hermescontrol.data.model.SessionInfo
+import com.m57.hermescontrol.data.model.SessionLiveStatus
+import com.m57.hermescontrol.theme.HermesControlTheme
+import com.m57.hermescontrol.ui.sessions.components.SearchResultCard
+import com.m57.hermescontrol.ui.sessions.components.SessionCard
+import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class SessionLiveStatusIndicatorTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun workingStatus_displaysRunningIndicator() {
+ composeTestRule.setContent {
+ HermesControlTheme {
+ SessionLiveStatusIndicator(
+ liveStatus = SessionLiveStatus.WORKING,
+ sessionId = "sess-working",
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("session_live_status_sess-working").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Running").assertIsDisplayed()
+ }
+
+ @Test
+ fun waitingStatus_displaysNeedsInputIndicator() {
+ composeTestRule.setContent {
+ HermesControlTheme {
+ SessionLiveStatusIndicator(
+ liveStatus = SessionLiveStatus.WAITING,
+ sessionId = "sess-waiting",
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("session_live_status_sess-waiting").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Needs input").assertIsDisplayed()
+ }
+
+ @Test
+ fun sessionCard_withLiveStatus_displaysIndicator() {
+ val session =
+ SessionInfo(
+ id = "sess-card-working",
+ title = "Working session",
+ message_count = 5,
+ )
+
+ composeTestRule.setContent {
+ HermesControlTheme {
+ SessionCard(
+ session = session,
+ displayTitle = "Working session",
+ branchStem = null,
+ isFork = false,
+ forkDepth = 0,
+ query = "",
+ isSelecting = false,
+ isSelected = false,
+ isDeleting = false,
+ isPinned = false,
+ isHidden = false,
+ liveStatus = SessionLiveStatus.WORKING,
+ highlightBackground = Color.Transparent,
+ highlightForeground = Color.Transparent,
+ onCardClick = {},
+ onToggleSelection = {},
+ onSelect = {},
+ onRename = {},
+ onTogglePin = {},
+ onToggleHide = {},
+ onDelete = {},
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("session_card_sess-card-working").assertIsDisplayed()
+ composeTestRule
+ .onNodeWithTag(
+ "session_live_status_sess-card-working",
+ useUnmergedTree = true,
+ ).assertIsDisplayed()
+ composeTestRule.onNodeWithText("Running", useUnmergedTree = true).assertIsDisplayed()
+ }
+
+ @Test
+ fun sessionCard_withoutLiveStatus_hidesIndicator() {
+ val session =
+ SessionInfo(
+ id = "sess-card-idle",
+ title = "Idle session",
+ message_count = 2,
+ )
+
+ composeTestRule.setContent {
+ HermesControlTheme {
+ SessionCard(
+ session = session,
+ displayTitle = "Idle session",
+ branchStem = null,
+ isFork = false,
+ forkDepth = 0,
+ query = "",
+ isSelecting = false,
+ isSelected = false,
+ isDeleting = false,
+ isPinned = false,
+ isHidden = false,
+ liveStatus = null,
+ highlightBackground = Color.Transparent,
+ highlightForeground = Color.Transparent,
+ onCardClick = {},
+ onToggleSelection = {},
+ onSelect = {},
+ onRename = {},
+ onTogglePin = {},
+ onToggleHide = {},
+ onDelete = {},
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("session_card_sess-card-idle").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("session_live_status_sess-card-idle").assertDoesNotExist()
+ }
+
+ @Test
+ fun searchResultCard_withLiveStatus_displaysIndicator() {
+ val session =
+ SessionInfo(
+ id = "sess-search-waiting",
+ preview = "Searching something",
+ source = "web",
+ )
+
+ composeTestRule.setContent {
+ HermesControlTheme {
+ SearchResultCard(
+ session = session,
+ query = "Searching",
+ isSelecting = false,
+ isSelected = false,
+ isDeleting = false,
+ liveStatus = SessionLiveStatus.WAITING,
+ highlightBackground = Color.Transparent,
+ highlightForeground = Color.Transparent,
+ onCardClick = {},
+ onToggleSelection = {},
+ onSelect = {},
+ onRename = {},
+ onDelete = {},
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("session_card_sess-search-waiting").assertIsDisplayed()
+ composeTestRule
+ .onNodeWithTag(
+ "session_live_status_sess-search-waiting",
+ useUnmergedTree = true,
+ ).assertIsDisplayed()
+ composeTestRule.onNodeWithText("Needs input", useUnmergedTree = true).assertIsDisplayed()
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt
index be5727ef..0f7d575d 100644
--- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt
+++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt
@@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
import com.m57.hermescontrol.ui.settings.SettingsChatPage
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
import com.m57.hermescontrol.ui.settings.SettingsLanguagePage
+import com.m57.hermescontrol.ui.settings.SettingsVaultPage
import com.m57.hermescontrol.ui.settings.SettingsViewModel
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
import kotlinx.coroutines.launch
@@ -137,6 +138,11 @@ private fun appEntryProvider(
viewModel = viewModel { SettingsViewModel() },
)
}
+ entry {
+ SettingsVaultPage(
+ onBack = { NavigationController.goBack() },
+ )
+ }
entry {
SettingsAboutPage(
onBack = { NavigationController.goBack() },
diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt
index 1934804c..776be2da 100644
--- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt
+++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt
@@ -87,4 +87,6 @@ import kotlinx.serialization.Serializable
@Serializable data object SettingsBehavior : NavKey
+@Serializable data object SettingsVault : NavKey
+
@Serializable data object SettingsAbout : NavKey
diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt
index 2139218c..1112d746 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt
@@ -12,6 +12,8 @@ data class ServerStoreState(
val port: Int = 9119,
val baseUrl: String? = null,
val autoReconnect: Boolean = true,
+ val restoreLastSession: Boolean = false,
+ val lastOpenedSessionIds: Map = emptyMap(),
val themePreference: ThemePreference = ThemePreference.SYSTEM,
val useDynamicColors: Boolean = false,
val themePreset: ThemePreset = ThemePreset.DEFAULT,
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
index ac2e6d77..2cf351ff 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.local
import android.content.Context
import android.content.SharedPreferences
+import androidx.annotation.VisibleForTesting
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.m57.hermescontrol.data.config.ConnectionProfile
@@ -500,6 +501,11 @@ object AuthManager {
_baseUrlFlow.value = ""
}
+ @VisibleForTesting
+ fun setBaseUrlForTest(baseUrl: String) {
+ _baseUrlFlow.value = baseUrl
+ }
+
fun getToken(): String? {
if (tokenInitialized) return cachedToken
synchronized(this) {
@@ -610,6 +616,47 @@ object AuthManager {
serverStore.update { it.copy(autoReconnect = enabled) }
}
+ // ── Restore last session on startup (issue #1102) ─────────────────────
+
+ private fun currentSessionScopeKey(): String {
+ val connId = getSelectedProfileId()?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
+ val serverProfile = activeProfileId.value?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
+ return "$connId:$serverProfile"
+ }
+
+ fun isRestoreLastSession(): Boolean = serverStore.getLatestState().restoreLastSession
+
+ fun setRestoreLastSession(enabled: Boolean) {
+ serverStore.update { it.copy(restoreLastSession = enabled) }
+ }
+
+ fun getLastOpenedSessionId(): String? = serverStore.getLatestState().lastOpenedSessionIds[currentSessionScopeKey()]
+
+ fun setLastOpenedSessionId(sessionId: String?) {
+ val key = currentSessionScopeKey()
+ serverStore.update { state ->
+ val updated =
+ if (sessionId != null) {
+ state.lastOpenedSessionIds + (key to sessionId)
+ } else {
+ state.lastOpenedSessionIds - key
+ }
+ state.copy(lastOpenedSessionIds = updated)
+ }
+ }
+
+ fun clearLastOpenedSessionId() {
+ setLastOpenedSessionId(null)
+ }
+
+ fun clearLastOpenedSessionIdsForConnection(connectionProfileId: String) {
+ serverStore.update { state ->
+ val prefix = "$connectionProfileId:"
+ val updated = state.lastOpenedSessionIds.filterKeys { !it.startsWith(prefix) }
+ state.copy(lastOpenedSessionIds = updated)
+ }
+ }
+
// ── Theme preference ──────────────────────────────────────────────────
fun getThemePreference(): ThemePreference = serverStore.getLatestState().themePreference
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt
index dcfebf18..548e45b5 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageEntity.kt
@@ -42,4 +42,8 @@ data class ChatMessageEntity(
val isStreaming: Boolean = false,
@ColumnInfo(name = "display_kind")
val displayKind: String? = null,
+ @ColumnInfo(name = "token_count")
+ val tokenCount: Int? = null,
+ @ColumnInfo(name = "tps")
+ val tps: Double? = null,
)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt
index a7f40f68..2600557b 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/ChatMessageMapper.kt
@@ -35,6 +35,8 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
else -> null
},
displayKind = displayKind,
+ tokenCount = tokenCount,
+ tps = tps,
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -50,4 +52,6 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
toolStatus = toolStatus?.name,
isStreaming = isStreaming,
displayKind = displayKind,
+ tokenCount = tokenCount,
+ tps = tps,
)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt
index 48b4b922..ab12e8b5 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/local/HermesDatabase.kt
@@ -12,7 +12,7 @@ import java.io.File
@Database(
entities = [ChatMessageEntity::class],
- version = 6,
+ version = 7,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -67,6 +67,18 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
+ val MIGRATION_6_7: Migration =
+ object : Migration(6, 7) {
+ override suspend fun migrate(connection: SQLiteConnection) {
+ connection.execSQL(
+ "ALTER TABLE `chat_messages` ADD COLUMN `token_count` INTEGER",
+ )
+ connection.execSQL(
+ "ALTER TABLE `chat_messages` ADD COLUMN `tps` REAL",
+ )
+ }
+ }
+
fun get(context: Context): HermesDatabase =
instance ?: synchronized(this) {
// SQLCipher can't open plaintext SQLite databases — if an old
@@ -97,6 +109,7 @@ abstract class HermesDatabase : RoomDatabase() {
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
+ MIGRATION_6_7,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt
new file mode 100644
index 00000000..13d16db0
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectorModels.kt
@@ -0,0 +1,233 @@
+package com.m57.hermescontrol.data.model
+
+import com.m57.hermescontrol.util.ConnectorUrlValidator
+
+/**
+ * High-level connection status for a connector item.
+ * Deliberate precedence: if [connected] is true, status resolves to [CONNECTED]
+ * regardless of expired/revoked/stale statuses reported by backend.
+ */
+enum class ConnectorStatus {
+ CONNECTED,
+ INITIATED,
+ EXPIRED,
+ REVOKED,
+ FAILED,
+ DISCONNECTED,
+ UNKNOWN,
+ ;
+
+ val isConnected: Boolean get() = this == CONNECTED
+
+ companion object {
+ fun from(
+ connected: Boolean,
+ rawStatus: String?,
+ ): ConnectorStatus = from(connected = connected as Boolean?, rawStatus = rawStatus)
+
+ fun from(
+ connected: Boolean?,
+ rawStatus: String?,
+ ): ConnectorStatus {
+ if (connected == true) return CONNECTED
+ if (connected == false) {
+ return when (rawStatus?.lowercase()?.trim()) {
+ "expired" -> EXPIRED
+ "revoked" -> REVOKED
+ "initiated" -> INITIATED
+ "failed", "error" -> FAILED
+ "disconnected" -> DISCONNECTED
+ null, "", "active", "connected", "authorized" -> DISCONNECTED
+ else -> UNKNOWN
+ }
+ }
+ // Missing boolean: "authorized", "active", or "connected" yields effective CONNECTED
+ return when (rawStatus?.lowercase()?.trim()) {
+ "authorized", "active", "connected" -> CONNECTED
+ "expired" -> EXPIRED
+ "revoked" -> REVOKED
+ "initiated" -> INITIATED
+ "failed", "error" -> FAILED
+ "disconnected" -> DISCONNECTED
+ null, "" -> DISCONNECTED
+ else -> UNKNOWN
+ }
+ }
+ }
+}
+
+/**
+ * Status resulting from a connect/reconnect initiation.
+ */
+enum class ConnectorConnectStatus {
+ ACTIVE,
+ INITIATED,
+ FAILED,
+ UNKNOWN,
+ ;
+
+ val isActive: Boolean get() = this == ACTIVE
+
+ companion object {
+ fun from(rawStatus: String?): ConnectorConnectStatus =
+ when (rawStatus?.lowercase()?.trim()) {
+ "active", "connected" -> ACTIVE
+ "initiated" -> INITIATED
+ "failed", "error" -> FAILED
+ null, "" -> UNKNOWN
+ else -> UNKNOWN
+ }
+ }
+}
+
+/**
+ * Individual connector item returned by `connectors.list`.
+ */
+data class ConnectorItem(
+ val connector: String,
+ val name: String = connector,
+ val connected: Boolean = false,
+ val enabled: Boolean = true,
+ val connectionStatus: String? = null,
+ val status: ConnectorStatus = ConnectorStatus.from(connected, connectionStatus),
+ val connectUrl: String? = null,
+) {
+ val isConnected: Boolean get() = connected || status.isConnected
+
+ /** Safe validated HTTPS URL, or null if invalid or absent. */
+ val safeConnectUrl: String?
+ get() = if (ConnectorUrlValidator.isValidHttpsUrl(connectUrl)) connectUrl else null
+
+ override fun toString(): String {
+ val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null"
+ return "ConnectorItem(" +
+ "connector=$connector, " +
+ "name=$name, " +
+ "connected=$connected, " +
+ "enabled=$enabled, " +
+ "status=$status, " +
+ "connectUrl=$redactedUrl" +
+ ")"
+ }
+}
+
+/**
+ * Result of a connect/reconnect action for an individual connector.
+ * Instructions from arbitrary backends are intentionally not exposed for UI display.
+ */
+data class ConnectorConnectItem(
+ val connector: String,
+ val status: ConnectorConnectStatus,
+ val connectUrl: String? = null,
+) {
+ val isConnectUrlValid: Boolean
+ get() = ConnectorUrlValidator.isValidHttpsUrl(connectUrl)
+
+ val safeConnectUrl: String?
+ get() = if (isConnectUrlValid) connectUrl else null
+
+ override fun toString(): String {
+ val redactedUrl = if (connectUrl != null) "[REDACTED]" else "null"
+ return "ConnectorConnectItem(" +
+ "connector=$connector, " +
+ "status=$status, " +
+ "connectUrl=$redactedUrl, " +
+ "isConnectUrlValid=$isConnectUrlValid" +
+ ")"
+ }
+}
+
+/**
+ * Summary counts returned by `connectors.connect`.
+ */
+data class ConnectorConnectSummary(
+ val total: Int = 0,
+ val active: Int = 0,
+ val initiated: Int = 0,
+ val failed: Int = 0,
+)
+
+/**
+ * Typed safe connector errors mapped from RPC codes and reasons.
+ * Raw exceptions or auth tokens are never exposed.
+ */
+sealed interface ConnectorError {
+ val message: String
+
+ data class InvalidParams(
+ override val message: String = "Invalid connector parameters.",
+ ) : ConnectorError
+
+ data class NotOwner(
+ override val message: String = "Session is not owned by this transport.",
+ ) : ConnectorError
+
+ data class Unavailable(
+ override val message: String = "Connectors are not available in this session.",
+ ) : ConnectorError
+
+ data class UnsupportedRuntime(
+ override val message: String = "Connectors must be managed on the session's compute host.",
+ ) : ConnectorError
+
+ data class RequestFailed(
+ override val message: String = "Connector request failed. Please try again.",
+ ) : ConnectorError
+
+ data class InvalidResponse(
+ override val message: String = "Connector service returned an invalid response.",
+ ) : ConnectorError
+
+ data class UnsupportedBackend(
+ override val message: String = "Connector service is not supported by this backend.",
+ ) : ConnectorError
+
+ data class MalformedEnvelope(
+ override val message: String = "Malformed response envelope received from gateway.",
+ ) : ConnectorError
+
+ data class NetworkError(
+ override val message: String = "Failed to communicate with gateway.",
+ ) : ConnectorError
+
+ data class Other(
+ val code: Int = 0,
+ override val message: String = "Connector operation failed.",
+ ) : ConnectorError
+}
+
+/**
+ * Result envelope for `connectors.list`.
+ */
+sealed interface ConnectorListResult {
+ data class Success(
+ val available: Boolean,
+ val connectors: List,
+ ) : ConnectorListResult
+
+ data class Error(
+ val error: ConnectorError,
+ ) : ConnectorListResult
+
+ fun getOrNull(): List? = (this as? Success)?.connectors
+
+ fun errorOrNull(): ConnectorError? = (this as? Error)?.error
+}
+
+/**
+ * Result envelope for `connectors.connect`.
+ */
+sealed interface ConnectorConnectResult {
+ data class Success(
+ val results: List,
+ val summary: ConnectorConnectSummary,
+ ) : ConnectorConnectResult
+
+ data class Error(
+ val error: ConnectorError,
+ ) : ConnectorConnectResult
+
+ fun getOrNull(): List? = (this as? Success)?.results
+
+ fun errorOrNull(): ConnectorError? = (this as? Error)?.error
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt
index 1dba8c43..1b29cc39 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt
@@ -1,5 +1,11 @@
package com.m57.hermescontrol.data.model
+import com.m57.hermescontrol.data.ws.toAny
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.longOrNull
+
/**
* Live context-window occupancy from the `session.context_breakdown` WS RPC —
* the same RPC the Hermes desktop app's status-bar meter uses.
@@ -30,20 +36,47 @@ data class ContextBreakdownResponse(
* Parse the decoded JSON-RPC result of `session.context_breakdown`.
*
* The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`),
- * so every count is read through [Number]. Returns null when the payload is
+ * so every count is read through [Number]. Also supports raw [JsonObject] from
+ * [HermesWsClient.request]. Returns null when the payload is
* not a map (error/malformed response) — callers treat that as unknown and
* keep the last known meter values.
*/
fun parseContextBreakdown(result: Any?): ContextBreakdownResponse? {
- val map = result as? Map<*, *> ?: return null
+ val rawMap =
+ when (result) {
+ is JsonElement -> result.toAny() as? Map<*, *>
+ is Map<*, *> -> result
+ else -> null
+ } ?: return null
- fun num(key: String): Long? = (map[key] as? Number)?.toLong()
+ fun num(key: String): Long? =
+ when (val value = rawMap[key]) {
+ is Number -> value.toLong()
+ is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
+ is String -> value.toLongOrNull()
+ else -> null
+ }
+
+ fun intNum(key: String): Int? =
+ when (val value = rawMap[key]) {
+ is Number -> value.toInt()
+ is JsonPrimitive -> value.intOrNull
+ is String -> value.toIntOrNull()
+ else -> null
+ }
+
+ fun str(key: String): String? =
+ when (val value = rawMap[key]) {
+ is String -> value
+ is JsonPrimitive -> value.content
+ else -> value?.toString()
+ }
return ContextBreakdownResponse(
contextUsed = num("context_used"),
contextMax = num("context_max"),
- contextPercent = (map["context_percent"] as? Number)?.toInt(),
+ contextPercent = intNum("context_percent"),
estimatedTotal = num("estimated_total"),
- model = map["model"] as? String,
+ model = str("model"),
)
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt
index 1ca609bd..5ba18ac5 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt
@@ -80,9 +80,63 @@ data class PluginsHubResponse(
@Serializable
data class AgentPluginInstallBody(
- val identifier: String,
+ val identifier: String = "",
val force: Boolean = false,
val enable: Boolean = true,
+ @SerialName("catalog_name") val catalogName: String? = null,
+ val ref: String? = null,
+)
+
+@Serializable
+data class PluginCatalogCapabilities(
+ @SerialName("provides_tools") val providesTools: List = emptyList(),
+ @SerialName("provides_hooks") val providesHooks: List = emptyList(),
+ @SerialName("provides_middleware") val providesMiddleware: List = emptyList(),
+ @SerialName("requires_env") val requiresEnv: List = emptyList(),
+)
+
+@Serializable
+data class PluginCatalogRemovedEntry(
+ val name: String? = null,
+ val repo: String? = null,
+ val reason: String? = null,
+ val date: String? = null,
+)
+
+@Serializable
+data class PluginCatalogEntry(
+ val name: String,
+ @SerialName("catalog_name") val catalogName: String? = null,
+ val repo: String? = null,
+ val sha: String? = null,
+ @SerialName("sha_short") val shaShort: String? = null,
+ val description: String? = null,
+ val maintainer: String? = null,
+ val tier: String? = null,
+ val category: String? = null,
+ @SerialName("requires_hermes") val requiresHermes: String? = null,
+ val subdir: String? = null,
+ @SerialName("docs_url") val docsUrl: String? = null,
+ val platforms: List = emptyList(),
+ val capabilities: PluginCatalogCapabilities? = null,
+ @SerialName("capability_summary") val capabilitySummary: String? = null,
+ val installed: Boolean = false,
+ @SerialName("installed_sha") val installedSha: String? = null,
+ @SerialName("update_available") val updateAvailable: Boolean = false,
+ @SerialName("runtime_status") val runtimeStatus: String? = null,
+) {
+ val displayName: String
+ get() = catalogName?.takeIf { it.isNotBlank() } ?: name
+
+ val isOfficial: Boolean
+ get() = tier?.equals("official", ignoreCase = true) == true
+}
+
+@Serializable
+data class PluginCatalogResponse(
+ val entries: List = emptyList(),
+ val removed: List = emptyList(),
+ @SerialName("generated_at") val generatedAt: String? = null,
)
@Serializable
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionBranchTree.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionBranchTree.kt
new file mode 100644
index 00000000..056e9e4c
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionBranchTree.kt
@@ -0,0 +1,116 @@
+package com.m57.hermescontrol.data.model
+
+private fun recency(s: SessionInfo): Double = s.last_active ?: s.started_at ?: 0.0
+
+/**
+ * Port of desktop session-branch-tree.ts#flattenSessionsWithBranches.
+ *
+ * - Aliases _lineage_root_id so forks find compression-projected parents.
+ * - Sorts sibling branches by own recency; sorts roots by groupRecency
+ * (max over subtree) so activity on any branch lifts the whole family.
+ * - preserveOrder keeps caller-chosen root order (pinned section).
+ * - Cycle-guarded; trailing sweep never drops an input row.
+ */
+fun flattenSessionsWithBranches(
+ sessions: List,
+ preserveOrder: Boolean = false,
+): List {
+ if (sessions.size < 2) {
+ return sessions.map { it.toTreeItem(depth = 0, siblingIndex = 0, siblingCount = 1) }
+ }
+
+ val byVisibleId = HashMap(sessions.size * 2)
+ for (s in sessions) {
+ byVisibleId[s.id] = s
+ s.lineageRootId
+ ?.trim()
+ ?.takeIf(String::isNotEmpty)
+ ?.let { byVisibleId[it] = s }
+ }
+
+ val childrenByParent = HashMap>()
+ val nestedIds = HashSet()
+ for (s in sessions) {
+ val parentId = s.parent_session_id?.trim()?.takeIf(String::isNotEmpty) ?: continue
+ val parent = byVisibleId[parentId] ?: continue
+ if (parent.id == s.id) continue
+ nestedIds += s.id
+ childrenByParent.getOrPut(parent.id) { mutableListOf() } += s
+ }
+ childrenByParent.values.forEach { it.sortByDescending(::recency) }
+
+ // Group sorts by its freshest member; memo pre-seed doubles as cycle guard.
+ val groupRecencyMemo = HashMap()
+
+ fun groupRecency(s: SessionInfo): Double {
+ groupRecencyMemo[s.id]?.let { return it }
+ groupRecencyMemo[s.id] = recency(s)
+ val max =
+ childrenByParent[s.id]
+ .orEmpty()
+ .fold(recency(s)) { acc, child -> maxOf(acc, groupRecency(child)) }
+ groupRecencyMemo[s.id] = max
+ return max
+ }
+
+ val out = mutableListOf()
+ val seen = HashSet()
+
+ fun emit(
+ s: SessionInfo,
+ depth: Int,
+ siblingIndex: Int,
+ siblingCount: Int,
+ ) {
+ if (!seen.add(s.id)) return
+ out += s.toTreeItem(depth, siblingIndex, siblingCount)
+ val kids = childrenByParent[s.id].orEmpty()
+ kids.forEachIndexed { i, child -> emit(child, depth + 1, i, kids.size) }
+ }
+
+ val roots = sessions.withIndex().filter { it.value.id !in nestedIds }
+ val ordered =
+ if (preserveOrder) {
+ roots
+ } else {
+ roots.sortedWith(
+ compareByDescending> { groupRecency(it.value) }
+ .thenBy { it.index },
+ )
+ }
+ ordered.forEachIndexed { i, r -> emit(r.value, 0, i, ordered.size) }
+ sessions.filter { it.id !in seen }.forEach { emit(it, 0, 0, 1) } // trailing sweep
+ return out
+}
+
+private fun SessionInfo.toTreeItem(
+ depth: Int,
+ siblingIndex: Int,
+ siblingCount: Int,
+): SessionTreeItem {
+ // G4 fix: fork identity from the field itself, never from load-set membership.
+ val isFork = !parent_session_id.isNullOrBlank()
+ val title =
+ title?.takeIf(String::isNotBlank)
+ ?: display_name?.takeIf(String::isNotBlank)
+ ?: if (isFork && depth > 0) {
+ "branch ${siblingIndex + 1}"
+ } else {
+ preview?.takeIf(String::isNotBlank)?.take(80) ?: "Untitled"
+ }
+ return SessionTreeItem(
+ session = this,
+ depth = depth,
+ branchStem =
+ if (depth == 0) {
+ null
+ } else if (siblingIndex == siblingCount - 1) {
+ "└─"
+ } else {
+ "├─"
+ },
+ isFork = isFork,
+ forkDepth = if (depth > 0) depth else 0,
+ displayTitle = title,
+ )
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt
index e01e6e0b..ab2e0eb0 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@@ -78,6 +79,14 @@ data class SessionInfo(
val display_name: String? = null,
val model: String? = null,
val terminal_backend: String? = null,
+ // Epoch seconds of the latest message (backend-computed, present on all
+ // list rows). Recency source of truth; started_at is the fallback.
+ val last_active: Double? = null,
+ // Original root id when this row is a compression-projected tip. Branch
+ // children reference the ROOT id in parent_session_id, so tree building
+ // must alias this id to this row (desktop byVisibleId parity).
+ @SerialName("_lineage_root_id")
+ val lineageRootId: String? = null,
// Durable "keep" flag: pinned sessions are exempt from the auto-archive
// sweep and are surfaced first in the history list (sorted client-side).
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt
new file mode 100644
index 00000000..3f201350
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionLiveStatus.kt
@@ -0,0 +1,41 @@
+package com.m57.hermescontrol.data.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * High-level live status for a session in History.
+ */
+enum class SessionLiveStatus {
+ WORKING,
+ WAITING,
+}
+
+/**
+ * RPC response from `session.active_list`.
+ */
+@Serializable
+data class ActiveSessionsResponse(
+ val sessions: List = emptyList(),
+)
+
+/**
+ * Individual item in `session.active_list`.
+ */
+@Serializable
+data class ActiveSessionItem(
+ val id: String? = null,
+ @SerialName("session_key")
+ val sessionKey: String? = null,
+ val status: String? = null,
+ @SerialName("last_active")
+ val lastActive: Double? = null,
+)
+
+/**
+ * Parsed authoritative snapshot of live sessions.
+ */
+data class LiveSessionSnapshot(
+ val statusByStoredId: Map = emptyMap(),
+ val storedIdByRuntimeId: Map = emptyMap(),
+)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt
index 7f2ca663..c05002a3 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt
@@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
+import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
@Serializable
@@ -50,10 +51,18 @@ data class SessionMessage(
val display_kind: String? = null,
/** Display-only metadata for the marker (e.g. delegation result counts). */
val display_metadata: JsonElement? = null,
+ /** Token count recorded by the backend. */
+ val token_count: JsonElement? = null,
) {
val timestampText: String?
get() = (timestamp as? JsonPrimitive)?.content
+ val tokenCount: Int?
+ get() {
+ val prim = token_count as? JsonPrimitive ?: return null
+ return prim.intOrNull ?: prim.content.toIntOrNull()
+ }
+
val timestampEpochMs: Long?
get() {
val prim = timestamp as? JsonPrimitive ?: return null
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SubagentModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SubagentModels.kt
new file mode 100644
index 00000000..c9f46819
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/SubagentModels.kt
@@ -0,0 +1,52 @@
+package com.m57.hermescontrol.data.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+
+/**
+ * Item returned in the [SubagentListResponse] from `subagent.list` (issue #1089).
+ */
+@Serializable
+data class SubagentListItem(
+ @SerialName("subagent_id") val subagentId: String = "",
+ val goal: String? = null,
+ val status: String? = null,
+ val model: String? = null,
+ @SerialName("elapsed_seconds") val elapsedSeconds: Double? = null,
+ @SerialName("started_at") val startedAt: Double? = null,
+ @SerialName("parent_id") val parentId: String? = null,
+ val depth: Int? = null,
+ @SerialName("delegation_id") val delegationId: String? = null,
+ @SerialName("tool_count") val toolCount: Int? = null,
+ @SerialName("last_tool") val lastTool: String? = null,
+ @SerialName("accepting_steer") val acceptingSteer: Boolean? = null,
+)
+
+/**
+ * Response returned from `subagent.list` (issue #1089).
+ */
+@Serializable
+data class SubagentListResponse(
+ val subagents: List = emptyList(),
+ val delegations: List = emptyList(),
+)
+
+/**
+ * Response returned from `subagent.tail` (issue #1089).
+ *
+ * Backend returns either `text` (desktop/TUI gateway) or `tail` (contract).
+ * [content] returns the authoritative transcript text from whichever field is present.
+ */
+@Serializable
+data class SubagentTailResponse(
+ @SerialName("subagent_id") val subagentId: String = "",
+ val tail: String? = null,
+ val text: String? = null,
+ val available: Boolean? = null,
+ val truncated: Boolean? = null,
+ @SerialName("bytes_read") val bytesRead: Long? = null,
+) {
+ /** Returns authoritative transcript text across backend schema variants. */
+ fun content(): String = text ?: tail ?: ""
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt
index 4250dc1f..c20e1058 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt
@@ -1,5 +1,12 @@
package com.m57.hermescontrol.data.model
+import com.m57.hermescontrol.data.ws.toAny
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.doubleOrNull
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.longOrNull
+
/**
* Minimal view of the `session.usage` WS RPC result or `session.usage` push event —
* only the fields the context widget consumes today. The backend sends many more keys
@@ -20,6 +27,8 @@ data class UsageSnapshotResponse(
val contextMax: Long? = null,
/** Cumulative session total tokens. */
val totalTokens: Long? = null,
+ /** Rolling tokens-per-second generation speed. */
+ val avgTps: Double? = null,
)
/**
@@ -27,16 +36,47 @@ data class UsageSnapshotResponse(
*
* Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }`
* (from push event payload). The WS event parser decodes JSON numbers as [Double]
- * (`JsonRpcModels.toAny`), so every count is read through [Number].
+ * (`JsonRpcModels.toAny`), so every count is read through [Number]. Also supports raw [JsonObject].
* Returns null when the payload is not a map (error/malformed response) — callers keep the last known value.
*/
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
- val map = result as? Map<*, *> ?: return null
- val usageMap = (map["usage"] as? Map<*, *>) ?: map
+ val rawMap =
+ when (result) {
+ is JsonElement -> result.toAny() as? Map<*, *>
+ is Map<*, *> -> result
+ else -> null
+ } ?: return null
+ val usageMap = (rawMap["usage"] as? Map<*, *>) ?: rawMap
+
+ fun num(key: String): Long? =
+ when (val value = usageMap[key]) {
+ is Number -> value.toLong()
+ is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
+ is String -> value.toLongOrNull()
+ else -> null
+ }
+
+ fun intNum(key: String): Int? =
+ when (val value = usageMap[key]) {
+ is Number -> value.toInt()
+ is JsonPrimitive -> value.intOrNull
+ is String -> value.toIntOrNull()
+ else -> null
+ }
+
+ fun doubleNum(key: String): Double? =
+ when (val value = usageMap[key]) {
+ is Number -> value.toDouble()
+ is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble()
+ is String -> value.toDoubleOrNull()
+ else -> null
+ }
+
return UsageSnapshotResponse(
- compressions = (usageMap["compressions"] as? Number)?.toInt(),
- contextUsed = (usageMap["context_used"] as? Number)?.toLong(),
- contextMax = (usageMap["context_max"] as? Number)?.toLong(),
- totalTokens = (usageMap["total"] as? Number)?.toLong(),
+ compressions = intNum("compressions"),
+ contextUsed = num("context_used"),
+ contextMax = num("context_max"),
+ totalTokens = num("total"),
+ avgTps = doubleNum("avg_tps"),
)
}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt
new file mode 100644
index 00000000..f03fc26b
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt
@@ -0,0 +1,61 @@
+package com.m57.hermescontrol.data.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * Models for the Credential Vault RPC surface (issue #1090).
+ *
+ * Backend: `tui_gateway/methods_vault.py`.
+ * Sources represent local encrypted vault or external managers (1Password, Bitwarden).
+ */
+
+@Serializable
+data class VaultSource(
+ val name: String,
+ @SerialName("display_name") val displayName: String = name,
+ val enabled: Boolean = false,
+ @SerialName("needs_unlock") val needsUnlock: Boolean = false,
+ val unlocked: Boolean = false,
+ val installed: Boolean = false,
+)
+
+@Serializable
+data class VaultSourcesResponse(
+ val sources: List = emptyList(),
+)
+
+@Serializable
+data class VaultItem(
+ val id: String,
+ val backend: String = "local",
+ val kind: String = "login",
+ val label: String = "",
+ val origin: String? = null,
+ @SerialName("created_at") val createdAt: String? = null,
+ @SerialName("identifier_type") val identifierType: String? = null,
+ val identifier: String? = null,
+ @SerialName("has_otp") val hasOtp: Boolean = false,
+)
+
+@Serializable
+data class VaultListResponse(
+ val items: List = emptyList(),
+)
+
+@Serializable
+data class VaultSourceSetResponse(
+ val name: String? = null,
+ val enabled: Boolean? = null,
+)
+
+@Serializable
+data class VaultUnlockResponse(
+ val name: String? = null,
+ val unlocked: Boolean? = null,
+)
+
+@Serializable
+data class VaultLockResponse(
+ val locked: Boolean? = null,
+)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ChatReloginAuthenticator.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ChatReloginAuthenticator.kt
new file mode 100644
index 00000000..643fe1c7
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ChatReloginAuthenticator.kt
@@ -0,0 +1,122 @@
+package com.m57.hermescontrol.data.remote
+
+import com.m57.hermescontrol.data.local.AuthManager
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+/**
+ * Handles basic credential login and WS ticket minting for in-chat reconnect/re-authentication.
+ *
+ * Extracted from [com.m57.hermescontrol.ui.chat.ChatViewModel] to isolate raw OkHttp network calls.
+ */
+class ChatReloginAuthenticator(
+ private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
+ private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
+) {
+ suspend fun relogin(
+ username: String,
+ password: String,
+ onSuccess: () -> Unit,
+ onFailure: (String) -> Unit,
+ ) {
+ withContext(ioDispatcher) {
+ val endpoint = AuthManager.endpointForBuild()
+ val jsonMediaType = "application/json; charset=utf-8".toMediaType()
+ val jsonBody =
+ JSONObject()
+ .put("provider", "basic")
+ .put("username", username)
+ .put("password", password)
+ .put("next", "")
+ .toString()
+
+ try {
+ val loginClient =
+ OkHttpProvider.probe
+ .newBuilder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .build()
+
+ val loginReq =
+ Request
+ .Builder()
+ .url(endpoint.resolve("auth/password-login").toString())
+ .header("Content-Type", "application/json")
+ .post(jsonBody.toRequestBody(jsonMediaType))
+ .build()
+
+ loginClient.newCall(loginReq).execute().use { loginResp ->
+ if (!loginResp.isSuccessful) {
+ val msg =
+ when (loginResp.code) {
+ 401 -> "Invalid username or password (401)"
+ 403 -> "Forbidden (403)"
+ else -> "HTTP error code: ${loginResp.code}"
+ }
+ withContext(mainDispatcher) {
+ onFailure(msg)
+ }
+ return@withContext
+ }
+ }
+
+ val ticketClient =
+ OkHttpProvider.base
+ .newBuilder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .build()
+
+ val ticketReq =
+ Request
+ .Builder()
+ .url(endpoint.resolve("api/auth/ws-ticket").toString())
+ .post("{}".toRequestBody(jsonMediaType))
+ .build()
+
+ ticketClient.newCall(ticketReq).execute().use { ticketResp ->
+ if (!ticketResp.isSuccessful) {
+ withContext(mainDispatcher) {
+ onFailure("Failed to mint WS ticket: HTTP ${ticketResp.code}")
+ }
+ return@withContext
+ }
+
+ val body = ticketResp.body.string()
+ val ticket = JSONObject(body).optString("ticket").takeIf { it.isNotBlank() }
+
+ if (ticket.isNullOrBlank()) {
+ withContext(mainDispatcher) {
+ onFailure("Invalid ticket returned from server")
+ }
+ return@withContext
+ }
+
+ AuthManager.setWsAuthParam("ticket")
+ AuthManager.setToken(ticket)
+
+ withContext(mainDispatcher) {
+ onSuccess()
+ }
+ }
+ } catch (e: IOException) {
+ withContext(mainDispatcher) {
+ onFailure("Connection failed: ${e.message}")
+ }
+ } catch (e: JSONException) {
+ withContext(mainDispatcher) {
+ onFailure("Connection failed: ${e.message}")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt
index a96eece2..88abd60a 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt
@@ -80,6 +80,7 @@ import com.m57.hermescontrol.data.model.OAuthSubmitResponse
import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest
+import com.m57.hermescontrol.data.model.PluginCatalogResponse
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.PortalResponse
@@ -95,6 +96,7 @@ import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.ScanStatus
import com.m57.hermescontrol.data.model.SessionDetailResponse
+import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionMessagesResponse
import com.m57.hermescontrol.data.model.SessionRenameRequest
@@ -267,6 +269,12 @@ interface HermesApiService {
@Query("profile") profile: String? = null,
): Response
+ @GET("api/sessions/{id}")
+ suspend fun getSessionInfo(
+ @Path("id", encoded = true) sessionId: String,
+ @Query("profile") profile: String? = null,
+ ): Response
+
@GET("api/model/info")
suspend fun getModelInfo(): Response
@@ -618,6 +626,9 @@ interface HermesApiService {
@GET("api/dashboard/plugins/hub")
suspend fun getPlugins(): Response
+ @GET("api/dashboard/plugins/catalog")
+ suspend fun getPluginCatalog(): Response
+
@POST("api/dashboard/agent-plugins/install")
suspend fun installPlugin(
@Body body: AgentPluginInstallBody,
diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorParser.kt
new file mode 100644
index 00000000..2e150d59
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorParser.kt
@@ -0,0 +1,389 @@
+package com.m57.hermescontrol.data.ws
+
+import com.m57.hermescontrol.data.model.ConnectorConnectItem
+import com.m57.hermescontrol.data.model.ConnectorConnectResult
+import com.m57.hermescontrol.data.model.ConnectorConnectStatus
+import com.m57.hermescontrol.data.model.ConnectorConnectSummary
+import com.m57.hermescontrol.data.model.ConnectorError
+import com.m57.hermescontrol.data.model.ConnectorItem
+import com.m57.hermescontrol.data.model.ConnectorListResult
+import com.m57.hermescontrol.data.model.ConnectorStatus
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.intOrNull
+
+/**
+ * Dedicated parser for connector payloads with strict envelope validation,
+ * resilient type coercion, legacy alias normalization, and safe error mapping.
+ */
+object ConnectorParser {
+ private val json =
+ Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ }
+
+ /**
+ * Parse raw result from `connectors.list` RPC.
+ * Fails with [ConnectorError.MalformedEnvelope] if the required envelope structure is missing.
+ */
+ fun parseListResult(raw: Any?): ConnectorListResult {
+ val root =
+ toJsonElement(raw) as? JsonObject
+ ?: return ConnectorListResult.Error(
+ ConnectorError.MalformedEnvelope("Response envelope must be a JSON object."),
+ )
+
+ // Required envelope check: "connectors" must exist and be an array.
+ val connectorsArray =
+ root["connectors"] as? JsonArray
+ ?: return ConnectorListResult.Error(
+ ConnectorError.MalformedEnvelope("Envelope missing required 'connectors' array."),
+ )
+
+ // Optional 'available' flag (defaults to true if omitted, unless explicitly false)
+ val available = parseBoolean(root["available"], default = true)
+
+ val items = mutableListOf()
+ val seenSlugs = mutableSetOf()
+ for (element in connectorsArray) {
+ val itemObj = element as? JsonObject ?: continue
+ val item = parseConnectorItem(itemObj) ?: continue
+ if (seenSlugs.add(item.connector)) {
+ items.add(item)
+ }
+ }
+
+ // Entirely invalid nonempty connectors array must fail rather than looking silently valid empty.
+ if (connectorsArray.isNotEmpty() && items.isEmpty()) {
+ return ConnectorListResult.Error(
+ ConnectorError.MalformedEnvelope("Connectors list contains only invalid items."),
+ )
+ }
+
+ return ConnectorListResult.Success(
+ available = available,
+ connectors = items,
+ )
+ }
+
+ /**
+ * Parse raw result from `connectors.connect` RPC.
+ * Fails with [ConnectorError.MalformedEnvelope] or [ConnectorError.InvalidResponse] if invalid.
+ */
+ fun parseConnectResult(raw: Any?): ConnectorConnectResult {
+ val rawElement = toJsonElement(raw)
+
+ // Tolerate purposeful legacy bare array form: [ { "connector": "linear", ... } ]
+ if (rawElement is JsonArray) {
+ val results = mutableListOf()
+ val seenSlugs = mutableSetOf()
+ for (element in rawElement) {
+ val itemObj = element as? JsonObject ?: continue
+ val item = parseConnectItem(itemObj) ?: continue
+ if (seenSlugs.add(item.connector)) {
+ results.add(item)
+ }
+ }
+ if (results.isNotEmpty()) {
+ val summary =
+ ConnectorConnectSummary(
+ total = results.size,
+ active = results.count { it.status == ConnectorConnectStatus.ACTIVE },
+ initiated = results.count { it.status == ConnectorConnectStatus.INITIATED },
+ failed = results.count { it.status == ConnectorConnectStatus.FAILED },
+ )
+ return ConnectorConnectResult.Success(
+ results = results,
+ summary = summary,
+ )
+ }
+ return ConnectorConnectResult.Error(
+ ConnectorError.InvalidResponse("Connect response contains no valid results."),
+ )
+ }
+
+ val root =
+ rawElement as? JsonObject
+ ?: return ConnectorConnectResult.Error(
+ ConnectorError.MalformedEnvelope("Connect response must be a JSON object."),
+ )
+
+ // Required envelope check: "results" must exist and be an array.
+ val resultsArray =
+ root["results"] as? JsonArray
+ ?: return ConnectorConnectResult.Error(
+ ConnectorError.MalformedEnvelope("Connect envelope missing required 'results' array."),
+ )
+
+ val results = mutableListOf()
+ val seenSlugs = mutableSetOf()
+ for (element in resultsArray) {
+ val itemObj = element as? JsonObject ?: continue
+ val item = parseConnectItem(itemObj) ?: continue
+ if (seenSlugs.add(item.connector)) {
+ results.add(item)
+ }
+ }
+
+ // Connect must return at least nonempty valid results
+ if (results.isEmpty()) {
+ return ConnectorConnectResult.Error(
+ ConnectorError.InvalidResponse("Connect response contains no valid results."),
+ )
+ }
+
+ // Current summary object is required in standard envelope
+ val summaryObj =
+ root["summary"] as? JsonObject
+ ?: return ConnectorConnectResult.Error(
+ ConnectorError.MalformedEnvelope("Connect envelope missing required 'summary' object."),
+ )
+ val summary = parseConnectSummary(summaryObj)
+
+ return ConnectorConnectResult.Success(
+ results = results,
+ summary = summary,
+ )
+ }
+
+ private fun parseConnectorItem(obj: JsonObject): ConnectorItem? {
+ // Strict string identifier with backend slug validation; legacy aliases supported
+ val slug =
+ parseIdentifier(obj["connector"])
+ ?: parseIdentifier(obj["slug"])
+ ?: parseIdentifier(obj["id"])
+ ?: return null // Skip items without valid connector identifier
+
+ // Display name: primary "name", legacy aliases "displayName", "title", fallback to slug
+ val name =
+ parseString(obj["name"])
+ ?: parseString(obj["displayName"])
+ ?: parseString(obj["title"])
+ ?: slug
+
+ val rawConnected = parseBooleanOrNull(obj["connected"])
+ val enabled = parseBoolean(obj["enabled"], default = true)
+
+ // Connection status string: primary "connectionStatus", legacy aliases "status", "connection_status"
+ val rawStatus =
+ parseString(obj["connectionStatus"])
+ ?: parseString(obj["status"])
+ ?: parseString(obj["connection_status"])
+
+ // Compute typed status respecting connected precedence:
+ // When connected boolean is missing (legacy), status "authorized"/"connected"/"active" yields CONNECTED.
+ // Explicit connected=false is authoritative and not overridden by status active.
+ val status = ConnectorStatus.from(connected = rawConnected, rawStatus = rawStatus)
+ val effectiveConnected = rawConnected ?: status.isConnected
+
+ // Connect URL (transient): primary "connect_url", legacy aliases "connectUrl", "auth_url", "authUrl"
+ val connectUrl =
+ parseString(obj["connect_url"])
+ ?: parseString(obj["connectUrl"])
+ ?: parseString(obj["auth_url"])
+ ?: parseString(obj["authUrl"])
+
+ return ConnectorItem(
+ connector = slug,
+ name = name,
+ connected = effectiveConnected,
+ enabled = enabled,
+ connectionStatus = rawStatus,
+ status = status,
+ connectUrl = connectUrl,
+ )
+ }
+
+ private fun parseConnectItem(obj: JsonObject): ConnectorConnectItem? {
+ val slug =
+ parseIdentifier(obj["connector"])
+ ?: parseIdentifier(obj["slug"])
+ ?: parseIdentifier(obj["id"])
+ ?: return null
+
+ val rawStatus =
+ parseString(obj["status"])
+ ?: parseString(obj["connectionStatus"])
+ ?: parseString(obj["connection_status"])
+
+ val status = ConnectorConnectStatus.from(rawStatus)
+
+ val connectUrl =
+ parseString(obj["connect_url"])
+ ?: parseString(obj["connectUrl"])
+ ?: parseString(obj["auth_url"])
+ ?: parseString(obj["authUrl"])
+
+ return ConnectorConnectItem(
+ connector = slug,
+ status = status,
+ connectUrl = connectUrl,
+ )
+ }
+
+ private fun parseConnectSummary(obj: JsonObject?): ConnectorConnectSummary {
+ if (obj == null) return ConnectorConnectSummary()
+ return ConnectorConnectSummary(
+ total = parseInt(obj["total"], default = 0),
+ active = parseInt(obj["active"], default = 0),
+ initiated = parseInt(obj["initiated"], default = 0),
+ failed = parseInt(obj["failed"], default = 0),
+ )
+ }
+
+ /**
+ * Map RPC error codes and reasons into typed [ConnectorError]s.
+ * Raw server strings and sensitive parameters are never forwarded or exposed in error messages.
+ */
+ fun mapRpcError(
+ code: Int,
+ message: String? = null,
+ data: JsonElement? = null,
+ ): ConnectorError {
+ val reason =
+ when (data) {
+ is JsonObject -> (data["reason"] as? JsonPrimitive)?.content
+ is JsonPrimitive -> data.content
+ else -> null
+ }?.uppercase()?.trim()
+
+ return when {
+ code == 4000 || reason == "INVALID_PARAMS" -> {
+ ConnectorError.InvalidParams()
+ }
+
+ code == 4001 || reason == "NOT_OWNER" -> {
+ ConnectorError.NotOwner()
+ }
+
+ code == 4031 || reason == "CONNECTORS_UNAVAILABLE" -> {
+ ConnectorError.Unavailable()
+ }
+
+ code == 5033 || reason == "UNSUPPORTED_RUNTIME" -> {
+ ConnectorError.UnsupportedRuntime()
+ }
+
+ code == 5034 && reason == "INVALID_CONNECTOR_RESPONSE" -> {
+ ConnectorError.InvalidResponse()
+ }
+
+ code == 5034 -> {
+ ConnectorError.RequestFailed()
+ }
+
+ code == -32601 -> {
+ ConnectorError.UnsupportedBackend()
+ }
+
+ else -> {
+ ConnectorError.Other(code = code)
+ }
+ }
+ }
+
+ internal fun parseStrictString(element: JsonElement?): String? {
+ if (element == null || element is JsonNull) return null
+ if (element is JsonPrimitive && element.isString) {
+ return element.content
+ }
+ return null
+ }
+
+ internal fun parseIdentifier(element: JsonElement?): String? {
+ val str = parseStrictString(element)?.trim() ?: return null
+ return if (ConnectorRepository.isValidSlug(str)) str else null
+ }
+
+ internal fun parseString(element: JsonElement?): String? {
+ if (element == null || element is JsonNull) return null
+ if (element is JsonPrimitive) {
+ if (!element.isString && element.content.isBlank()) return null
+ return element.content
+ }
+ return null
+ }
+
+ internal fun parseBooleanOrNull(element: JsonElement?): Boolean? {
+ if (element == null || element is JsonNull) return null
+ if (element is JsonPrimitive) {
+ element.booleanOrNull?.let { return it }
+ val content = element.content.trim()
+ if (content.equals("true", ignoreCase = true) || content == "1") return true
+ if (content.equals("false", ignoreCase = true) || content == "0") return false
+ }
+ return null
+ }
+
+ internal fun parseBoolean(
+ element: JsonElement?,
+ default: Boolean,
+ ): Boolean = parseBooleanOrNull(element) ?: default
+
+ internal fun parseInt(
+ element: JsonElement?,
+ default: Int,
+ ): Int {
+ if (element == null || element is JsonNull) return default
+ if (element is JsonPrimitive) {
+ element.intOrNull?.let { return it }
+ element.content.toIntOrNull()?.let { return it }
+ }
+ return default
+ }
+
+ fun toJsonElement(value: Any?): JsonElement =
+ when (value) {
+ null -> {
+ JsonNull
+ }
+
+ is JsonElement -> {
+ value
+ }
+
+ is Map<*, *> -> {
+ JsonObject(
+ @Suppress("UNCHECKED_CAST")
+ (value as Map).mapValues { (_, v) -> toJsonElement(v) },
+ )
+ }
+
+ is List<*> -> {
+ JsonArray(value.map { toJsonElement(it) })
+ }
+
+ is String -> {
+ val trimmed = value.trim()
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))
+ ) {
+ try {
+ json.parseToJsonElement(value)
+ } catch (_: Exception) {
+ JsonPrimitive(value)
+ }
+ } else {
+ JsonPrimitive(value)
+ }
+ }
+
+ is Boolean -> {
+ JsonPrimitive(value)
+ }
+
+ is Number -> {
+ JsonPrimitive(value)
+ }
+
+ else -> {
+ JsonPrimitive(value.toString())
+ }
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt
new file mode 100644
index 00000000..1c39ce40
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectorRepository.kt
@@ -0,0 +1,126 @@
+package com.m57.hermescontrol.data.ws
+
+import com.m57.hermescontrol.data.model.ConnectorConnectResult
+import com.m57.hermescontrol.data.model.ConnectorError
+import com.m57.hermescontrol.data.model.ConnectorListResult
+import kotlinx.coroutines.CancellationException
+
+/**
+ * Repository interface for connector operations: `connectors.list` and `connectors.connect` (issue #1091).
+ * Public injectable interface enabling isolated testing in ViewModels and controllers.
+ */
+interface ConnectorRepository {
+ /**
+ * Request connector catalog and authorization status for [sessionId].
+ */
+ suspend fun listConnectors(sessionId: String): ConnectorListResult
+
+ /**
+ * Initiate authorization flow for [connectors] slugs in [sessionId].
+ * If [reconnect] is true, restarts authorization flow even if already connected.
+ */
+ suspend fun connect(
+ sessionId: String,
+ connectors: List,
+ reconnect: Boolean = false,
+ ): ConnectorConnectResult
+
+ companion object : ConnectorRepository by HermesConnectorRepository() {
+ private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]*$")
+
+ fun isValidSlug(slug: String?): Boolean {
+ if (slug.isNullOrBlank()) return false
+ return SLUG_REGEX.matches(slug)
+ }
+ }
+}
+
+/**
+ * Default implementation of [ConnectorRepository] backed by [HermesWsClient].
+ */
+class HermesConnectorRepository(
+ private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params ->
+ val deferred = HermesWsClient.request(method, params)
+ try {
+ deferred.await()
+ } catch (e: CancellationException) {
+ deferred.cancel(e)
+ throw e
+ } finally {
+ if (!deferred.isCompleted) {
+ deferred.cancel()
+ }
+ }
+ },
+) : ConnectorRepository {
+ override suspend fun listConnectors(sessionId: String): ConnectorListResult {
+ if (sessionId.isBlank()) {
+ return ConnectorListResult.Error(
+ ConnectorError.InvalidParams("session_id required"),
+ )
+ }
+
+ return try {
+ val params = mapOf("session_id" to sessionId)
+ val result = rpcRequest(WsMethods.CONNECTORS_LIST, params)
+ ConnectorParser.parseListResult(result)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: HermesWsClient.HermesRpcException) {
+ ConnectorListResult.Error(
+ ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data),
+ )
+ } catch (_: Exception) {
+ ConnectorListResult.Error(
+ ConnectorError.NetworkError("Failed to communicate with gateway."),
+ )
+ }
+ }
+
+ override suspend fun connect(
+ sessionId: String,
+ connectors: List,
+ reconnect: Boolean,
+ ): ConnectorConnectResult {
+ if (sessionId.isBlank()) {
+ return ConnectorConnectResult.Error(
+ ConnectorError.InvalidParams("session_id required"),
+ )
+ }
+
+ if (connectors.isEmpty()) {
+ return ConnectorConnectResult.Error(
+ ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"),
+ )
+ }
+
+ for (slug in connectors) {
+ if (!ConnectorRepository.isValidSlug(slug)) {
+ return ConnectorConnectResult.Error(
+ ConnectorError.InvalidParams("connectors must be nonempty slugs; reconnect must be boolean"),
+ )
+ }
+ }
+
+ return try {
+ val params =
+ mapOf(
+ "session_id" to sessionId,
+ "connectors" to connectors,
+ "reconnect" to reconnect,
+ )
+ val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params)
+ ConnectorParser.parseConnectResult(result)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: HermesWsClient.HermesRpcException) {
+ ConnectorConnectResult.Error(
+ ConnectorParser.mapRpcError(code = e.code, message = e.message ?: "", data = e.data),
+ )
+ } catch (_: Exception) {
+ ConnectorConnectResult.Error(
+ ConnectorError.NetworkError("Failed to communicate with gateway."),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt
index 4a19bd98..22bdd752 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt
@@ -87,7 +87,7 @@ object EventParser {
"message.complete" -> {
val text = payload?.get("text") as? String ?: ""
val reasoning = payload?.get("reasoning") as? String
- WsEvent.MessageComplete(text, sessionId, reasoning)
+ WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload)
}
"message.done" -> {
@@ -341,6 +341,43 @@ object EventParser {
WsEvent.SecretExpire(requestId, sessionId)
}
+ // ── Credential Vault prompts (issue #1090) ──────────────────
+ "vault.unlock.request" -> {
+ val requestId = payload?.get("request_id") as? String
+ val backend = payload?.get("backend") as? String
+ val displayName = payload?.get("display_name") as? String
+ WsEvent.VaultUnlockRequest(requestId, sessionId, backend, displayName)
+ }
+
+ "vault.unlock.expire" -> {
+ val requestId = payload?.get("request_id") as? String
+ WsEvent.VaultUnlockExpire(requestId, sessionId)
+ }
+
+ "vault.save_login.request" -> {
+ val requestId = payload?.get("request_id") as? String
+ val origin = payload?.get("origin") as? String
+ val site = payload?.get("site") as? String
+ WsEvent.VaultSaveLoginRequest(requestId, sessionId, origin, site)
+ }
+
+ "vault.save_login.expire" -> {
+ val requestId = payload?.get("request_id") as? String
+ WsEvent.VaultSaveLoginExpire(requestId, sessionId)
+ }
+
+ "vault.code.request" -> {
+ val requestId = payload?.get("request_id") as? String
+ val site = payload?.get("site") as? String
+ val hint = payload?.get("hint") as? String
+ WsEvent.VaultCodeRequest(requestId, sessionId, site, hint)
+ }
+
+ "vault.code.expire" -> {
+ val requestId = payload?.get("request_id") as? String
+ WsEvent.VaultCodeExpire(requestId, sessionId)
+ }
+
else -> {
Log.w(TAG, "Unknown event type: $eventType")
WsEvent.Unknown(rawJson)
diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
index 80dde78e..4a2c7515 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
@@ -9,10 +9,12 @@ import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.session.ActiveSessionHolder
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.currentCoroutineContext
@@ -25,9 +27,12 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
@@ -62,34 +67,48 @@ enum class ConnectionStatus {
object HermesWsClient {
private const val TAG = "HermesWsClient"
- // ── Backoff settings ─────────────────────────────────────────────────
+ // ── Monotonic time helper (immune to NTP and wall-clock jumps) ─────────
+ private fun monotonicTimeMs(): Long = System.nanoTime() / 1_000_000L
+
+ // ── Backoff settings (desktop parity: reconnect-backoff.ts full-jitter) ─
private var initialBackoffMs = 1_000L
+ private const val BACKOFF_BASE_MS = 300L
+ private const val BACKOFF_CAP_MS = 15_000L
private const val MAX_BACKOFF_MS = 30_000L
private const val BACKOFF_MULTIPLIER = 2.0
+ private var testBackoffOverrideMs: Long? = null
/**
* Test hook: the initial reconnect backoff, overridable so reconnect
* tests are deterministic instead of racing real time (CI runners
- * routinely exceed fixed latch windows). Production keeps 1s.
+ * routinely exceed fixed latch windows). Production keeps full-jitter backoff.
*/
@VisibleForTesting
internal fun setReconnectBackoffForTest(initialMillis: Long) {
initialBackoffMs = initialMillis
currentBackoff = initialMillis
+ testBackoffOverrideMs = initialMillis
+ }
+
+ private fun nextBackoffDelayMs(): Long {
+ val override = testBackoffOverrideMs
+ if (override != null) return override
+ val ceiling = currentBackoff
+ if (ceiling <= 0L) return 0L
+ return (Math.random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling)
}
private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
/**
- * No inbound frame for this long means the link is dead even though the
- * TCP session may still look open (silent NAT/VPN drop). OkHttp pings
- * every 30s (OkHttpProvider.websocket) and any inbound frame refreshes
- * [lastPongTimestamp], so a healthy link never exceeds ~30s of silence.
- * 90s tolerates exactly one missed ping cycle before acting.
+ * Liveness thresholds aligned with reference clients (desktop/web):
+ * Ping every 15s using gateway.ping; 45s inbound silence deadline.
*/
- private const val STALE_THRESHOLD_MS = 90_000L
+ private const val HEARTBEAT_INTERVAL_MS = 15_000L
+ private const val STALE_THRESHOLD_MS = 45_000L
+ private const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L
// ── Internal state (all access through synchronized / atomic) ────────
@@ -150,7 +169,7 @@ object HermesWsClient {
private set
val isHealthy: Boolean
- get() = isConnected && (System.currentTimeMillis() - lastPongTimestamp < 60_000L)
+ get() = isConnected && (monotonicTimeMs() - lastPongTimestamp < STALE_THRESHOLD_MS)
// ── Observable latency tracking (issue #1017) ─────────────────────────
private val _lastLatencyMs = MutableStateFlow(null)
@@ -162,14 +181,14 @@ object HermesWsClient {
private fun startHealthTracking() {
healthJob?.cancel()
- lastPongTimestamp = System.currentTimeMillis()
+ lastPongTimestamp = monotonicTimeMs()
healthJob =
wsScope.launch {
while (connected.get()) {
- delay(30_000L)
+ delay(HEARTBEAT_INTERVAL_MS)
if (connected.get()) {
try {
- ping(timeoutMs = 10_000L)
+ ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS)
} catch (e: Exception) {
Log.w(TAG, "Health check ping failed: ${e.message}")
}
@@ -181,14 +200,15 @@ object HermesWsClient {
/**
* Ultra-lightweight JSON-RPC liveness check and RTT measurement (issue #1017).
- * Bypasses agent queues on the backend; measures round-trip time in milliseconds.
+ * Uses [WsMethods.GATEWAY_PING] which is answered inline in the WebSocket read
+ * loop (ws.py:350) and never queues behind worker dispatch pools.
* Updates [lastLatencyMs] and [lastPongTimestamp].
*/
- suspend fun ping(timeoutMs: Long = 5_000L): Long {
- val start = System.currentTimeMillis()
- request(WsMethods.PING, emptyMap(), timeoutMs = timeoutMs).await()
- val latency = (System.currentTimeMillis() - start).coerceAtLeast(0L)
- lastPongTimestamp = System.currentTimeMillis()
+ suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long {
+ val start = monotonicTimeMs()
+ request(WsMethods.GATEWAY_PING, emptyMap(), timeoutMs = timeoutMs).await()
+ val latency = (monotonicTimeMs() - start).coerceAtLeast(0L)
+ lastPongTimestamp = monotonicTimeMs()
_lastLatencyMs.value = latency
return latency
}
@@ -207,7 +227,7 @@ object HermesWsClient {
*/
private fun runHealthCheckPass(): Boolean {
if (!connected.get()) return false
- val staleMs = System.currentTimeMillis() - lastPongTimestamp
+ val staleMs = monotonicTimeMs() - lastPongTimestamp
if (staleMs <= STALE_THRESHOLD_MS) return true
Log.w(TAG, "WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect")
synchronized(outboundLock) {
@@ -220,11 +240,11 @@ object HermesWsClient {
/**
* Test hook: backdate liveness past the staleness threshold and force one
* synchronous watchdog pass, so reconnect tests are deterministic instead
- * of racing the real 30s/90s cadence.
+ * of racing the real 15s/45s cadence.
*/
@VisibleForTesting
internal fun forceHealthCheckForTest(staleMillis: Long) {
- lastPongTimestamp = System.currentTimeMillis() - staleMillis
+ lastPongTimestamp = monotonicTimeMs() - staleMillis
runHealthCheckPass()
}
@@ -238,7 +258,7 @@ object HermesWsClient {
private val parsedEvents =
MutableSharedFlow(
- extraBufferCapacity = 512,
+ extraBufferCapacity = 2048,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@@ -346,9 +366,34 @@ object HermesWsClient {
@VisibleForTesting
val isConnected: Boolean get() = connected.get()
+ private var foregroundProbeJob: Job? = null
+
fun setAppForeground(foreground: Boolean) {
appInForeground.set(foreground)
- if (!foreground) disconnectIfIdleInBackground()
+ if (!foreground) {
+ disconnectIfIdleInBackground()
+ return
+ }
+ probeLivenessOnWake()
+ }
+
+ private fun probeLivenessOnWake(deferredOnce: Boolean = false) {
+ if (!connected.get()) return
+ foregroundProbeJob?.cancel()
+ foregroundProbeJob =
+ wsScope.launch {
+ val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess
+ if (alive) return@launch
+ if (pendingReply && !deferredOnce) {
+ delay(3_000L)
+ probeLivenessOnWake(deferredOnce = true)
+ return@launch
+ }
+ Log.w(TAG, "Foreground liveness probe failed — cancelling socket")
+ synchronized(outboundLock) {
+ if (connected.get()) webSocket?.cancel()
+ }
+ }
}
fun acquireExternalActivityConnectionLease() {
@@ -629,6 +674,8 @@ object HermesWsClient {
reconnectJob = null
outboundDrainJob?.cancel()
outboundDrainJob = null
+ foregroundProbeJob?.cancel()
+ foregroundProbeJob = null
stopHealthTracking()
webSocket?.close(1000, "Client closed")
webSocket = null
@@ -658,6 +705,8 @@ object HermesWsClient {
/** Thrown when an awaited [request] is neither answered nor rejected within [REQUEST_TIMEOUT_MS], or is rejected by a disconnect. */
class HermesRpcException(
message: String,
+ val code: Int = 0,
+ val data: JsonElement? = null,
) : Exception(message)
/**
@@ -697,6 +746,15 @@ object HermesWsClient {
send(method, params) { reqId ->
pendingCalls[reqId] = PendingCall(method, deferred)
}
+ deferred.invokeOnCompletion { cause ->
+ if (cause is CancellationException) {
+ pendingCalls.remove(id)?.let { call ->
+ call.timeoutJob?.cancel()
+ removeQueuedMessage(id)
+ disconnectIfIdleInBackground()
+ }
+ }
+ }
// Arm the per-request timeout (fires if the server never answers).
pendingCalls[id]?.timeoutJob =
wsScope.launch {
@@ -716,7 +774,13 @@ object HermesWsClient {
removeQueuedMessage(id)
call.timeoutJob?.cancel()
if (error != null) {
- call.deferred.completeExceptionally(HermesRpcException(error.message))
+ call.deferred.completeExceptionally(
+ HermesRpcException(
+ message = error.message,
+ code = error.code,
+ data = error.data,
+ ),
+ )
} else {
call.deferred.complete(result)
}
@@ -764,7 +828,9 @@ object HermesWsClient {
params = decoratedParams.mapValues { it.value.toJsonElement() },
)
val json = OkHttpProvider.json.encodeToString(request)
- if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false))
+ }
var reconnect = false
synchronized(outboundLock) {
if (method == WsMethods.PROMPT_SUBMIT) {
@@ -890,21 +956,23 @@ object HermesWsClient {
// ── Sequence tracking & replay on reconnect (issue #1016) ─────────────
private fun triggerReplay() {
+ // Arm the hold SYNCHRONOUSLY (still under onOpen's outboundLock) so a
+ // live frame racing the replay coroutine is parked instead of advancing
+ // lastSeenSeq past events the replay would then skip-drop (G2).
if (lastSeenSeq.isEmpty() || replayInFlight) return
- replayJob?.cancel()
- replayJob =
- wsScope.launch {
- fetchReplay()
- }
- }
-
- private suspend fun fetchReplay() {
- if (replayInFlight || lastSeenSeq.isEmpty()) return
replayInFlight = true
val sessionsToReplay = lastSeenSeq.keys().toList()
for (sid in sessionsToReplay) {
replayHold[sid] = Collections.synchronizedList(mutableListOf())
}
+ replayJob?.cancel()
+ replayJob =
+ wsScope.launch {
+ fetchReplay(sessionsToReplay)
+ }
+ }
+
+ private suspend fun fetchReplay(sessionsToReplay: List = lastSeenSeq.keys().toList()) {
try {
for (sid in sessionsToReplay) {
val lastSeen = lastSeenSeq[sid] ?: continue
@@ -926,13 +994,21 @@ object HermesWsClient {
}
if (resultMap != null) {
val epoch = resultMap["epoch"] as? String
- if (!epoch.isNullOrEmpty()) {
- if (replayEpoch != null && replayEpoch != epoch) {
- replayEpoch = epoch
- lastSeenSeq.clear()
- continue
- }
- replayEpoch = epoch
+ val epochChanged =
+ !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch
+ val truncated = resultMap["truncated"] == true
+ val latestSeq = (resultMap["latest_seq"] as? Number)?.toInt()
+
+ if (epochChanged) replayEpoch = epoch
+
+ if (epochChanged || truncated) {
+ // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted).
+ // Partial replay would silently hole the transcript — fast-forward watermark
+ // and request full transcript resync.
+ if (epochChanged) lastSeenSeq.clear()
+ if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq
+ parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid))
+ continue
}
val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance