Compare commits

..
Author SHA1 Message Date
Bailey Dixon 447ec356d7 Merge pull request #359 from Codename-11/dev
release(android): android-v1.9.0
2026-08-14 21:32:50 -04:00
Bailey Dixon ab359e5efb release(android): android-v1.9.0 2026-08-14 21:01:51 -04:00
Bailey Dixon 86b8161cb7 feat(android): refine sessions and messaging 2026-08-14 20:58:31 -04:00
Bailey Dixon e401fdb0c7 fix(android): persist landed message reactions 2026-08-14 17:04:54 -04:00
Bailey Dixon d4325d5aab fix(android): restore compact chat interactions 2026-08-14 16:57:19 -04:00
Bailey Dixon c734d75484 feat(android): add desktop-style session profiles 2026-08-14 16:57:18 -04:00
Bailey Dixon 0411804780 fix(android): accept compatible session flags 2026-08-14 16:57:18 -04:00
Bailey Dixon 8f89c2841d fix(android): keep dashboard teardown off main thread 2026-08-14 16:57:18 -04:00
Bailey Dixon 933c1842a0 Merge pull request #358 from Codename-11/chore/backmerge-desktop-beta3
chore: back-merge Desktop beta.3 release
2026-08-14 16:31:24 -04:00
Bailey Dixon dfe1b53327 chore: back-merge desktop beta.3 release 2026-08-14 16:30:37 -04:00
Bailey Dixon d36580a983 Merge pull request #356 from Codename-11/dev
release: Desktop beta.3 process-containment patch
2026-08-14 16:11:35 -04:00
Bailey Dixon 2d178ff884 Merge pull request #357 from Codename-11/release/desktop-0.4.0-beta.3
fix(desktop): close process containment race
2026-08-14 16:05:00 -04:00
Bailey Dixon d61955f82a fix(desktop): close process containment race 2026-08-14 15:58:57 -04:00
Bailey Dixon adec6ed2c0 Merge pull request #355 from Codename-11/release/desktop-0.4.0-beta.3
release(desktop): desktop-v0.4.0-beta.3
2026-08-14 15:44:24 -04:00
Bailey Dixon e9e44c8bb2 release(desktop): desktop-v0.4.0-beta.3 2026-08-14 15:36:53 -04:00
Bailey Dixon c6b0732a02 Merge pull request #354 from Codename-11/fix/desktop-tray-process-containment
fix(desktop): contain tray subprocess storms
2026-08-14 15:22:21 -04:00
Bailey Dixon d919115788 fix(desktop): contain tray subprocess storms 2026-08-14 15:15:12 -04:00
Bailey Dixon 49da085ae8 Merge pull request #353 from Codename-11/chore/backmerge-desktop-beta2-server-1.8.0
chore: backmerge Desktop beta.2 and Server 1.8.0 releases
2026-08-14 15:05:21 -04:00
Bailey Dixon 889b6f0858 chore: merge desktop beta.2 and server 1.8.0 release history into dev 2026-08-14 15:05:12 -04:00
Bailey Dixon 5100c524f6 Merge pull request #351 from Codename-11/dev
release: Desktop beta.2 and Server 1.8.0
2026-08-14 15:04:03 -04:00
Bailey Dixon c609ae867f Merge pull request #352 from Codename-11/chore/backmerge-desktop-beta1
chore: back-merge Desktop beta.1 release history
2026-08-14 14:49:23 -04:00
Bailey Dixon 107f8c7720 chore: merge desktop beta.1 release history into dev 2026-08-14 14:49:08 -04:00
Bailey Dixon 8963e4fafd Merge pull request #350 from Codename-11/release/server-1.8.0
release(server): server-v1.8.0
2026-08-14 14:46:57 -04:00
Bailey Dixon 0d6c3bd6b0 Merge pull request #346 from Codename-11/dev
release(desktop): desktop-v0.4.0-beta.1
2026-08-13 21:18:41 -04:00
Bailey Dixon 7440ef2948 Merge pull request #343 from Codename-11/dev
release: server 1.7.0 and desktop 0.4.0-alpha.8
2026-08-13 17:14:58 -04:00
71 changed files with 4504 additions and 527 deletions
+37 -2
View File
@@ -80,7 +80,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.x'
bun-version-file: 'desktop/.bun-version'
- name: Install deps
run: npm ci
@@ -153,6 +153,40 @@ jobs:
desktop/dist/bin/hermes-relay-darwin-arm64
retention-days: 7
smoke-windows-cli-release-asset:
name: Smoke exact Windows CLI release asset
runs-on: windows-latest
needs:
- validate-release
- build-cli-binaries
steps:
- uses: actions/download-artifact@v8
with:
name: cli-binaries
path: release-assets
- name: Repeated launch and process cleanup gate
shell: pwsh
env:
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
run: |
$ErrorActionPreference = 'Stop'
$exe = (Resolve-Path 'release-assets/hermes-relay-win-x64.exe').Path
1..20 | ForEach-Object {
$output = & $exe --version
if ($LASTEXITCODE -ne 0) { throw "Windows CLI smoke failed with exit $LASTEXITCODE" }
if ($output -ne "hermes-relay $env:EXPECTED_DESKTOP_VERSION") {
throw "Unexpected Windows CLI version output: $output"
}
}
Start-Sleep -Milliseconds 500
$leftovers = Get-CimInstance Win32_Process | Where-Object {
$_.ExecutablePath -eq $exe
}
if ($leftovers) {
throw "Windows CLI smoke left $(@($leftovers).Count) process(es) behind"
}
build-windows-tray-installer:
name: Build Windows tray installer
runs-on: windows-latest
@@ -175,7 +209,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.x'
bun-version-file: 'desktop/.bun-version'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -375,6 +409,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-cli-binaries
- smoke-windows-cli-release-asset
- build-windows-tray-installer
steps:
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
+14
View File
@@ -6,19 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [1.9.0] - 2026-08-14
### Added
- **Android session browsing matches Hermes Desktop's recent organization model.** The primary session drawer can toggle between the active profile and all profiles, group by recency, project, status, or profile, order by supported session metrics, and narrow rows by status, project, profile, or pull-request state without collapsing duplicate IDs across profile stores. Named profiles receive stable identity-color badges with locally persisted color overrides.
- **Android can edit current Hermes profiles through the standard Gateway.** The Profile Inspector capability-gates `profiles.describe` and `profiles.configure`, keeps Relay-only memory editing and older-Hermes fallback intact, and reports partial section saves without discarding failed drafts.
- **Android sessions show their coding context when Hermes supplies it.** Session rows can display repository, Git branch, and the current state of the pull request created by that session while older hosts remain unchanged.
- Android Manage can now finish host-owned backup workflows, edit or remove learning nodes with explicit recovery guidance, configure and activate memory providers, and complete profile-scoped WhatsApp QR onboarding through the authenticated upstream Dashboard contracts.
### Fixed
- **Android network clients shut down safely during route changes.** Replacing an authenticated Dashboard client now moves OkHttp connection-pool eviction off the main thread, preventing a live TLS socket close from crashing the app with `NetworkOnMainThreadException`. (#334)
- **Android preserves authoritative Gateway outcomes.** Protected-file cards cannot offer forbidden persistent scopes, compression no-ops show the server result, bounded resume failures do not create context-free replacement sessions, and edit/regenerate retains durable row identities across consecutive rewinds.
- **Android routes and uploads against live upstream truth.** Multiplex API fallback trusts `served_profiles` instead of installed profiles, and generic documents carry the Gateway-issued `@file:` reference into ordinary and queued prompts.
- **Android clarify cards preserve upstream decision semantics.** Multi-select prompts keep independent selections and submit one exact list, while server expiry events—not an invented local deadline—retire unanswered cards.
- **Android keeps profile management and retained automation truthful.** Custom Endpoint list and mutation routes now follow the selected Hermes profile, while completed one-shot cron jobs show their retained outcome and expose only valid Runs/Delete actions.
- **Android and Relay recover more generated media reliably.** Android accepts upstream-valid wrapped, punctuated, adjacent, spaced, and Windows `MEDIA:` markers without consuming fenced examples, and Relay translates Docker-visible workspace, home, cache, and configured-mount paths before applying its existing credential, sandbox, and size checks.
- **Android keeps cross-profile sessions with their owning agent.** Opening a session from All Profiles hydrates, resumes, sends, and renders with that session's profile without changing the global profile selection; New Chat from that view starts with the default profile.
- **Android reactions and standard voice follow the active conversation.** Reactions resolve durable rows for both user and assistant messages, while Vanilla Hermes voice remains on the authenticated Gateway instead of requiring the optional API fallback.
- **Android session navigation behaves predictably.** The drawer closes on outside taps, uses an ungrouped recent-session list by default, retains project grouping as an explicit option, and exposes secondary actions in All Profiles mode.
## [0.4.0-beta.3] - 2026-08-14
### Fixed
- **Windows tray polling can no longer accumulate unbounded helper processes.** Grant discovery now uses lightweight local state, management refreshes are single-flight and visibility-aware, and child probes have hard timeouts, bounded output, tree cleanup, caching, and backoff. A dedicated bounded `tray.log` records sanitized operational failures without mixing them into daemon logs.
- **Concurrent Desktop lifecycle requests cannot start duplicate daemons.** Cross-process lifecycle and runtime ownership locks serialize startup and recovery while preserving stale-owner cleanup.
## [1.8.0] - 2026-08-14
+6 -10
View File
@@ -2,26 +2,22 @@
**Release Date:** 2026-08-14
This beta makes connection recovery and Activity evidence inspectable in the compact management UI, and keeps the preferred CUA control engine usable when its upstream whole-desktop accessibility probe times out.
This patch prevents the Windows management tray from accumulating Hermes-Relay, registry, and ADB helper processes when a refresh is slow or fails, and adds bounded diagnostics for future recovery.
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64; the management UI is Windows-only.
## What's changed
### Added
- **Inspectable Activity evidence.** Commands, files, device work, connection lifecycle, and computer control share a consistent event stepper with dedicated failure details.
- **Optional screenshot retention.** Screenshot events can keep bounded local PNG evidence for Off, 1 day, 7 days, or 30 days and open it in a larger borderless viewer. Evidence stays outside the JSON activity log.
### Changed
- **Connection state is live and actionable.** The UI distinguishes connected, reconnecting, and stopped states, shows retry timing, and offers Retry now without freezing the popup.
- **Connection notices stay out of the way.** Compact connect, disconnect, and reconnect cards appear only while the main management UI is hidden.
- **Grant discovery stays lightweight.** The approval window reads local bridge state instead of rebuilding the complete management snapshot, schedules each refresh only after the previous one finishes, and pauses idle polling while hidden.
- **Management refreshes are visibility-aware and resilient.** Concurrent snapshot requests share one bounded result, optional static checks are cached, and repeated failures back off instead of creating more work.
### Fixed
- **CUA readiness no longer depends on the flaky global accessibility scan.** Hermes verifies the canonical runtime, required tools, daemon, and safe permission mode before structured control; explicit accessibility health remains available for diagnosis and individual actions still fail closed.
- **Connection errors retain useful context.** Activity records bounded retry and recovery evidence without flooding one event per backoff attempt.
- **Windows helper processes are contained.** Tray-launched commands have hard deadlines, bounded output capture, descendant cleanup, and suppressed loader-error dialogs, preventing stalled probes from growing into a process storm.
- **Daemon startup is serialized across launchers.** Cross-process lifecycle and runtime ownership locks prevent concurrent start or restart requests from leaving duplicate daemons behind.
- **Tray failures are diagnosable without exposing command data.** A rotated, sanitized `tray.log` records bounded probe and lifecycle outcomes separately from `daemon.log`.
## Install
+3
View File
@@ -128,6 +128,7 @@ optional Windows installer.
| File | Purpose |
|---|---|
| `desktop/package.json` | canonical CLI version |
| `desktop/.bun-version` | exact Bun compiler/runtime for standalone binaries |
| `desktop/package-lock.json` | npm root/workspace package metadata |
| `desktop/src/version.ts` | compiled CLI runtime version |
| `desktop/tray/Cargo.toml` | native systray package version |
@@ -152,6 +153,8 @@ manually, run `npm run sync:version` before checking. `npm run verify` is the
single Windows release-parity gate: version sync, type-check, tests, TypeScript
build, compiled CLI smoke, and tray formatting, Clippy, check, and tests. CI runs
the portable portions on every desktop change and the Windows tray gates separately.
Release jobs read `desktop/.bun-version`; cross-built and Windows-built artifacts
must not silently embed different Bun runtime versions.
## Branching policy
+27 -13
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.8.1
# Hermes-Relay-Android v1.9.0
**Release Date:** August 9, 2026
**Release Date:** August 14, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.8.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.9.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,22 +12,36 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch keeps long Hermes conversations complete and aligns Android's
Gateway behavior with current upstream turn contracts.
This release makes multi-profile session browsing feel native, keeps reactions
and voice attached to the correct conversation, and expands standard Gateway
management without requiring the optional Relay plugin.
## Added
- Browse one profile or all profiles, customize sorting and filters, and
optionally group sessions by project, recency, status, or profile.
- See profile identity, repository, branch, and pull-request context directly
in session rows when Hermes supplies it.
- Edit current Hermes profiles and complete more Manage workflows through the
authenticated standard Gateway.
## Fixed
- Complete transcript reads page explicitly across both API-server and
profile-scoped Dashboard routes, so sessions beyond Hermes' latest-500
default retain stable history, sharing, retry, edit, and recovery anchors.
- Gateway submit rejections preserve the authoritative server message without
silently falling through to SSE.
- Gateway event envelopes reconcile consistently, and edit-and-regenerate
requests send the required truncation confirmation.
- Cross-profile sessions hydrate and resume with their owning agent while All
Profiles remains selected; New Chat from that view respects the default
profile.
- Reactions pin to both user and assistant messages using durable message rows.
- Vanilla Hermes voice stays on the Gateway instead of depending on the
optional API fallback.
- Session navigation defaults to an ungrouped list, keeps project grouping
opt-in, restores secondary actions, and closes on outside taps.
- Route changes shut down network clients off the main thread, and Gateway
outcomes, uploads, clarify cards, automation state, and media recovery follow
authoritative upstream behavior.
## Install / Verify
- App version: **1.8.1** (versionCode **42**).
- App version: **1.9.0** (versionCode **43**).
- Standard Chat, sessions, Manage, and Vanilla Hermes voice continue to work
against unmodified upstream Hermes.
- The optional Relay plugin is not required for standard Android chat or hosted
@@ -1 +1 @@
Long sessions now retain complete history beyond Hermes' latest-500 default, keeping edit, retry, sharing, and recovery anchors stable. Gateway submit rejections preserve the server's message without unintended SSE fallback, while event envelopes and edit-and-regenerate requests follow current upstream contracts.
Browse sessions across profiles without losing their owning agent or your selected scope. Reactions now pin to both user and assistant messages, Vanilla Hermes voice stays on the authenticated Gateway, and New Chat in All Profiles respects the default profile. Sessions are ungrouped by default, with project grouping available in Customize Sessions.
+28
View File
@@ -1,5 +1,33 @@
{
"versions": [
{
"version": "1.9.0",
"title": "Better sessions, reactions, and voice",
"date": "2026-08-14",
"sections": [
{
"header": "Sessions keep their identity",
"bullets": [
"Browse one profile or all profiles, customize sorting and filters, and optionally group sessions by project, recency, status, or profile.",
"Cross-profile sessions hydrate, resume, and send with their owning agent without changing the global profile selection; New Chat in All Profiles uses the default profile."
]
},
{
"header": "Conversation controls stay attached",
"bullets": [
"Reactions pin to durable rows on both user and assistant messages.",
"Vanilla Hermes voice stays on the authenticated Gateway instead of requiring the optional API fallback."
]
},
{
"header": "Context without clutter",
"bullets": [
"Session rows show profile, project, branch, and pull-request context when Hermes supplies it, while the default view remains ungrouped.",
"The session drawer restores secondary actions in All Profiles and closes when you tap outside it."
]
}
]
},
{
"version": "1.8.1",
"title": "Complete, reliable transcripts",
+6 -5
View File
@@ -1,6 +1,7 @@
v1.8.1 - Complete, reliable transcripts
v1.9.0 - Better sessions, reactions, and voice
* Keep complete history in long sessions beyond Hermes' latest-500 default.
* Preserve stable edit, retry, sharing, and recovery anchors while paging history.
* Show authoritative Gateway rejection messages without an unintended fallback.
* Reconcile Gateway events and edit-and-regenerate requests with current upstream contracts.
* Browse all profiles without switching away from the selected scope.
* Start new chats with the default profile and hydrate history with the session owner.
* Pin reactions to both user and assistant messages.
* Keep Vanilla Hermes voice on the authenticated Gateway.
* Use an ungrouped session list by default, with project grouping available in Customize Sessions.
@@ -147,6 +147,12 @@ data class ChatMessage(
* never used as a Compose key or synthesized client-side.
*/
val rowId: Long? = null,
/**
* Durable iOS-style tapbacks attached to this server message. Hermes keeps
* one reaction per author in the message's display metadata; the UI also
* updates this list optimistically while a reaction write is in flight.
*/
val reactions: List<MessageReaction> = emptyList(),
/**
* Mixture-of-Agents advisor responses surfaced during the live turn.
* Unavailable advisors retain only neutral state, never their raw failure
@@ -156,6 +162,29 @@ data class ChatMessage(
val moaReferences: List<MoaReference> = emptyList(),
)
data class MessageReaction(
val emoji: String,
val author: String,
/** Epoch seconds, matching the Gateway/Desktop contract. */
val at: Double,
)
/** Apply Hermes' one-reaction-per-author, re-tap-to-retract semantics. */
internal fun applyMessageReaction(
reactions: List<MessageReaction>,
emoji: String?,
author: String = "user",
at: Double = System.currentTimeMillis() / 1000.0,
): List<MessageReaction> {
val previous = reactions.firstOrNull { it.author == author }
val withoutAuthor = reactions.filterNot { it.author == author }
return if (emoji.isNullOrBlank() || previous?.emoji == emoji) {
withoutAuthor
} else {
withoutAuthor + MessageReaction(emoji = emoji, author = author, at = at)
}
}
data class MoaReference(
val index: Int,
val count: Int?,
@@ -412,6 +441,11 @@ data class ChatSession(
val title: String?,
val model: String?,
val messageCount: Int = 0,
val inputTokens: Int = 0,
val outputTokens: Int = 0,
val actualCostUsd: Double? = null,
val estimatedCostUsd: Double? = null,
val isActive: Boolean = false,
val updatedAt: Long = 0L,
val startedAt: Long = 0L,
val lastActivityAt: Long = 0L,
@@ -436,6 +470,12 @@ data class ChatSession(
val pullRequestState: String? = null,
val pullRequestDraft: Boolean = false,
) {
val totalTokens: Int
get() = inputTokens + outputTokens
val costUsd: Double
get() = actualCostUsd ?: estimatedCostUsd ?: 0.0
val activityTimestamp: Long
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
@@ -16,6 +17,8 @@ import kotlinx.serialization.json.Json
data class ProfilePresentation(
val order: List<String> = emptyList(),
val hidden: Set<String> = emptySet(),
/** Local-only named-profile accent overrides, stored as normalized RGB hex. */
val colors: Map<String, String> = emptyMap(),
)
/**
@@ -63,14 +66,17 @@ class ProfilePresentationStore(
private val json = Json { ignoreUnknownKeys = true }
private val listSerializer = ListSerializer(String.serializer())
private val mapSerializer = MapSerializer(String.serializer(), String.serializer())
private fun orderKey(connectionId: String) = stringPreferencesKey("order_$connectionId")
private fun hiddenKey(connectionId: String) = stringPreferencesKey("hidden_$connectionId")
private fun colorsKey(connectionId: String) = stringPreferencesKey("colors_$connectionId")
fun presentationFlow(connectionId: String): Flow<ProfilePresentation> = dataStore.data.map { prefs ->
ProfilePresentation(
order = decode(prefs[orderKey(connectionId)]),
hidden = decode(prefs[hiddenKey(connectionId)]).toSet(),
colors = decodeMap(prefs[colorsKey(connectionId)]),
)
}
@@ -82,10 +88,18 @@ class ProfilePresentationStore(
dataStore.edit { it[hiddenKey(connectionId)] = json.encodeToString(listSerializer, hidden.sorted()) }
}
suspend fun setColors(connectionId: String, colors: Map<String, String>) {
dataStore.edit {
if (colors.isEmpty()) it.remove(colorsKey(connectionId))
else it[colorsKey(connectionId)] = json.encodeToString(mapSerializer, colors.toSortedMap())
}
}
suspend fun clear(connectionId: String) {
dataStore.edit {
it.remove(orderKey(connectionId))
it.remove(hiddenKey(connectionId))
it.remove(colorsKey(connectionId))
}
}
@@ -98,6 +112,12 @@ class ProfilePresentationStore(
} else {
runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())
}
private fun decodeMap(raw: String?): Map<String, String> = if (raw == null) {
emptyMap()
} else {
runCatching { json.decodeFromString(mapSerializer, raw) }.getOrDefault(emptyMap())
}
}
internal val Context.profilePresentationDataStore: DataStore<Preferences>
@@ -148,6 +148,8 @@ class ConnectionManager(
private val reconnectJitterUnit: () -> Double = { kotlin.random.Random.nextDouble() },
/** Exact-authority pinned client for a plugin-proxy WSS URL. */
private val proxyClientProvider: ((String) -> OkHttpClient?)? = null,
/** Test seam for observing lifecycle teardown without opening a socket. */
private val okHttpClientFactory: (() -> OkHttpClient)? = null,
) {
private val supervisorJob = SupervisorJob()
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
@@ -158,6 +160,7 @@ class ConnectionManager(
}
private fun buildClient(url: String? = null): OkHttpClient {
okHttpClientFactory?.let { return it() }
val builder = OkHttpClient.Builder()
// OkHttp's 10s default connectTimeout is LAN-tuned; a Tailscale
// DERP-relayed cold-start handshake can exceed it, and a failed
@@ -1520,7 +1520,8 @@ class ChatHandler {
// this as the same visible row across the post-turn reload.
prior.copy(
id = messageId,
rowId = item.rowId,
rowId = item.resolvedRowId,
reactions = item.reactions,
role = role,
content = cleanedContent,
attachments = carriedAttachments,
@@ -1551,7 +1552,8 @@ class ChatHandler {
// nothing local to carry).
ChatMessage(
id = messageId,
rowId = item.rowId,
rowId = item.resolvedRowId,
reactions = item.reactions,
role = role,
content = cleanedContent,
attachments = carriedAttachments,
@@ -2050,6 +2052,11 @@ class ChatHandler {
title = resolvedTitle,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
updatedAt = activityAtMs,
startedAt = startedAtMs,
lastActivityAt = lastActivityAtMs,
@@ -1661,7 +1661,7 @@ class GatewayChatClient(
* id. This follows the upstream gateway contract, which resolves the row
* atomically inside the active session. A null emoji removes the reaction.
*/
suspend fun reactToNewest(role: String, emoji: String?): Result<JsonObject> {
suspend fun reactToMessage(rowId: Long?, role: String, emoji: String?): Result<JsonObject> {
require(role == "user" || role == "assistant") { "unsupported reaction role" }
val sid = liveSessionId
?: return Result.failure(GatewayRpcException("no live session"))
@@ -1669,7 +1669,7 @@ class GatewayChatClient(
"message.react",
buildJsonObject {
put("session_id", sid)
put("newest_role", role)
if (rowId != null) put("row_id", rowId) else put("newest_role", role)
if (emoji == null) put("emoji", JsonNull) else put("emoji", emoji)
put("author", "user")
},
@@ -436,7 +436,8 @@ class HermesApiClient(
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
}
},
okHttpClient: OkHttpClient? = null,
) {
@Volatile
private var lastCapabilities: ServerCapabilities? = null
@@ -480,7 +481,7 @@ class HermesApiClient(
private val mainHandler = Handler(Looper.getMainLooper())
private val client: OkHttpClient = httpClient ?: OkHttpClient.Builder()
private val client: OkHttpClient = httpClient ?: okHttpClient ?: OkHttpClient.Builder()
.readTimeout(5, TimeUnit.MINUTES)
.connectTimeout(10, TimeUnit.SECONDS)
.build()
@@ -1,19 +1,24 @@
package com.hermesandroid.relay.network.upstream.models
import com.hermesandroid.relay.data.MessageReaction
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
@@ -97,6 +102,36 @@ object FlexibleLongSerializer : KSerializer<Long?> {
}
}
/**
* Dashboard versions may expose SQLite JSON columns either as an object or as
* their raw JSON string. Normalize both shapes so persisted presentation data
* (notably message reactions) survives a history reload on every supported
* upstream version.
*/
object FlexibleJsonObjectSerializer : KSerializer<JsonObject?> {
override val descriptor = JsonObject.serializer().descriptor
override fun deserialize(decoder: Decoder): JsonObject? {
return try {
val jsonDecoder = decoder as? JsonDecoder ?: return null
when (val element = jsonDecoder.decodeJsonElement()) {
is JsonObject -> element
is JsonPrimitive -> element.content.takeIf { it.isNotBlank() }
?.let { Json.parseToJsonElement(it) as? JsonObject }
else -> null
}
} catch (_: Exception) {
null
}
}
override fun serialize(encoder: Encoder, value: JsonObject?) {
val jsonEncoder = encoder as? JsonEncoder
?: throw SerializationException("FlexibleJsonObjectSerializer requires JSON")
jsonEncoder.encodeJsonElement(value ?: JsonNull)
}
}
/** Timestamp serializer for Hermes session metadata.
*
* Upstream currently returns epoch seconds for `started_at` / `last_active`;
@@ -134,6 +169,32 @@ object FlexibleTimestampSerializer : KSerializer<Double?> {
}
}
/**
* Boolean serializer for session flags backed by SQLite integer columns.
*
* Older Dashboard responses can expose those columns as `0` / `1` instead of
* JSON booleans. Accept the equivalent primitive forms without making arbitrary
* numbers or strings truthy, and always serialize back to a real JSON boolean.
*/
object FlexibleBooleanSerializer : KSerializer<Boolean> {
override val descriptor = PrimitiveSerialDescriptor("FlexibleBoolean", PrimitiveKind.BOOLEAN)
override fun deserialize(decoder: Decoder): Boolean {
val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeBoolean()
val element = jsonDecoder.decodeJsonElement()
val value = (element as? JsonPrimitive)?.content?.trim()?.lowercase()
return when (value) {
"true", "1" -> true
"false", "0" -> false
else -> throw SerializationException("Expected a boolean-compatible value, got $element")
}
}
override fun serialize(encoder: Encoder, value: Boolean) {
encoder.encodeBoolean(value)
}
}
// --- Session CRUD responses ---
@Serializable
@@ -187,9 +248,16 @@ data class SessionItem(
@SerialName("tool_call_count") val toolCallCount: Int? = null,
@SerialName("input_tokens") val inputTokens: Int? = null,
@SerialName("output_tokens") val outputTokens: Int? = null,
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
@SerialName("actual_cost_usd") val actualCostUsd: Double? = null,
@SerialName("estimated_cost_usd") val estimatedCostUsd: Double? = null,
@SerialName("is_active") val isActive: Boolean = false,
@SerialName("has_model_config")
@Serializable(with = FlexibleBooleanSerializer::class)
val hasModelConfig: Boolean = false,
/** Durable flags returned by current Dashboard and API-server session resources. */
@Serializable(with = FlexibleBooleanSerializer::class)
val pinned: Boolean = false,
@Serializable(with = FlexibleBooleanSerializer::class)
val archived: Boolean = false,
/** Optional workspace metadata added by newer Dashboard session lists. */
val cwd: String? = null,
@@ -327,7 +395,9 @@ data class MessageItem(
val timestamp: Double? = null,
@SerialName("finish_reason") val finishReason: String? = null,
@SerialName("display_kind") val displayKind: String? = null,
@SerialName("display_metadata") val displayMetadata: JsonObject? = null,
@SerialName("display_metadata")
@Serializable(with = FlexibleJsonObjectSerializer::class)
val displayMetadata: JsonObject? = null,
// Reasoning persisted with the assistant message (upstream serializes
// both names; reasoning is the canonical one). Restored into
// ChatMessage.thinkingContent so the Thought-process block survives a
@@ -335,11 +405,24 @@ data class MessageItem(
val reasoning: String? = null,
@SerialName("reasoning_content") val reasoningContent: String? = null,
) {
/**
* Dashboard history uses the SQLite row id as numeric `id`; Gateway
* history exposes the same value explicitly as `row_id`. Match Desktop by
* accepting either representation so persisted rows remain directly
* reactable after reload.
*/
val resolvedRowId: Long?
get() = rowId ?: id?.toLongOrNull()
/** Reasoning text under whichever field name the server used. */
val resolvedReasoning: String?
get() = reasoning?.takeIf { it.isNotBlank() }
?: reasoningContent?.takeIf { it.isNotBlank() }
/** Persisted tapbacks stored by Hermes in display_metadata.reactions. */
val reactions: List<MessageReaction>
get() = parseMessageReactions(displayMetadata?.get("reactions"))
/** Extract content as plain text string. Handles both string and array-of-parts formats. */
val contentText: String?
get() = when (content) {
@@ -367,6 +450,21 @@ data class MessageItem(
}
}
fun parseMessageReactions(element: JsonElement?): List<MessageReaction> =
(element as? JsonArray).orEmpty().mapNotNull { raw ->
val reaction = raw as? JsonObject ?: return@mapNotNull null
val emoji = (reaction["emoji"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
val author = (reaction["author"] as? JsonPrimitive)?.content
?.takeIf { it == "user" || it == "agent" }
?: return@mapNotNull null
MessageReaction(
emoji = emoji,
author = author,
at = (reaction["at"] as? JsonPrimitive)?.doubleOrNull ?: 0.0,
)
}
// --- SSE streaming events from /api/sessions/{id}/chat/stream ---
//
// Hermes WebAPI event types (from server source):
@@ -176,7 +176,9 @@ internal class HermesRuntimeBinder(
chat.setDisplayProfileProvider { connection.effectiveDisplayProfile.value }
chat.setDisplayAliasProvider { connection.profileDisplayAlias.value }
chat.setProfileSessionLister { connection.listProfileScopedSessions() }
chat.setProfileMessageLoaderWithMode(connection::loadProfileScopedMessages)
chat.setProfileMessageLoaderWithMode { profileName, sessionId, mode ->
connection.loadProfileScopedMessages(profileName, sessionId, mode)
}
chat.setDashboardConfigLoader { connection.loadActiveDashboardConfig() }
chat.profileSessionDeleter = connection::deleteProfileScopedSession
chat.profileSessionRenamer = connection::renameProfileScopedSession
@@ -94,7 +94,7 @@ import kotlinx.coroutines.delay
*/
enum class ChatInputTrailing { SEND, VOICE, STOP, STEER, QUEUE }
private val ChatComposerShape = RoundedCornerShape(18.dp)
private val ChatComposerShape = RoundedCornerShape(26.dp)
private val ChatInputChipShape = RoundedCornerShape(12.dp)
internal const val CHAT_INPUT_FIELD_TEST_TAG = "chat-input-field"
@@ -315,7 +315,7 @@ fun ChatInputBar(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp)
.padding(horizontal = 8.dp, vertical = 3.dp)
.then(surfaceModifier),
) {
Column {
@@ -341,15 +341,15 @@ fun ChatInputBar(
}
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
) {
BasicTextField(
value = value,
onValueChange = { if (it.length <= charLimit) onValueChange(it) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 34.dp)
.padding(horizontal = 8.dp, vertical = 4.dp)
.heightIn(min = 30.dp)
.padding(horizontal = 10.dp, vertical = 2.dp)
// Keep directional keys inside the editor. Compose's
// BasicTextField owns normal caret/selection movement;
// cancelling focus traversal prevents a boundary arrow
@@ -408,7 +408,7 @@ fun ChatInputBar(
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
.heightIn(min = 44.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
@@ -43,6 +44,7 @@ import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -90,6 +92,7 @@ import java.text.SimpleDateFormat
import java.util.Date
internal const val CHAT_PET_IDENTITY_OBSTACLE_PREFIX = "chat-message-identity:"
private val MESSAGE_REACTIONS = listOf("❤️", "👍", "👎", "😂", "‼️", "❓")
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -455,11 +458,51 @@ fun MessageBubble(
val showEditAction = onEditMessage != null && isUser
val showSpeakAction = shouldShowSpeakResponseAction(message, onSpeakMessage != null)
val showStopSpeakingAction = shouldShowStopSpeakingAction(message, onStopSpeaking != null)
val selectedUserReaction = message.reactions.firstOrNull { it.author == "user" }?.emoji
if (onQuoteMessage != null || onReact != null || showEditAction || showSpeakAction || showStopSpeakingAction) {
DropdownMenu(
expanded = showMessageActions,
onDismissRequest = { showMessageActions = false },
shape = RoundedCornerShape(24.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 3.dp,
shadowElevation = 8.dp,
) {
if (onReact != null) {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 6.dp),
) {
MESSAGE_REACTIONS.forEach { emoji ->
IconButton(
onClick = {
showMessageActions = false
onReact(emoji)
},
modifier = Modifier.background(
color = if (selectedUserReaction == emoji) {
MaterialTheme.colorScheme.secondaryContainer
} else {
Color.Transparent
},
shape = CircleShape,
),
) {
Text(
text = emoji,
fontSize = 24.sp,
modifier = Modifier.semantics {
contentDescription = "React with $emoji"
},
)
}
}
}
HorizontalDivider()
}
DropdownMenuItem(
text = { Text(stringResource(R.string.msg_bubble_copy)) },
onClick = {
@@ -515,8 +558,22 @@ fun MessageBubble(
},
)
}
if (onReact != null && selectedUserReaction != null) {
DropdownMenuItem(
text = { Text("Remove reaction") },
onClick = {
showMessageActions = false
onReact(null)
},
)
}
}
}
Box(
modifier = Modifier.padding(
bottom = if (message.reactions.isNotEmpty()) 8.dp else 0.dp,
),
) {
Surface(
shape = bubbleShape,
color = backgroundColor,
@@ -558,7 +615,7 @@ fun MessageBubble(
// same tactile confirm every chat app fires.
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (
onQuoteMessage != null || showEditAction || showSpeakAction ||
onQuoteMessage != null || onReact != null || showEditAction || showSpeakAction ||
showStopSpeakingAction
) {
showMessageActions = true
@@ -645,25 +702,6 @@ fun MessageBubble(
SelectionContainer { messageTextContent() }
}
}
if (onReact != null) {
listOf("👍", "❤️", "😂").forEach { emoji ->
DropdownMenuItem(
text = { Text("React $emoji") },
onClick = {
showMessageActions = false
onReact(emoji)
},
)
}
DropdownMenuItem(
text = { Text("Remove reaction") },
onClick = {
showMessageActions = false
onReact(null)
},
)
}
if (onSessionReference != null && sessionReferences.isNotEmpty()) {
sessionReferences.forEach { reference ->
TextButton(
@@ -838,6 +876,19 @@ fun MessageBubble(
}
}
}
if (message.reactions.isNotEmpty()) {
MessageReactionBadge(
reactions = message.reactions.map { it.emoji },
onOpen = onReact?.let { { showMessageActions = true } },
modifier = Modifier
.align(if (isUser) Alignment.BottomEnd else Alignment.BottomStart)
.offset(
x = if (isUser) (-10).dp else 10.dp,
y = 9.dp,
),
)
}
}
val inlineActions: @Composable () -> Unit = {
MessageInlineActions(
showQuote = onQuoteMessage != null,
@@ -890,6 +941,41 @@ fun MessageBubble(
} // end CompositionLocalProvider(LocalMediaBlurMode)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun MessageReactionBadge(
reactions: List<String>,
onOpen: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val description = "Reactions: ${reactions.joinToString(" ")}"
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
tonalElevation = 2.dp,
shadowElevation = 2.dp,
modifier = modifier
.then(
if (onOpen != null) {
Modifier.combinedClickable(onClick = onOpen, onLongClick = onOpen)
} else {
Modifier
},
)
.semantics { contentDescription = description },
) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp),
) {
reactions.forEach { emoji ->
Text(text = emoji, fontSize = 14.sp, lineHeight = 16.sp)
}
}
}
}
@Composable
private fun MessageInlineActions(
showQuote: Boolean,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import java.util.Locale
internal enum class SessionDrawerGrouping {
None,
Updated,
Project,
Status,
Profile,
}
internal enum class SessionDrawerOrdering {
Updated,
Created,
Title,
Status,
Tokens,
Cost,
}
internal enum class SessionDrawerStatus {
NeedsInput,
Working,
Idle,
}
internal enum class SessionDrawerPrState {
Open,
Draft,
Merged,
Closed,
None,
}
internal data class SessionDrawerViewOptions(
val grouping: SessionDrawerGrouping = SessionDrawerGrouping.None,
val ordering: SessionDrawerOrdering = SessionDrawerOrdering.Updated,
val statuses: Set<SessionDrawerStatus> = emptySet(),
val profiles: Set<String> = emptySet(),
val projects: Set<String> = emptySet(),
val pullRequests: Set<SessionDrawerPrState> = emptySet(),
val showProfile: Boolean = false,
val showUpdated: Boolean = true,
val showTokens: Boolean = false,
val showCost: Boolean = false,
)
internal data class SessionDrawerGroup(
val key: String,
val label: String?,
val rows: List<ProfileSessionRow>,
)
internal fun sessionRowKey(row: ProfileSessionRow): String =
"${row.profile.lowercase(Locale.ROOT)}:${row.session.sessionId}"
internal fun sessionProjectLabel(session: ChatSession): String {
val raw = (session.gitRepoRoot ?: session.workingDirectory)
?.trim()
?.trimEnd('/', '\\')
.orEmpty()
if (raw.isBlank()) return "No project"
return raw.substringAfterLast('/').substringAfterLast('\\').ifBlank { raw }
}
internal fun sessionDrawerStatus(
row: ProfileSessionRow,
activityStates: Map<String, SessionActivityState>,
): SessionDrawerStatus = when (
activityStates[sessionRowKey(row)] ?: activityStates[row.session.sessionId]
) {
SessionActivityState.NeedsInput -> SessionDrawerStatus.NeedsInput
SessionActivityState.Working -> SessionDrawerStatus.Working
null -> if (row.session.isActive) SessionDrawerStatus.Working else SessionDrawerStatus.Idle
}
internal fun sessionDrawerPrState(session: ChatSession): SessionDrawerPrState = when {
session.pullRequestNumber == null -> SessionDrawerPrState.None
session.pullRequestDraft -> SessionDrawerPrState.Draft
session.pullRequestState.equals("merged", ignoreCase = true) -> SessionDrawerPrState.Merged
session.pullRequestState.equals("closed", ignoreCase = true) -> SessionDrawerPrState.Closed
else -> SessionDrawerPrState.Open
}
internal fun filterAndSortSessionRows(
rows: List<ProfileSessionRow>,
options: SessionDrawerViewOptions,
activityStates: Map<String, SessionActivityState> = emptyMap(),
): List<ProfileSessionRow> {
val filtered = rows.asSequence()
.filter { options.statuses.isEmpty() || sessionDrawerStatus(it, activityStates) in options.statuses }
.filter { options.profiles.isEmpty() || it.profile in options.profiles }
.filter { options.projects.isEmpty() || sessionProjectLabel(it.session) in options.projects }
.filter { options.pullRequests.isEmpty() || sessionDrawerPrState(it.session) in options.pullRequests }
.toList()
val statusRank = mapOf(
SessionDrawerStatus.NeedsInput to 0,
SessionDrawerStatus.Working to 1,
SessionDrawerStatus.Idle to 2,
)
val comparator = when (options.ordering) {
SessionDrawerOrdering.Updated -> compareByDescending<ProfileSessionRow> { it.session.activityTimestamp }
SessionDrawerOrdering.Created -> compareByDescending { it.session.startTimestamp }
SessionDrawerOrdering.Title -> compareBy { it.session.title.orEmpty().lowercase(Locale.ROOT) }
SessionDrawerOrdering.Status -> compareBy { statusRank.getValue(sessionDrawerStatus(it, activityStates)) }
SessionDrawerOrdering.Tokens -> compareByDescending { it.session.totalTokens }
SessionDrawerOrdering.Cost -> compareByDescending { it.session.costUsd }
}
return filtered.sortedWith(
compareByDescending<ProfileSessionRow> { it.session.pinned }
.then(comparator)
.thenBy { it.session.title.orEmpty().lowercase(Locale.ROOT) },
)
}
internal fun groupSessionRows(
rows: List<ProfileSessionRow>,
grouping: SessionDrawerGrouping,
activityStates: Map<String, SessionActivityState> = emptyMap(),
nowMillis: Long = System.currentTimeMillis(),
): List<SessionDrawerGroup> {
if (rows.isEmpty()) return emptyList()
val grouped = rows.groupBy { row ->
when (grouping) {
SessionDrawerGrouping.None -> null
SessionDrawerGrouping.Updated -> updatedBucket(row.session.activityTimestamp, nowMillis)
SessionDrawerGrouping.Project -> sessionProjectLabel(row.session)
SessionDrawerGrouping.Status -> sessionDrawerStatus(row, activityStates).displayLabel
SessionDrawerGrouping.Profile -> row.profile
}
}
val groups = grouped.map { (label, groupRows) ->
SessionDrawerGroup(key = "${grouping.name}:$label", label = label, rows = groupRows)
}
return if (grouping == SessionDrawerGrouping.Project) {
groups.sortedWith(
compareBy<SessionDrawerGroup> { it.label != "No project" }
.thenByDescending { group -> group.rows.maxOfOrNull { it.session.activityTimestamp } ?: 0L }
.thenBy { it.label.orEmpty().lowercase(Locale.ROOT) },
)
} else {
groups
}
}
private val SessionDrawerStatus.displayLabel: String
get() = when (this) {
SessionDrawerStatus.NeedsInput -> "Needs input"
SessionDrawerStatus.Working -> "Working"
SessionDrawerStatus.Idle -> "Idle"
}
private fun updatedBucket(timestamp: Long, nowMillis: Long): String {
if (timestamp <= 0L) return "Older"
val age = (nowMillis - timestamp).coerceAtLeast(0L)
return when {
age < DAY_MILLIS -> "Today"
age < 2 * DAY_MILLIS -> "Yesterday"
age < 7 * DAY_MILLIS -> "Last 7 days"
else -> "Older"
}
}
private const val DAY_MILLIS = 24L * 60L * 60L * 1_000L
@@ -300,14 +300,12 @@ internal fun resolveSessionActivityStates(
internal fun resolveChatHeaderSubtitle(
isStreaming: Boolean,
statusText: String,
projectName: String?,
personalityName: String?,
modelName: String?,
): String = if (isStreaming) {
statusText
} else {
listOfNotNull(
projectName?.takeIf { it.isNotBlank() },
personalityName?.takeIf { it.isNotBlank() },
modelName?.takeIf { it.isNotBlank() },
).joinToString(" \u00B7 ").ifBlank { statusText }
@@ -823,6 +821,27 @@ fun ChatScreen(
// has been made (the /api/config fallback is more useful than the bare
// connection label).
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
var allProfileSessionsLoading by remember { mutableStateOf(false) }
val openedSessionProfileName by chatViewModel.openedSessionProfileName.collectAsState()
val conversationProfile = openedSessionProfileName?.let { owner ->
agentProfiles.firstOrNull { it.name.equals(owner, ignoreCase = true) }
?: allProfileSessions.firstOrNull {
it.profile.equals(owner, ignoreCase = true) &&
it.session.sessionId == currentSessionId
}?.session?.let { session ->
com.hermesandroid.relay.data.Profile(
name = owner,
model = session.model.orEmpty(),
description = owner,
)
}
?: com.hermesandroid.relay.data.Profile(
name = owner,
model = "",
description = owner,
)
} ?: effectiveProfile
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
@@ -834,7 +853,6 @@ fun ChatScreen(
val selectedProviderOverride by chatViewModel.selectedProviderOverride.collectAsState()
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val gatewayCurrentProvider by chatViewModel.gatewayCurrentProvider.collectAsState()
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
val currentSession = remember(sessions, currentSessionId) {
sessions.firstOrNull { it.sessionId == currentSessionId }
@@ -846,8 +864,8 @@ fun ChatScreen(
gatewayModel = gatewayCurrentModel,
gatewayProvider = gatewayCurrentProvider,
persistedSessionModel = currentSession?.model,
profileDefaultModel = effectiveProfile?.model,
serverDefaultModel = serverModelName,
profileDefaultModel = conversationProfile?.model,
serverDefaultModel = serverModelName.takeIf { openedSessionProfileName == null },
)
val sessionPickerProvider = sessionModelState.pickerProvider
?: sessionModelState.pickerModel?.let { model ->
@@ -1138,8 +1156,6 @@ fun ChatScreen(
}
val listState = rememberLazyListState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
var allProfileSessionsLoading by remember { mutableStateOf(false) }
PetInteractionLayer(
owner = "chat-interaction-layer",
active = shouldHideChatPet(
@@ -1997,7 +2013,7 @@ fun ChatScreen(
// four keys, which missed updates in some cases (most notably a
// profile switch while the ConnectionInfoSheet was open, where the
// ambient sheet scope appeared to swallow the key comparison).
val agentDisplayName by remember(
val globalSelectedAgentDisplayName by remember(
effectiveProfile,
selectedPersonality,
defaultPersonality,
@@ -2005,13 +2021,31 @@ fun ChatScreen(
activeConnection?.label,
) {
derivedStateOf {
val profile = effectiveProfile
AgentDisplay.agentName(
profile = effectiveProfile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = activeConnection?.label,
localDisplayAlias = profileDisplayAlias,
)
}
}
val agentDisplayName by remember(
conversationProfile,
selectedPersonality,
defaultPersonality,
profileDisplayAlias,
openedSessionProfileName,
activeConnection?.label,
) {
derivedStateOf {
val profile = conversationProfile
AgentDisplay.agentName(
profile = profile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = activeConnection?.label,
localDisplayAlias = profileDisplayAlias,
localDisplayAlias = profileDisplayAlias.takeIf { openedSessionProfileName == null },
)
}
}
@@ -2040,13 +2074,13 @@ fun ChatScreen(
ModalNavigationDrawer(
drawerState = drawerState,
// Disable the drawer's edge-swipe while voice mode is up so the
// overlay reads as a true modal — the swipe gesture lives on the
// drawer itself, so the overlay's pointer scrim alone can't block it.
gesturesEnabled = !voiceUiState.voiceMode,
// Material routes scrim taps through the drawer's gesture handler.
// Keep it enabled so tapping outside always dismisses the drawer; the
// voice overlay already owns input while voice mode is visible.
gesturesEnabled = true,
drawerContent = {
val drawerTitle = if (effectiveProfile != null) {
stringResource(R.string.chat_profile_sessions, agentDisplayName)
stringResource(R.string.chat_profile_sessions, globalSelectedAgentDisplayName)
} else {
stringResource(R.string.chat_server_default_sessions)
}
@@ -2090,6 +2124,7 @@ fun ChatScreen(
currentSessionId = currentSessionId,
scopeTitle = drawerTitle,
scopeSubtitle = drawerSubtitle,
activeProfileName = effectiveProfile?.name ?: "default",
isLoading = isLoadingSessions,
isOpen = drawerState.isOpen,
activityStates = sessionActivityStates,
@@ -2101,6 +2136,24 @@ fun ChatScreen(
chatViewModel.createNewChat()
scope.launch { drawerState.close() }
},
onNewDefaultChat = {
val defaultProfile = agentProfiles.firstOrNull {
it.name.equals("default", ignoreCase = true)
} ?: com.hermesandroid.relay.data.Profile(
name = "default",
model = "",
description = "Default",
)
chatViewModel.createProfileChat(
profileName = "default",
profile = defaultProfile,
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnection?.id,
profileName = "default",
),
)
scope.launch { drawerState.close() }
},
onSelectSession = { sessionId ->
chatViewModel.switchSession(sessionId)
scope.launch { drawerState.close() }
@@ -2142,8 +2195,11 @@ fun ChatScreen(
onToggleSourceHidden = { source, hidden ->
connectionViewModel.setSourceHidden(source, hidden)
},
allProfilesSupported = !activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
allProfileSessions = allProfileSessions,
allProfileSessionsLoading = allProfileSessionsLoading,
profileColors = profilePresentation.colors,
onProfileColorChange = connectionViewModel::setProfileColor,
onRefreshAllProfiles = {
if (!allProfileSessionsLoading) scope.launch {
allProfileSessionsLoading = true
@@ -2160,6 +2216,11 @@ fun ChatScreen(
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
@@ -2190,9 +2251,29 @@ fun ChatScreen(
it.name.equals(profileName, ignoreCase = true)
}
if (target != null || profileName.equals("default", ignoreCase = true)) {
connectionViewModel.selectProfile(target)
chatViewModel.activateGatewayProfile(target)
chatViewModel.switchSession(sessionId)
val ownerProfile = target ?: allProfileSessions.firstOrNull {
it.profile.equals(profileName, ignoreCase = true) &&
it.session.sessionId == sessionId
}?.session?.let { session ->
com.hermesandroid.relay.data.Profile(
name = profileName,
model = session.model.orEmpty(),
description = profileName,
)
} ?: com.hermesandroid.relay.data.Profile(
name = profileName,
model = "",
description = profileName,
)
chatViewModel.openProfileSession(
profileName = profileName,
profile = ownerProfile,
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnection?.id,
profileName = profileName,
),
sessionId = sessionId,
)
scope.launch { drawerState.close() }
} else {
scope.launch {
@@ -2200,6 +2281,54 @@ fun ChatScreen(
}
}
},
onDeleteProfileSession = { profileName, sessionId ->
scope.launch {
if (connectionViewModel.deleteSession(profileName, sessionId)) {
allProfileSessions = allProfileSessions.filterNot {
it.profile == profileName && it.session.sessionId == sessionId
}
}
}
},
onRenameProfileSession = { profileName, sessionId, title ->
scope.launch {
if (connectionViewModel.renameSession(profileName, sessionId, title)) {
allProfileSessions = allProfileSessions.map { row ->
if (row.profile == profileName && row.session.sessionId == sessionId) {
row.copy(session = row.session.copy(title = title))
} else {
row
}
}
}
}
},
onSetProfileSessionPinned = { profileName, sessionId, pinned ->
scope.launch {
if (connectionViewModel.setSessionPinned(profileName, sessionId, pinned)) {
allProfileSessions = allProfileSessions.map { row ->
if (row.profile == profileName && row.session.sessionId == sessionId) {
row.copy(session = row.session.copy(pinned = pinned))
} else {
row
}
}
}
}
},
onSetProfileSessionArchived = { profileName, sessionId, archived ->
scope.launch {
if (connectionViewModel.setSessionArchived(profileName, sessionId, archived)) {
allProfileSessions = allProfileSessions.map { row ->
if (row.profile == profileName && row.session.sessionId == sessionId) {
row.copy(session = row.session.copy(archived = archived))
} else {
row
}
}
}
}
},
)
}
) {
@@ -2305,7 +2434,6 @@ fun ChatScreen(
resolveChatHeaderSubtitle(
isStreaming = isStreaming,
statusText = statusText,
projectName = gatewayProjectName,
personalityName = nonDefaultPersonality,
modelName = modelName,
)
@@ -2612,7 +2740,7 @@ fun ChatScreen(
selectedProfile = selectedProfile,
resolvedProfile = effectiveProfile,
presentation = profilePresentation,
activeDisplayName = agentDisplayName,
activeDisplayName = globalSelectedAgentDisplayName,
isProfileLocked = isProfileLocked,
lockedProfileName = lockedProfileName,
switchEnabled = profileSwitchEnabled,
@@ -3138,9 +3266,12 @@ fun ChatScreen(
isGatewayTransport &&
messageReactionsSupported &&
!message.isStreaming &&
message.uiKey in newestReactableMessageKeys
(
message.rowId != null ||
message.uiKey in newestReactableMessageKeys
)
) {
{ emoji -> chatViewModel.reactToNewest(message.role, emoji) }
{ emoji -> chatViewModel.reactToMessage(message, emoji) }
} else {
null
},
@@ -0,0 +1,51 @@
package com.hermesandroid.relay.ui.theme
import androidx.compose.ui.graphics.Color
import java.util.Locale
import kotlin.math.abs
import kotlin.math.roundToInt
private const val PROFILE_SATURATION = 0.68f
private const val PROFILE_LIGHTNESS = 0.58f
/** The same evenly-spaced 12-hue picker model used by Hermes Desktop. */
val ProfileAccentSwatches: List<String> = (0 until 12).map { index ->
hslColor(index * 30f, PROFILE_SATURATION, PROFILE_LIGHTNESS).toRgbHex()
}
/** Desktop-compatible deterministic profile identity color; default remains neutral. */
fun resolveProfileAccent(name: String?, overrides: Map<String, String>): Color? {
val key = name?.trim().orEmpty()
if (key.isBlank() || key.equals("default", ignoreCase = true)) return null
return accentColor(overrides[key]) ?: deterministicProfileAccent(key)
}
internal fun deterministicProfileAccent(name: String): Color {
var hash = 0u
name.forEach { character -> hash = hash * 31u + character.code.toUInt() }
return hslColor((hash % 360u).toFloat(), PROFILE_SATURATION, PROFILE_LIGHTNESS)
}
private fun hslColor(hue: Float, saturation: Float, lightness: Float): Color {
val chroma = (1f - abs(2f * lightness - 1f)) * saturation
val section = (hue / 60f) % 6f
val x = chroma * (1f - abs(section % 2f - 1f))
val (red, green, blue) = when {
section < 1f -> Triple(chroma, x, 0f)
section < 2f -> Triple(x, chroma, 0f)
section < 3f -> Triple(0f, chroma, x)
section < 4f -> Triple(0f, x, chroma)
section < 5f -> Triple(x, 0f, chroma)
else -> Triple(chroma, 0f, x)
}
val match = lightness - chroma / 2f
return Color(red + match, green + match, blue + match)
}
private fun Color.toRgbHex(): String = String.format(
Locale.ROOT,
"#%02X%02X%02X",
(red * 255f).roundToInt(),
(green * 255f).roundToInt(),
(blue * 255f).roundToInt(),
)
@@ -31,6 +31,7 @@ import com.hermesandroid.relay.data.MediaSettings
import com.hermesandroid.relay.data.MediaSettingsRepository
import com.hermesandroid.relay.data.MessageDeliveryStatus
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.applyMessageReaction
import com.hermesandroid.relay.data.parseChatQuotedPrompt
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProactiveInboxEntry
@@ -93,6 +94,7 @@ import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.shared.LocalDispatchResult
import com.hermesandroid.relay.network.upstream.formatPhoneActionResult
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.parseMessageReactions
import com.hermesandroid.relay.network.upstream.SESSION_MESSAGE_PAGE_SIZE
import com.hermesandroid.relay.network.upstream.SessionMessageLoadMode
import com.hermesandroid.relay.network.upstream.models.SessionItem
@@ -136,6 +138,7 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import okhttp3.sse.EventSource
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@@ -833,7 +836,7 @@ class ChatViewModel : ViewModel() {
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
return
}
val launchProfileOwned = sessionProfileNameProvider() == null
val launchProfileOwned = currentSessionProfileName() == null
_approvalModeWritable.value = launchProfileOwned
_approvalModeReadOnlyForProfile.value = !launchProfileOwned
if (!launchProfileOwned) {
@@ -983,7 +986,7 @@ class ChatViewModel : ViewModel() {
if (pairs.isEmpty()) return
val generation = relayCapabilityGeneration.incrementAndGet()
val profileKey = reasoningCapabilityContextKey()
val profile = sessionProfileNameProvider()
val profile = currentSessionProfileName()
viewModelScope.launch {
val result = relay.fetchModelCapabilities(
models = pairs.map {
@@ -1154,7 +1157,7 @@ class ChatViewModel : ViewModel() {
// here skips the setModel below, leaving the gateway on its true
// server-configured default.
val defaultModel = AgentDisplay.requestModelName(
(effectiveProfileProvider() ?: selectedProfileProvider())?.model
(openedSessionDisplayProfile ?: effectiveProfileProvider() ?: selectedProfileProvider())?.model
?: _serverModelName.value,
)
if (!defaultModel.isNullOrBlank()) {
@@ -1462,7 +1465,8 @@ class ChatViewModel : ViewModel() {
* new profile's agent. SSE turns already carry the profile per-request as
* `profileName`. [profile] = the new pick; null = the default profile.
*/
fun activateGatewayProfile(profile: Profile?) {
fun activateGatewayProfile(profile: Profile?, refreshModelOptions: Boolean = true) {
clearOpenedSessionOwner()
val gateway = gatewayClient ?: return
if (streamingEndpoint != "gateway") return
// A profile switch is a UI/context detach, not a Stop action. Preserve
@@ -1531,11 +1535,13 @@ class ChatViewModel : ViewModel() {
// config.get would read the launch/global profile's effort (wrong
// scope). It's left unknown above and confirmed by session.info on the
// first turn — the same honesty discipline yolo/fast use.
viewModelScope.launch {
gateway.modelOptions().onSuccess {
_modelProviders.value = it.providers
_gatewayCurrentModel.value = it.currentModel
_gatewayCurrentProvider.value = it.currentProvider
if (refreshModelOptions) {
viewModelScope.launch {
gateway.modelOptions().onSuccess {
_modelProviders.value = it.providers
_gatewayCurrentModel.value = it.currentModel
_gatewayCurrentProvider.value = it.currentProvider
}
}
}
refreshActiveAgentName()
@@ -1663,7 +1669,7 @@ class ChatViewModel : ViewModel() {
}
// Bind each gateway session.create/resume to the currently-selected
// profile (pulled live) — the upstream gateway builds the agent from it.
client?.sessionProfileProvider = { sessionProfileNameProvider() }
client?.sessionProfileProvider = { currentSessionProfileName() }
// Bind the in-chat picks onto each fresh session.create so a brand-new
// chat actually runs on the picked model/provider AND the chosen
// reasoning effort / fast tier (not the global default) — and so setting
@@ -2323,15 +2329,33 @@ class ChatViewModel : ViewModel() {
private val _transientNotice = MutableSharedFlow<String>(extraBufferCapacity = 8)
val transientNotice: SharedFlow<String> = _transientNotice.asSharedFlow()
fun reactToNewest(role: MessageRole, emoji: String?) {
fun reactToMessage(message: ChatMessage, emoji: String?) {
val gateway = gatewayClient ?: return
val role = message.role
if (role != MessageRole.USER && role != MessageRole.ASSISTANT) return
val handler = chatHandler ?: return
val snapshot = messages.value.firstOrNull { it.uiKey == message.uiKey }?.reactions
?: message.reactions
handler.mutateMessage(message.uiKey) { current ->
current.copy(reactions = applyMessageReaction(current.reactions, emoji))
}
viewModelScope.launch {
gateway.reactToNewest(role.name.lowercase(), emoji).fold(
onSuccess = {
gateway.reactToMessage(message.rowId, role.name.lowercase(), emoji).fold(
onSuccess = { result ->
val persisted = parseMessageReactions(result["reactions"])
val rowId = (result["row_id"] as? JsonPrimitive)?.longOrNull
handler.mutateMessage(message.uiKey) { current ->
current.copy(
rowId = rowId ?: current.rowId,
reactions = persisted,
)
}
_transientNotice.tryEmit(if (emoji == null) "Reaction removed." else "Reaction added.")
},
onFailure = { error ->
handler.mutateMessage(message.uiKey) { current ->
current.copy(reactions = snapshot)
}
if ((error as? GatewayRpcException)?.code == -32601) {
_messageReactionsSupported.value = false
_transientNotice.tryEmit("Message reactions aren't supported by this gateway.")
@@ -2492,6 +2516,22 @@ class ChatViewModel : ViewModel() {
}
private var displayAliasProvider: () -> String? = { null }
private var activeProfileContextKey: String? = null
private val _openedSessionProfileName = MutableStateFlow<String?>(null)
val openedSessionProfileName: StateFlow<String?> = _openedSessionProfileName.asStateFlow()
private var openedSessionDisplayProfile: Profile? = null
/**
* Profile namespace owned by the conversation currently on screen. Opening a
* row from the global All Profiles browser must not mutate the persistent
* profile selector, but resume/history/send still need the row's exact owner.
*/
private fun currentSessionProfileName(): String? =
_openedSessionProfileName.value ?: sessionProfileNameProvider()
private fun clearOpenedSessionOwner() {
_openedSessionProfileName.value = null
openedSessionDisplayProfile = null
}
/** Process ownership is profile+session scoped; stored IDs alone are not globally unique. */
private fun selectBackgroundProcessSession(
@@ -2595,14 +2635,14 @@ class ChatViewModel : ViewModel() {
* read that profile's own DB. Returns `null` off the dashboard surface.
*/
private var profileMessageLoader:
(suspend (String, SessionMessageLoadMode) -> Result<List<MessageItem>>?)? = null
(suspend (String?, String, SessionMessageLoadMode) -> Result<List<MessageItem>>?)? = null
fun setProfileMessageLoader(loader: suspend (String) -> Result<List<MessageItem>>?) {
profileMessageLoader = { sessionId, _ -> loader(sessionId) }
profileMessageLoader = { _, sessionId, _ -> loader(sessionId) }
}
fun setProfileMessageLoaderWithMode(
loader: suspend (String, SessionMessageLoadMode) -> Result<List<MessageItem>>?,
loader: suspend (String?, String, SessionMessageLoadMode) -> Result<List<MessageItem>>?,
) {
profileMessageLoader = loader
}
@@ -2617,9 +2657,10 @@ class ChatViewModel : ViewModel() {
sessionId: String,
requireProfileScope: Boolean = false,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
profileName: String? = currentSessionProfileName(),
): List<MessageItem> {
if (streamingEndpoint == "gateway") {
return loadGatewaySessionHistory(sessionId, requireProfileScope, mode)
return loadGatewaySessionHistory(sessionId, requireProfileScope, mode, profileName)
}
return apiClient?.getMessages(sessionId, mode) ?: emptyList()
}
@@ -2634,8 +2675,9 @@ class ChatViewModel : ViewModel() {
sessionId: String,
requireProfileScope: Boolean = false,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
profileName: String? = currentSessionProfileName(),
): List<MessageItem> {
val scoped = profileMessageLoader?.invoke(sessionId, mode)
val scoped = profileMessageLoader?.invoke(profileName, sessionId, mode)
if (scoped != null) {
// A gateway profile owns a distinct state.db. Never fall through to
// the launch/default API database when its scoped read fails: an
@@ -2792,7 +2834,7 @@ class ChatViewModel : ViewModel() {
approvalModeRevision.incrementAndGet()
_approvalMode.value = mode
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
val launchProfileOwned = sessionProfileNameProvider() == null
val launchProfileOwned = currentSessionProfileName() == null
_approvalModeWritable.value = launchProfileOwned
_approvalModeReadOnlyForProfile.value = !launchProfileOwned
}
@@ -3252,7 +3294,50 @@ class ChatViewModel : ViewModel() {
}
}
fun openProfileSession(
profileName: String,
profile: Profile?,
contextKey: String,
sessionId: String,
) {
// Detach the old live gateway session without reading launch/global
// model options: session.info for the resumed owner is authoritative.
activateGatewayProfile(profile, refreshModelOptions = false)
_openedSessionProfileName.value = profileName
openedSessionDisplayProfile = profile
refreshActiveAgentName(profile, relabelGenericMessages = true)
switchProfileContextInternal(contextKey, sessionId, persistLastSession = false)
}
/**
* Start a fresh draft owned by an explicit profile without changing the
* persistent profile selector. The All Profiles browser uses this for its
* New chat action, where upstream's literal `default` profile must win over
* the server's sticky active profile.
*/
fun createProfileChat(
profileName: String,
profile: Profile?,
contextKey: String,
) {
activateGatewayProfile(profile, refreshModelOptions = false)
_openedSessionProfileName.value = profileName
openedSessionDisplayProfile = profile
refreshActiveAgentName(profile, relabelGenericMessages = true)
switchProfileContextInternal(contextKey, sessionId = null, persistLastSession = false)
AppAnalytics.onSessionCreated()
}
fun switchProfileContext(contextKey: String, sessionId: String?) {
clearOpenedSessionOwner()
switchProfileContextInternal(contextKey, sessionId)
}
private fun switchProfileContextInternal(
contextKey: String,
sessionId: String?,
persistLastSession: Boolean = true,
) {
val handler = chatHandler ?: return
val isInitialContextBinding = activeProfileContextKey == null
handler.activeAgentName = currentAgentDisplayName()
@@ -3286,6 +3371,7 @@ class ChatViewModel : ViewModel() {
if (!isInitialContextBinding) releaseTurnForNavigation(handler)
cancelAnswerRecovery(settleUi = false)
val loadGeneration = historyLoadGeneration.incrementAndGet()
val sessionProfileName = currentSessionProfileName()
sessionRefreshGeneration.incrementAndGet()
sessionRefreshJob?.cancel()
_isLoadingSessions.value = false
@@ -3335,7 +3421,7 @@ class ChatViewModel : ViewModel() {
handler.setSessionId(sessionId)
publishQueuedMessages()
selectBackgroundProcessSession(sessionId, contextKey)
if (sessionId != null) {
if (sessionId != null && persistLastSession) {
onSessionChanged?.invoke(sessionId)
}
@@ -3368,7 +3454,7 @@ class ChatViewModel : ViewModel() {
false
}
if (!recovered) {
val messages = loadSessionHistory(sessionId)
val messages = loadSessionHistory(sessionId, profileName = sessionProfileName)
if (stillCurrent()) {
handler.loadMessageHistory(messages)
if (streamingEndpoint == "gateway") gatewayClient?.prewarm(sessionId)
@@ -3557,6 +3643,7 @@ class ChatViewModel : ViewModel() {
fun createNewChat() {
val handler = chatHandler ?: return
clearOpenedSessionOwner()
pendingThread = null
creatingThread = null
@@ -3756,6 +3843,7 @@ class ChatViewModel : ViewModel() {
fun switchSession(sessionId: String) {
val handler = chatHandler ?: return
clearOpenedSessionOwner()
if (streamingEndpoint != "gateway" && apiClient == null) return
pendingThread = null
creatingThread = null
@@ -4815,7 +4903,7 @@ class ChatViewModel : ViewModel() {
private fun maybeNotifyInteraction(
sessionId: String,
ask: GatewayAsk,
profile: String? = sessionProfileNameProvider(),
profile: String? = currentSessionProfileName(),
) {
val context = appContext ?: return
InteractionRequestNotifier.notify(
@@ -4831,7 +4919,7 @@ class ChatViewModel : ViewModel() {
private fun cancelInteractionNotification(
sessionId: String,
ask: GatewayAsk,
profile: String? = sessionProfileNameProvider(),
profile: String? = currentSessionProfileName(),
) {
val context = appContext ?: return
InteractionRequestNotifier.cancel(context, sessionId, ask, profile)
@@ -7722,49 +7810,17 @@ class ChatViewModel : ViewModel() {
val gateway = gatewayClient
_steerableTurn.value = false
// Voice turns must deliver their interface + spoken-output-formatting
// context to the model, but the gateway prompt.submit RPC has NO
// system-message slot (it is bare text — see the else branch). Prepending
// to the user text would persist the instruction into the transcript.
// The SSE endpoints carry it in the non-persisted system_message field
// instead, so force any turn that has a per-turn interface context
// (set only by sendVoiceMessage) onto SSE. resolveSseFallback picks the
// best available SSE route.
// Gateway prompt.submit has no system-message slot, so its voice turn
// cannot carry the optional spoken-output formatting hint. Keep the turn
// on Gateway anyway: the API server is an optional fallback and may not
// be reachable from the phone. A working vanilla Gateway turn is more
// important than silently making standard voice depend on port 8642.
// SSE-selected connections still receive the interface context normally.
//
// Synthetic voice-intent and provider-answered realtime traces have the
// same gateway limitation — prompt.submit can't carry them — but on a
// gateway-primary phone "leave them for the next SSE turn" means *never*.
// Drain those voice-only traces by forcing this one turn onto the sessions
// SSE route, but ONLY when that is strictly safe:
// - an existing session id + the sessions fallback route (a stateless
// completions/runs detour would drop THIS turn from the transcript
// to save a trace — worse than deferring), and
// - the default profile (a non-default profile's gateway session lives
// in that profile's own state.db, which the shared api_server surface
// can't see — the sessions POST would 404 and fail the user's turn).
// Cost when it fires: one turn without live gateway thinking. The synced
// traces persist server-side, so this happens at most once per batch.
//
// Rich-card actions are different: the action itself is the current user
// turn. Keep it on the selected Gateway and defer its optional synthetic
// audit trace until a naturally selected SSE turn. A card must never
// activate or depend on the optional API fallback.
val sseDrainEndpoint = resolveSseFallback(handler)
val forceSseForVoiceTraceDrain =
client != null &&
(hasVoiceIntents || hasRealtimeTurns) &&
streamingEndpoint == "gateway" &&
profileName == null &&
sseDrainEndpoint == "sessions"
val effectiveEndpoint =
if (client != null &&
(interfaceContextPrompt != null || forceSseForVoiceTraceDrain) &&
streamingEndpoint == "gateway"
) {
sseDrainEndpoint
} else {
streamingEndpoint
}
// Synthetic local traces also wait for a naturally selected SSE turn.
// They are supplemental context and must never make a connected Gateway
// turn depend on the optional API server.
val effectiveEndpoint = streamingEndpoint
updateTurnCheckpointTransport(effectiveEndpoint)
// Remember whether this turn runs on the gateway client (vs an SSE
// EventSource) so a mid-turn route handoff doesn't cancel it — only the
@@ -7952,6 +8008,7 @@ class ChatViewModel : ViewModel() {
): String? {
val selectedProfile = selectedProfileProvider()
val effectiveProfile = effectiveProfileOverride
?: openedSessionDisplayProfile
?: displayProfileProvider()
?: effectiveProfileProvider()
?: selectedProfile
@@ -7960,7 +8017,11 @@ class ChatViewModel : ViewModel() {
selectedPersonality = _selectedPersonality.value,
defaultPersonality = _defaultPersonality.value,
connectionLabel = null,
localDisplayAlias = displayAliasProvider(),
localDisplayAlias = if (_openedSessionProfileName.value == null) {
displayAliasProvider()
} else {
null
},
).ifBlank { null }
}
@@ -1571,11 +1571,30 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
suspend fun listAllProfileSessions(limit: Int = 200): Result<List<SessionItem>>? =
profileController.listAllProfileSessions(limit)
suspend fun deleteSession(profileName: String, sessionId: String): Boolean =
profileController.deleteSession(profileName, sessionId)
suspend fun renameSession(profileName: String, sessionId: String, title: String): Boolean =
profileController.renameSession(profileName, sessionId, title)
suspend fun setSessionPinned(profileName: String, sessionId: String, pinned: Boolean): Boolean =
profileController.setSessionPinned(profileName, sessionId, pinned)
suspend fun setSessionArchived(profileName: String, sessionId: String, archived: Boolean): Boolean =
profileController.setSessionArchived(profileName, sessionId, archived)
suspend fun loadProfileScopedMessages(
sessionId: String,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
): Result<List<MessageItem>>? = profileController.loadProfileScopedMessages(sessionId, mode)
suspend fun loadProfileScopedMessages(
profileName: String?,
sessionId: String,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
): Result<List<MessageItem>>? =
profileController.loadProfileScopedMessages(profileName, sessionId, mode)
/**
* Delete a session scoped to the ACTIVE PROFILE via the dashboard
* `DELETE /api/sessions/{id}?profile=` surface — the write twin of
@@ -1624,6 +1643,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun setProfileHidden(profileName: String?, hidden: Boolean) =
profileController.setProfileHidden(profileName, hidden)
fun setProfileColor(profileName: String, colorHex: String?) =
profileController.setProfileColor(profileName, colorHex)
fun resetProfilePresentation() = profileController.resetProfilePresentation()
/**
@@ -350,6 +350,38 @@ class ProfileController(
return dashboardClientFactory(connectionId, dashboardUrl).listAllProfileSessions(limit)
}
suspend fun deleteSession(profileName: String, sessionId: String): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrlProvider() ?: return false
return dashboardClientFactory(connectionId, dashboardUrl)
.deleteSession(sessionId, profileName)
.isSuccess
}
suspend fun renameSession(profileName: String, sessionId: String, title: String): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrlProvider() ?: return false
return dashboardClientFactory(connectionId, dashboardUrl)
.renameSession(sessionId, title, profileName)
.isSuccess
}
suspend fun setSessionPinned(profileName: String, sessionId: String, pinned: Boolean): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrlProvider() ?: return false
return dashboardClientFactory(connectionId, dashboardUrl)
.setSessionPinned(sessionId, pinned, profileName)
.isSuccess
}
suspend fun setSessionArchived(profileName: String, sessionId: String, archived: Boolean): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrlProvider() ?: return false
return dashboardClientFactory(connectionId, dashboardUrl)
.setSessionArchived(sessionId, archived, profileName)
.isSuccess
}
/**
* A session's transcript, scoped to the active profile via the dashboard
* `/api/sessions/{id}/messages?profile=`. Returns `null` off the dashboard
@@ -358,10 +390,19 @@ class ProfileController(
suspend fun loadProfileScopedMessages(
sessionId: String,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
): Result<List<MessageItem>>? = loadProfileScopedMessages(
profileName = resolveSessionProfileName(),
sessionId = sessionId,
mode = mode,
)
suspend fun loadProfileScopedMessages(
profileName: String?,
sessionId: String,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
): Result<List<MessageItem>>? {
val connectionId = activeConnectionId.value ?: return null
val dashboardUrl = activeDashboardUrlProvider() ?: return null
val profileName = resolveSessionProfileName()
return dashboardClientFactory(connectionId, dashboardUrl)
.getSessionMessages(sessionId, profileName, mode)
}
@@ -583,6 +624,24 @@ class ProfileController(
}
}
/** Persist or clear a local cosmetic accent for a named profile. */
fun setProfileColor(profileName: String, colorHex: String?) {
val connectionId = activeConnectionId.value ?: return
val key = profileName.trim()
if (key.isBlank() || key.equals("default", ignoreCase = true)) return
scope.launch {
profilePresentationWriteMutex.withLock {
val updated = profilePresentationStore
.presentationFlow(connectionId)
.first()
.colors
.toMutableMap()
.apply { if (colorHex == null) remove(key) else put(key, colorHex) }
profilePresentationStore.setColors(connectionId, updated)
}
}
}
fun resetProfilePresentation() {
val connectionId = activeConnectionId.value ?: return
scope.launch {
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligib
import com.hermesandroid.relay.network.upstream.ServerCapabilities
import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreference
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
import com.hermesandroid.relay.network.shutdownOffMainThread
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -80,6 +81,10 @@ class UpstreamTransportController(
/** Applies pairing-bound TLS to a standard authenticated client when needed. */
private val pinnedClientProvider: (String, okhttp3.OkHttpClient) -> okhttp3.OkHttpClient? =
{ _, _ -> null },
private val dashboardHttpClientFactory:
(DashboardCookieStore, DashboardBearerAuth?) -> okhttp3.OkHttpClient = { cookieStore, bearerAuth ->
DashboardApiClient.defaultClient(cookieStore, bearerAuth)
},
) {
// --- Per-connection dashboard cookie stores ----------------------------
@@ -151,9 +156,9 @@ class UpstreamTransportController(
* scoping, the gateway client, standard-API setup probe) route through.
*/
fun dashboardClientFor(connectionId: String, dashboardUrl: String): DashboardApiClient {
val base = DashboardApiClient.defaultClient(
cookieStore = dashboardCookieStoreFor(connectionId),
bearerAuth = bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
val base = dashboardHttpClientFactory(
dashboardCookieStoreFor(connectionId),
bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
)
return DashboardApiClient(
baseUrl = dashboardUrl,
@@ -167,9 +172,9 @@ class UpstreamTransportController(
* no active connection (the standard-voice probe path).
*/
fun dashboardClientForActive(dashboardUrl: String): DashboardApiClient {
val base = DashboardApiClient.defaultClient(
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
bearerAuth = activeConnectionIdProvider()?.let {
val base = dashboardHttpClientFactory(
activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
activeConnectionIdProvider()?.let {
bearerAuthForTrustedDashboard(it, dashboardUrl)
},
)
@@ -219,9 +224,9 @@ class UpstreamTransportController(
disposeDashboardHttpClient(client)
dashboardHttpClientCache = null
}
val base = DashboardApiClient.defaultClient(
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
bearerAuth = activeConnectionIdProvider()?.let { activeId ->
val base = dashboardHttpClientFactory(
activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
activeConnectionIdProvider()?.let { activeId ->
bearerAuthForTrustedDashboard(activeId, dashboardUrl)
},
)
@@ -250,10 +255,12 @@ class UpstreamTransportController(
}
private fun disposeDashboardHttpClient(client: okhttp3.OkHttpClient) {
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
runCatching { client.cache?.close() }
client.dispatcher.executorService.shutdown()
shutdownOffMainThread("DashboardHttpClient-shutdown") {
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
runCatching { client.cache?.close() }
client.dispatcher.executorService.shutdown()
}
}
// --- Gateway availability ----------------------------------------------
@@ -3884,6 +3884,35 @@
<string name="dashboard_whatsapp_saved">WhatsApp ativado; o Hermes iniciou a reinicialização do gateway.</string>
<string name="drawer_all_profiles">Todos os perfis</string>
<string name="drawer_no_profile_sessions">Nenhuma sessão de perfil correspondente.</string>
<string name="drawer_customize_sessions">Personalizar sessões</string>
<string name="drawer_group_by">Agrupar por</string>
<string name="drawer_order_by">Ordenar por</string>
<string name="drawer_show_metadata">Mostrar nas linhas</string>
<string name="drawer_show_details">Mostrar detalhes</string>
<string name="drawer_filters">Filtros</string>
<string name="drawer_project_home">Início</string>
<string name="drawer_expand_project">Expandir %1$s</string>
<string name="drawer_collapse_project">Recolher %1$s</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d sessão</item>
<item quantity="other">%1$d sessões</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d perfil</item>
<item quantity="other">%1$d perfis</item>
</plurals>
<string name="drawer_filter_status">Status</string>
<string name="drawer_filter_profile">Perfil</string>
<string name="drawer_filter_project">Projeto</string>
<string name="drawer_filter_pull_request">Pull request</string>
<string name="drawer_option_updated">Atualizado</string>
<string name="drawer_option_profile">Perfil</string>
<string name="drawer_option_tokens">Tokens</string>
<string name="drawer_option_cost">Custo</string>
<string name="drawer_reset_filters">Redefinir</string>
<string name="drawer_profile_colors">Cores dos perfis</string>
<string name="drawer_profile_color_auto">Automática</string>
<string name="drawer_profile_color_set">Definir a cor do perfil %1$s como %2$s</string>
<string name="drawer_close">Fechar</string>
<string name="profile_inspector_gateway_settings">Configurações do perfil do Gateway</string>
<string name="profile_inspector_gateway_settings_hint">Salvo diretamente pelo Hermes para este perfil selecionado.</string>
@@ -3972,6 +3972,33 @@
<string name="dashboard_whatsapp_saved">WhatsApp 已启用;Hermes 已开始重启网关。</string>
<string name="drawer_all_profiles">所有配置文件</string>
<string name="drawer_no_profile_sessions">没有匹配的配置文件会话。</string>
<string name="drawer_customize_sessions">自定义会话</string>
<string name="drawer_group_by">分组方式</string>
<string name="drawer_order_by">排序方式</string>
<string name="drawer_show_metadata">在行中显示</string>
<string name="drawer_show_details">显示详细信息</string>
<string name="drawer_filters">筛选器</string>
<string name="drawer_project_home">主页</string>
<string name="drawer_expand_project">展开%1$s</string>
<string name="drawer_collapse_project">折叠%1$s</string>
<plurals name="drawer_project_session_count">
<item quantity="other">%1$d 个会话</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="other">%1$d 个配置文件</item>
</plurals>
<string name="drawer_filter_status">状态</string>
<string name="drawer_filter_profile">配置文件</string>
<string name="drawer_filter_project">项目</string>
<string name="drawer_filter_pull_request">拉取请求</string>
<string name="drawer_option_updated">更新时间</string>
<string name="drawer_option_profile">配置文件</string>
<string name="drawer_option_tokens">令牌</string>
<string name="drawer_option_cost">费用</string>
<string name="drawer_reset_filters">重置</string>
<string name="drawer_profile_colors">配置文件颜色</string>
<string name="drawer_profile_color_auto">自动</string>
<string name="drawer_profile_color_set">将 %1$s 配置文件颜色设为 %2$s</string>
<string name="drawer_close">关闭</string>
<string name="profile_inspector_gateway_settings">Gateway 配置文件设置</string>
<string name="profile_inspector_gateway_settings_hint">通过 Hermes 直接保存到当前所选配置文件。</string>
+29
View File
@@ -4044,6 +4044,35 @@
<string name="dashboard_whatsapp_saved">WhatsApp aktiviert; Hermes hat einen Gateway-Neustart gestartet.</string>
<string name="drawer_all_profiles">Alle Profile</string>
<string name="drawer_no_profile_sessions">Keine passenden Profilsitzungen.</string>
<string name="drawer_customize_sessions">Sitzungen anpassen</string>
<string name="drawer_group_by">Gruppieren nach</string>
<string name="drawer_order_by">Sortieren nach</string>
<string name="drawer_show_metadata">In Zeilen anzeigen</string>
<string name="drawer_show_details">Details anzeigen</string>
<string name="drawer_filters">Filter</string>
<string name="drawer_project_home">Startseite</string>
<string name="drawer_expand_project">%1$s erweitern</string>
<string name="drawer_collapse_project">%1$s reduzieren</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d Sitzung</item>
<item quantity="other">%1$d Sitzungen</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d Profil</item>
<item quantity="other">%1$d Profile</item>
</plurals>
<string name="drawer_filter_status">Status</string>
<string name="drawer_filter_profile">Profil</string>
<string name="drawer_filter_project">Projekt</string>
<string name="drawer_filter_pull_request">Pull Request</string>
<string name="drawer_option_updated">Aktualisiert</string>
<string name="drawer_option_profile">Profil</string>
<string name="drawer_option_tokens">Tokens</string>
<string name="drawer_option_cost">Kosten</string>
<string name="drawer_reset_filters">Zurücksetzen</string>
<string name="drawer_profile_colors">Profilfarben</string>
<string name="drawer_profile_color_auto">Automatisch</string>
<string name="drawer_profile_color_set">Profilfarbe von %1$s auf %2$s setzen</string>
<string name="drawer_close">Schließen</string>
<string name="profile_inspector_gateway_settings">Gateway-Profileinstellungen</string>
<string name="profile_inspector_gateway_settings_hint">Wird für dieses ausgewählte Profil direkt über Hermes gespeichert.</string>
+29
View File
@@ -3729,6 +3729,35 @@
<string name="dashboard_whatsapp_saved">WhatsApp activado; Hermes inició un reinicio del gateway.</string>
<string name="drawer_all_profiles">Todos los perfiles</string>
<string name="drawer_no_profile_sessions">No hay sesiones de perfil coincidentes.</string>
<string name="drawer_customize_sessions">Personalizar sesiones</string>
<string name="drawer_group_by">Agrupar por</string>
<string name="drawer_order_by">Ordenar por</string>
<string name="drawer_show_metadata">Mostrar en las filas</string>
<string name="drawer_show_details">Mostrar detalles</string>
<string name="drawer_filters">Filtros</string>
<string name="drawer_project_home">Inicio</string>
<string name="drawer_expand_project">Expandir %1$s</string>
<string name="drawer_collapse_project">Contraer %1$s</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d sesión</item>
<item quantity="other">%1$d sesiones</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d perfil</item>
<item quantity="other">%1$d perfiles</item>
</plurals>
<string name="drawer_filter_status">Estado</string>
<string name="drawer_filter_profile">Perfil</string>
<string name="drawer_filter_project">Proyecto</string>
<string name="drawer_filter_pull_request">Solicitud de incorporación</string>
<string name="drawer_option_updated">Actualizado</string>
<string name="drawer_option_profile">Perfil</string>
<string name="drawer_option_tokens">Tokens</string>
<string name="drawer_option_cost">Coste</string>
<string name="drawer_reset_filters">Restablecer</string>
<string name="drawer_profile_colors">Colores de perfil</string>
<string name="drawer_profile_color_auto">Automático</string>
<string name="drawer_profile_color_set">Establecer el color del perfil %1$s en %2$s</string>
<string name="drawer_close">Cerrar</string>
<string name="profile_inspector_gateway_settings">Ajustes del perfil de Gateway</string>
<string name="profile_inspector_gateway_settings_hint">Se guarda directamente mediante Hermes para este perfil seleccionado.</string>
+29
View File
@@ -4043,6 +4043,35 @@
<string name="dashboard_whatsapp_saved">WhatsApp を有効にしました。Hermes がゲートウェイの再起動を開始しました。</string>
<string name="drawer_all_profiles">すべてのプロファイル</string>
<string name="drawer_no_profile_sessions">一致するプロファイルセッションはありません。</string>
<string name="drawer_customize_sessions">セッションをカスタマイズ</string>
<string name="drawer_group_by">グループ化</string>
<string name="drawer_order_by">並び順</string>
<string name="drawer_show_metadata">行に表示</string>
<string name="drawer_show_details">詳細を表示</string>
<string name="drawer_filters">フィルター</string>
<string name="drawer_project_home">ホーム</string>
<string name="drawer_expand_project">%1$sを展開</string>
<string name="drawer_collapse_project">%1$sを折りたたむ</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d件のセッション</item>
<item quantity="other">%1$d件のセッション</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d件のプロファイル</item>
<item quantity="other">%1$d件のプロファイル</item>
</plurals>
<string name="drawer_filter_status">ステータス</string>
<string name="drawer_filter_profile">プロフィール</string>
<string name="drawer_filter_project">プロジェクト</string>
<string name="drawer_filter_pull_request">プルリクエスト</string>
<string name="drawer_option_updated">更新日時</string>
<string name="drawer_option_profile">プロフィール</string>
<string name="drawer_option_tokens">トークン</string>
<string name="drawer_option_cost">コスト</string>
<string name="drawer_reset_filters">リセット</string>
<string name="drawer_profile_colors">プロフィールの色</string>
<string name="drawer_profile_color_auto">自動</string>
<string name="drawer_profile_color_set">%1$s のプロフィール色を %2$s に設定</string>
<string name="drawer_close">閉じる</string>
<string name="profile_inspector_gateway_settings">Gateway プロファイル設定</string>
<string name="profile_inspector_gateway_settings_hint">選択中のプロファイルに Hermes 経由で直接保存します。</string>
+33
View File
@@ -3765,6 +3765,39 @@
<string name="dashboard_whatsapp_saved">WhatsApp включён; Hermes начал перезапуск шлюза.</string>
<string name="drawer_all_profiles">Все профили</string>
<string name="drawer_no_profile_sessions">Нет подходящих сеансов профиля.</string>
<string name="drawer_customize_sessions">Настроить сеансы</string>
<string name="drawer_group_by">Группировать по</string>
<string name="drawer_order_by">Сортировать по</string>
<string name="drawer_show_metadata">Показывать в строках</string>
<string name="drawer_show_details">Показывать подробности</string>
<string name="drawer_filters">Фильтры</string>
<string name="drawer_project_home">Главная</string>
<string name="drawer_expand_project">Развернуть %1$s</string>
<string name="drawer_collapse_project">Свернуть %1$s</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d сеанс</item>
<item quantity="few">%1$d сеанса</item>
<item quantity="many">%1$d сеансов</item>
<item quantity="other">%1$d сеанса</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d профиль</item>
<item quantity="few">%1$d профиля</item>
<item quantity="many">%1$d профилей</item>
<item quantity="other">%1$d профиля</item>
</plurals>
<string name="drawer_filter_status">Статус</string>
<string name="drawer_filter_profile">Профиль</string>
<string name="drawer_filter_project">Проект</string>
<string name="drawer_filter_pull_request">Запрос на слияние</string>
<string name="drawer_option_updated">Обновлено</string>
<string name="drawer_option_profile">Профиль</string>
<string name="drawer_option_tokens">Токены</string>
<string name="drawer_option_cost">Стоимость</string>
<string name="drawer_reset_filters">Сбросить</string>
<string name="drawer_profile_colors">Цвета профилей</string>
<string name="drawer_profile_color_auto">Автоматически</string>
<string name="drawer_profile_color_set">Установить цвет профиля %1$s: %2$s</string>
<string name="drawer_close">Закрыть</string>
<string name="profile_inspector_gateway_settings">Настройки профиля Gateway</string>
<string name="profile_inspector_gateway_settings_hint">Сохраняются напрямую через Hermes для выбранного профиля.</string>
+30 -1
View File
@@ -4063,8 +4063,37 @@
<string name="support_bundle_copied">Support information copied</string>
<string name="support_bundle_no_share">Support information copied — no app found to share to</string>
<string name="support_bundle_share_title">Share Hermes-Relay support information</string>
<string name="drawer_all_profiles">All profiles</string>
<string name="drawer_all_profiles">All Profiles</string>
<string name="drawer_no_profile_sessions">No matching profile sessions.</string>
<string name="drawer_customize_sessions">Customize sessions</string>
<string name="drawer_group_by">Group by</string>
<string name="drawer_order_by">Order by</string>
<string name="drawer_show_metadata">Show on rows</string>
<string name="drawer_show_details">Show details</string>
<string name="drawer_filters">Filters</string>
<string name="drawer_project_home">Home</string>
<string name="drawer_expand_project">Expand %1$s</string>
<string name="drawer_collapse_project">Collapse %1$s</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d session</item>
<item quantity="other">%1$d sessions</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d profile</item>
<item quantity="other">%1$d profiles</item>
</plurals>
<string name="drawer_filter_status">Status</string>
<string name="drawer_filter_profile">Profile</string>
<string name="drawer_filter_project">Project</string>
<string name="drawer_filter_pull_request">Pull request</string>
<string name="drawer_option_updated">Updated</string>
<string name="drawer_option_profile">Profile</string>
<string name="drawer_option_tokens">Tokens</string>
<string name="drawer_option_cost">Cost</string>
<string name="drawer_reset_filters">Reset</string>
<string name="drawer_profile_colors">Profile colors</string>
<string name="drawer_profile_color_auto">Auto</string>
<string name="drawer_profile_color_set">Set %1$s profile color to %2$s</string>
<string name="drawer_close">Close</string>
<string name="profile_inspector_gateway_settings">Gateway profile settings</string>
<string name="profile_inspector_gateway_settings_hint">Saved directly through upstream Hermes for this selected profile.</string>
@@ -12,6 +12,18 @@ import org.junit.Test
*/
class ChatMessageTest {
@Test
fun messageReaction_replacesAndRetractsTheUsersTapback() {
val agent = MessageReaction("🔥", "agent", 1.0)
val heart = applyMessageReaction(listOf(agent), "❤️", at = 2.0)
assertEquals(listOf(agent, MessageReaction("❤️", "user", 2.0)), heart)
val replaced = applyMessageReaction(heart, "😂", at = 3.0)
assertEquals(listOf(agent, MessageReaction("😂", "user", 3.0)), replaced)
assertEquals(listOf(agent), applyMessageReaction(replaced, "😂", at = 4.0))
}
// --- ChatMessage creation with defaults ---
@Test
@@ -25,9 +25,14 @@ class ProfilePresentationStoreTest {
fun orderAndHiddenProfilesRoundTripPerConnection() = runBlocking {
store.setOrder("one", listOf("beta", "alpha"))
store.setHidden("one", setOf("gamma"))
store.setColors("one", mapOf("alpha" to "#356CFF"))
assertEquals(
ProfilePresentation(order = listOf("beta", "alpha"), hidden = setOf("gamma")),
ProfilePresentation(
order = listOf("beta", "alpha"),
hidden = setOf("gamma"),
colors = mapOf("alpha" to "#356CFF"),
),
store.presentationFlow("one").first(),
)
assertEquals(ProfilePresentation(), store.presentationFlow("two").first())
@@ -104,6 +109,7 @@ class ProfilePresentationStoreTest {
fun clearRemovesOnlyOneConnectionsPreferences() = runBlocking {
store.setOrder("one", listOf("beta"))
store.setHidden("one", setOf("alpha"))
store.setColors("one", mapOf("beta" to "#ED4E8B"))
store.setOrder("two", listOf("gamma"))
store.clear("one")
@@ -0,0 +1,130 @@
package com.hermesandroid.relay.network
import android.content.Context
import android.os.Looper
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.ConnectionManager
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
import io.mockk.mockk
import java.util.concurrent.AbstractExecutorService
import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutorService
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Owner-level coverage for every production ConnectionPool.evictAll() teardown. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NetworkShutdownOwnerTest {
@Test
fun hermesApiClient_shutdownLeavesMainThread() {
val teardown = TrackingExecutorService()
val client = HermesApiClient(
baseUrl = "https://hermes.example.test",
apiKey = "test-key",
okHttpClient = clientWith(teardown),
)
client.shutdown()
teardown.assertShutdownOffMainThread()
}
@Test
fun dashboardApiClient_shutdownLeavesMainThread() {
val teardown = TrackingExecutorService()
val client = DashboardApiClient(
baseUrl = "https://hermes.example.test",
okHttpClient = clientWith(teardown),
)
client.shutdown()
teardown.assertShutdownOffMainThread()
}
@Test
fun connectionManager_shutdownLeavesMainThread() {
val teardown = TrackingExecutorService()
val manager = ConnectionManager(
multiplexer = ChannelMultiplexer(),
okHttpClientFactory = { clientWith(teardown) },
)
manager.shutdown()
teardown.assertShutdownOffMainThread()
}
@Test
fun upstreamTransportController_connectionSwitchResetLeavesMainThread() {
val dashboardUrl = "https://hermes.example.test"
val teardown = TrackingExecutorService()
val controller = UpstreamTransportController(
context = mockk<Context>(relaxed = true),
activeConnectionIdProvider = { null },
dashboardUrlProvider = { dashboardUrl },
gatewayKeepAliveProvider = { false },
dashboardHttpClientFactory = { _, _ -> clientWith(teardown) },
)
controller.dashboardHttpClientForActive(dashboardUrl)
controller.resetGatewayForConnectionSwitch()
teardown.assertShutdownOffMainThread()
}
private fun clientWith(executor: ExecutorService): OkHttpClient =
OkHttpClient.Builder()
.dispatcher(Dispatcher(executor))
.build()
private class TrackingExecutorService : AbstractExecutorService() {
private val shutdown = AtomicBoolean(false)
private val shutdownLatch = CountDownLatch(1)
private val shutdownThread = AtomicReference<Thread>()
override fun shutdown() {
shutdownThread.compareAndSet(null, Thread.currentThread())
shutdown.set(true)
shutdownLatch.countDown()
}
override fun shutdownNow(): MutableList<Runnable> {
shutdown()
return mutableListOf()
}
override fun isShutdown(): Boolean = shutdown.get()
override fun isTerminated(): Boolean = shutdown.get()
override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean =
shutdownLatch.await(timeout, unit)
override fun execute(command: Runnable) = command.run()
fun assertShutdownOffMainThread() {
assertSame(Looper.myLooper(), Looper.getMainLooper())
assertTrue("owner did not shut its OkHttp dispatcher down", shutdownLatch.await(5, TimeUnit.SECONDS))
assertNotSame(
"owner performed OkHttp teardown on the main thread",
Looper.getMainLooper().thread,
shutdownThread.get(),
)
}
}
}
@@ -975,6 +975,49 @@ class ChatHandlerTest {
assertEquals(42L, handler.messages.value.last().rowId)
}
@Test
fun loadMessageHistory_hydratesPersistedReactionsAndLetsServerWin() {
fun metadata(emoji: String) = buildJsonObject {
put("reactions", buildJsonArray {
add(buildJsonObject {
put("emoji", emoji)
put("author", "user")
put("at", 1_700_000_000.0)
})
})
}
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-1",
rowId = 42L,
role = "assistant",
content = JsonPrimitive("Reply"),
displayMetadata = metadata("❤️"),
),
),
)
assertEquals("❤️", handler.messages.value.single().reactions.single().emoji)
handler.mutateMessage("assistant-1") { message ->
message.copy(reactions = listOf(com.hermesandroid.relay.data.MessageReaction("👍", "user", 2.0)))
}
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-1",
rowId = 42L,
role = "assistant",
content = JsonPrimitive("Reply"),
displayMetadata = metadata("😂"),
),
),
)
assertEquals("😂", handler.messages.value.single().reactions.single().emoji)
}
@Test
fun rebindSurvivorUserRowIds_replacesPrefixAndClearsUnboundTurns() {
handler.loadMessageHistory(
@@ -1986,7 +1986,7 @@ class GatewayChatClientTest {
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
val result = runBlocking { client.reactToNewest("assistant", "👍") }
val result = runBlocking { client.reactToMessage(null, "assistant", "👍") }
assertTrue(result.isSuccess)
val params = harness.awaitRpc("message.react")
@@ -1997,6 +1997,20 @@ class GatewayChatClientTest {
assertFalse("row_id" in params)
}
@Test
fun `message reaction targets durable row when history provides it`() {
client.sendTurn("stored-1", "hi", null, Recorder().callbacks) {}
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
val result = runBlocking { client.reactToMessage(42L, "assistant", "❤️") }
assertTrue(result.isSuccess)
val params = harness.awaitRpc("message.react")
assertEquals(42L, (params["row_id"] as? JsonPrimitive)?.longOrNull)
assertFalse("newest_role" in params)
}
@Test
fun `pet thumbnail connects on demand and sends upstream params`() {
client.sessionProfileProvider = { "work" }
@@ -1,14 +1,17 @@
package com.hermesandroid.relay.network.upstream.models
import kotlinx.serialization.encodeToString
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
@@ -71,7 +74,10 @@ class SessionModelsTest {
"message_count": 5,
"tool_call_count": 2,
"input_tokens": 100,
"output_tokens": 200
"output_tokens": 200,
"actual_cost_usd": 1.25,
"estimated_cost_usd": 1.50,
"is_active": true
}
""".trimIndent()
@@ -82,6 +88,11 @@ class SessionModelsTest {
assertEquals(1700000900.0, item.resolvedLastActivity!!, 0.001)
assertEquals(5, item.messageCount)
assertEquals(2, item.toolCallCount)
assertEquals(100, item.inputTokens)
assertEquals(200, item.outputTokens)
assertEquals(1.25, item.actualCostUsd!!, 0.001)
assertEquals(1.50, item.estimatedCostUsd!!, 0.001)
assertTrue(item.isActive)
}
@Test
@@ -94,6 +105,42 @@ class SessionModelsTest {
assertTrue(item.archived)
}
@Test
fun sessionItem_deserializesNumericSessionFlags() {
val item = json.decodeFromString<SessionItem>(
"""{"id":"s1","has_model_config":1,"pinned":0,"archived":1}""",
)
assertTrue(item.hasModelConfig)
assertFalse(item.pinned)
assertTrue(item.archived)
}
@Test
fun sessionItem_deserializesStringBooleanSessionFlags() {
val item = json.decodeFromString<SessionItem>(
"""{"id":"s1","has_model_config":"false","pinned":"1","archived":"0"}""",
)
assertFalse(item.hasModelConfig)
assertTrue(item.pinned)
assertFalse(item.archived)
}
@Test
fun sessionItem_rejectsNonBooleanSessionFlagValues() {
assertThrows(SerializationException::class.java) {
json.decodeFromString<SessionItem>(
"""{"id":"s1","pinned":2}""",
)
}
assertThrows(SerializationException::class.java) {
json.decodeFromString<SessionItem>(
"""{"id":"s1","archived":"yes"}""",
)
}
}
@Test
fun sessionItem_deserializesOptionalWorkspaceMetadata() {
val item = json.decodeFromString<SessionItem>(
@@ -320,6 +367,25 @@ class SessionModelsTest {
"""{"role":"user","row_id":{"unexpected":true}}""",
).rowId,
)
assertEquals(
75L,
json.decodeFromString<MessageItem>(
"""{"id":75,"role":"assistant"}""",
).resolvedRowId,
)
}
@Test
fun messageItem_readsReactionsFromObjectOrRawJsonMetadata() {
val objectBacked = json.decodeFromString<MessageItem>(
"""{"role":"assistant","display_metadata":{"reactions":[{"emoji":"👍","author":"user","at":1.0}]}}""",
)
val stringBacked = json.decodeFromString<MessageItem>(
"""{"role":"assistant","display_metadata":"{\"reactions\":[{\"emoji\":\"❤️\",\"author\":\"user\",\"at\":2.0}]}"}""",
)
assertEquals("👍", objectBacked.reactions.single().emoji)
assertEquals("❤️", stringBacked.reactions.single().emoji)
}
@Test
@@ -17,6 +17,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.ChatQuoteReference
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.MessageReaction
import com.hermesandroid.relay.data.buildChatQuotedPrompt
import org.junit.Assert.assertEquals
import org.junit.Rule
@@ -117,6 +118,65 @@ class MessageBubbleInteractionTest {
compose.onNodeWithText("Quote in reply").assertIsDisplayed().assertHasClickAction()
}
@Test
fun reactionsStayOutsideBubbleAndOpenAsFloatingTapbacks() {
val reactions = mutableListOf<String?>()
val message = ChatMessage(
id = "assistant-reactions",
role = MessageRole.ASSISTANT,
content = "React to this response.",
timestamp = 1_700_000_000_000L,
)
compose.setContent {
MaterialTheme {
MessageBubble(
message = message,
onReact = { reactions += it },
)
}
}
compose.onNodeWithContentDescription("React with 👍").assertDoesNotExist()
compose.onNodeWithText("Remove reaction").assertDoesNotExist()
compose.onNodeWithContentDescription("assistant message: ${message.content}")
.performTouchInput { longClick() }
compose.onNodeWithContentDescription("React with 👍")
.assertIsDisplayed()
.assertHasClickAction()
.performClick()
compose.runOnIdle { assertEquals(listOf("👍"), reactions) }
compose.onNodeWithContentDescription("React with 👍").assertDoesNotExist()
}
@Test
fun landedReactionStaysPinnedToTheBubbleAndReopensPicker() {
val message = ChatMessage(
id = "assistant-landed-reaction",
role = MessageRole.ASSISTANT,
content = "A reacted response.",
timestamp = 1_700_000_000_000L,
reactions = listOf(MessageReaction("❤️", "user", 1_700_000_000.0)),
)
compose.setContent {
MaterialTheme {
MessageBubble(message = message, onReact = {})
}
}
compose.onNodeWithContentDescription("Reactions: ❤️")
.assertIsDisplayed()
.assertHasClickAction()
.performClick()
compose.onNodeWithContentDescription("React with ❤️").assertIsDisplayed()
compose.onNodeWithText("Remove reaction").assertIsDisplayed()
}
@Test
fun quotedReplyRendersStructuredReferenceAndKeepsMarkupOutOfActions() {
var quotedContent: String? = null
@@ -0,0 +1,135 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Test
class SessionDrawerPolicyTest {
@Test
fun `sessions are ungrouped by default`() {
assertEquals(SessionDrawerGrouping.None, SessionDrawerViewOptions().grouping)
}
@Test
fun `cross profile rows retain composite identity`() {
val alpha = row("alpha", "same")
val beta = row("beta", "same")
assertEquals("alpha:same", sessionRowKey(alpha))
assertEquals("beta:same", sessionRowKey(beta))
}
@Test
fun `profile project status and pull request filters compose`() {
val wanted = row(
profile = "work",
id = "wanted",
repo = "/src/hermes-relay",
prNumber = 347,
prState = "open",
)
val wrongProfile = row("personal", "other", repo = "/src/hermes-relay", prNumber = 22)
val wrongProject = row("work", "notes", repo = "/src/notes", prNumber = 23)
val states = mapOf(sessionRowKey(wanted) to SessionActivityState.NeedsInput)
val filtered = filterAndSortSessionRows(
rows = listOf(wrongProfile, wrongProject, wanted),
options = SessionDrawerViewOptions(
profiles = setOf("work"),
projects = setOf("hermes-relay"),
statuses = setOf(SessionDrawerStatus.NeedsInput),
pullRequests = setOf(SessionDrawerPrState.Open),
),
activityStates = states,
)
assertEquals(listOf("wanted"), filtered.map { it.session.sessionId })
}
@Test
fun `token and cost ordering use authoritative session metrics`() {
val small = row("default", "small", inputTokens = 10, outputTokens = 20, cost = 3.0)
val large = row("default", "large", inputTokens = 500, outputTokens = 600, cost = 1.0)
assertEquals(
listOf("large", "small"),
filterAndSortSessionRows(
listOf(small, large),
SessionDrawerViewOptions(ordering = SessionDrawerOrdering.Tokens),
).map { it.session.sessionId },
)
assertEquals(
listOf("small", "large"),
filterAndSortSessionRows(
listOf(small, large),
SessionDrawerViewOptions(ordering = SessionDrawerOrdering.Cost),
).map { it.session.sessionId },
)
}
@Test
fun `desktop style grouping supports project profile status and updated buckets`() {
val now = 10 * DAY
val working = row("work", "working", repo = "/src/hermes-relay", updatedAt = now - 1_000)
val idle = row("personal", "idle", repo = "/src/notes", updatedAt = now - 3 * DAY)
val states = mapOf(sessionRowKey(working) to SessionActivityState.Working)
assertEquals(
listOf("hermes-relay", "notes"),
groupSessionRows(listOf(working, idle), SessionDrawerGrouping.Project, states, now)
.mapNotNull { it.label },
)
assertEquals(
listOf("work", "personal"),
groupSessionRows(listOf(working, idle), SessionDrawerGrouping.Profile, states, now)
.mapNotNull { it.label },
)
assertEquals(
listOf("Working", "Idle"),
groupSessionRows(listOf(working, idle), SessionDrawerGrouping.Status, states, now)
.mapNotNull { it.label },
)
assertEquals(
listOf("Today", "Last 7 days"),
groupSessionRows(listOf(working, idle), SessionDrawerGrouping.Updated, states, now)
.mapNotNull { it.label },
)
assertEquals(
listOf(null),
groupSessionRows(listOf(working, idle), SessionDrawerGrouping.None, states, now)
.map { it.label },
)
}
private fun row(
profile: String,
id: String,
repo: String? = null,
prNumber: Int? = null,
prState: String? = null,
inputTokens: Int = 0,
outputTokens: Int = 0,
cost: Double? = null,
updatedAt: Long = 0L,
) = ProfileSessionRow(
profile = profile,
session = ChatSession(
sessionId = id,
title = id,
model = null,
gitRepoRoot = repo,
pullRequestNumber = prNumber,
pullRequestState = prState,
inputTokens = inputTokens,
outputTokens = outputTokens,
actualCostUsd = cost,
lastActivityAt = updatedAt,
),
)
private companion object {
const val DAY = 24L * 60L * 60L * 1_000L
}
}
@@ -8,10 +8,15 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertEquals
@@ -66,6 +71,42 @@ class SessionDrawerTest {
listOf("hermes-relay", "feature/android-session-context", "PR #134 · Open"),
sessionWorkLabels(session),
)
assertEquals(
listOf(
SessionWorkBadgeKind.PROJECT,
SessionWorkBadgeKind.BRANCH,
SessionWorkBadgeKind.PULL_REQUEST,
),
sessionWorkBadges(session).map(SessionWorkBadge::kind),
)
}
@Test
fun `session rows identify project and branch badges`() {
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = listOf(
ChatSession(
sessionId = "coding-1",
title = "Ship it",
model = null,
gitRepoRoot = "/work/hermes-relay",
gitBranch = "feature/chat-polish",
),
),
currentSessionId = null,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("hermes-relay").assertIsDisplayed()
compose.onNodeWithContentDescription("Project: hermes-relay").assertIsDisplayed()
compose.onNodeWithContentDescription("Branch: feature/chat-polish").assertIsDisplayed()
}
@Test
@@ -120,4 +161,168 @@ class SessionDrawerTest {
compose.onNodeWithText("Now latest").assertIsDisplayed()
}
@Test
fun `all profiles toggle renders duplicate ids together in the primary list`() {
var pinned: Triple<String, String, Boolean>? = null
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = listOf(ChatSession("same", "Current", null)),
currentSessionId = null,
activeProfileName = "alpha",
allProfilesSupported = true,
allProfileSessions = listOf(
ProfileSessionRow("alpha", ChatSession("same", "Alpha session", null)),
ProfileSessionRow("beta", ChatSession("same", "Beta session", null)),
),
onRefreshAllProfiles = {},
onSelectProfileSession = { _, _ -> },
onSetProfileSessionPinned = { profile, sessionId, value ->
pinned = Triple(profile, sessionId, value)
},
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("All Profiles").performClick()
compose.onNodeWithText("Alpha session").assertIsDisplayed()
compose.onNodeWithText("Beta session").assertIsDisplayed()
compose.onAllNodesWithContentDescription("Session actions")[0]
.assertIsDisplayed()
.performClick()
compose.onNodeWithText("Pin session").performClick()
compose.runOnIdle { assertEquals(Triple("alpha", "same", true), pinned) }
}
@Test
fun `opening an owned session keeps all profiles browsing selected`() {
var scopeTitle by mutableStateOf("Mizu Sessions")
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = emptyList(),
currentSessionId = null,
scopeTitle = scopeTitle,
activeProfileName = "mizu",
allProfilesSupported = true,
allProfileSessions = listOf(
ProfileSessionRow("mizu", ChatSession("m", "Mizu chat", null)),
ProfileSessionRow("x-bot", ChatSession("x", "X Bot chat", null)),
),
onRefreshAllProfiles = {},
onSelectProfileSession = { _, _ -> scopeTitle = "X Bot Sessions" },
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("All Profiles").performClick()
compose.onNodeWithText("X Bot chat").performClick()
compose.onNodeWithText("Mizu chat").assertIsDisplayed()
compose.onNodeWithText("X Bot Sessions").assertDoesNotExist()
compose.onNodeWithText("2 profiles · 2 sessions").assertIsDisplayed()
}
@Test
fun `new chat from all profiles requests an explicit default draft`() {
var scopedNewChats = 0
var defaultNewChats = 0
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = emptyList(),
currentSessionId = null,
allProfilesSupported = true,
allProfileSessions = listOf(
ProfileSessionRow("default", ChatSession("d", "Default chat", null)),
),
onRefreshAllProfiles = {},
onSelectProfileSession = { _, _ -> },
onNewChat = { scopedNewChats++ },
onNewDefaultChat = { defaultNewChats++ },
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("All Profiles").performClick()
compose.waitForIdle()
compose.onNodeWithText("New Chat").performClick()
compose.runOnIdle {
assertEquals(0, scopedNewChats)
assertEquals(1, defaultNewChats)
}
}
@Test
fun `customize sessions exposes desktop backed view variants`() {
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = listOf(ChatSession("one", "One", null)),
currentSessionId = null,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("Customize sessions").performClick()
compose.onNodeWithText("Group by").assertIsDisplayed()
compose.onNodeWithText("Order by").assertIsDisplayed()
compose.onNodeWithText("Show details").assertIsDisplayed()
compose.onNodeWithText("Filters").assertIsDisplayed()
}
@Test
fun `all profiles customization can override a profile identity color`() {
var changed: Pair<String, String?>? = null
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = emptyList(),
currentSessionId = null,
allProfilesSupported = true,
allProfileSessions = listOf(
ProfileSessionRow("alpha", ChatSession("a", "Alpha session", null)),
ProfileSessionRow("beta", ChatSession("b", "Beta session", null)),
),
onProfileColorChange = { profile, color -> changed = profile to color },
onRefreshAllProfiles = {},
onSelectProfileSession = { _, _ -> },
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("All Profiles").performClick()
compose.onNodeWithText("Customize sessions").performClick()
compose.onNodeWithText("Profile colors").performScrollTo().assertIsDisplayed()
compose.onNodeWithContentDescription(
"Set alpha profile color to ${ProfileAccentSwatches.first()}",
).performScrollTo().performClick()
compose.runOnIdle {
assertEquals("alpha" to ProfileAccentSwatches.first(), changed)
}
}
}
@@ -11,7 +11,6 @@ class ChatHeaderSubtitleTest {
resolveChatHeaderSubtitle(
isStreaming = true,
statusText = "Streaming",
projectName = "Hermes Relay",
personalityName = "Victor",
modelName = "GPT-5.6",
),
@@ -19,13 +18,12 @@ class ChatHeaderSubtitleTest {
}
@Test
fun `idle subtitle retains project personality and model metadata`() {
fun `idle subtitle retains only personality and model metadata`() {
assertEquals(
"Hermes Relay \u00B7 Victor \u00B7 GPT-5.6",
"Victor \u00B7 GPT-5.6",
resolveChatHeaderSubtitle(
isStreaming = false,
statusText = "Connected",
projectName = "Hermes Relay",
personalityName = "Victor",
modelName = "GPT-5.6",
),
@@ -0,0 +1,25 @@
package com.hermesandroid.relay.ui.theme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ProfileAccentTest {
@Test
fun namedProfilesReceiveStableDistinctColors() {
assertEquals(deterministicProfileAccent("alpha"), deterministicProfileAccent("alpha"))
assertNotEquals(deterministicProfileAccent("alpha"), deterministicProfileAccent("beta"))
}
@Test
fun overrideWinsAndDefaultRemainsNeutral() {
assertEquals(accentColor("#356CFF"), resolveProfileAccent("alpha", mapOf("alpha" to "#356CFF")))
assertNull(resolveProfileAccent("default", mapOf("default" to "#356CFF")))
}
@Test
fun pickerOffersDesktopSizedPalette() {
assertEquals(12, ProfileAccentSwatches.distinct().size)
}
}
@@ -12,6 +12,7 @@ import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
@@ -148,6 +149,75 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("victor", gatewayClient.sessionProfileProvider())
}
@Test
fun allProfilesOpenKeepsGlobalSelectionButScopesHistoryResumeAndSendToOwner() {
val global = Profile(name = "mizu", model = "grok-4.5", description = "Mizu")
val owner = Profile(name = "x-bot", model = "grok-4.3", description = "X Bot")
var loadedProfile: String? = null
var persistedSession = "unchanged"
viewModel.setSelectedProfileProvider { global }
viewModel.setSessionProfileNameProvider { global.name }
viewModel.onSessionChanged = { persistedSession = it ?: "cleared" }
viewModel.setProfileMessageLoaderWithMode { profileName, _, _ ->
loadedProfile = profileName
Result.success(emptyList())
}
viewModel.openProfileSession(
profileName = owner.name,
profile = owner,
contextKey = AgentDisplay.profileContextKey("connection-a", owner.name),
sessionId = "x-bot-session",
)
awaitCondition { loadedProfile == owner.name }
assertEquals(owner.name, viewModel.openedSessionProfileName.value)
assertEquals(owner.name, gatewayClient.sessionProfileProvider())
assertEquals("X-bot", handler.activeAgentName)
assertEquals("unchanged", persistedSession)
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", global.name),
sessionId = null,
)
assertEquals(null, viewModel.openedSessionProfileName.value)
assertEquals(global.name, gatewayClient.sessionProfileProvider())
viewModel.openProfileSession(
profileName = owner.name,
profile = owner,
contextKey = AgentDisplay.profileContextKey("connection-a", owner.name),
sessionId = "x-bot-session",
)
assertEquals(owner.name, viewModel.openedSessionProfileName.value)
viewModel.createNewChat()
assertEquals(null, viewModel.openedSessionProfileName.value)
assertEquals(global.name, gatewayClient.sessionProfileProvider())
}
@Test
fun allProfilesNewChatUsesLiteralDefaultWithoutChangingGlobalSelection() {
val global = Profile(name = "victor", model = "grok-4.5", description = "Victor")
val rootDefault = Profile(name = "default", model = "gpt-5.5", description = "Hermes")
var persistedSession = "unchanged"
viewModel.setSelectedProfileProvider { global }
viewModel.setSessionProfileNameProvider { global.name }
viewModel.onSessionChanged = { persistedSession = it ?: "cleared" }
viewModel.createProfileChat(
profileName = "default",
profile = rootDefault,
contextKey = AgentDisplay.profileContextKey("connection-a", "default"),
)
assertEquals("default", viewModel.openedSessionProfileName.value)
assertEquals("default", gatewayClient.sessionProfileProvider())
assertEquals(null, handler.currentSessionId.value)
assertEquals("Hermes", handler.activeAgentName)
assertEquals("unchanged", persistedSession)
}
@Test
fun gatewaySessionCreateProviderPreservesExplicitFastFalse() {
serverWs.send(
@@ -1383,38 +1453,12 @@ class ChatViewModelGatewayInboundTurnTest {
}
@Test
fun unsolicitedTurnDoesNotReplaceAnActiveForcedSseTurn() {
holdCompletionsStream = true
fun gatewayVoiceTurnDoesNotRequireApiFallback() {
viewModel.sendVoiceMessage("local voice turn", "Respond for spoken playback")
awaitCondition { handler.isStreaming.value }
val localPlaceholderId = handler.messages.value.last().id
val params = gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", BACKGROUND_ANSWER) },
"live-resumed",
),
)
persistedHistory = persistedAnswerHistory()
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", BACKGROUND_ANSWER) },
"live-resumed",
),
)
Thread.sleep(150)
shadowOf(Looper.getMainLooper()).idle()
assertTrue("the forced SSE turn must still own streaming", handler.isStreaming.value)
assertTrue(handler.messages.value.any { it.id == localPlaceholderId && it.isStreaming })
assertFalse(handler.messages.value.any { it.content == BACKGROUND_ANSWER })
viewModel.cancelStream()
awaitCondition { !handler.isStreaming.value }
awaitCondition { handler.messages.value.any { it.content == BACKGROUND_ANSWER } }
assertEquals(JsonPrimitive("local voice turn"), params["text"])
assertEquals(0, apiCompletionsRequestCount.get())
}
@Test
@@ -241,6 +241,35 @@ class ProfileControllerLockTest {
}
}
@Test
fun explicitProfileSessionMutations_useOwningProfileWithoutChangingSelection() {
dashboardUrl = "https://dashboard.example"
coEvery { dashboardClient.deleteSession("session-2", profile = "coder") } returns
Result.success(buildJsonObject { })
coEvery { dashboardClient.renameSession("session-2", "Updated", profile = "coder") } returns
Result.success(buildJsonObject { })
coEvery { dashboardClient.setSessionPinned("session-2", true, profile = "coder") } returns
Result.success(buildJsonObject { })
coEvery { dashboardClient.setSessionArchived("session-2", true, profile = "coder") } returns
Result.success(buildJsonObject { })
assertTrue(runBlocking { controller.deleteSession("coder", "session-2") })
assertTrue(runBlocking { controller.renameSession("coder", "session-2", "Updated") })
assertTrue(runBlocking { controller.setSessionPinned("coder", "session-2", true) })
assertTrue(runBlocking { controller.setSessionArchived("coder", "session-2", true) })
coVerify(exactly = 1) { dashboardClient.deleteSession("session-2", profile = "coder") }
coVerify(exactly = 1) {
dashboardClient.renameSession("session-2", "Updated", profile = "coder")
}
coVerify(exactly = 1) {
dashboardClient.setSessionPinned("session-2", true, profile = "coder")
}
coVerify(exactly = 1) {
dashboardClient.setSessionArchived("session-2", true, profile = "coder")
}
}
// --- selectProfile gating while locked ----------------------------------
@Test
@@ -6,6 +6,7 @@ import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.TimeUnit
class UpstreamTransportControllerAuthClientTest {
@Test
@@ -25,6 +26,10 @@ class UpstreamTransportControllerAuthClientTest {
dashboardUrl = "https://hermes.example.test/alternate"
val moved = controller.dashboardHttpClientForActive(dashboardUrl)
assertNotSame(first, moved)
assertTrue(first.dispatcher.executorService.isShutdown)
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (!first.dispatcher.executorService.isShutdown && System.nanoTime() < deadline) {
Thread.yield()
}
assertTrue("replaced dashboard client was not disposed", first.dispatcher.executorService.isShutdown)
}
}
+1
View File
@@ -0,0 +1 @@
1.3.14
+18 -1
View File
@@ -50,7 +50,9 @@ for existing installs until explicitly enabled. Settings also exposes **Open
terminal**, **Open Hermes CLI**, **View daemon log**, and **Run diagnostics**,
and manages Desktop release updates. **Help &
About** reports the UI, CLI, and connected Relay versions and links to the docs,
troubleshooting, release notes, logs, and diagnostics. The installer download is
troubleshooting, release notes, logs, and diagnostics. Tray lifecycle and child-
process failures are written to `~/.hermes/tray.log`; daemon connection and tool-
router events remain in `~/.hermes/daemon.log`. The installer download is
verified against the release
`SHA256SUMS.txt`, preserves the startup preference, restores a previously
running daemon, and relaunches the tray after the silent replacement.
@@ -712,6 +714,21 @@ Precedence for credentials: `--token` → `HERMES_RELAY_TOKEN` → `--code` →
## Troubleshooting
- **Many `Bun` / `hermes-relay.exe` processes, or Windows error `0xc0000142` from `reg.exe`, `adb.exe`, or `hermes-relay.exe`** — quit **Hermes-Relay CLI UI** first, then run `hermes-relay daemon stop` from a fresh PowerShell. If the CLI cannot start, inspect exact executable paths before stopping only Hermes-Relay-owned processes:
```powershell
$relayBin = [IO.Path]::GetFullPath("$env:USERPROFILE\.hermes\bin\")
$relayProcesses = Get-CimInstance Win32_Process | Where-Object {
$_.ExecutablePath -and
[IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($relayBin, [StringComparison]::OrdinalIgnoreCase) -and
$_.Name -in @('hermes-relay.exe', 'hermes-relay-tray.exe')
}
$relayProcesses | Select-Object ProcessId, ParentProcessId, Name, ExecutablePath, CommandLine
# Review the rows above before stopping them:
$relayProcesses | ForEach-Object { Stop-Process -Id $_.ProcessId }
```
Do not broadly stop every process named `Bun`: unrelated development tools may use the same runtime name. After recovery, inspect `~/.hermes/tray.log` for snapshot, subprocess timeout, launch, and exit failures. Use `~/.hermes/daemon.log` for the single long-running daemon's authentication, transport, and tool-router lifecycle. If unrelated Windows programs still fail to initialize, restart Windows before relaunching the tray.
- **`auth timed out after 15000ms`** — the relay subprocess takes 15–30 s on first attach because it initializes the full agent. Bump the timeout: `HERMES_RELAY_AUTH_TIMEOUT_MS=30000 hermes-relay …`.
- **`relay rejected credentials: auth failed`** — your stored token expired or was revoked. Re-pair: `hermes-relay pair --remote ws://…`.
- **`RelayTransport: global WebSocket not available`** — your Node is too old. Need >=21.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@hermes-relay/cli",
"version": "0.4.0-beta.2",
"version": "0.4.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hermes-relay/cli",
"version": "0.4.0-beta.2",
"version": "0.4.0-beta.3",
"license": "MIT",
"bin": {
"hermes-relay": "bin/hermes-relay.js"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hermes-relay/cli",
"version": "0.4.0-beta.2",
"version": "0.4.0-beta.3",
"description": "Thin-client CLI for Hermes-Relay — talk to a remote Hermes agent over WSS with pairing auth, stream-renders tool calls and responses to plain stdout.",
"type": "module",
"bin": {
+212 -5
View File
@@ -31,6 +31,7 @@
import { spawn } from 'node:child_process'
import { closeSync, openSync, promises as fs } from 'node:fs'
import { randomUUID } from 'node:crypto'
import * as os from 'node:os'
import * as path from 'node:path'
@@ -87,6 +88,156 @@ const VOICE_DISCOVERY_FILE = 'desktop-voice.json'
const STATUS_HEARTBEAT_MS = 30_000
const DETACHED_START_TIMEOUT_MS = 20_000
const DETACHED_START_POLL_MS = 100
const LIFECYCLE_LOCK_TIMEOUT_MS = 25_000
const INSTANCE_LOCK_TIMEOUT_MS = 2_000
const LOCK_POLL_MS = 50
const INCOMPLETE_LOCK_GRACE_MS = 1_000
interface ProcessLockOwner {
pid: number
process_name: string
token: string
created_at: number
purpose: string
}
interface ProcessLock {
owner: ProcessLockOwner
release: () => Promise<void>
}
interface ProcessLockOptions {
timeoutMs: number
purpose: string
now?: () => number
sleep?: (ms: number) => Promise<void>
ownerAlive?: (owner: ProcessLockOwner) => boolean
}
function daemonLockPath(kind: 'lifecycle' | 'instance'): string {
return path.join(os.homedir(), '.hermes', `daemon-${kind}.lock`)
}
async function readProcessLockOwner(lockPath: string): Promise<ProcessLockOwner | null> {
try {
const parsed = JSON.parse(await fs.readFile(path.join(lockPath, 'owner.json'), 'utf8')) as Partial<ProcessLockOwner>
if (
typeof parsed.pid !== 'number' ||
typeof parsed.process_name !== 'string' ||
typeof parsed.token !== 'string' ||
typeof parsed.created_at !== 'number' ||
typeof parsed.purpose !== 'string'
) return null
return parsed as ProcessLockOwner
} catch {
return null
}
}
function processLockOwnerAlive(owner: ProcessLockOwner): boolean {
// PID liveness is deliberately authoritative here. Unlike status/stop, a
// conservative false positive only causes a bounded lock timeout; a false
// negative could let a second daemon start. It also avoids spawning tasklist
// or ps from the startup hot path (and executable-name truncation on Unix).
return isPidAlive(owner.pid)
}
async function releaseProcessLock(lockPath: string, token: string): Promise<void> {
const owner = await readProcessLockOwner(lockPath)
if (owner?.token !== token) return
try {
// Remove the ownership record before the directory. mkdir remains blocked
// until rmdir succeeds, so a newer owner cannot appear between the token
// check and release.
await fs.unlink(path.join(lockPath, 'owner.json'))
await fs.rmdir(lockPath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
/** Atomic directory lock shared by compiled Bun binaries and Node-based dev
* invocations. Stale recovery quarantines an observed directory before it is
* removed, while token-checked release never recursively removes the path. */
async function acquireProcessLock(lockPath: string, options: ProcessLockOptions): Promise<ProcessLock> {
const now = options.now ?? Date.now
const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)))
const ownerAlive = options.ownerAlive ?? processLockOwnerAlive
const deadline = now() + options.timeoutMs
const owner: ProcessLockOwner = {
pid: process.pid,
process_name: path.basename(process.execPath),
token: randomUUID(),
created_at: now(),
purpose: options.purpose
}
await fs.mkdir(path.dirname(lockPath), { recursive: true })
while (true) {
try {
await fs.mkdir(lockPath)
try {
await fs.writeFile(
path.join(lockPath, 'owner.json'),
JSON.stringify(owner) + '\n',
{ encoding: 'utf8', flag: 'wx', mode: 0o600 }
)
} catch (error) {
await fs.rm(lockPath, { recursive: true, force: true }).catch(() => undefined)
throw error
}
return { owner, release: () => releaseProcessLock(lockPath, owner.token) }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
}
const observed = await readProcessLockOwner(lockPath)
let stale = observed !== null && !ownerAlive(observed)
if (!observed) {
try {
const stat = await fs.stat(lockPath)
stale = now() - stat.mtimeMs >= INCOMPLETE_LOCK_GRACE_MS
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
throw error
}
}
if (stale) {
const quarantinePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`
try {
await fs.rename(lockPath, quarantinePath)
await fs.rm(quarantinePath, { recursive: true, force: true })
continue
} catch (error) {
if (['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code ?? '')) continue
throw error
}
}
if (now() >= deadline) {
const detail = observed
? `owner pid ${observed.pid} (${observed.purpose})`
: 'owner metadata is still being created'
throw new Error(`timed out after ${options.timeoutMs}ms waiting for ${options.purpose} lock; ${detail}`)
}
await sleep(Math.min(LOCK_POLL_MS, Math.max(1, deadline - now())))
}
}
async function withDaemonLifecycleLock<T>(operation: string, fn: () => Promise<T>): Promise<T> {
const lock = await acquireProcessLock(daemonLockPath('lifecycle'), {
timeoutMs: LIFECYCLE_LOCK_TIMEOUT_MS,
purpose: `daemon ${operation}`
})
try {
return await fn()
} finally {
try {
await lock.release()
} catch (error) {
process.stderr.write(`daemon: lifecycle_lock_release_failed: ${rpcErrorMessage(error)}\n`)
}
}
}
const DAEMON_USAGE: UsageSpec = {
name: 'daemon',
@@ -480,7 +631,7 @@ async function runElevatedDaemonLifecycle(
/** `daemon start` / `--detach` — spawn the foreground daemon as a detached
* background process (no console window on Windows), logging to a file. */
async function startDetachedDaemon(args: ParsedArgs): Promise<number> {
async function startDetachedDaemonLocked(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const existing = await readDaemonStatus()
@@ -576,8 +727,17 @@ async function startDetachedDaemon(args: ParsedArgs): Promise<number> {
return 0
}
async function startDetachedDaemon(args: ParsedArgs): Promise<number> {
try {
return await withDaemonLifecycleLock('start', () => startDetachedDaemonLocked(args))
} catch (error) {
process.stderr.write(`daemon: lifecycle_lock_failed: ${rpcErrorMessage(error)}\n`)
return 1
}
}
/** `daemon stop` — terminate the running background daemon by its status pid. */
async function stopDaemon(args: ParsedArgs): Promise<number> {
async function stopDaemonLocked(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const status = await readDaemonStatus()
if (!status) {
@@ -602,7 +762,16 @@ async function stopDaemon(args: ParsedArgs): Promise<number> {
return 0
}
async function restartDaemon(args: ParsedArgs): Promise<number> {
async function stopDaemon(args: ParsedArgs): Promise<number> {
try {
return await withDaemonLifecycleLock('stop', () => stopDaemonLocked(args))
} catch (error) {
process.stderr.write(`daemon: lifecycle_lock_failed: ${rpcErrorMessage(error)}\n`)
return 1
}
}
async function restartDaemonLocked(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const existing = await readDaemonStatus()
if (existing && isDaemonProcessAlive(existing)) {
@@ -625,7 +794,16 @@ async function restartDaemon(args: ParsedArgs): Promise<number> {
}
}
await clearDaemonStatus()
return startDetachedDaemon(args)
return startDetachedDaemonLocked(args)
}
async function restartDaemon(args: ParsedArgs): Promise<number> {
try {
return await withDaemonLifecycleLock('restart', () => restartDaemonLocked(args))
} catch (error) {
process.stderr.write(`daemon: lifecycle_lock_failed: ${rpcErrorMessage(error)}\n`)
return 1
}
}
async function restartDaemonAsUser(args: ParsedArgs): Promise<number> {
@@ -719,6 +897,26 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
const human = humanFlag || (!args.flags['log-json'] && !!process.stderr.isTTY)
const log = makeLogger(human)
let instanceLock: ProcessLock
try {
instanceLock = await acquireProcessLock(daemonLockPath('instance'), {
timeoutMs: INSTANCE_LOCK_TIMEOUT_MS,
purpose: 'daemon runtime'
})
log.info({
event: 'instance_lock_acquired',
lock_path: daemonLockPath('instance'),
pid: process.pid
})
} catch (error) {
log.error({
event: 'instance_lock_failed',
message: rpcErrorMessage(error),
lock_path: daemonLockPath('instance')
})
return 1
}
// URL resolution mirrors chat/shell — explicit --remote / HERMES_RELAY_URL
// win, otherwise fall back to a stored session. resolveFirstRunUrl with
// nonInteractive:true auto-picks when exactly one session exists, throws a
@@ -737,6 +935,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
(e instanceof Error ? e.message : String(e)) +
' Pass --remote <url>, set HERMES_RELAY_URL, or pair first with `hermes-relay pair`.'
})
await instanceLock.release()
return 1
}
}
@@ -789,6 +988,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
url,
message: 'no session token. Run `hermes-relay pair --remote <url>` once, then start the daemon.'
})
await instanceLock.release()
return 1
}
@@ -891,6 +1091,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
} catch {
/* ignore */
}
await instanceLock.release()
return 1
}
@@ -1048,7 +1249,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
restoreGrantListener()
restoreControlLifecycleListener()
try {
await clearDaemonStatus()
await clearDaemonStatus(process.pid)
} catch {
/* ignore */
}
@@ -1072,6 +1273,11 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
} catch {
/* ignore */
}
try {
await instanceLock.release()
} catch (error) {
log.warn({ event: 'instance_lock_release_failed', message: rpcErrorMessage(error) })
}
}
setupGracefulExit({ cleanups: [cleanup] })
@@ -1093,6 +1299,7 @@ export {
daemonStatusIsReady as __daemonStatusIsReadyForTests,
buildDaemonChildArgs as __buildDaemonChildArgsForTests,
buildElevationLaunchPlan as __buildElevationLaunchPlanForTests,
acquireProcessLock as __acquireProcessLockForTests,
quoteWindowsArgument as __quoteWindowsArgumentForTests,
makeLogger as __makeLoggerForTests,
readDetachedStartupFailure as __readDetachedStartupFailureForTests,
+21 -7
View File
@@ -61,14 +61,23 @@ export function daemonStatusPath(): string {
return join(homedir(), '.hermes', 'daemon-status.json')
}
let statusWriteChain: Promise<void> = Promise.resolve()
export async function writeDaemonStatus(status: DaemonStatus): Promise<void> {
const filePath = daemonStatusPath()
try {
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, JSON.stringify(status, null, 2) + '\n', { mode: 0o600 })
} catch {
// Best-effort — never let status bookkeeping take down the daemon.
}
const snapshot = JSON.stringify(status, null, 2) + '\n'
statusWriteChain = statusWriteChain.then(async () => {
const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
try {
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(temporaryPath, snapshot, { mode: 0o600 })
await fs.rename(temporaryPath, filePath)
} catch {
// Best-effort — never let status bookkeeping take down the daemon.
await fs.unlink(temporaryPath).catch(() => undefined)
}
})
await statusWriteChain
}
export async function readDaemonStatus(): Promise<DaemonStatus | null> {
@@ -80,8 +89,13 @@ export async function readDaemonStatus(): Promise<DaemonStatus | null> {
}
}
export async function clearDaemonStatus(): Promise<void> {
export async function clearDaemonStatus(expectedPid?: number): Promise<void> {
await statusWriteChain
try {
if (expectedPid !== undefined) {
const current = await readDaemonStatus()
if (current?.pid !== expectedPid) return
}
await fs.unlink(daemonStatusPath())
} catch {
/* missing — fine */
+1 -1
View File
@@ -1,2 +1,2 @@
// Regenerated from package.json by gen:version script. Do not edit by hand.
export const VERSION = "0.4.0-beta.2" as const
export const VERSION = "0.4.0-beta.3" as const
+54
View File
@@ -6,6 +6,7 @@ import test from 'node:test'
import type { DaemonStatus } from '../src/lib/daemonStatus.js'
import {
__acquireProcessLockForTests as acquireProcessLock,
__buildDaemonChildArgsForTests as buildDaemonChildArgs,
__buildElevationLaunchPlanForTests as buildElevationLaunchPlan,
__daemonStatusIsReadyForTests as daemonStatusIsReady,
@@ -44,6 +45,59 @@ test('detached startup ignores stale status and succeeds only for the child pid'
assert.equal(result.outcome === 'ready' ? result.status.pid : null, 42)
})
test('concurrent daemon starts elect exactly one cross-process lock owner', async t => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'hermes-daemon-lock-'))
t.after(() => fs.rm(dir, { recursive: true, force: true }))
const lockPath = path.join(dir, 'daemon-lifecycle.lock')
const attempts = await Promise.allSettled([
acquireProcessLock(lockPath, { timeoutMs: 0, purpose: 'concurrent start A' }),
acquireProcessLock(lockPath, { timeoutMs: 0, purpose: 'concurrent start B' })
])
const winners = attempts.filter(
(attempt): attempt is PromiseFulfilledResult<Awaited<ReturnType<typeof acquireProcessLock>>> =>
attempt.status === 'fulfilled'
)
const losers = attempts.filter(attempt => attempt.status === 'rejected')
assert.equal(winners.length, 1)
assert.equal(losers.length, 1)
assert.match(String((losers[0] as PromiseRejectedResult).reason), /timed out.*lock/s)
await winners[0]!.value.release()
})
test('daemon lock recovers a dead owner and release cannot remove a replacement owner', async t => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'hermes-daemon-stale-lock-'))
t.after(() => fs.rm(dir, { recursive: true, force: true }))
const lockPath = path.join(dir, 'daemon-instance.lock')
await fs.mkdir(lockPath)
await fs.writeFile(path.join(lockPath, 'owner.json'), JSON.stringify({
pid: 999_999,
process_name: 'dead-hermes-relay.exe',
token: 'dead-owner',
created_at: 1,
purpose: 'daemon runtime'
}))
const lock = await acquireProcessLock(lockPath, {
timeoutMs: 100,
purpose: 'daemon runtime replacement',
ownerAlive: () => false
})
const replacement = {
...lock.owner,
token: 'newer-owner'
}
await fs.writeFile(path.join(lockPath, 'owner.json'), JSON.stringify(replacement))
await lock.release()
assert.equal((await fs.stat(lockPath)).isDirectory(), true)
assert.equal(
JSON.parse(await fs.readFile(path.join(lockPath, 'owner.json'), 'utf8')).token,
'newer-owner'
)
})
test('detached startup reports log evidence before generic child-exit failure', async () => {
let now = 0
const result = await waitForDetachedStartup(42, 1_000, {
+1 -1
View File
@@ -1232,7 +1232,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermes-relay-tray"
version = "0.4.0-beta.2"
version = "0.4.0-beta.3"
dependencies = [
"base64 0.22.1",
"serde",
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hermes-relay-tray"
version = "0.4.0-beta.2"
version = "0.4.0-beta.3"
description = "Compact Windows management UI for Hermes-Relay CLI"
authors = ["Axiom Labs"]
edition = "2021"
@@ -26,6 +26,10 @@ windows = { version = "0.61.3", features = [
"Win32_Graphics_Gdi",
"Win32_Security",
"Win32_System_Com",
"Win32_System_Diagnostics_Debug",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_IO",
"Win32_System_JobObjects",
"Win32_System_Ole",
"Win32_System_Threading",
"Win32_System_Variant",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@hermes-relay/tray-ui",
"version": "0.4.0-beta.2",
"version": "0.4.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hermes-relay/tray-ui",
"version": "0.4.0-beta.2",
"version": "0.4.0-beta.3",
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"lucide-react": "^0.468.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hermes-relay/tray-ui",
"private": true,
"version": "0.4.0-beta.2",
"version": "0.4.0-beta.3",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
+575
View File
@@ -0,0 +1,575 @@
//! Bounded execution for short-lived Windows helper commands.
//!
//! Each invocation owns a Job Object. Closing it terminates any processes that
//! the command spawned, so a timed-out CLI cannot leave an orphaned process
//! tree behind. Output is drained concurrently while retaining only a bounded
//! prefix for diagnostics and JSON parsing.
use std::{
fmt,
io::{self, Read},
mem::size_of,
os::windows::{io::AsRawHandle, process::CommandExt},
process::{Command, ExitStatus, Stdio},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use windows::{
core::{Error as WindowsError, PCWSTR},
Win32::{
Foundation::{CloseHandle, HANDLE},
System::{
Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD,
THREADENTRY32,
},
JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
Threading::{
OpenThread, ResumeThread, CREATE_NO_WINDOW, CREATE_SUSPENDED, THREAD_SUSPEND_RESUME,
},
IO::CancelSynchronousIo,
},
},
};
const WAIT_POLL_INTERVAL: Duration = Duration::from_millis(10);
#[derive(Clone, Copy, Debug)]
pub(crate) struct RunOptions {
pub(crate) timeout: Duration,
/// Maximum number of bytes retained from each output stream.
pub(crate) max_capture_bytes: usize,
pub(crate) process_tree: ProcessTreePolicy,
}
impl RunOptions {
pub(crate) const fn new(timeout: Duration, max_capture_bytes: usize) -> Self {
Self {
timeout,
max_capture_bytes,
process_tree: ProcessTreePolicy::KillDescendants,
}
}
pub(crate) const fn direct_child_only(mut self) -> Self {
self.process_tree = ProcessTreePolicy::DirectChildOnly;
self
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ProcessTreePolicy {
/// Contain the invocation in a Job Object and end every descendant when the
/// direct child exits or times out. This is the safe default for probes.
KillDescendants,
/// Allow a successful launcher to leave a deliberately detached daemon.
/// On timeout, only the direct child can be terminated.
DirectChildOnly,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct CapturedOutput {
pub(crate) bytes: Vec<u8>,
pub(crate) total_bytes: u64,
pub(crate) truncated: bool,
}
#[derive(Debug)]
pub(crate) struct ProcessOutcome {
pub(crate) status: ExitStatus,
pub(crate) stdout: CapturedOutput,
pub(crate) stderr: CapturedOutput,
pub(crate) timed_out: bool,
pub(crate) duration: Duration,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ProcessStage {
CreateJob,
ConfigureJob,
Spawn,
AssignJob,
Resume,
Wait,
ReadStdout,
ReadStderr,
}
fn resume_suspended_process(process_id: u32, started: Instant) -> Result<(), ProcessError> {
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }
.map_err(|error| ProcessError::new(ProcessStage::Resume, started.elapsed(), error))?;
let mut entry = THREADENTRY32 {
dwSize: size_of::<THREADENTRY32>() as u32,
..THREADENTRY32::default()
};
let thread_id = (|| {
unsafe { Thread32First(snapshot, &mut entry) }
.map_err(|error| ProcessError::new(ProcessStage::Resume, started.elapsed(), error))?;
loop {
if entry.th32OwnerProcessID == process_id {
return Ok(entry.th32ThreadID);
}
if unsafe { Thread32Next(snapshot, &mut entry) }.is_err() {
return Err(ProcessError::new(
ProcessStage::Resume,
started.elapsed(),
io::Error::new(
io::ErrorKind::NotFound,
"suspended process thread was not found",
),
));
}
}
})();
unsafe {
let _ = CloseHandle(snapshot);
}
let thread_id = thread_id?;
let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, false, thread_id) }
.map_err(|error| ProcessError::new(ProcessStage::Resume, started.elapsed(), error))?;
let resume_result = unsafe { ResumeThread(thread) };
unsafe {
let _ = CloseHandle(thread);
}
if resume_result == u32::MAX {
return Err(ProcessError::new(
ProcessStage::Resume,
started.elapsed(),
WindowsError::from_win32(),
));
}
Ok(())
}
#[derive(Debug)]
pub(crate) struct ProcessError {
pub(crate) stage: ProcessStage,
pub(crate) duration: Duration,
source: Box<dyn std::error::Error + Send + Sync>,
}
impl ProcessError {
fn new(
stage: ProcessStage,
duration: Duration,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self {
stage,
duration,
source: Box::new(source),
}
}
}
impl fmt::Display for ProcessError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"process {:?} failed after {:?}: {}",
self.stage, self.duration, self.source
)
}
}
impl std::error::Error for ProcessError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.source.as_ref())
}
}
struct KillOnCloseJob(Option<HANDLE>);
impl KillOnCloseJob {
fn create(started: Instant) -> Result<Self, ProcessError> {
let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) }.map_err(|error| {
ProcessError::new(ProcessStage::CreateJob, started.elapsed(), error)
})?;
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let configured = unsafe {
SetInformationJobObject(
handle,
JobObjectExtendedLimitInformation,
(&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if let Err(error) = configured {
unsafe {
let _ = CloseHandle(handle);
}
return Err(ProcessError::new(
ProcessStage::ConfigureJob,
started.elapsed(),
error,
));
}
Ok(Self(Some(handle)))
}
fn assign(&self, process: HANDLE, started: Instant) -> Result<(), ProcessError> {
unsafe { AssignProcessToJobObject(self.0.expect("open job handle"), process) }
.map_err(|error| ProcessError::new(ProcessStage::AssignJob, started.elapsed(), error))
}
/// Closing a KILL_ON_JOB_CLOSE job is the process-tree termination signal.
fn close(&mut self) {
if let Some(handle) = self.0.take() {
unsafe {
let _ = CloseHandle(handle);
}
}
}
}
impl Drop for KillOnCloseJob {
fn drop(&mut self) {
self.close();
}
}
/// Runs a command with bounded lifetime, process-tree ownership, and output.
///
/// The command's stdin is disconnected and stdout/stderr are replaced with
/// pipes. The retained bytes are a prefix; readers continue draining after the
/// limit so verbose children cannot deadlock on a full pipe.
pub(crate) fn run(
command: &mut Command,
options: RunOptions,
) -> Result<ProcessOutcome, ProcessError> {
let started = Instant::now();
let mut job = match options.process_tree {
ProcessTreePolicy::KillDescendants => Some(KillOnCloseJob::create(started)?),
ProcessTreePolicy::DirectChildOnly => None,
};
let creation_flags = match options.process_tree {
ProcessTreePolicy::KillDescendants => CREATE_NO_WINDOW | CREATE_SUSPENDED,
ProcessTreePolicy::DirectChildOnly => CREATE_NO_WINDOW,
};
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.creation_flags(creation_flags.0);
let mut child = command
.spawn()
.map_err(|error| ProcessError::new(ProcessStage::Spawn, started.elapsed(), error))?;
let process_handle = HANDLE(child.as_raw_handle());
if let Some(job) = job.as_ref() {
if let Err(error) = job.assign(process_handle, started) {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
if let Err(error) = resume_suspended_process(child.id(), started) {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
}
// Start both readers only after containment succeeds, but before waiting.
let stdout = child.stdout.take().expect("stdout was configured as piped");
let stderr = child.stderr.take().expect("stderr was configured as piped");
let stdout_reader = OutputReader::spawn(stdout, options.max_capture_bytes);
let stderr_reader = OutputReader::spawn(stderr, options.max_capture_bytes);
let deadline = started.checked_add(options.timeout);
let mut timed_out = false;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {}
Err(error) => {
if let Some(job) = job.as_mut() {
job.close();
} else {
let _ = child.kill();
}
let _ = child.wait();
return Err(ProcessError::new(
ProcessStage::Wait,
started.elapsed(),
error,
));
}
}
if match deadline {
Some(deadline) => Instant::now() >= deadline,
None => true,
} {
timed_out = true;
if let Some(job) = job.as_mut() {
job.close();
} else {
let _ = child.kill();
}
break child.wait().map_err(|error| {
ProcessError::new(ProcessStage::Wait, started.elapsed(), error)
})?;
}
let remaining = deadline
.and_then(|deadline| deadline.checked_duration_since(Instant::now()))
.unwrap_or(Duration::ZERO);
thread::sleep(WAIT_POLL_INTERVAL.min(remaining));
};
// End any descendants that outlived the direct child, then collect EOF from
// both pipes. This also prevents successful helper commands from leaking.
if let Some(job) = job.as_mut() {
job.close();
}
let allow_detached_descendants = options.process_tree == ProcessTreePolicy::DirectChildOnly;
let stdout = stdout_reader.finish(
ProcessStage::ReadStdout,
started,
allow_detached_descendants,
)?;
let stderr = stderr_reader.finish(
ProcessStage::ReadStderr,
started,
allow_detached_descendants,
)?;
Ok(ProcessOutcome {
status,
stdout,
stderr,
timed_out,
duration: started.elapsed(),
})
}
struct OutputReader {
thread: Option<JoinHandle<io::Result<()>>>,
output: Arc<Mutex<CapturedOutput>>,
stop: Arc<AtomicBool>,
}
impl OutputReader {
fn spawn<R>(mut reader: R, max_capture_bytes: usize) -> Self
where
R: Read + Send + 'static,
{
let output = Arc::new(Mutex::new(CapturedOutput {
bytes: Vec::with_capacity(max_capture_bytes.min(8 * 1024)),
..CapturedOutput::default()
}));
let stop = Arc::new(AtomicBool::new(false));
let worker_output = Arc::clone(&output);
let worker_stop = Arc::clone(&stop);
let thread = thread::spawn(move || {
let mut buffer = [0_u8; 8 * 1024];
loop {
if worker_stop.load(Ordering::Acquire) {
return Ok(());
}
let read = match reader.read(&mut buffer) {
Ok(read) => read,
Err(_) if worker_stop.load(Ordering::Acquire) => return Ok(()),
Err(error) => return Err(error),
};
if read == 0 {
return Ok(());
}
let mut output = worker_output
.lock()
.map_err(|_| io::Error::other("output capture lock was poisoned"))?;
output.total_bytes = output.total_bytes.saturating_add(read as u64);
let remaining = max_capture_bytes.saturating_sub(output.bytes.len());
output
.bytes
.extend_from_slice(&buffer[..read.min(remaining)]);
output.truncated = output.total_bytes > output.bytes.len() as u64;
}
});
Self {
thread: Some(thread),
output,
stop,
}
}
fn finish(
mut self,
stage: ProcessStage,
started: Instant,
allow_detached_descendants: bool,
) -> Result<CapturedOutput, ProcessError> {
let thread = self.thread.take().expect("reader thread is present");
if allow_detached_descendants && !thread.is_finished() {
// Give the direct child a moment to flush. If a deliberately
// detached descendant inherited the pipe, cancel this thread's
// pending read so the bounded runner does not wait for that daemon.
let grace_deadline = Instant::now() + Duration::from_millis(25);
while !thread.is_finished() && Instant::now() < grace_deadline {
thread::sleep(Duration::from_millis(1));
}
self.stop.store(true, Ordering::Release);
let cancel_deadline = Instant::now() + Duration::from_millis(100);
while !thread.is_finished() && Instant::now() < cancel_deadline {
unsafe {
let _ = CancelSynchronousIo(HANDLE(thread.as_raw_handle()));
}
thread::sleep(Duration::from_millis(1));
}
}
if !thread.is_finished() && allow_detached_descendants {
// Cancellation is best-effort at the Win32 boundary. Return the
// bounded snapshot instead of allowing an inherited pipe to turn a
// timeout into an unbounded wait; the stop flag retires the reader
// after its pending operation returns.
return self.snapshot(stage, started);
}
match thread.join() {
Ok(Ok(())) => self.snapshot(stage, started),
Ok(Err(error)) => Err(ProcessError::new(stage, started.elapsed(), error)),
Err(_) => Err(ProcessError::new(
stage,
started.elapsed(),
io::Error::other("output reader thread panicked"),
)),
}
}
fn snapshot(
&self,
stage: ProcessStage,
started: Instant,
) -> Result<CapturedOutput, ProcessError> {
self.output
.lock()
.map(|output| output.clone())
.map_err(|_| {
ProcessError::new(
stage,
started.elapsed(),
io::Error::other("output capture lock was poisoned"),
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn cmd(script: &str) -> Command {
let mut command = Command::new("cmd.exe");
command.args(["/D", "/S", "/C", script]);
command
}
#[test]
fn captures_stdout_and_stderr() {
let outcome = run(
&mut cmd("echo stdout-text & echo stderr-text 1>&2"),
RunOptions::new(Duration::from_secs(2), 1024),
)
.expect("command should run");
assert!(outcome.status.success());
assert!(!outcome.timed_out);
assert!(String::from_utf8_lossy(&outcome.stdout.bytes).contains("stdout-text"));
assert!(String::from_utf8_lossy(&outcome.stderr.bytes).contains("stderr-text"));
}
#[test]
fn drains_output_after_capture_limit() {
let outcome = run(
&mut cmd("for /L %i in (1,1,2000) do @echo 12345678901234567890"),
RunOptions::new(Duration::from_secs(5), 64),
)
.expect("verbose command should not deadlock");
assert!(outcome.status.success());
assert_eq!(outcome.stdout.bytes.len(), 64);
assert!(outcome.stdout.truncated);
assert!(outcome.stdout.total_bytes > outcome.stdout.bytes.len() as u64);
}
#[test]
fn times_out_and_reaps_child() {
let outcome = run(
&mut cmd("ping 127.0.0.1 -n 30 >nul"),
RunOptions::new(Duration::from_millis(100), 1024),
)
.expect("timed out command should still return an outcome");
assert!(outcome.timed_out);
assert!(outcome.duration < Duration::from_secs(5));
assert!(outcome.status.code().is_some());
}
#[test]
fn direct_child_mode_times_out_and_reaps_child() {
let mut command = Command::new("ping.exe");
command.args(["127.0.0.1", "-n", "30"]);
let outcome = run(
&mut command,
RunOptions::new(Duration::from_millis(100), 1024).direct_child_only(),
)
.expect("timed out direct child should still return an outcome");
assert!(outcome.timed_out);
assert!(outcome.duration < Duration::from_secs(5));
assert!(outcome.status.code().is_some());
}
#[test]
fn timeout_reaps_a_spawned_descendant() {
let pid_path = std::env::temp_dir().join(format!(
"hermes-bounded-process-descendant-{}-{}.txt",
std::process::id(),
Instant::now().elapsed().as_nanos()
));
let escaped_path = pid_path.display().to_string().replace('\'', "''");
let script = format!(
"$child = Start-Process ping.exe -ArgumentList '127.0.0.1 -n 30' -WindowStyle Hidden -PassThru; Set-Content -LiteralPath '{escaped_path}' -Value $child.Id; Wait-Process -Id $child.Id"
);
let mut command = Command::new("powershell.exe");
command.args(["-NoProfile", "-Command", &script]);
let outcome = run(&mut command, RunOptions::new(Duration::from_secs(8), 1024))
.expect("timed out process tree should return an outcome");
assert!(outcome.timed_out);
let pid = fs::read_to_string(&pid_path)
.expect("descendant pid should be recorded")
.trim()
.parse::<u32>()
.expect("descendant pid should be numeric");
let probe = Command::new("powershell.exe")
.args([
"-NoProfile",
"-Command",
&format!("if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ exit 1 }}"),
])
.status()
.expect("descendant liveness probe should run");
let _ = fs::remove_file(pid_path);
assert!(probe.success(), "descendant process {pid} survived timeout");
}
}
+419 -96
View File
@@ -3,8 +3,12 @@
#[cfg(not(windows))]
compile_error!("hermes-relay-tray is a Windows-only optional systray");
#[cfg(windows)]
mod bounded_process;
#[cfg(windows)]
mod app {
use super::bounded_process::{self, ProcessOutcome, RunOptions};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -15,10 +19,10 @@ mod app {
io::Write,
os::windows::process::CommandExt,
path::{Path, PathBuf},
process::{Command, Output, Stdio},
process::{Command, Stdio},
sync::{mpsc, Arc, Mutex, OnceLock},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tauri::{
image::Image,
@@ -33,6 +37,10 @@ mod app {
Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE, POINT},
System::{
Com::{CoCreateInstance, CLSCTX_INPROC_SERVER},
Diagnostics::Debug::{
SetErrorMode, SEM_FAILCRITICALERRORS, SEM_NOGPFAULTERRORBOX,
SEM_NOOPENFILEERRORBOX,
},
Threading::{CreateMutexW, CREATE_NO_WINDOW},
Variant::VARIANT,
},
@@ -59,6 +67,11 @@ mod app {
const MAIN_LOGICAL_HEIGHT: f64 = 620.0;
const MAIN_MIN_LOGICAL_WIDTH: f64 = 340.0;
const MAIN_MIN_LOGICAL_HEIGHT: f64 = 460.0;
const TRAY_LOG_MAX_BYTES: u64 = 512 * 1024;
const PROCESS_CAPTURE_LIMIT: usize = 1024 * 1024;
const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
const ACTION_TIMEOUT: Duration = Duration::from_secs(45);
const LONG_ACTION_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const OFFICIAL_URLS: &[&str] = &[
"https://hermes-relay.dev/docs/",
"https://hermes-relay.dev/docs/desktop/",
@@ -67,6 +80,52 @@ mod app {
"https://github.com/Codename-11/hermes-relay/releases",
];
static TRAY_LOG_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
static TRAY_LOG_LAST_EVENT: OnceLock<Mutex<BTreeMap<String, Instant>>> = OnceLock::new();
fn append_tray_log(event: &str, probe: Option<&str>, detail: Option<&str>) {
let key = format!("{event}:{}", probe.unwrap_or_default());
let recent = TRAY_LOG_LAST_EVENT.get_or_init(|| Mutex::new(BTreeMap::new()));
if let Ok(mut guard) = recent.lock() {
if guard
.get(&key)
.is_some_and(|seen| seen.elapsed() < Duration::from_secs(30))
{
return;
}
guard.insert(key, Instant::now());
}
let Ok(directory) = home_dir().map(|home| home.join(".hermes")) else {
return;
};
let _guard = match TRAY_LOG_LOCK.get_or_init(|| Mutex::new(())).lock() {
Ok(guard) => guard,
Err(_) => return,
};
if fs::create_dir_all(&directory).is_err() {
return;
}
let path = directory.join("tray.log");
if fs::metadata(&path).is_ok_and(|metadata| metadata.len() >= TRAY_LOG_MAX_BYTES) {
let backup = directory.join("tray.log.1");
let _ = fs::remove_file(&backup);
let _ = fs::rename(&path, backup);
}
let timestamp_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let event = serde_json::json!({
"ts": timestamp_ms,
"event": event,
"probe": probe,
"detail": detail.map(|value| value.chars().take(512).collect::<String>()),
});
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "{event}");
}
}
#[derive(Clone, Copy, Debug)]
struct TrayAnchor {
x: f64,
@@ -340,6 +399,12 @@ mod app {
}
#[derive(Debug, Serialize)]
struct PendingGrantContext {
grant: Option<PendingGrantRequest>,
active_url: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct Snapshot {
hosts: Vec<Host>,
active_url: Option<String>,
@@ -407,6 +472,15 @@ mod app {
static COMPUTER_CONTROL_ENGINE_CACHE: OnceLock<Mutex<ComputerControlEngineCache>> =
OnceLock::new();
type SnapshotCache = Option<(Instant, Snapshot)>;
static SNAPSHOT_CACHE: OnceLock<Mutex<SnapshotCache>> = OnceLock::new();
type HardwareAvailabilityCache = Option<(Instant, HardwareAvailability)>;
static HARDWARE_AVAILABILITY_CACHE: OnceLock<Mutex<HardwareAvailabilityCache>> =
OnceLock::new();
static CLI_DETAILS_CACHE: OnceLock<(Option<String>, Option<String>)> = OnceLock::new();
type BooleanProbeCache = Option<(Instant, bool)>;
static STARTUP_ENABLED_CACHE: OnceLock<Mutex<BooleanProbeCache>> = OnceLock::new();
static DAEMON_AUTOSTART_CACHE: OnceLock<Mutex<BooleanProbeCache>> = OnceLock::new();
fn probe_computer_control_engine() -> Option<ComputerControlEngine> {
run_json(&["computer-use", "status", "--json"])
@@ -439,7 +513,7 @@ mod app {
}
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
struct HardwareAvailability {
usb: bool,
adb: bool,
@@ -464,9 +538,14 @@ mod app {
.unwrap_or(home_dir()?.join(".hermes").join("grant-bridge")))
}
fn first_pending_grant_id() -> Option<String> {
let mut requests = fs::read_dir(grant_bridge_dir().ok()?)
.ok()?
fn pending_grants_from_bridge() -> Vec<PendingGrantRequest> {
let Ok(directory) = grant_bridge_dir() else {
return Vec::new();
};
let Ok(entries) = fs::read_dir(directory) else {
return Vec::new();
};
let mut requests = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
@@ -480,7 +559,13 @@ mod app {
})
.collect::<Vec<_>>();
requests.sort_by(|left, right| left.created_at.cmp(&right.created_at));
requests.first().map(|request| request.id.clone())
requests
}
fn first_pending_grant_id() -> Option<String> {
pending_grants_from_bridge()
.first()
.map(|request| request.id.clone())
}
fn cli_candidates(explicit: Option<&Path>, current_exe: &Path, home: &Path) -> Vec<PathBuf> {
@@ -519,58 +604,137 @@ mod app {
)
}
fn run_cli(args: &[&str]) -> Result<Output, String> {
Command::new(resolve_cli()?)
.args(args)
.creation_flags(CREATE_NO_WINDOW.0)
.output()
.map_err(|e| format!("failed to run hermes-relay {}: {e}", args.join(" ")))
fn run_bounded(
command: &mut Command,
probe: &str,
timeout: Duration,
direct_child_only: bool,
) -> Result<ProcessOutcome, String> {
let mut options = RunOptions::new(timeout, PROCESS_CAPTURE_LIMIT);
if direct_child_only {
options = options.direct_child_only();
}
let outcome = bounded_process::run(command, options).map_err(|error| {
let detail = format!(
"stage={:?} duration_ms={}",
error.stage,
error.duration.as_millis()
);
append_tray_log("process_error", Some(probe), Some(&detail));
format!("{probe} failed to start or wait: {error}")
})?;
if outcome.timed_out {
let detail = format!("duration_ms={}", outcome.duration.as_millis());
append_tray_log("process_timeout", Some(probe), Some(&detail));
return Err(format!(
"{probe} timed out after {} seconds",
timeout.as_secs()
));
}
if outcome.stdout.truncated || outcome.stderr.truncated {
let detail = format!(
"duration_ms={} stdout_bytes={} stderr_bytes={}",
outcome.duration.as_millis(),
outcome.stdout.total_bytes,
outcome.stderr.total_bytes
);
append_tray_log("process_output_truncated", Some(probe), Some(&detail));
return Err(format!(
"{probe} produced more output than the safety limit"
));
}
if !outcome.status.success() {
let detail = format!(
"duration_ms={} exit_code={:?}",
outcome.duration.as_millis(),
outcome.status.code()
);
append_tray_log("process_exit_failure", Some(probe), Some(&detail));
}
Ok(outcome)
}
fn run_cli_with_timeout(args: &[&str], timeout: Duration) -> Result<ProcessOutcome, String> {
let direct_child_only = matches!(args, ["daemon", "start" | "restart", ..]);
let mut command = Command::new(resolve_cli()?);
command.args(args).env("NODE_USE_SYSTEM_CA", "1");
let probe = format!("cli.{}", args.first().copied().unwrap_or("unknown"));
run_bounded(&mut command, &probe, timeout, direct_child_only)
}
fn run_cli(args: &[&str]) -> Result<ProcessOutcome, String> {
run_cli_with_timeout(args, ACTION_TIMEOUT)
}
fn run_cli_checked(args: &[&str]) -> Result<String, String> {
let output = run_cli(args)?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout.bytes)
.trim()
.to_string();
if output.status.success() {
return Ok(stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr.bytes)
.trim()
.to_string();
Err(if stderr.is_empty() { stdout } else { stderr })
}
fn run_cli_checked_with_env(args: &[&str], key: &str, value: &str) -> Result<String, String> {
let output = Command::new(resolve_cli()?)
let mut command = Command::new(resolve_cli()?);
command
.args(args)
.env(key, value)
.creation_flags(CREATE_NO_WINDOW.0)
.output()
.map_err(|e| format!("failed to run hermes-relay {}: {e}", args.join(" ")))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
.env("NODE_USE_SYSTEM_CA", "1")
.env(key, value);
let probe = format!("cli.{}", args.first().copied().unwrap_or("unknown"));
let output = run_bounded(&mut command, &probe, ACTION_TIMEOUT, false)?;
let stdout = String::from_utf8_lossy(&output.stdout.bytes)
.trim()
.to_string();
if output.status.success() {
return Ok(stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr.bytes)
.trim()
.to_string();
Err(if stderr.is_empty() { stdout } else { stderr })
}
fn run_json(args: &[&str]) -> Result<Value, String> {
let output = run_cli_checked(args)?;
serde_json::from_str(&output)
fn run_json_with_timeout(args: &[&str], timeout: Duration) -> Result<Value, String> {
let output = run_cli_with_timeout(args, timeout)?;
let stdout = String::from_utf8_lossy(&output.stdout.bytes)
.trim()
.to_string();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr.bytes)
.trim()
.to_string();
return Err(if stderr.is_empty() { stdout } else { stderr });
}
serde_json::from_str(&stdout)
.map_err(|e| format!("invalid JSON from hermes-relay {}: {e}", args.join(" ")))
}
fn run_json(args: &[&str]) -> Result<Value, String> {
run_json_with_timeout(args, PROBE_TIMEOUT)
}
fn cli_details() -> (Option<String>, Option<String>) {
let Ok(path) = resolve_cli() else {
return (None, None);
};
let version = Command::new(&path)
.arg("--version")
.creation_flags(CREATE_NO_WINDOW.0)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| clean_cli_version(&String::from_utf8_lossy(&output.stdout)))
.filter(|value| !value.is_empty());
(version, Some(path.display().to_string()))
CLI_DETAILS_CACHE
.get_or_init(|| {
let Ok(path) = resolve_cli() else {
return (None, None);
};
let mut command = Command::new(&path);
command.arg("--version").env("NODE_USE_SYSTEM_CA", "1");
let version = run_bounded(&mut command, "cli.version", PROBE_TIMEOUT, false)
.ok()
.filter(|output| output.status.success())
.map(|output| clean_cli_version(&String::from_utf8_lossy(&output.stdout.bytes)))
.filter(|value| !value.is_empty());
(version, Some(path.display().to_string()))
})
.clone()
}
fn clean_cli_version(output: &str) -> String {
@@ -803,31 +967,63 @@ mod app {
Ok(())
}
fn cached_boolean_probe(
cache: &'static OnceLock<Mutex<BooleanProbeCache>>,
probe: impl FnOnce() -> bool,
) -> bool {
let cache = cache.get_or_init(|| Mutex::new(None));
if let Ok(guard) = cache.lock() {
if let Some((checked_at, value)) = guard.as_ref() {
if checked_at.elapsed() < Duration::from_secs(60) {
return *value;
}
}
}
let value = probe();
if let Ok(mut guard) = cache.lock() {
*guard = Some((Instant::now(), value));
}
value
}
fn update_boolean_probe_cache(cache: &'static OnceLock<Mutex<BooleanProbeCache>>, value: bool) {
let cache = cache.get_or_init(|| Mutex::new(None));
if let Ok(mut guard) = cache.lock() {
*guard = Some((Instant::now(), value));
}
}
fn startup_enabled() -> bool {
Command::new("reg.exe")
.args(["query", RUN_KEY, "/v", RUN_VALUE])
.creation_flags(CREATE_NO_WINDOW.0)
.status()
.is_ok_and(|s| s.success())
cached_boolean_probe(&STARTUP_ENABLED_CACHE, || {
let mut command = Command::new("reg.exe");
command.args(["query", RUN_KEY, "/v", RUN_VALUE]);
run_bounded(&mut command, "registry.startup.query", PROBE_TIMEOUT, false)
.is_ok_and(|outcome| outcome.status.success())
})
}
fn daemon_autostart_enabled() -> bool {
Command::new("reg.exe")
.args(["query", SETTINGS_KEY, "/v", DAEMON_AUTOSTART_VALUE])
.creation_flags(CREATE_NO_WINDOW.0)
.status()
.is_ok_and(|status| status.success())
cached_boolean_probe(&DAEMON_AUTOSTART_CACHE, || {
let mut command = Command::new("reg.exe");
command.args(["query", SETTINGS_KEY, "/v", DAEMON_AUTOSTART_VALUE]);
run_bounded(
&mut command,
"registry.daemon_autostart.query",
PROBE_TIMEOUT,
false,
)
.is_ok_and(|outcome| outcome.status.success())
})
}
fn hardware_availability() -> HardwareAvailability {
fn probe_hardware_availability() -> HardwareAvailability {
let adb = env::var_os("HERMES_RELAY_ADB_PATH")
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "adb".into());
let adb = Command::new(adb)
.arg("version")
.creation_flags(CREATE_NO_WINDOW.0)
.status()
.is_ok_and(|status| status.success());
let mut command = Command::new(adb);
command.arg("version");
let adb = run_bounded(&mut command, "adb.version", PROBE_TIMEOUT, false)
.is_ok_and(|outcome| outcome.status.success());
HardwareAvailability {
usb: true,
adb,
@@ -836,6 +1032,22 @@ mod app {
}
}
fn hardware_availability() -> HardwareAvailability {
let cache = HARDWARE_AVAILABILITY_CACHE.get_or_init(|| Mutex::new(None));
if let Ok(guard) = cache.lock() {
if let Some((checked_at, availability)) = guard.as_ref() {
if checked_at.elapsed() < Duration::from_secs(60) {
return availability.clone();
}
}
}
let availability = probe_hardware_availability();
if let Ok(mut guard) = cache.lock() {
*guard = Some((Instant::now(), availability.clone()));
}
availability
}
fn build_snapshot() -> Result<Snapshot, String> {
let selected = active_url();
let mut hosts = match run_json(&["hosts", "list", "--json"]) {
@@ -845,7 +1057,7 @@ mod app {
.cloned()
.unwrap_or(Value::Array(Vec::new())),
)
.unwrap_or_default(),
.map_err(|_| "the installed CLI returned an invalid host list".to_string())?,
Err(_) => hosts_from_status(selected.as_deref())?,
};
for host in &mut hosts {
@@ -855,14 +1067,10 @@ mod app {
host.access_mode = "ask-every-time".to_string();
}
}
let daemon = run_json(&["daemon", "status", "--json"])
.ok()
.and_then(|value| serde_json::from_value::<DaemonStatus>(value).ok())
.unwrap_or_default();
let pending_grants = run_json(&["grants", "--json"])
.ok()
.and_then(|value| serde_json::from_value::<Vec<PendingGrantRequest>>(value).ok())
.unwrap_or_default();
let daemon =
serde_json::from_value::<DaemonStatus>(run_json(&["daemon", "status", "--json"])?)
.map_err(|_| "the installed CLI returned an invalid daemon status".to_string())?;
let pending_grants = pending_grants_from_bridge();
let (cli_version, cli_path) = cli_details();
let computer_control_engine = cached_computer_control_engine();
let activity_screenshot_retention = run_json(&["audit", "screenshots", "--json"])
@@ -886,6 +1094,23 @@ mod app {
})
}
fn build_snapshot_coalesced() -> Result<Snapshot, String> {
let cache = SNAPSHOT_CACHE.get_or_init(|| Mutex::new(None));
let mut guard = cache
.lock()
.map_err(|_| "snapshot cache is unavailable".to_string())?;
if let Some((built_at, snapshot)) = guard.as_ref() {
if built_at.elapsed() < Duration::from_millis(750) {
return Ok(snapshot.clone());
}
}
let snapshot = build_snapshot().inspect_err(|_| {
append_tray_log("snapshot_failed", None, None);
})?;
*guard = Some((Instant::now(), snapshot.clone()));
Ok(snapshot)
}
fn computer_control_engine_status() -> Result<ComputerControlEngine, String> {
run_json(&["computer-use", "status", "--json"])?
.get("computer_control_engine")
@@ -921,7 +1146,10 @@ mod app {
#[tauri::command]
async fn computer_cua_install() -> Result<Value, String> {
tauri::async_runtime::spawn_blocking(|| {
let result = run_json(&["computer-use", "cua", "install", "--yes", "--json"])?;
let result = run_json_with_timeout(
&["computer-use", "cua", "install", "--yes", "--json"],
LONG_ACTION_TIMEOUT,
)?;
clear_computer_control_engine_cache();
Ok(result)
})
@@ -932,7 +1160,10 @@ mod app {
#[tauri::command]
async fn computer_cua_check_update() -> Result<Value, String> {
tauri::async_runtime::spawn_blocking(|| {
run_json(&["computer-use", "cua", "check-update", "--json"])
run_json_with_timeout(
&["computer-use", "cua", "check-update", "--json"],
ACTION_TIMEOUT,
)
})
.await
.map_err(|error| format!("CUA update check task failed: {error}"))?
@@ -941,7 +1172,10 @@ mod app {
#[tauri::command]
async fn computer_cua_update() -> Result<Value, String> {
tauri::async_runtime::spawn_blocking(|| {
let result = run_json(&["computer-use", "cua", "update", "--yes", "--json"])?;
let result = run_json_with_timeout(
&["computer-use", "cua", "update", "--yes", "--json"],
LONG_ACTION_TIMEOUT,
)?;
clear_computer_control_engine_cache();
Ok(result)
})
@@ -982,15 +1216,26 @@ mod app {
#[tauri::command]
async fn get_snapshot() -> Result<Snapshot, String> {
tauri::async_runtime::spawn_blocking(build_snapshot)
tauri::async_runtime::spawn_blocking(build_snapshot_coalesced)
.await
.map_err(|error| format!("snapshot task failed: {error}"))?
}
#[tauri::command]
fn get_pending_grant_context() -> PendingGrantContext {
PendingGrantContext {
grant: pending_grants_from_bridge().into_iter().next(),
active_url: active_url(),
}
}
#[tauri::command]
async fn check_desktop_update() -> Result<Value, String> {
tauri::async_runtime::spawn_blocking(|| {
run_json(&["update", "--installer", "--check", "--json"])
run_json_with_timeout(
&["update", "--installer", "--check", "--json"],
ACTION_TIMEOUT,
)
})
.await
.map_err(|error| format!("desktop update check task failed: {error}"))?
@@ -1005,13 +1250,16 @@ mod app {
.is_some_and(|status| status.running);
// The tray owns exit/restart orchestration. Ask the CLI only to
// download and verify so setup is launched exactly once.
let report = run_json(&[
"update",
"--installer",
"--download-only",
"--yes",
"--json",
])?;
let report = run_json_with_timeout(
&[
"update",
"--installer",
"--download-only",
"--yes",
"--json",
],
LONG_ACTION_TIMEOUT,
)?;
Ok::<_, String>((report, restart_daemon))
})
.await
@@ -1218,8 +1466,49 @@ mod app {
{
return Err("invalid grant request id".to_string());
}
let verb = if approved { "approve" } else { "reject" };
run_cli_checked(&["grants", verb, &id])?;
let directory = grant_bridge_dir()?;
fs::create_dir_all(&directory)
.map_err(|error| format!("cannot open grant bridge: {error}"))?;
let request_path = directory.join(format!("request-{id}.json"));
let request = fs::read(&request_path)
.map_err(|_| format!("pending grant request not found: {id}"))?;
let request = serde_json::from_slice::<PendingGrantRequest>(&request)
.map_err(|_| "pending grant request is invalid".to_string())?;
if request.id != id {
return Err("pending grant request identity does not match its file".to_string());
}
let resolved_at_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let response = serde_json::to_vec_pretty(&serde_json::json!({
"approved": approved,
"reason": if approved { "" } else { "Rejected from Hermes-Relay CLI UI" },
"resolved_at_ms": resolved_at_ms,
}))
.map_err(|error| format!("cannot serialize grant decision: {error}"))?;
let temporary_path = directory.join(format!(
".response-{id}-{}-{resolved_at_ms}.tmp",
std::process::id()
));
let mut temporary = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary_path)
.map_err(|error| format!("cannot stage grant decision: {error}"))?;
temporary
.write_all(&response)
.and_then(|_| temporary.write_all(b"\n"))
.and_then(|_| temporary.sync_all())
.map_err(|error| format!("cannot persist grant decision: {error}"))?;
drop(temporary);
let response_path = directory.join(format!("response-{id}.json"));
if let Err(error) = fs::rename(&temporary_path, &response_path) {
let _ = fs::remove_file(&temporary_path);
return Err(format!("cannot publish grant decision: {error}"));
}
fs::remove_file(&request_path)
.map_err(|error| format!("cannot retire resolved grant request: {error}"))?;
append_management_event(
"grant.resolve",
if approved {
@@ -1292,7 +1581,10 @@ mod app {
#[tauri::command]
async fn test_host_route(remote: String) -> Result<Value, String> {
tauri::async_runtime::spawn_blocking(move || {
run_json(&["hosts", "test", "--remote", remote.trim(), "--json"])
run_json_with_timeout(
&["hosts", "test", "--remote", remote.trim(), "--json"],
ACTION_TIMEOUT,
)
})
.await
.map_err(|error| format!("Secure Link test task failed: {error}"))?
@@ -1389,22 +1681,30 @@ mod app {
spawn_cli_terminal(&[])
}
#[tauri::command]
fn open_logs() -> Result<(), String> {
let path = home_dir()?.join(".hermes").join("daemon.log");
fn open_log_file(name: &str) -> Result<(), String> {
let path = home_dir()?.join(".hermes").join(name);
if !path.exists() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create daemon log directory: {error}"))?;
.map_err(|error| format!("cannot create log directory: {error}"))?;
}
fs::write(&path, b"")
.map_err(|error| format!("cannot create daemon log file: {error}"))?;
fs::write(&path, b"").map_err(|error| format!("cannot create log file: {error}"))?;
}
Command::new("notepad.exe")
.arg(path)
.spawn()
.map(|_| ())
.map_err(|error| format!("failed to open daemon logs: {error}"))
.map_err(|error| format!("failed to open log: {error}"))
}
#[tauri::command]
fn open_logs() -> Result<(), String> {
open_log_file("daemon.log")
}
#[tauri::command]
fn open_tray_logs() -> Result<(), String> {
open_log_file("tray.log")
}
#[tauri::command]
@@ -1506,11 +1806,14 @@ mod app {
} else {
command.args(["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
}
let output = command
.creation_flags(CREATE_NO_WINDOW.0)
.output()
.map_err(|e| format!("failed to update sign-in setting: {e}"))?;
let output = run_bounded(
&mut command,
"registry.startup.update",
PROBE_TIMEOUT,
false,
)?;
if output.status.success() {
update_boolean_probe_cache(&STARTUP_ENABLED_CACHE, enabled);
append_management_event(
"startup.change",
if enabled {
@@ -1523,7 +1826,9 @@ mod app {
);
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
Err(String::from_utf8_lossy(&output.stderr.bytes)
.trim()
.to_string())
}
}
@@ -1548,11 +1853,14 @@ mod app {
} else {
command.args(["delete", SETTINGS_KEY, "/v", DAEMON_AUTOSTART_VALUE, "/f"]);
}
let output = command
.creation_flags(CREATE_NO_WINDOW.0)
.output()
.map_err(|error| format!("failed to update daemon autostart setting: {error}"))?;
let output = run_bounded(
&mut command,
"registry.daemon_autostart.update",
PROBE_TIMEOUT,
false,
)?;
if output.status.success() {
update_boolean_probe_cache(&DAEMON_AUTOSTART_CACHE, enabled);
append_management_event(
"daemon.autostart",
if enabled {
@@ -1565,7 +1873,9 @@ mod app {
);
Ok(())
} else {
let error = String::from_utf8_lossy(&output.stderr).trim().to_string();
let error = String::from_utf8_lossy(&output.stderr.bytes)
.trim()
.to_string();
Err(if error.is_empty() {
"failed to update daemon autostart setting".to_string()
} else {
@@ -1726,6 +2036,7 @@ mod app {
}
fn request_main_hide(window: &tauri::WebviewWindow) {
let _ = window.eval("window.dispatchEvent(new Event('hermes-hide'))");
let _ = window.hide();
}
@@ -1774,10 +2085,14 @@ mod app {
});
}
TrayAction::RestartDaemon => {
let _ = run_cli_checked(&["daemon", "restart"]);
if run_cli_checked(&["daemon", "restart"]).is_err() {
append_tray_log("tray_action_failed", Some("daemon.restart"), None);
}
}
TrayAction::StopDaemon => {
let _ = run_cli_checked(&["daemon", "stop"]);
if run_cli_checked(&["daemon", "stop"]).is_err() {
append_tray_log("tray_action_failed", Some("daemon.stop"), None);
}
}
}
}
@@ -2011,6 +2326,12 @@ mod app {
}
pub fn run() {
std::panic::set_hook(Box::new(|_| {
append_tray_log("tray_panic", None, None);
}));
unsafe {
SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX);
}
// Establish one physical coordinate space before Tauri creates any
// windows. Without this, Windows virtualizes tray/cursor coordinates on
// scaled monitors and the popup can anchor to the wrong screen edge.
@@ -2028,6 +2349,7 @@ mod app {
let app = tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
get_snapshot,
get_pending_grant_context,
check_desktop_update,
install_desktop_update,
select_host,
@@ -2050,6 +2372,7 @@ mod app {
open_terminal,
open_cli_terminal,
open_logs,
open_tray_logs,
run_diagnostics,
open_external_url,
set_startup,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hermes-Relay CLI UI",
"version": "0.4.0-beta.2",
"version": "0.4.0-beta.3",
"identifier": "com.axiomlabs.hermes-relay-tray",
"build": {
"beforeDevCommand": "npm run dev",
@@ -0,0 +1,6 @@
#![cfg(windows)]
// The tray is a binary crate. Include the module directly so its focused unit
// tests remain runnable independently of main.rs integration.
#[path = "../src/bounded_process.rs"]
mod bounded_process;
+40 -1
View File
@@ -156,6 +156,7 @@ fn grants_use_the_dedicated_card_and_host_changes_reconcile_daemon_truth() {
assert!(ui.contains("isNoticeWindow ? <ConnectionNoticeWindow />"));
assert!(ui.contains("isEvidenceWindow ? <EvidenceWindow />"));
assert!(ui.contains("present_grant_window"));
assert!(ui.contains("get_pending_grant_context"));
assert!(ui.contains("Remote access request"));
assert!(native.contains("start_grant_watcher"));
assert!(native.contains("get_webview_window(\"grant\")"));
@@ -188,7 +189,7 @@ fn grants_use_the_dedicated_card_and_host_changes_reconcile_daemon_truth() {
assert!(native.contains("mpsc::channel::<TrayAction>()"));
assert!(native.contains("app.run_on_main_thread"));
assert!(native.contains("async fn get_snapshot"));
assert!(native.contains("spawn_blocking(build_snapshot)"));
assert!(native.contains("spawn_blocking(build_snapshot_coalesced)"));
assert!(native.contains("async fn check_desktop_update"));
assert!(native.contains("async fn install_desktop_update"));
for command in [
@@ -234,6 +235,44 @@ fn grants_use_the_dedicated_card_and_host_changes_reconcile_daemon_truth() {
assert!(!native.contains("TRAY_SLOT_LOGICAL_WIDTH"));
}
#[test]
fn grant_window_uses_narrow_single_flight_visibility_aware_polling() {
let ui = include_str!("../ui/App.tsx");
let grant_window = ui
.split("function GrantWindow()")
.nth(1)
.and_then(|source| source.split("function ActivityList(").next())
.expect("GrantWindow source");
assert!(grant_window.contains("call<PendingGrantContext>('get_pending_grant_context')"));
assert!(grant_window.contains("const incoming = next.grant"));
assert!(grant_window.contains("activeUrl ? displayHost(activeUrl)"));
assert!(!grant_window.contains("get_snapshot"));
assert!(!grant_window.contains("setInterval"));
assert!(grant_window.contains("if (running)"));
assert!(grant_window.contains("schedule(activeId.current ? 1000 : 5000)"));
assert!(grant_window.contains("document.visibilityState === 'visible'"));
assert!(grant_window.contains("document.addEventListener('visibilitychange', wake)"));
assert!(grant_window.contains("window.addEventListener('focus', wake)"));
}
#[test]
fn tray_subprocesses_are_bounded_cached_and_do_not_inherit_the_bun_relauncher() {
let native = include_str!("../src/main.rs");
let runner = include_str!("../src/bounded_process.rs");
assert!(native.contains("bounded_process::run(command, options)"));
assert!(native.contains("env(\"NODE_USE_SYSTEM_CA\", \"1\")"));
assert!(native.contains("build_snapshot_coalesced"));
assert!(native.contains("HARDWARE_AVAILABILITY_CACHE"));
assert!(native.contains("CLI_DETAILS_CACHE"));
assert!(!native.contains(".output()"));
assert!(!native.contains(".status()"));
assert!(runner.contains("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"));
assert!(runner.contains("max_capture_bytes"));
assert!(runner.contains("timed_out"));
}
#[test]
fn release_build_embeds_the_ui_instead_of_using_the_vite_server() {
let cargo = include_str!("../Cargo.toml");
+112 -17
View File
@@ -17,6 +17,7 @@ type Page = 'overview' | 'access' | 'capabilities' | 'hosts' | 'pair-host' | 'ho
type PendingAction = { type: 'access'; mode: AccessMode } | { type: 'capability'; capability: Capability; mode: CapabilityMode } | { type: 'revoke'; client: AuthorizedClient; remote: string } | { type: 'repair' | 'forget'; host: Host } | { type: 'clear-activity' } | null
type RouteTestResult = { label: string; url: string; reachable: boolean; elapsed_ms: number; encrypted: boolean; security: string; error?: string | null }
type RouteTestReport = { best?: RouteTestResult | null; routes?: RouteTestResult[] }
type PendingGrantContext = { grant: PendingGrantRequest | null; active_url?: string | null }
const windowLabel = '__TAURI_INTERNALS__' in window ? getCurrentWindow().label : 'main'
const isGrantWindow = windowLabel === 'grant'
@@ -49,6 +50,7 @@ const demo: Snapshot = {
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (!('__TAURI_INTERNALS__' in window)) {
if (command === 'get_snapshot') return demo as T
if (command === 'get_pending_grant_context') return { grant: demo.pending_grants[0] ?? null, active_url: demo.active_url } as T
if (command === 'list_authorized_clients') return [
{ token_prefix: 'f83a21c4', device_name: 'WORKSTATION', last_seen: Math.floor(Date.now() / 1000), transport_hint: 'desktop', is_current: true, grants: { chat: null, tools: null } },
{ token_prefix: '9d210b7e', device_name: 'Pixel 10 Pro', last_seen: Math.floor(Date.now() / 1000) - 420, transport_hint: 'android', grants: { chat: null } }
@@ -327,7 +329,7 @@ function ManagementApp() {
const refreshInFlight = useRef(false)
const refresh = useCallback(async () => {
if (refreshInFlight.current) return
if (refreshInFlight.current) return true
refreshInFlight.current = true
try {
const next = await call<Snapshot>('get_snapshot')
@@ -349,16 +351,59 @@ function ManagementApp() {
setSnapshot(next)
setSelectedUrl(current => current && next.hosts.some(h => h.url === current) ? current : next.active_url ?? next.hosts[0]?.url ?? null)
setError(null)
} catch (e) { setError(String(e)) }
return true
} catch (e) { setError(String(e)); return false }
finally { refreshInFlight.current = false }
}, [])
const daemonRetrying = Boolean(snapshot?.daemon.running && snapshot.daemon.state === 'reconnecting')
useEffect(() => {
refresh()
const timer = window.setInterval(refresh, connectionTransition ? 350 : daemonRetrying ? 1000 : 5000)
return () => window.clearInterval(timer)
let disposed = false
let enabled = false
let running = false
let failures = 0
let timer: number | null = null
const stop = () => {
enabled = false
if (timer !== null) window.clearTimeout(timer)
timer = null
}
const schedule = (delay: number) => {
if (disposed || !enabled) return
if (timer !== null) window.clearTimeout(timer)
timer = window.setTimeout(poll, delay)
}
const poll = async () => {
if (disposed || !enabled || running) return
running = true
timer = null
const ok = await refresh()
failures = ok ? 0 : Math.min(failures + 1, 4)
running = false
const baseDelay = connectionTransition ? 350 : daemonRetrying ? 1000 : 5000
schedule(ok ? baseDelay : Math.min(30_000, baseDelay * (2 ** failures)))
}
const start = () => {
if (disposed) return
enabled = true
if (timer === null && !running) void poll()
}
const visibilityChanged = () => {
if (document.visibilityState === 'visible') start()
else stop()
}
window.addEventListener('hermes-show', start)
window.addEventListener('hermes-hide', stop)
document.addEventListener('visibilitychange', visibilityChanged)
void getCurrentWindow().isVisible().then(visible => { if (visible) start() })
return () => {
disposed = true
stop()
window.removeEventListener('hermes-show', start)
window.removeEventListener('hermes-hide', stop)
document.removeEventListener('visibilitychange', visibilityChanged)
}
}, [refresh, connectionTransition, daemonRetrying])
useEffect(() => {
@@ -497,6 +542,10 @@ function ManagementApp() {
}, 145)
}, [])
const requestHide = useCallback(() => {
window.dispatchEvent(new Event('hermes-hide'))
}, [])
useEffect(() => {
const show = () => setWindowVisible(true)
const visibilityChanged = () => {
@@ -530,7 +579,7 @@ function ManagementApp() {
<div className="brand"><img src={logo} alt="" /><span>Hermes-Relay CLI UI</span></div>
<div className="window-controls">
<button aria-label="Help and About" title="Help and About" onClick={() => setPage('help')}><CircleHelp /></button>
<button aria-label="Hide window" onClick={hideWindow}><X /></button>
<button aria-label="Hide window" onClick={requestHide}><X /></button>
</div>
</header>
@@ -640,7 +689,7 @@ function ManagementApp() {
}
function GrantWindow() {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [activeUrl, setActiveUrl] = useState<string | null>(null)
const [grant, setGrant] = useState<PendingGrantRequest | null>(null)
const [expanded, setExpanded] = useState(false)
const [visible, setVisible] = useState(false)
@@ -649,9 +698,9 @@ function GrantWindow() {
const refreshGrant = useCallback(async () => {
try {
const next = await call<Snapshot>('get_snapshot')
const incoming = next.pending_grants[0] ?? null
setSnapshot(next)
const next = await call<PendingGrantContext>('get_pending_grant_context')
const incoming = next.grant
setActiveUrl(next.active_url ?? null)
if (!incoming) {
if (activeId.current) {
activeId.current = null
@@ -673,9 +722,56 @@ function GrantWindow() {
}, [])
useEffect(() => {
void refreshGrant()
const timer = window.setInterval(refreshGrant, 1000)
return () => window.clearInterval(timer)
let stopped = false
let running = false
let rerunRequested = false
let timer: number | null = null
const schedule = (delay: number) => {
if (stopped) return
if (timer !== null) window.clearTimeout(timer)
timer = window.setTimeout(poll, delay)
}
const poll = async () => {
if (stopped) return
if (running) {
rerunRequested = true
return
}
running = true
if (timer !== null) {
window.clearTimeout(timer)
timer = null
}
try { await refreshGrant() }
finally {
running = false
if (stopped) return
if (rerunRequested) {
rerunRequested = false
schedule(0)
} else if (activeId.current || document.visibilityState === 'visible') {
schedule(activeId.current ? 1000 : 5000)
}
}
}
const wake = () => {
if (document.visibilityState === 'visible') void poll()
else if (!activeId.current && timer !== null) {
window.clearTimeout(timer)
timer = null
}
}
document.addEventListener('visibilitychange', wake)
window.addEventListener('focus', wake)
void poll()
return () => {
stopped = true
if (timer !== null) window.clearTimeout(timer)
document.removeEventListener('visibilitychange', wake)
window.removeEventListener('focus', wake)
}
}, [refreshGrant])
async function toggleExpanded() {
@@ -699,9 +795,8 @@ function GrantWindow() {
}
}
if (!grant || !snapshot) return <div className="grant-shell" />
const hostName = snapshot.hosts.find(item => item.url === (snapshot.daemon.configured_url ?? snapshot.daemon.url))?.name
?? (snapshot.daemon.url ? displayHost(snapshot.daemon.url) : 'Connected host')
if (!grant) return <div className="grant-shell" />
const hostName = activeUrl ? displayHost(activeUrl) : 'Connected host'
const scope = formatGrantScope(grant.scope)
const action = grantAction(grant.scope)
const minutes = Math.max(1, Math.round(grant.duration_seconds / 60))
@@ -1001,7 +1096,7 @@ function SettingsPage({ daemon, computerControl, startup, daemonAutostart, activ
</div>}
<div className="cua-maintenance"><span><strong>{cuaManagement?.installed ? `CUA Driver ${cuaManagement.current_version ?? ''}`.trim() : 'CUA Driver'}</strong><small>{cuaError ?? cuaManagement?.update?.error ?? cuaManagement?.compatibility_reason ?? (cuaManagement?.update?.update_available ? `${cuaManagement.update.latest_version} available` : cuaManagement?.installed ? 'Installed from the verified upstream release.' : 'Install the verified compatible driver explicitly.')}</small></span><div>{!cuaManagement?.installed ? <button disabled={cuaBusy !== null} onClick={() => void cuaOperation('install')}>{cuaBusy === 'install' ? <LoaderCircle className="spin" /> : <Download />} Install</button> : cuaManagement.update?.update_available && cuaManagement.update.compatible ? <button disabled={cuaBusy !== null} onClick={() => void cuaOperation('update')}>{cuaBusy === 'update' ? <LoaderCircle className="spin" /> : <Download />} Update</button> : <button disabled={cuaBusy !== null} onClick={() => void cuaOperation('check')}>{cuaBusy === 'check' || cuaBusy === 'status' ? <LoaderCircle className="spin" /> : <RefreshCw />} Check</button>}</div></div>
</div><p className="group-help engine-help"><ShieldCheck /> CUA is the preferred structured engine. Hermes permissions, grants, audit, and emergency stop remain in control.</p></div>
<div className="settings-group"><h2>CLI & diagnostics</h2><div className="settings-card quick-action-grid"><button onClick={() => onAction('open_terminal')}><TerminalSquare /><span>Open terminal</span></button><button onClick={() => onAction('open_cli_terminal')}><Bot /><span>Open Hermes CLI</span></button><button onClick={() => onAction('open_logs')}><FolderOpen /><span>View daemon log</span></button><button onClick={() => onAction('run_diagnostics')}><ActivityIcon /><span>Run diagnostics</span></button></div></div>
<div className="settings-group"><h2>CLI & diagnostics</h2><div className="settings-card quick-action-grid"><button onClick={() => onAction('open_terminal')}><TerminalSquare /><span>Open terminal</span></button><button onClick={() => onAction('open_cli_terminal')}><Bot /><span>Open Hermes CLI</span></button><button onClick={() => onAction('open_logs')}><FolderOpen /><span>View daemon log</span></button><button onClick={() => onAction('open_tray_logs')}><FolderOpen /><span>View UI log</span></button><button onClick={() => onAction('run_diagnostics')}><ActivityIcon /><span>Run diagnostics</span></button></div></div>
<div className="settings-group"><h2>Updates</h2><div className={`settings-card update-card ${updateError ? 'error' : update?.ahead_of_latest ? 'ahead' : update?.up_to_date ? 'current' : ''}`}><div className="setting-row update-row"><span><strong>Hermes-Relay CLI UI</strong><small>{updateSummary}</small></span>{update && !update.up_to_date && !update.ahead_of_latest && !update.installed ? <button className="compact-button update-button" disabled={updateBusy !== null} onClick={installUpdate}>{updateBusy === 'install' ? <LoaderCircle className="spin" /> : <Download />} Install</button> : <button className="compact-button" disabled={updateBusy !== null} onClick={checkUpdate}>{updateBusy === 'check' ? <LoaderCircle className="spin" /> : <RefreshCw />} Check</button>}</div></div><p className="group-help update-help">Updates the management UI and CLI together, then restarts the tray automatically.</p></div>
<button className="about-link" onClick={onHelp}><span className="setting-icon"><Info /></span><span><strong>Help & About</strong><small>Versions, documentation and troubleshooting.</small></span><ChevronRight /></button>
<div className="settings-group"><div className="settings-group-heading"><h2>Activity</h2><button onClick={onViewActivity}>View all <ChevronRight /></button></div><p className="group-help">Recent remote actions recorded on this PC.</p><div className="settings-card activity-retention-card"><div className="setting-row"><span><strong>Screenshot evidence</strong><small>{screenshotRetention.count} retained · {formatBytes(screenshotRetention.bytes)} · stored only on this PC</small></span><div className="retention-options" role="radiogroup" aria-label="Screenshot evidence retention">{([{ label: 'Off', enabled: false, days: 7 }, { label: '1d', enabled: true, days: 1 }, { label: '7d', enabled: true, days: 7 }, { label: '30d', enabled: true, days: 30 }] as const).map(option => <button key={option.label} role="radio" aria-checked={screenshotRetention.enabled === option.enabled && (!option.enabled || screenshotRetention.days === option.days)} className={screenshotRetention.enabled === option.enabled && (!option.enabled || screenshotRetention.days === option.days) ? 'active' : ''} onClick={() => onAction('set_activity_screenshot_retention', { enabled: option.enabled, days: option.days })}>{option.label}</button>)}</div></div></div><div className="settings-card padded"><ActivityList entries={activity.slice(-3).reverse()} host={null} onOpen={onOpenActivity} /></div></div>
@@ -0,0 +1,120 @@
# Desktop tray subprocess containment audit
**Date:** 2026-08-14
**Surface:** Windows Hermes-Relay CLI UI and its local CLI probes
**Severity:** High — local resource exhaustion and loss of desktop availability
## Summary
The Windows tray created a hidden grant window at application startup. That
window requested the complete management snapshot once per second, including
while it was not visible. Its polling loop did not prevent a second request from
starting while the previous request was still pending.
Building one complete snapshot launched multiple external programs in sequence:
- `hermes-relay.exe hosts list --json`
- `hermes-relay.exe daemon status --json`
- `hermes-relay.exe grants --json`
- `hermes-relay.exe --version`
- two `reg.exe query` probes
- `adb version`
The host-list fallback could add another CLI launch. The main management window
also refreshed its snapshot every five seconds and more frequently during a
connection transition.
Under normal conditions these children exited quickly. When process startup or
one probe slowed, the hidden-window timer continued submitting work. Concurrent
snapshot tasks then accumulated, each owning or waiting to launch another set of
children. The polling path turned a transient slowdown into an unbounded local
process queue.
## Observed evidence
The reported machine showed many processes named `Bun` and
`hermes-relay.exe`, followed by Windows `0xc0000142` application-initialization
dialogs for `reg.exe`, `adb.exe`, and `hermes-relay.exe`. Windows Application
events recorded several clustered `hermes-relay.exe` failures in
`KERNELBASE.dll`. The local desktop audit then recorded a PowerShell
`System.OutOfMemoryException`.
Failures across several unrelated executables, together with the process backlog
and out-of-memory evidence, support resource exhaustion as the cause of the
application-initialization failures. They do not prove the root cause of a later
Windows stop or reboot. Attributing a blue-screen or kernel stop conclusively
requires the matching Windows crash dump and stop-code analysis.
## Corrective design
The tray process contract now applies these invariants:
1. **One refresh in flight.** Snapshot work is single-flight and coalesced across
callers. A timer tick never queues a duplicate refresh.
2. **No full snapshot for grant discovery.** The grant window reads the bounded
local grant bridge or receives a native event. It does not launch CLI,
registry, or ADB probes merely to decide whether a card should appear.
3. **Static probes stay static.** CLI path/version, sign-in settings, and optional
hardware availability are cached and invalidated by their owning actions or
an explicit refresh.
4. **Every child is bounded.** External commands have a timeout, termination
path, and bounded output capture. Failed probes back off instead of retrying
at the normal polling rate.
5. **Failure degrades one field.** A missing optional probe does not discard the
complete snapshot or make the tray unavailable.
6. **Operational failures are visible.** The tray records sanitized lifecycle,
snapshot, timeout, launch, exit, truncation, and panic events in the bounded
local `~/.hermes/tray.log`. It never writes credentials or sensitive command
arguments. The separate `~/.hermes/daemon.log` remains the source for daemon
authentication, transport, reconnect, and tool-router lifecycle.
7. **No broad process cleanup.** Recovery targets only reviewed processes whose
executable path belongs to the installed Hermes-Relay bundle. A process name
such as `Bun`, `adb.exe`, or `reg.exe` is not sufficient ownership evidence.
## Safe operator recovery
1. Quit **Hermes-Relay CLI UI** so it cannot create more probes.
2. Run `hermes-relay daemon stop` from a fresh PowerShell when the CLI can start.
3. If it cannot start, enumerate `Win32_Process` and review `ExecutablePath` and
`CommandLine`. Stop only `hermes-relay.exe` and
`hermes-relay-tray.exe` rows under `%USERPROFILE%\.hermes\bin`.
4. Do not terminate every process named `Bun`; other tools may use that runtime.
5. Inspect `~/.hermes/tray.log` and `~/.hermes/daemon.log` according to their
ownership above.
6. If unrelated applications still fail to initialize, restart Windows before
starting the tray again.
The copyable Windows commands live in
[`user-docs/desktop/troubleshooting.md`](../../user-docs/desktop/troubleshooting.md).
## Verification gates
Automated checks cover the following containment contracts:
- The grant window invokes only the local pending-grant context, schedules its
next poll after completion, pauses while hidden and idle, and cannot overlap
itself.
- The management window polls only while visible, permits one refresh in
flight, retries failures with exponential backoff, and uses the native
coalesced snapshot builder.
- The process runner bounds stdout and stderr, times out a hung child, reaps a
spawned descendant through its Windows Job Object, and preserves the explicit
direct-child mode needed by a detached daemon launcher.
- Static CLI-version, registry, and ADB probes are cached; tray-launched CLI
commands bypass the Bun system-CA self-relauncher.
- The bounded tray log contains only allowlisted event/probe names and numeric
process metadata, while rotation prevents unlimited growth.
- Concurrent daemon lifecycle requests elect one owner and dead lock owners are
recovered without allowing an older owner to remove a replacement lock.
- Existing CLI, tray, daemon, grant, update, and installer tests remain green.
Packaged Windows verification should additionally cover hidden and visible tray
states, an active connection transition, and a pending grant while recording a
bounded child-process high-water mark. Fault injection should hold a probe open
past its timeout and verify that process count returns to baseline rather than
increasing on every timer tick.
These checks establish application-level containment. They do not substitute
for Windows dump analysis when the operating system itself reports a stop.
+26 -3
View File
@@ -2131,6 +2131,20 @@ starting connectivity does not itself require or imply a tool grant.
session or remote server identity.
- The tray is a small Rust/Tauri process; NSIS packages it with the same compiled
CLI released separately.
- **2026-08-14 process-containment amendment.** Treat every external executable
launch as a bounded resource. Periodic UI refreshes must be single-flight and
coalesced across windows, must not start another refresh while one is pending,
and must apply a timeout, termination, and retry backoff to every child.
Grant-card discovery reads the local bridge directly rather than polling the
complete management snapshot. Static probes such as the CLI version, registry
settings, and optional ADB availability are cached or refreshed only when the
owning setting changes. The tray must remain useful when a probe fails and
must never amplify that failure into an unbounded subprocess queue.
- Tray lifecycle, refresh, and child-process diagnostics are recorded in the
bounded local `~/.hermes/tray.log`; daemon authentication, transport, and tool-
router lifecycle remain in `~/.hermes/daemon.log`. Operational logs exclude
credentials and sensitive command arguments. Management failures are reported
as failures rather than silently dropped or appended as successful activity.
- Rich full-window desktop chat and management remain upstream desktop-product
concerns, not a Hermes-Relay surface.
- The CLI package version remains canonical for both binaries and the installer.
@@ -2756,7 +2770,13 @@ personality, reasoning, approval, Fast, and YOLO state reset at the ViewModel
context boundary before destination session truth can repopulate them.
**Consequences.** The hamburger remains exclusively the Session Drawer. Agent
Passport stays focused on inspection and configuration. The shelf adds no fake
Passport stays focused on inspection and configuration. The drawer may widen
its read-only browse scope to all profiles and organize that combined set by
recency, project, status, or profile; opening a cross-profile row performs the
same explicit profile-context switch as the shelf. Named-profile ownership uses
a deterministic identity accent with an optional per-connection local override,
while Server default remains a neutral home identity; color never communicates
activity or health. The shelf adds no fake
activity indicator, hides for one visible identity, retains 48 dp touch targets,
and exposes meaningful TalkBack actions in compact, large-font, light, and dark
layouts.
@@ -2942,8 +2962,11 @@ Settings, with scope determining ownership:
- **Open terminal** starts a normal terminal with `hermes-relay` available.
**Open Hermes CLI** starts the paired remote Hermes TUI in a real terminal;
neither action embeds a terminal emulator in the tray.
- **View daemon log** opens the local daemon log. **Run diagnostics** delegates
to the CLI diagnostic contract rather than creating a second health model.
- **View daemon log** opens the daemon connection/tool-router log. Tray startup,
refresh, and child-process failures use the separate local tray log so a UI
failure remains observable even when no daemon is running. **Run diagnostics**
delegates to the CLI diagnostic contract rather than creating a second health
model and reports both log locations when relevant.
- Help & About reports UI, CLI, and connected Relay versions and links to the
documentation, troubleshooting guide, and release notes through the default
browser. Log and diagnostic shortcuts remain available there as well.
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "237a62aa018de09af7880be01960c2baa4667a51522305affe88bce3077aeacb",
"main": "8fc1f2601c324be5ede141fb591ec0b2d47a5bc0533bdd5868090016027060e9",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "237a62aa018de09af7880be01960c2baa4667a51522305affe88bce3077aeacb",
"main": "8fc1f2601c324be5ede141fb591ec0b2d47a5bc0533bdd5868090016027060e9",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "237a62aa018de09af7880be01960c2baa4667a51522305affe88bce3077aeacb",
"main": "8fc1f2601c324be5ede141fb591ec0b2d47a5bc0533bdd5868090016027060e9",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "237a62aa018de09af7880be01960c2baa4667a51522305affe88bce3077aeacb",
"main": "8fc1f2601c324be5ede141fb591ec0b2d47a5bc0533bdd5868090016027060e9",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "237a62aa018de09af7880be01960c2baa4667a51522305affe88bce3077aeacb",
"main": "8fc1f2601c324be5ede141fb591ec0b2d47a5bc0533bdd5868090016027060e9",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "237a62aa018de09af7880be01960c2baa4667a51522305affe88bce3077aeacb",
"main": "8fc1f2601c324be5ede141fb591ec0b2d47a5bc0533bdd5868090016027060e9",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+6 -6
View File
@@ -89,13 +89,13 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.8.1 - Complete, reliable transcripts
v1.9.0 - Better sessions, reactions, and voice
* Keep complete history beyond Hermes' latest-500 default.
* Preserve stable edit, retry, sharing, and recovery anchors.
* Show authoritative Gateway rejection messages without unintended fallback.
* Follow current event and edit-and-regenerate contracts.
* Live theme, Sphere, and pet previews.
* Browse all profiles without losing the session's owning agent.
* Start new chats with the default profile.
* Pin reactions to user and assistant messages.
* Keep Vanilla Hermes voice on the authenticated Gateway.
* Default to an ungrouped session list; project grouping remains available.
```
## Category
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.8.1"
appVersionCode = "42"
appVersionName = "1.9.0"
appVersionCode = "43"
agp = "9.3.1"
kotlin = "2.4.10"
compose-bom = "2026.06.01"
+51 -1
View File
@@ -49,6 +49,56 @@ hermes-relay ui status
For a CLI-only installation, add the UI with `hermes-relay ui install`.
## Many `Bun` or `hermes-relay.exe` processes / Windows error `0xc0000142`
Quit **Hermes-Relay CLI UI** first, then open a fresh PowerShell and stop the
normal background daemon:
```powershell
hermes-relay daemon stop
```
If the CLI cannot start, identify Hermes-Relay-owned processes by their exact
installed executable path. Review the rows before stopping anything:
```powershell
$relayBin = [IO.Path]::GetFullPath("$env:USERPROFILE\.hermes\bin\")
$relayProcesses = Get-CimInstance Win32_Process | Where-Object {
$_.ExecutablePath -and
[IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($relayBin, [StringComparison]::OrdinalIgnoreCase) -and
$_.Name -in @('hermes-relay.exe', 'hermes-relay-tray.exe')
}
$relayProcesses | Select-Object ProcessId, ParentProcessId, Name, ExecutablePath, CommandLine
# Run only after confirming every row belongs to the installed Hermes-Relay bundle:
$relayProcesses | ForEach-Object { Stop-Process -Id $_.ProcessId }
```
Do not stop every process named `Bun`. Bun is a shared runtime name, so a broad
name-based cleanup can terminate unrelated development tools. Do not broadly
terminate `adb.exe` or `reg.exe` either; exit the tray and let its owned probes
end, then target only processes whose executable path and command line you have
reviewed.
The affected tray build refreshed its complete management snapshot from a
hidden grant window every second. Each refresh launched several short-lived CLI,
registry, and ADB probes. A slow or stuck probe allowed refreshes to overlap,
building an unbounded process queue until Windows began rejecting new process
initialization. Updated builds coalesce refreshes, use a lightweight local grant
read, cache static probes, and apply child timeouts and retry backoff.
After cleanup, inspect these separate logs:
- `~/.hermes/tray.log` — tray snapshot, subprocess timeout, launch, exit, and
panic failures.
- `~/.hermes/daemon.log` — the one long-running daemon's authentication,
transport, reconnect, and desktop-tool router lifecycle.
If unrelated Windows programs still fail with `0xc0000142`, restart Windows
before relaunching the tray. A process storm and memory exhaustion can explain
the application failures, but a Windows stop or reboot cannot be attributed to
this defect conclusively without the corresponding Windows crash dump.
## The daemon is User but I need Administrator access
Normal-user operation is the safe default. Open UI Settings and choose **Restart as Administrator...**, then approve the Windows UAC prompt. The UI remains a normal user process; only the daemon and its approved tool/input actions are elevated.
@@ -59,7 +109,7 @@ When elevated access is no longer needed, choose **Return to user mode**. The tr
## I need the CLI, logs, or a diagnostic report
Open UI Settings and use **Open terminal** for a normal command prompt with `hermes-relay` available, or **Open Hermes CLI** to start the paired Hermes TUI directly. **View daemon log** opens the local daemon log, while **Run diagnostics** uses the CLI diagnostic path so the UI and `hermes-relay doctor` report the same local install state.
Open UI Settings and use **Open terminal** for a normal command prompt with `hermes-relay` available, or **Open Hermes CLI** to start the paired Hermes TUI directly. **View daemon log** opens `~/.hermes/daemon.log`; tray startup and child-process failures are recorded separately in `~/.hermes/tray.log`. **Run diagnostics** uses the CLI diagnostic path so the UI and `hermes-relay doctor` report the same local install state.
The **Help & About** page links to the [desktop documentation](https://hermes-relay.dev/docs/desktop/), [troubleshooting guide](https://hermes-relay.dev/docs/desktop/troubleshooting/), and [release notes](https://github.com/Codename-11/hermes-relay/releases?q=desktop) in your default browser and also provides the log and diagnostic shortcuts.