Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
447ec356d7 | ||
|
|
ab359e5efb | ||
|
|
86b8161cb7 | ||
|
|
e401fdb0c7 | ||
|
|
d4325d5aab | ||
|
|
c734d75484 | ||
|
|
0411804780 | ||
|
|
8f89c2841d | ||
|
|
933c1842a0 | ||
|
|
dfe1b53327 | ||
|
|
d36580a983 | ||
|
|
2d178ff884 | ||
|
|
d61955f82a | ||
|
|
adec6ed2c0 | ||
|
|
e9e44c8bb2 | ||
|
|
c6b0732a02 | ||
|
|
d919115788 | ||
|
|
49da085ae8 | ||
|
|
889b6f0858 | ||
|
|
5100c524f6 | ||
|
|
c609ae867f | ||
|
|
107f8c7720 | ||
|
|
8963e4fafd | ||
|
|
0d6c3bd6b0 | ||
|
|
7440ef2948 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
+100
-2
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
+60
-1
@@ -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 {
|
||||
|
||||
+20
-13
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
+15
-1
@@ -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" }
|
||||
|
||||
+67
-1
@@ -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
|
||||
|
||||
+60
@@ -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)
|
||||
}
|
||||
}
|
||||
+74
-30
@@ -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
|
||||
|
||||
+29
@@ -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
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.3.14
|
||||
+18
-1
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user