feat: pivot mobile client to direct gateway

This commit is contained in:
Hermes Agent
2026-07-24 10:49:48 +00:00
parent 983fb31801
commit 25a83e0b6f
14 changed files with 793 additions and 82 deletions
+5
View File
@@ -5,6 +5,7 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
## Unreleased
### Added
- Added `docs/DIRECT_GATEWAY_ARCHITECTURE.md` to define the direct upstream Hermes gateway architecture, documented endpoints, unverified assumptions, and legacy companion compatibility policy.
- Added transport DTOs and domain repository contracts for server profiles, authentication, sessions, messages, stream events, tool results, and artifacts.
- Added DTO-to-domain/UI mappers, stream reducer behavior, and a fake Hermes repository with unit coverage for mappings, stream reduction, and repository fixtures.
- Added a fake-data Compose chat proving ground with local session rail/list, context controls, keyboard-safe composer, send/cancel/retry, and seeded empty/loading/reconnecting/error states.
@@ -16,6 +17,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
### Changed
- Pivoted planning and Android connection/domain terminology toward direct gateway auth, sessions, streaming, and later legacy companion removal.
- Changed Android transport/domain capability models and fake repository data to describe direct gateway support first, with legacy companion compatibility explicitly flagged.
- Changed the fake chat seed path to reuse the fake Hermes repository preview state.
- Extracted shared Compose cards, rows, buttons, text fields, notices, message bubbles, list scaffolds, and code surfaces out of `MainActivity.kt`.
- Updated the native Appearance screen to describe system light/dark theming and design tokens.
@@ -27,6 +30,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
### Verification
- `git diff --check` — passed for direct gateway planning/contract milestone.
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest :app:assembleDebug` from `apps/mobile/android` — passed for direct gateway planning/contract milestone.
- Parent preflight immediately before B1 completion — script PASS; Android `:app:assembleDebug` BUILD SUCCESSFUL, 35 tasks total and 6 executed.
- Parent preflight immediately before B1 completion — `git diff --check` passed.
- `git diff --check` — passed
+17 -10
View File
@@ -20,26 +20,26 @@
---
## Now — finish the foundation already in progress
## Now — direct gateway pivot
_Foundation milestones complete. Continue with B1._
Complete the Android/client contract pivot before implementing backend behavior. The next release path targets a remote upstream Hermes Agent gateway/API server directly; `apps/companion` is legacy compatibility only.
## Beta path — upstream-connected MVP
- [ ] **B2 — Connection and authentication UX**
- Server connection screen with validation, health, auth/session configuration, offline/unauthorized/retry states.
- [ ] **B2 — Direct gateway connection and authentication UX**
- Server connection screen with validation, `GET /health`, bearer/no-auth configuration, optional `GET /v1/models` auth check, offline/unauthorized/retry states.
- Store sensitive values using Android-appropriate secure storage; never log secrets.
- Verification: connection-state tests and manual unhappy-path walkthrough.
- [ ] **B3 — Session list and durable chat state**
- Fetch/list/search/select/create sessions through repository contracts.
- [ ] **B3 — Direct gateway session contract and durable chat state**
- Fetch/list/search/select/create sessions through repository contracts after upstream session routes are verified.
- Restore selected session and protect against duplicate sends / lost drafts.
- Verification: fake-repository UI tests; companion/integration checks where available.
- Verification: fake-repository UI tests; direct gateway compatibility checks where available.
- [ ] **B4 — Streaming, reconnects, and resilient long-running work**
- Stream event reducer: token deltas, tool lifecycle, final/error/cancel, reconnect/backoff.
- [ ] **B4 — Direct gateway streaming, reconnects, and resilient long-running work**
- Stream event reducer: final response/token deltas first; tool lifecycle, session events, artifacts, cancel, and reconnect/backoff only where upstream capability is verified.
- Preserve partial output and make recovery obvious and safe.
- Verification: deterministic fake stream tests and real upstream-compatible exercise.
- Verification: deterministic fake stream tests and real direct gateway exercise.
- [ ] **B5 — Tool, artifact, and output experience**
- Compact tool cards with lifecycle/status, expandable output, copy/share/save affordances.
@@ -66,6 +66,7 @@ _Foundation milestones complete. Continue with B1._
- [ ] Notifications for task completion.
- [ ] Mature tablet/two-pane session + optional terminal/log pane.
- [ ] Offline cache/search and diagnostic export with privacy controls.
- [ ] Remove legacy companion Android routes and deprecate `apps/companion` install/runtime docs after direct gateway chat/session/streaming is verified.
---
@@ -99,6 +100,12 @@ _Move finished items here with date, commit, and verification. Keep this section
- Kept the companion path as an optional adapter capability without changing upstream Hermes semantics.
- Verification: parent preflight immediately before B1 completion passed: script PASS; Android `:app:assembleDebug` BUILD SUCCESSFUL, 35 tasks total and 6 executed. Parent also verified `git diff --check` passed.
- [x] **B2a — Direct gateway planning and compatibility contract** — 2026-07-24, commit `docs: pivot mobile contract to direct gateway`.
- Added `docs/DIRECT_GATEWAY_ARCHITECTURE.md` with documented upstream endpoints separated from unverified assumptions.
- Reprioritized the beta path around direct gateway auth, sessions, streaming, and later companion removal.
- Updated Android transport/domain/fake capability contracts and connection copy so new code no longer assumes a companion server.
- Verification: `git diff --check` passed; Android `:app:testDebugUnitTest :app:assembleDebug` passed from `apps/mobile/android`.
## Work-loop checklist
Before coding:
@@ -102,6 +102,15 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cloud.molberg.hermesmobile.connection.ConnectionAuthMode
import cloud.molberg.hermesmobile.connection.ConnectionCheckResult
import cloud.molberg.hermesmobile.connection.ConnectionForm
import cloud.molberg.hermesmobile.connection.ConnectionStateReducer
import cloud.molberg.hermesmobile.connection.ConnectionStatus
import cloud.molberg.hermesmobile.connection.ConnectionUiState
import cloud.molberg.hermesmobile.connection.ConnectionValidator
import cloud.molberg.hermesmobile.connection.SecureConnectionStorage
import cloud.molberg.hermesmobile.connection.StoredConnectionRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -155,30 +164,36 @@ data class AppearancePrefs(
data class HealthState(
val reachable: Boolean = false,
val status: String = "Tap refresh to check companion status.",
val status: String = "Tap refresh to check gateway status.",
val hermesCli: String = "Unknown / unavailable",
val workspace: String = "Unknown",
val uptime: String = "-",
val mode: String = "-"
)
class CompanionApi(private val context: Context) {
class CompanionApi(context: Context) {
val connectionStorage = SecureConnectionStorage(context)
private val connectionRepository = StoredConnectionRepository(connectionStorage) { checkConnection() }
private val prefs = context.getSharedPreferences("companion", Context.MODE_PRIVATE)
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
init {
migrateLegacyConnectionPrefs()
}
var baseUrl: String
get() = prefs.getString("baseUrl", "") ?: ""
get() = connectionStorage.serverUrl
set(value) {
prefs.edit().putString("baseUrl", value.trim()).apply()
connectionStorage.serverUrl = value
}
var accessKey: String
get() = prefs.getString("accessKey", "") ?: ""
get() = connectionStorage.bearerToken
set(value) {
prefs.edit().putString("accessKey", value.trim()).apply()
connectionStorage.bearerToken = value
}
var defaults: AgentDefaults
@@ -207,15 +222,24 @@ class CompanionApi(private val context: Context) {
.apply()
}
private fun endpoint(path: String): String = baseUrl.trimEnd('/') + path
fun connectionConfig() = connectionRepository.config()
fun saveConnection(form: ConnectionForm): Result<ConnectionUiState> =
connectionRepository.save(form)
suspend fun checkConnectionState(): ConnectionUiState =
connectionRepository.check()
private fun request(path: String, method: String = "GET", body: JSONObject? = null): JSONObject {
if (baseUrl.isBlank()) throw IllegalStateException("Enter your companion server URL in Settings.")
val normalizedUrl = ConnectionValidator.normalizeServerUrl(baseUrl)
.getOrElse { throw IllegalStateException(it.message ?: "Enter a valid server URL in Settings.") }
val mediaType = "application/json; charset=utf-8".toMediaType()
val builder = Request.Builder()
.url(endpoint(path))
.url(normalizedUrl + path)
.header("Accept", "application/json")
if (accessKey.isNotBlank()) builder.header("Authorization", "Bearer $accessKey")
if (connectionStorage.authMode == ConnectionAuthMode.BearerToken && accessKey.isNotBlank()) {
builder.header("Authorization", "Bearer $accessKey")
}
val request = when (method) {
"POST" -> builder.post((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
"PUT" -> builder.put((body ?: JSONObject()).toString().toRequestBody(mediaType)).build()
@@ -225,23 +249,38 @@ class CompanionApi(private val context: Context) {
val text = cleanServerText(response.body?.string().orEmpty())
val json = runCatching { if (text.isBlank()) JSONObject() else JSONObject(text) }.getOrNull()
if (!response.isSuccessful) {
if (response.code == 401 || response.code == 403) {
throw UnauthorizedConnectionException("Authentication was rejected. Check the access key and retry.")
}
val message = json?.optString("reply")?.takeIf { it.isNotBlank() }
?: json?.optString("message")?.takeIf { it.isNotBlank() }
?: json?.optString("error")?.takeIf { it.isNotBlank() }
?: text.take(600).ifBlank { "Companion returned HTTP ${response.code}." }
?: text.take(600).ifBlank { "Server returned HTTP ${response.code}." }
throw IllegalStateException(message)
}
return json ?: throw IllegalStateException("Companion returned a non-JSON response: ${text.take(220).ifBlank { "empty body" }}")
return json ?: throw IllegalStateException("Server returned a non-JSON response: ${text.take(220).ifBlank { "empty body" }}")
}
}
private fun requestFirst(paths: List<String>): JSONObject {
var lastError: Throwable? = null
for (path in paths) {
val result = runCatching { request(path) }
result.onSuccess { return it }
val error = result.exceptionOrNull()
if (error is UnauthorizedConnectionException) throw error
lastError = error
}
throw lastError ?: IllegalStateException("Server is offline or unreachable.")
}
suspend fun health(): HealthState = withContext(Dispatchers.IO) {
val json = request("/api/health")
val json = requestFirst(listOf("/health", "/api/health"))
val checks = json.optJSONObject("checks")?.optJSONObject("hermes")
val config = json.optJSONObject("config")
HealthState(
reachable = true,
status = "Companion is reachable.",
status = json.optString("status", "Gateway is reachable.").ifBlank { "Gateway is reachable." },
hermesCli = if (checks?.optBoolean("cliAvailable") == true) "Found at ${checks.optString("cliPath")}" else "Hermes CLI was not found on PATH.",
workspace = config?.optString("workspaceRoot") ?: "Unknown",
uptime = "${json.optDouble("uptimeSeconds", 0.0).toInt()}s",
@@ -250,7 +289,25 @@ class CompanionApi(private val context: Context) {
}
suspend fun validate(): String = withContext(Dispatchers.IO) {
request("/api/auth/validate").optString("message", "Connection works.")
requestFirst(listOf("/v1/models", "/api/auth/validate")).optString("message", "Connection works.")
}
suspend fun checkConnection(): ConnectionCheckResult = withContext(Dispatchers.IO) {
runCatching {
val health = health()
val authMessage = if (connectionStorage.authMode == ConnectionAuthMode.BearerToken) {
validate()
} else {
""
}
ConnectionCheckResult.Healthy(authMessage.ifBlank { health.status })
}.getOrElse { error ->
if (error is UnauthorizedConnectionException) {
ConnectionCheckResult.Unauthorized(error.message ?: "Authentication was rejected.")
} else {
ConnectionCheckResult.Offline(error.message ?: "Server is offline or unreachable.")
}
}
}
suspend fun chat(prompt: String): ChatResult = withContext(Dispatchers.IO) {
@@ -268,7 +325,7 @@ class CompanionApi(private val context: Context) {
return if (result.hermesAvailable) {
"Hermes responded${result.exitCode?.let { " with exit code $it" } ?: ""}."
} else {
result.reply.ifBlank { "Hermes CLI is not available on the companion machine." }
result.reply.ifBlank { "Hermes is not available through this server." }
}
}
@@ -307,8 +364,24 @@ class CompanionApi(private val context: Context) {
"exitCode=${json.opt("exitCode")} timedOut=${json.optBoolean("timedOut")}"
).filter { it.isNotBlank() }.joinToString("\n")
}
private fun migrateLegacyConnectionPrefs() {
val legacyUrl = prefs.getString("baseUrl", null).orEmpty()
val legacyAccessKey = prefs.getString("accessKey", null).orEmpty()
if (connectionStorage.serverUrl.isBlank() && legacyUrl.isNotBlank()) {
connectionStorage.serverUrl = legacyUrl
}
if (connectionStorage.bearerToken.isBlank() && legacyAccessKey.isNotBlank()) {
connectionStorage.bearerToken = legacyAccessKey
}
if (legacyUrl.isNotBlank() || legacyAccessKey.isNotBlank()) {
prefs.edit().remove("baseUrl").remove("accessKey").apply()
}
}
}
class UnauthorizedConnectionException(message: String) : IllegalStateException(message)
fun cleanServerText(text: String): String =
text.replace(Regex("\\u001B\\[[;?0-9]*[ -/]*[@-~]"), "")
.replace("\u0000", "")
@@ -336,8 +409,8 @@ fun HermesNativeApp() {
scope.launch { snackbar.showSnackbar(message) }
}
LaunchedEffect(screen, api.baseUrl, api.accessKey) {
runCatching { api.health() }.onSuccess { online = true }.onFailure { online = false }
LaunchedEffect(screen) {
api.checkConnection().let { online = it is ConnectionCheckResult.Healthy }
}
HermesMobileTheme {
@@ -667,7 +740,7 @@ fun TerminalHome(api: CompanionApi, notify: (String) -> Unit, onNewChat: () -> U
var output by remember { mutableStateOf("Command output appears here.") }
var busy by remember { mutableStateOf(false) }
ScreenList {
SectionHeader("Command", "Run a shell command on the companion workspace.")
SectionHeader("Command", "Legacy companion command runner.")
FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FieldLabel("Working directory")
@@ -689,9 +762,9 @@ fun TerminalHome(api: CompanionApi, notify: (String) -> Unit, onNewChat: () -> U
}
}
CodeCard(output)
SectionHeader("Workspace", "Browse files or inspect the companion host.")
ListRow(icon = Icons.Filled.Folder, title = "Files", subtitle = "Browse, read, and edit workspace files", onClick = { onPanel(TerminalPanel.Files) })
ListRow(icon = Icons.Filled.Terminal, title = "Machine", subtitle = "Companion health, workspace, and CLI status") { onPanel(TerminalPanel.Machine) }
SectionHeader("Workspace", "Legacy companion utilities kept for compatibility.")
ListRow(icon = Icons.Filled.Folder, title = "Files", subtitle = "Browse legacy workspace files", onClick = { onPanel(TerminalPanel.Files) })
ListRow(icon = Icons.Filled.Terminal, title = "Machine", subtitle = "Gateway health and legacy host details") { onPanel(TerminalPanel.Machine) }
SectionHeader("Chat", "Start over in the messenger view.")
ListRow(icon = Icons.Filled.ChatBubble, title = "New Inbox chat", subtitle = "Reset the chat session and start fresh", onClick = onNewChat)
}
@@ -743,7 +816,7 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit)
LaunchedEffect(Unit) { load(".") }
DetailList("Files", onBack) {
SectionHeader("Workspace files", "Navigate the companion workspace and edit one file at a time.")
SectionHeader("Workspace files", "Legacy companion file access.")
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Up", Icons.Filled.ArrowBack, Modifier.weight(0.8f), enabled = path != ".") { load(parentPath(path)) }
PrimaryButton(if (loading) "Loading..." else "Refresh", Icons.Filled.Refresh, Modifier.weight(1f), enabled = !loading) { load() }
@@ -810,47 +883,95 @@ fun SettingsScreen(api: CompanionApi, notify: (String) -> Unit) {
@Composable
fun SettingsHome(api: CompanionApi, notify: (String) -> Unit, onPanel: (SettingsPanel) -> Unit) {
val scope = remember { CoroutineScope(Dispatchers.Main) }
var baseUrl by remember { mutableStateOf(api.baseUrl) }
var accessKey by remember { mutableStateOf(api.accessKey) }
var status by remember { mutableStateOf("Server URL is blank until you enter your companion address.") }
val initialConfig = remember { api.connectionConfig() }
var baseUrl by remember { mutableStateOf(initialConfig.serverUrl) }
var authMode by remember { mutableStateOf(initialConfig.authMode) }
var accessKey by remember { mutableStateOf("") }
var sessionLabel by remember { mutableStateOf(initialConfig.sessionLabel) }
var connectionState by remember {
mutableStateOf(ConnectionStateReducer.initial(initialConfig))
}
fun saveAndCheck() {
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel)
val saved = api.saveConnection(form).getOrElse {
connectionState = ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
notify(connectionState.message)
return
}
connectionState = saved
accessKey = ""
scope.launch {
val checked = api.checkConnectionState()
connectionState = checked
notify(checked.message)
}
}
ScreenList {
SectionHeader("Companion", "Connect this device before using Inbox, terminal, or files.")
SectionHeader("Gateway", "Connect this device to an upstream Hermes gateway.")
FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
FieldLabel("Companion server URL")
FieldLabel("Hermes gateway URL")
AppTextField(baseUrl, { baseUrl = it }, "", keyboardType = KeyboardType.Uri)
FieldLabel("Authentication")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
ModeChip("Bearer", authMode == ConnectionAuthMode.BearerToken, Modifier.weight(1f)) {
authMode = ConnectionAuthMode.BearerToken
}
ModeChip("None", authMode == ConnectionAuthMode.None, Modifier.weight(1f)) {
authMode = ConnectionAuthMode.None
}
}
if (authMode == ConnectionAuthMode.BearerToken) {
FieldLabel("Access key")
AppTextField(accessKey, { accessKey = it }, "hm_...", password = true)
AppTextField(
accessKey,
{ accessKey = it },
if (initialConfig.hasBearerToken) "Stored token unchanged" else "hm_...",
password = true
)
}
FieldLabel("Session label")
AppTextField(sessionLabel, { sessionLabel = it }, "Default session")
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Save", Icons.Filled.Save, Modifier.weight(1f)) {
api.baseUrl = baseUrl
api.accessKey = accessKey
status = "Settings saved."
notify(status)
val form = ConnectionForm(baseUrl, authMode, accessKey, sessionLabel)
connectionState = api.saveConnection(form).getOrElse {
ConnectionStateReducer.invalid(form, it.message ?: "Connection settings are invalid.")
}
if (connectionState.status == ConnectionStatus.Checking) {
connectionState = ConnectionStateReducer.savedForManualCheck(connectionState.config)
accessKey = ""
}
notify(connectionState.message)
}
PrimaryButton("Test", Icons.Filled.Wifi, Modifier.weight(1f)) {
api.baseUrl = baseUrl
api.accessKey = accessKey
status = "Testing..."
scope.launch {
status = runCatching { api.validate() }.getOrElse { it.message ?: "Connection failed" }
notify(status)
saveAndCheck()
}
}
}
}
}
NoticeCard(status, danger = status.contains("failed", true) || status.contains("Unauthorized", true))
SectionHeader("Machine", "Inspect companion health and credentials.")
ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "Health, workspace, and CLI status") { onPanel(SettingsPanel.Health) }
ListRow(icon = Icons.Outlined.Security, title = "Accounts", subtitle = "Use companion environment and Hermes CLI auth") { onPanel(SettingsPanel.Accounts) }
ConnectionNotice(connectionState)
SectionHeader("Gateway", "Inspect server health and authentication.")
ListRow(icon = Icons.Filled.Terminal, title = "Hermes", subtitle = "Health and compatibility status") { onPanel(SettingsPanel.Health) }
ListRow(icon = Icons.Outlined.Security, title = "Accounts", subtitle = "Provider credentials stay upstream, outside the app") { onPanel(SettingsPanel.Accounts) }
SectionHeader("Preferences", "Local defaults stored on this Android device.")
ListRow(icon = Icons.Filled.Tune, title = "Agent Defaults", subtitle = api.defaults.run { "$model - $effort" }) { onPanel(SettingsPanel.Defaults) }
ListRow(icon = Icons.Filled.Palette, title = "Appearance", subtitle = "Dark native theme settings") { onPanel(SettingsPanel.Appearance) }
}
}
@Composable
fun ConnectionNotice(state: ConnectionUiState) {
NoticeCard(
state.noticeText,
danger = state.status == ConnectionStatus.Offline ||
state.status == ConnectionStatus.Unauthorized ||
state.urlError != null
)
}
@Composable
fun AppearanceSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit) {
var density by remember { mutableStateOf(api.appearance.density) }
@@ -911,7 +1032,7 @@ fun HealthSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Un
DetailList("Hermes", onBack) {
NoticeCard(health.status, danger = !health.reachable)
MetricRow("Companion URL", api.baseUrl.ifBlank { "Not set" })
MetricRow("Gateway URL", api.baseUrl.ifBlank { "Not set" })
MetricRow("Hermes CLI", health.hermesCli)
MetricRow("Workspace", health.workspace)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
@@ -931,9 +1052,9 @@ fun HealthSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Un
@Composable
fun ConnectedAccountsSettings(onBack: () -> Unit) {
DetailList("Accounts", onBack) {
MetricRow("Hermes CLI", "Managed on companion machine")
MetricRow("API keys", "Read from companion environment")
NoticeCard("The mobile app does not store provider account tokens. Configure Hermes and provider credentials on the companion host, then use Settings > Hermes to test the CLI.")
MetricRow("Hermes runtime", "Managed by the upstream server")
MetricRow("API keys", "Read from upstream Hermes configuration")
NoticeCard("The mobile app does not store provider account tokens. Configure Hermes and provider credentials upstream, then use Settings > Hermes to test connectivity.")
}
}
@@ -0,0 +1,213 @@
package cloud.molberg.hermesmobile.connection
import java.net.URI
import java.net.URISyntaxException
enum class ConnectionAuthMode { None, BearerToken }
enum class ConnectionStatus { Unconfigured, Offline, Checking, Connected, Unauthorized }
data class ConnectionConfig(
val serverUrl: String = "",
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
val hasBearerToken: Boolean = false,
val sessionLabel: String = "Default session"
)
data class ConnectionForm(
val serverUrl: String = "",
val authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken,
val bearerToken: String = "",
val sessionLabel: String = "Default session"
)
data class ConnectionUiState(
val config: ConnectionConfig = ConnectionConfig(),
val status: ConnectionStatus = ConnectionStatus.Unconfigured,
val message: String = "Enter a server URL to connect.",
val urlError: String? = null,
val canRetry: Boolean = false
) {
val connected: Boolean get() = status == ConnectionStatus.Connected
val noticeText: String
get() {
val label = when (status) {
ConnectionStatus.Unconfigured -> "Not configured"
ConnectionStatus.Checking -> "Checking"
ConnectionStatus.Connected -> "Connected"
ConnectionStatus.Offline -> "Offline"
ConnectionStatus.Unauthorized -> "Unauthorized"
}
val retry = if (canRetry) " Retry after updating the URL or authentication." else ""
return "$label: $message$retry"
}
}
sealed interface ConnectionCheckResult {
data class Healthy(val message: String) : ConnectionCheckResult
data class Offline(val message: String) : ConnectionCheckResult
data class Unauthorized(val message: String) : ConnectionCheckResult
}
object ConnectionValidator {
fun normalizeServerUrl(input: String): Result<String> {
val trimmed = input.trim().trimEnd('/')
if (trimmed.isBlank()) return Result.failure(IllegalArgumentException("Server URL is required."))
val uri = try {
URI(trimmed)
} catch (_: URISyntaxException) {
return Result.failure(IllegalArgumentException("Enter a valid http or https URL."))
}
if (uri.scheme !in listOf("http", "https") || uri.host.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("Enter a valid http or https URL."))
}
if (uri.rawUserInfo != null) {
return Result.failure(IllegalArgumentException("Do not include credentials in the server URL."))
}
return Result.success(trimmed)
}
fun validateForm(form: ConnectionForm, hasStoredBearerToken: Boolean = false): Result<ConnectionConfig> =
normalizeServerUrl(form.serverUrl).map { normalized ->
val hasBearerToken = form.bearerToken.isNotBlank() || hasStoredBearerToken
ConnectionConfig(
serverUrl = normalized,
authMode = form.authMode,
hasBearerToken = form.authMode == ConnectionAuthMode.BearerToken && hasBearerToken,
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
)
}.fold(
onSuccess = { config ->
if (form.authMode == ConnectionAuthMode.BearerToken && !config.hasBearerToken) {
Result.failure(IllegalArgumentException("Bearer token is required for token authentication."))
} else {
Result.success(config)
}
},
onFailure = { Result.failure(it) }
)
}
object ConnectionStateReducer {
fun initial(config: ConnectionConfig): ConnectionUiState =
ConnectionUiState(
config = config,
status = if (config.serverUrl.isBlank()) ConnectionStatus.Unconfigured else ConnectionStatus.Offline,
message = if (config.serverUrl.isBlank()) {
"Server URL is blank until you enter your Hermes gateway address."
} else {
"Saved connection is ready to check."
},
canRetry = config.serverUrl.isNotBlank()
)
fun saved(config: ConnectionConfig): ConnectionUiState =
ConnectionUiState(
config = config,
status = ConnectionStatus.Checking,
message = "Checking server health...",
canRetry = false
)
fun savedForManualCheck(config: ConnectionConfig): ConnectionUiState =
ConnectionUiState(
config = config,
status = ConnectionStatus.Offline,
message = "Settings saved. Run Test to check the server.",
canRetry = true
)
fun result(config: ConnectionConfig, result: ConnectionCheckResult): ConnectionUiState =
when (result) {
is ConnectionCheckResult.Healthy -> ConnectionUiState(
config = config,
status = ConnectionStatus.Connected,
message = result.message.ifBlank { "Server is reachable." },
canRetry = false
)
is ConnectionCheckResult.Offline -> ConnectionUiState(
config = config,
status = ConnectionStatus.Offline,
message = result.message.ifBlank { "Server is offline or unreachable." },
canRetry = true
)
is ConnectionCheckResult.Unauthorized -> ConnectionUiState(
config = config,
status = ConnectionStatus.Unauthorized,
message = result.message.ifBlank { "Authentication was rejected." },
canRetry = true
)
}
fun invalid(form: ConnectionForm, message: String): ConnectionUiState =
ConnectionUiState(
config = ConnectionConfig(
serverUrl = form.serverUrl.trim(),
authMode = form.authMode,
hasBearerToken = form.bearerToken.isNotBlank(),
sessionLabel = form.sessionLabel.trim().ifBlank { "Default session" }
),
status = ConnectionStatus.Unconfigured,
message = message,
urlError = message,
canRetry = false
)
}
interface ConnectionStore {
var serverUrl: String
var sessionLabel: String
var authMode: ConnectionAuthMode
var bearerToken: String
fun config(): ConnectionConfig =
ConnectionConfig(
serverUrl = serverUrl,
authMode = authMode,
hasBearerToken = bearerToken.isNotBlank(),
sessionLabel = sessionLabel
)
fun save(form: ConnectionForm, normalizedUrl: String) {
serverUrl = normalizedUrl
authMode = form.authMode
sessionLabel = form.sessionLabel
bearerToken = if (form.authMode == ConnectionAuthMode.BearerToken) form.bearerToken else ""
}
}
interface ConnectionRepository {
fun config(): ConnectionConfig
fun save(form: ConnectionForm): Result<ConnectionUiState>
suspend fun check(): ConnectionUiState
}
class StoredConnectionRepository(
private val store: ConnectionStore,
private val healthCheck: suspend () -> ConnectionCheckResult
) : ConnectionRepository {
override fun config(): ConnectionConfig = store.config()
override fun save(form: ConnectionForm): Result<ConnectionUiState> {
val effectiveForm = if (
form.authMode == ConnectionAuthMode.BearerToken &&
form.bearerToken.isBlank() &&
store.bearerToken.isNotBlank()
) {
form.copy(bearerToken = store.bearerToken)
} else {
form
}
return ConnectionValidator.validateForm(effectiveForm).mapCatching { config ->
store.save(effectiveForm, config.serverUrl)
ConnectionStateReducer.saved(store.config())
}.recoverCatching {
throw IllegalArgumentException(it.message ?: "Connection settings are invalid.")
}
}
override suspend fun check(): ConnectionUiState =
ConnectionStateReducer.result(store.config(), healthCheck())
}
@@ -0,0 +1,92 @@
package cloud.molberg.hermesmobile.connection
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import android.util.Base64
class SecureConnectionStorage(context: Context) : ConnectionStore {
private val prefs: SharedPreferences =
context.getSharedPreferences("connection", Context.MODE_PRIVATE)
override var serverUrl: String
get() = prefs.getString(KEY_URL, "") ?: ""
set(value) {
prefs.edit().putString(KEY_URL, value.trim().trimEnd('/')).apply()
}
override var sessionLabel: String
get() = prefs.getString(KEY_SESSION, "Default session") ?: "Default session"
set(value) {
prefs.edit().putString(KEY_SESSION, value.trim().ifBlank { "Default session" }).apply()
}
override var authMode: ConnectionAuthMode
get() = runCatching {
ConnectionAuthMode.valueOf(prefs.getString(KEY_AUTH_MODE, ConnectionAuthMode.BearerToken.name)!!)
}.getOrDefault(ConnectionAuthMode.BearerToken)
set(value) {
prefs.edit().putString(KEY_AUTH_MODE, value.name).apply()
}
override var bearerToken: String
get() = decrypt(prefs.getString(KEY_BEARER_TOKEN, null))
set(value) {
prefs.edit().apply {
if (value.isBlank()) remove(KEY_BEARER_TOKEN) else putString(KEY_BEARER_TOKEN, encrypt(value.trim()))
}.apply()
}
private fun encrypt(value: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
return listOf(cipher.iv, encrypted).joinToString(":") {
Base64.encodeToString(it, Base64.NO_WRAP)
}
}
private fun decrypt(value: String?): String {
if (value.isNullOrBlank()) return ""
return runCatching {
val parts = value.split(":")
if (parts.size != 2) return ""
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val encrypted = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
String(cipher.doFinal(encrypted), Charsets.UTF_8)
}.getOrDefault("")
}
private fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
)
return generator.generateKey()
}
private companion object {
const val KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "hermes_mobile_connection"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val GCM_TAG_BITS = 128
const val KEY_URL = "serverUrl"
const val KEY_SESSION = "sessionLabel"
const val KEY_AUTH_MODE = "authMode"
const val KEY_BEARER_TOKEN = "bearerToken"
}
}
@@ -26,11 +26,15 @@ data class ServerProfile(
)
data class ServerCapabilities(
val health: Boolean,
val models: Boolean,
val chatCompletions: Boolean,
val responses: Boolean,
val sessions: Boolean,
val streaming: Boolean,
val toolEvents: Boolean,
val artifacts: Boolean,
val companionAdapter: Boolean
val legacyCompanionAdapter: Boolean
)
data class AuthSession(
@@ -42,7 +42,7 @@ fun ServerProfileDto.toDomain(): ServerProfile =
}
fun ServerCapabilitiesDto.toDomain(): ServerCapabilities =
ServerCapabilities(sessions, streaming, toolEvents, artifacts, companionAdapter)
ServerCapabilities(health, models, chatCompletions, responses, sessions, streaming, toolEvents, artifacts, legacyCompanionAdapter)
fun AuthSessionDto.toDomain(): AuthSession =
AuthSession(id, profileId, state.toDomain(), principal, scopes, issuedAt, expiresAt)
@@ -197,9 +197,19 @@ private object FakeHermesFixtures {
val profile = ServerProfileDto(
id = "profile-local",
name = "Local Hermes",
baseUrl = "http://10.0.2.2:8787/",
baseUrl = "http://10.0.2.2:8000/",
auth = AuthConfigDto(mode = AuthModeDto.BearerToken, tokenAlias = "local-dev-key"),
capabilities = ServerCapabilitiesDto(companionAdapter = true)
capabilities = ServerCapabilitiesDto(
health = true,
models = true,
chatCompletions = true,
responses = true,
sessions = false,
streaming = false,
toolEvents = false,
artifacts = false,
legacyCompanionAdapter = false
)
)
val auth = AuthSessionDto(
@@ -228,8 +238,8 @@ private object FakeHermesFixtures {
callId = "call-workspace",
name = "workspace.inspect",
status = ToolStatusDto.Succeeded,
summary = "Found Android app, companion package, and project docs.",
output = "apps/mobile/android\napps/companion\nPROJECT_PLAN.md\ndocs/ROADMAP.md"
summary = "Found Android app and project docs.",
output = "apps/mobile/android\nPROJECT_PLAN.md\ndocs/ROADMAP.md"
)
)
@@ -262,7 +272,7 @@ private object FakeHermesFixtures {
sessionId = "session-release",
role = MessageRoleDto.Assistant,
parts = listOf(
MessagePartDto.Text("Use the local Android build as the first gate, then exercise the companion flows separately."),
MessagePartDto.Text("Use the local Android build as the first gate, then exercise direct gateway flows separately."),
MessagePartDto.Code("bash", "GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug"),
MessagePartDto.ToolResultRef("tool-workspace"),
MessagePartDto.ArtifactRef("artifact-checklist")
@@ -31,11 +31,15 @@ data class AuthConfigDto(
)
data class ServerCapabilitiesDto(
val health: Boolean = true,
val models: Boolean = true,
val chatCompletions: Boolean = true,
val responses: Boolean = true,
val sessions: Boolean = true,
val streaming: Boolean = true,
val toolEvents: Boolean = true,
val artifacts: Boolean = true,
val companionAdapter: Boolean = false
val legacyCompanionAdapter: Boolean = false
)
data class AuthSessionDto(
@@ -0,0 +1,163 @@
package cloud.molberg.hermesmobile.connection
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionStateTest {
@Test
fun validatorNormalizesHttpUrlsAndRejectsUnsafeCredentials() {
val normalized = ConnectionValidator.normalizeServerUrl(" https://hermes.example.test/ ").getOrThrow()
val embeddedCredentials = ConnectionValidator.normalizeServerUrl("https://user:secret@hermes.example.test")
assertEquals("https://hermes.example.test", normalized)
assertTrue(embeddedCredentials.isFailure)
}
@Test
fun validatorRequiresTokenForBearerAuthentication() {
val missingToken = ConnectionValidator.validateForm(
ConnectionForm(serverUrl = "http://10.0.2.2:8787", authMode = ConnectionAuthMode.BearerToken)
)
val blankToken = ConnectionValidator.validateForm(
ConnectionForm(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.BearerToken,
bearerToken = " "
)
)
val anonymous = ConnectionValidator.validateForm(
ConnectionForm(serverUrl = "http://10.0.2.2:8787", authMode = ConnectionAuthMode.None)
)
assertTrue(missingToken.isFailure)
assertTrue(blankToken.isFailure)
assertTrue(anonymous.isSuccess)
assertFalse(anonymous.getOrThrow().hasBearerToken)
}
@Test
fun validatorAcceptsStoredBearerTokenWithoutExposingItInConfig() {
val config = ConnectionValidator.validateForm(
ConnectionForm(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.BearerToken,
bearerToken = ""
),
hasStoredBearerToken = true
).getOrThrow()
assertTrue(config.hasBearerToken)
assertEquals("http://10.0.2.2:8787", config.serverUrl)
}
@Test
fun reducerSurfacesOfflineUnauthorizedRetryAndConnectedStates() {
val config = ConnectionConfig(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.BearerToken,
hasBearerToken = true
)
val offline = ConnectionStateReducer.result(config, ConnectionCheckResult.Offline("No route to host."))
val unauthorized = ConnectionStateReducer.result(config, ConnectionCheckResult.Unauthorized("Rejected."))
val connected = ConnectionStateReducer.result(config, ConnectionCheckResult.Healthy("Server is reachable."))
assertEquals(ConnectionStatus.Offline, offline.status)
assertTrue(offline.canRetry)
assertEquals(ConnectionStatus.Unauthorized, unauthorized.status)
assertTrue(unauthorized.canRetry)
assertEquals(ConnectionStatus.Connected, connected.status)
assertFalse(connected.canRetry)
}
@Test
fun invalidFormKeepsDraftValuesWithoutClaimingConnection() {
val state = ConnectionStateReducer.invalid(
ConnectionForm(serverUrl = "ftp://example.test", bearerToken = "redacted-test-token"),
"Enter a valid http or https URL."
)
assertEquals(ConnectionStatus.Unconfigured, state.status)
assertEquals("ftp://example.test", state.config.serverUrl)
assertTrue(state.config.hasBearerToken)
assertEquals("Enter a valid http or https URL.", state.urlError)
}
@Test
fun repositoryPreservesExistingBearerTokenWhenSavingMetadataOnly() {
val store = InMemoryConnectionStore().apply {
serverUrl = "https://old.example.test"
authMode = ConnectionAuthMode.BearerToken
bearerToken = "stored-token"
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Healthy("ok")
}
val saved = repository.save(
ConnectionForm(
serverUrl = " https://hermes.example.test/ ",
authMode = ConnectionAuthMode.BearerToken,
bearerToken = "",
sessionLabel = "Mobile"
)
).getOrThrow()
assertEquals(ConnectionStatus.Checking, saved.status)
assertEquals("https://hermes.example.test", store.serverUrl)
assertEquals("stored-token", store.bearerToken)
assertEquals("Mobile", store.sessionLabel)
assertTrue(saved.config.hasBearerToken)
}
@Test
fun repositoryClearsBearerTokenForAnonymousConnection() {
val store = InMemoryConnectionStore().apply {
bearerToken = "stored-token"
authMode = ConnectionAuthMode.BearerToken
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Healthy("ok")
}
repository.save(
ConnectionForm(
serverUrl = "http://10.0.2.2:8787",
authMode = ConnectionAuthMode.None,
bearerToken = "ignored-token"
)
).getOrThrow()
assertEquals(ConnectionAuthMode.None, store.authMode)
assertEquals("", store.bearerToken)
assertFalse(repository.config().hasBearerToken)
}
@Test
fun repositoryCheckMapsHealthResultToUiState() {
val store = InMemoryConnectionStore().apply {
serverUrl = "http://10.0.2.2:8787"
authMode = ConnectionAuthMode.BearerToken
bearerToken = "stored-token"
}
val repository = StoredConnectionRepository(store) {
ConnectionCheckResult.Unauthorized("Authentication was rejected.")
}
val state = kotlinx.coroutines.runBlocking { repository.check() }
assertEquals(ConnectionStatus.Unauthorized, state.status)
assertTrue(state.canRetry)
assertTrue(state.config.hasBearerToken)
}
private class InMemoryConnectionStore : ConnectionStore {
override var serverUrl: String = ""
override var sessionLabel: String = "Default session"
override var authMode: ConnectionAuthMode = ConnectionAuthMode.BearerToken
override var bearerToken: String = ""
}
}
@@ -25,19 +25,37 @@ import org.junit.Test
class HermesMapperTest {
@Test
fun serverProfileTrimsUrlAndPreservesOptionalCompanionCapability() {
fun serverProfileTrimsUrlAndPreservesDirectGatewayCapabilities() {
val profile = ServerProfileDto(
id = "p1",
name = "",
baseUrl = "https://hermes.example.test/",
auth = AuthConfigDto(AuthModeDto.BearerToken, tokenAlias = "prod"),
capabilities = ServerCapabilitiesDto(companionAdapter = true)
capabilities = ServerCapabilitiesDto(
health = true,
models = true,
chatCompletions = true,
responses = true,
sessions = false,
streaming = false,
toolEvents = false,
artifacts = false,
legacyCompanionAdapter = true
)
).toDomain()
assertEquals("https://hermes.example.test", profile.baseUrl)
assertEquals("https://hermes.example.test", profile.name)
assertEquals(AuthMode.BearerToken, profile.authMode)
assertTrue(profile.capabilities.companionAdapter)
assertTrue(profile.capabilities.health)
assertTrue(profile.capabilities.models)
assertTrue(profile.capabilities.chatCompletions)
assertTrue(profile.capabilities.responses)
assertFalse(profile.capabilities.sessions)
assertFalse(profile.capabilities.streaming)
assertFalse(profile.capabilities.toolEvents)
assertFalse(profile.capabilities.artifacts)
assertTrue(profile.capabilities.legacyCompanionAdapter)
}
@Test
@@ -23,11 +23,15 @@ class FakeHermesRepositoryTest {
authMode = AuthMode.BearerToken,
tokenAlias = "test-token",
capabilities = ServerCapabilities(
health = true,
models = true,
chatCompletions = true,
responses = true,
sessions = true,
streaming = true,
toolEvents = true,
artifacts = true,
companionAdapter = false
legacyCompanionAdapter = false
),
createdAt = null,
updatedAt = null
@@ -56,6 +60,11 @@ class FakeHermesRepositoryTest {
val events = repository.streamEvents(profile.id, session.id).toList()
assertEquals(AuthState.Authenticated, auth!!.state)
assertTrue(profile.capabilities.health)
assertTrue(profile.capabilities.models)
assertTrue(profile.capabilities.chatCompletions)
assertTrue(profile.capabilities.responses)
assertEquals(false, profile.capabilities.legacyCompanionAdapter)
assertEquals(3, messages.size)
assertEquals("workspace.inspect", tools.single().name)
assertEquals("beta-checklist.md", artifacts.single().name)
+65
View File
@@ -0,0 +1,65 @@
# Hermes Mobile Direct Gateway Architecture
Hermes Mobile is pivoting to a native Android client that talks directly to a remote upstream Hermes Agent gateway/API server. The `apps/companion` TypeScript server is legacy compatibility code and must not be required by Android or future releases.
## Architecture
```text
Android app
Kotlin/Compose UI
connection/auth storage
transport DTOs
domain repositories
|
| HTTPS, bearer token when configured
v
Upstream Hermes Agent gateway/API server
auth/session authority
chat/completion authority
streaming/tool/artifact authority
provider credentials and Hermes runtime
```
The Android app remains a thin client. It may store server profiles, non-provider UI preferences, selected session IDs, and Android-secured bearer tokens. It must not embed provider credentials, clone Hermes backend behavior, or make the legacy companion server part of the release path.
## Documented Upstream Surface
The current repository/context documents these upstream Hermes API-server endpoints:
| Endpoint | Status | Mobile contract |
| --- | --- | --- |
| `GET /health` | Documented | Primary reachability and compatibility probe. Response shape beyond a successful JSON response is treated as server-specific. |
| `GET /v1/models` | Documented | Authenticated compatibility probe for bearer-token connections and future model picker source. |
| `POST /v1/chat/completions` | Documented | Baseline OpenAI-compatible chat path. Mobile must assume this may not expose Hermes session IDs or structured tool events. |
| `POST /v1/responses` | Documented | Preferred future request/streaming path if upstream exposes richer response events. Exact Hermes event mapping is not yet verified. |
These are the only upstream API-server routes currently treated as documented by this repository. Any route not listed here must be labeled provisional until checked against official Hermes Agent docs or a running gateway.
## Unverified Assumptions
The following are useful design assumptions, not confirmed contracts:
- Bearer authentication is accepted through an `Authorization: Bearer <token>` header on `/v1/*`.
- `/health` returns a stable `status` field.
- `/v1/responses` can provide enough streaming metadata to preserve Hermes sessions, tool lifecycle, artifacts, cancellation, and reconnect state.
- Hermes session listing, creation, continuation, and deletion have stable direct HTTP endpoints.
- Tool result and artifact listing/download have stable direct HTTP endpoints.
- Cancellation has a stable direct HTTP endpoint.
- Error payloads consistently include `message` or `error`.
Android contracts may model these needs, but implementation must keep them capability-gated until verified.
## Compatibility Policy
- Direct gateway routes are the default target for all new Android transport work.
- Legacy companion routes such as `/api/health`, `/api/auth/validate`, `/api/chat`, `/api/files/*`, and `/api/terminal/*` are temporary compatibility only.
- UI and domain terminology should say "gateway", "server", or "upstream Hermes" unless explicitly describing legacy companion behavior.
- The transport/domain model exposes direct gateway capabilities first. `legacyCompanionAdapter` exists only to identify old adapters during migration.
- Backend behavior belongs upstream in Hermes Agent. Hermes Mobile may add client adapters and mappers, not server-side replacements.
## Android Contract Milestones
1. Auth and connection: validate URL, store bearer tokens securely, probe `GET /health`, and use `GET /v1/models` as the first authenticated check.
2. Sessions: keep repository interfaces for list/create/select/continue, but mark route mapping provisional until upstream session docs are verified.
3. Streaming: support final response and token delta reduction now; gate structured tool/artifact/session events behind server capabilities.
4. Companion removal: after direct gateway chat/session/streaming works end to end, remove legacy companion UI routes and then deprecate install/docs around `apps/companion`.
+11 -11
View File
@@ -1,23 +1,23 @@
# Hermes Mobile Beta Roadmap
## Current Milestone: Native Android Beta Chat and Connection UX
## Current Milestone: Native Android Direct Gateway Contract
Status: in progress, close to beta validation. F0 source landing and F1 design-system extraction are complete; Android debug builds now pass in the shared Gradle cache environment.
Status: in progress. Android foundation work is complete, and the beta path now targets a remote upstream Hermes Agent gateway/API server directly. The legacy `apps/companion` server is compatibility-only and not required for future Android releases.
Done:
- Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface.
- Companion settings flow stores companion URL and access key locally, validates bearer auth, and surfaces health errors in-app.
- Android connection flow stores a gateway URL and bearer token locally, validates URL/auth state, and surfaces offline/unauthorized/retry states in-app.
- Chat UI supports conversation list/thread navigation, native composer, busy state, selectable replies, error bubbles, and new-chat reset.
- Companion `/api/chat` invokes Hermes through the CLI with `hermes chat -Q --source tool -q <prompt>`.
- Companion chat responses sanitize terminal banners/help/control output, truncate large replies for mobile, report CLI availability, and return structured failure responses.
- Workspace utilities remain native: command runner, health view, file browsing, read, and write.
- Direct gateway compatibility contract is documented in `docs/DIRECT_GATEWAY_ARCHITECTURE.md`.
- Legacy companion chat, terminal, and file utilities remain in the repo during migration but are not the target architecture.
- Local build artifacts, Android transient build output, `.gradle-user/`, and `.dev/` are ignored.
- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24.
Remaining for beta:
- Android debug build validation passed on 2026-07-24: `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android`.
- Exercise a real paired device against a running companion and authenticated Hermes CLI.
- Exercise upstream-compatible transport/auth/session/streaming behavior end to end: bearer auth, health, chat request/response, session continuity expectations, streamed/final output handling, and failure recovery.
- Exercise a real device or emulator against a running upstream Hermes gateway/API server.
- Verify direct gateway behavior end to end: bearer auth, `GET /health`, `GET /v1/models`, chat request/response, session continuity expectations, streamed/final output handling, and failure recovery.
- Verify or revise provisional session, streaming, tool-event, artifact, and cancellation route assumptions against official upstream docs.
- Add or run focused Android UI validation for chat/settings flows on small and large screens.
- Confirm adaptive accessibility: font scaling, TalkBack labels, contrast, keyboard/IME behavior, and reduced-motion tolerance.
- Document beta install/run steps after the native build is verified.
@@ -26,9 +26,9 @@ Remaining for beta:
- Native Kotlin/Compose Android app is the shipped beta client.
- No WebView, Capacitor runtime dependency, fake backend, embedded secrets, or new external infrastructure is introduced for beta.
- Companion transport and auth are upstream-compatible and exercised against the real Hermes CLI.
- Chat/session/streaming behavior is exercised with Hermes, including success, CLI unavailable, timeout, non-zero exit, and malformed response cases.
- Direct gateway transport and auth are exercised against the upstream Hermes gateway/API server.
- Chat/session/streaming behavior is exercised with Hermes, including success, unauthorized, timeout, server error, malformed response, and stream interruption cases.
- Settings connection flow is reliable: save, validate, health, Hermes test, offline/unauthorized states, and recovery are clear.
- Adaptive accessibility passes on target Android devices or emulators.
- Required checks pass: `./gradlew :app:assembleDebug` from `apps/mobile/android`, companion typecheck/build, and relevant lint.
- Required checks pass: `./gradlew :app:testDebugUnitTest :app:assembleDebug` from `apps/mobile/android` and relevant source lint.
- Release notes clearly list remaining known beta limitations.