Compare commits

...
Author SHA1 Message Date
Bailey Dixon 4605b87c10 Merge pull request #333 from Codename-11/dev
release(server): server-v1.6.3
2026-08-11 22:00:43 -04:00
Bailey Dixon 6a91d6ee7e fix(android): complete upstream feature translations 2026-08-11 21:46:45 -04:00
Bailey Dixon 95a2813efe fix(server): validate translated media path components 2026-08-11 21:37:54 -04:00
Bailey Dixon 2ecf521c8c chore: merge main release history into dev 2026-08-11 21:35:40 -04:00
Bailey Dixon 7752c5c404 release(server): server-v1.6.3 2026-08-11 21:35:05 -04:00
Bailey Dixon e1d3764cd2 feat(android): browse sessions across profiles 2026-08-11 20:50:36 -04:00
Bailey Dixon e34171b5ad feat(android): add gateway message reactions 2026-08-11 20:48:09 -04:00
Bailey Dixon 8040cac39a feat(android): redirect running subagents 2026-08-11 20:45:33 -04:00
Bailey Dixon aab70520ca feat(android): open referenced Hermes sessions 2026-08-11 20:40:27 -04:00
Bailey Dixon 5a0cd8123c feat(android): expand Hermes management surfaces 2026-08-11 20:37:03 -04:00
Bailey Dixon 4370d9a925 feat(android): adopt richer gateway chat contracts 2026-08-11 20:30:38 -04:00
Bailey Dixon 726308d2ef fix: harden gateway recovery diagnostics 2026-08-11 20:26:05 -04:00
Bailey Dixon 1acc3a4c80 fix: align app and relay upstream contracts 2026-08-11 20:04:43 -04:00
Bailey Dixon 11ccbd6e5c Merge pull request #332 from Codename-11/dev
release(desktop): desktop-v0.4.0-alpha.7
2026-08-11 19:55:05 -04:00
Bailey Dixon d13af35357 chore: merge main release history into dev 2026-08-11 19:48:26 -04:00
Bailey Dixon e3752d43f3 release(desktop): desktop-v0.4.0-alpha.7 2026-08-11 19:48:24 -04:00
Bailey Dixon 97293b62c3 Merge pull request #331 from Codename-11/dev
release(desktop): desktop-v0.4.0-alpha.6
2026-08-11 19:33:13 -04:00
Bailey Dixon 454e770648 chore: merge main release history into dev 2026-08-11 19:26:15 -04:00
Bailey Dixon e18572e8e3 release(desktop): desktop-v0.4.0-alpha.6 2026-08-11 19:24:27 -04:00
Bailey Dixon 83f69725b9 Merge pull request #330 from Codename-11/dev
release: Desktop 0.4.0-alpha.5
2026-08-11 19:05:39 -04:00
Bailey Dixon 72aa7c3046 chore: merge main release history into dev 2026-08-11 18:59:06 -04:00
Bailey Dixon 3995c64493 release(desktop): desktop-v0.4.0-alpha.5 2026-08-11 18:59:04 -04:00
Bailey Dixon ce538ced3d Merge pull request #329 from Codename-11/dev
release: Desktop 0.4.0-alpha.4
2026-08-11 18:43:32 -04:00
Bailey Dixon 352bc5b439 chore: merge main release history into dev 2026-08-11 18:35:52 -04:00
Bailey Dixon 9ec163b27b release(desktop): desktop-v0.4.0-alpha.4 2026-08-11 18:35:32 -04:00
Bailey Dixon 7a3efa2c4d Merge pull request #328 from Codename-11/dev
release: Desktop 0.4.0-alpha.3 and Server 1.6.2
2026-08-11 18:19:36 -04:00
Bailey Dixon c28be7c92e style(desktop): format tray contract test 2026-08-11 18:03:02 -04:00
Bailey Dixon 8d1758ec8b fix(desktop): build tray assets before Rust checks 2026-08-11 18:01:17 -04:00
Bailey Dixon ce8b8702c3 test(desktop): make UI install coverage portable 2026-08-11 17:53:56 -04:00
Bailey Dixon ca0a9eb524 release(desktop): desktop-v0.4.0-alpha.3 2026-08-11 17:51:25 -04:00
Bailey Dixon b91d8c9a09 release(server): server-v1.6.2 2026-08-11 17:51:13 -04:00
Bailey Dixon bebd327816 Merge pull request #327 from Codename-11/feature/desktop-relay-management-release
feat: ship desktop management UI and paired-device identity
2026-08-11 17:49:18 -04:00
Bailey Dixon 8fa97e0b46 feat(desktop): add host management tray UI 2026-08-11 17:48:47 -04:00
Bailey Dixon 45dc82e573 feat(server): enrich paired device identity 2026-08-11 17:48:09 -04:00
dependabot[bot] 48b23f4d9e chore(deps): bump gradle-wrapper from 9.6.1 to 9.7.0 (#326)
Bumps [gradle-wrapper](https://github.com/gradle/gradle) from 9.6.1 to 9.7.0.
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.6.1...v9.7.0)

---
updated-dependencies:
- dependency-name: gradle-wrapper
  dependency-version: 9.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 11:56:21 +00:00
dependabot[bot] f708ef3353 chore(deps): bump androidx.media3:media3-exoplayer from 1.10.1 to 1.11.0 (#325)
Bumps [androidx.media3:media3-exoplayer](https://github.com/androidx/media) from 1.10.1 to 1.11.0.
- [Release notes](https://github.com/androidx/media/releases)
- [Changelog](https://github.com/androidx/media/blob/release/RELEASENOTES.md)
- [Commits](https://github.com/androidx/media/compare/1.10.1...1.11.0)

---
updated-dependencies:
- dependency-name: androidx.media3:media3-exoplayer
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 11:55:39 +00:00
dependabot[bot] 3224595a46 chore(deps): bump gradle/actions from 6.2.0 to 6.3.0 (#324)
Bumps [gradle/actions](https://github.com/gradle/actions) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](https://github.com/gradle/actions/compare/v6.2.0...v6.3.0)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 11:55:08 +00:00
dependabot[bot] c0453f040d chore(deps): bump the testing group with 2 updates (#323)
Bumps the testing group with 2 updates: [io.github.takahirom.roborazzi:roborazzi](https://github.com/takahirom/roborazzi) and [io.github.takahirom.roborazzi:roborazzi-compose](https://github.com/takahirom/roborazzi).


Updates `io.github.takahirom.roborazzi:roborazzi` from 1.70.0 to 1.71.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.70.0...1.71.0)

Updates `io.github.takahirom.roborazzi:roborazzi-compose` from 1.70.0 to 1.71.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.70.0...1.71.0)

---
updated-dependencies:
- dependency-name: io.github.takahirom.roborazzi:roborazzi
  dependency-version: 1.71.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
- dependency-name: io.github.takahirom.roborazzi:roborazzi-compose
  dependency-version: 1.71.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 11:54:54 +00:00
Bailey Dixon 33a0ea3216 chore: merge main back after android-v1.8.1 2026-08-10 00:05:29 -04:00
159 changed files with 12840 additions and 1447 deletions
+4 -4
View File
@@ -63,7 +63,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.2.0
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
@@ -95,7 +95,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.2.0
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
@@ -139,7 +139,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.2.0
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
@@ -203,7 +203,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.2.0
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
+4 -2
View File
@@ -100,13 +100,15 @@ jobs:
with:
node-version: '22'
cache: npm
cache-dependency-path: desktop/package-lock.json
cache-dependency-path: |
desktop/package-lock.json
desktop/tray/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install deps
run: npm ci
run: npm ci && npm --prefix tray ci
- name: Check tray formatting
run: npm run tray:fmt
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.2.0
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: false
+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.2.0
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: false
+2 -2
View File
@@ -125,7 +125,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.2.0
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: false
@@ -163,7 +163,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.2.0
uses: gradle/actions/setup-gradle@v6.3.0
with:
cache-read-only: false
+149 -6
View File
@@ -168,7 +168,9 @@ jobs:
with:
node-version: '22'
cache: npm
cache-dependency-path: desktop/package-lock.json
cache-dependency-path: |
desktop/package-lock.json
desktop/tray/package-lock.json
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -179,7 +181,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Install deps
run: npm ci
run: npm ci && npm --prefix tray ci
- name: Type-check
run: npm run type-check
@@ -213,12 +215,153 @@ jobs:
$proc = Start-Process -FilePath tray/target/release/hermes-relay-tray.exe -WindowStyle Hidden -PassThru
Start-Sleep -Seconds 5
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
$proc.Refresh()
if ($proc.MainWindowHandle -ne 0) { throw 'menu-only systray created an application window' }
$traySize = (Get-Item tray/target/release/hermes-relay-tray.exe).Length
if ($traySize -gt 5242880) { throw "tray executable exceeds 5 MiB: $traySize bytes" }
if ($traySize -le 0) { throw 'tray executable is empty' }
Stop-Process -Id $proc.Id -Force
Write-Host "menu-only tray launch smoke OK pid=$($proc.Id) bytes=$traySize"
Write-Host "management tray launch smoke OK pid=$($proc.Id) bytes=$traySize"
- name: Smoke-test packaged installer lifecycle
shell: pwsh
env:
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
run: |
$ErrorActionPreference = 'Stop'
function Normalize-UserPath([string]$Value) {
return (@($Value -split ';' | Where-Object { $_ }) -join ';')
}
function Get-RawUserPath {
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment')
if ($null -eq $environmentKey) { return '' }
try {
return [string]$environmentKey.GetValue(
'Path',
'',
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames
)
} finally {
$environmentKey.Dispose()
}
}
$setup = (Resolve-Path 'dist/tray/hermes-relay-windows-x64-setup.exe').Path
$smokeRoot = Join-Path $env:RUNNER_TEMP 'hermes-installer-lifecycle-smoke'
$smokeProfile = Join-Path $smokeRoot 'profile'
$installDir = Join-Path $smokeRoot 'installed files'
$sessionDir = Join-Path $smokeProfile '.hermes'
$sessionSentinel = Join-Path $sessionDir 'remote-sessions.json'
$uninstaller = Join-Path $installDir 'uninstall-hermes-relay.exe'
$uninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesRelay'
$productKey = 'HKCU:\Software\HermesRelay'
$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Hermes-Relay CLI'
$oldUserProfile = $env:USERPROFILE
$oldHomeEnv = $env:HOME
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$hadUserPath = $environmentKey.GetValueNames() -contains 'Path'
$originalUserPath = Get-RawUserPath
$originalUserPathKind = if ($hadUserPath) { $environmentKey.GetValueKind('Path') } else { $null }
$userPathBefore = 'C:\Windows\System32'
$environmentKey.Dispose()
$startupBefore = (Get-ItemProperty -Path $startupKey -Name HermesRelayTray -ErrorAction SilentlyContinue).HermesRelayTray
if (Test-Path $uninstallKey) { throw 'installer smoke requires a clean HermesRelay uninstall registry key' }
if (Test-Path $productKey) { throw 'installer smoke requires a clean HermesRelay product registry key' }
if (Test-Path $smokeRoot) { Remove-Item -LiteralPath $smokeRoot -Recurse -Force }
New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null
Set-Content -LiteralPath $sessionSentinel -Value '{"sentinel":"preserve-me"}' -Encoding UTF8
$env:USERPROFILE = $smokeProfile
$env:HOME = $smokeProfile
try {
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$environmentKey.SetValue('Path', $userPathBefore, [Microsoft.Win32.RegistryValueKind]::String)
$environmentKey.Dispose()
$installProcess = Start-Process -FilePath $setup -ArgumentList @('/S', "/D=$installDir") -Wait -PassThru
if ($installProcess.ExitCode -ne 0) { throw "installer exited with code $($installProcess.ExitCode)" }
$expectedFiles = @(
'hermes-relay.exe',
'hermes-relay-tray.exe',
'hermes-relay-ui.cmd',
'hermes-relay-path.ps1',
'uninstall-hermes-relay.exe'
)
foreach ($name in $expectedFiles) {
$path = Join-Path $installDir $name
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "packaged installer did not create $path"
}
}
$cli = Join-Path $installDir 'hermes-relay.exe'
$versionOutput = (& $cli --version | Out-String).Trim()
if ($LASTEXITCODE -ne 0) { throw "installed CLI --version exited with code $LASTEXITCODE" }
if ($versionOutput -ne "hermes-relay $env:EXPECTED_DESKTOP_VERSION") {
throw "installed CLI version mismatch: expected $env:EXPECTED_DESKTOP_VERSION, got '$versionOutput'"
}
$helpOutput = (& $cli --help | Out-String)
if ($LASTEXITCODE -ne 0 -or $helpOutput -notmatch 'Usage:') {
throw 'installed CLI --help smoke failed'
}
if (-not (Test-Path -LiteralPath $sessionSentinel -PathType Leaf)) {
throw 'installer removed profile session data'
}
$uninstallProcess = Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait -PassThru
if ($uninstallProcess.ExitCode -ne 0) { throw "uninstaller exited with code $($uninstallProcess.ExitCode)" }
$deadline = [DateTime]::UtcNow.AddSeconds(20)
while ((Test-Path -LiteralPath $uninstaller) -and [DateTime]::UtcNow -lt $deadline) {
Start-Sleep -Milliseconds 250
}
foreach ($name in $expectedFiles) {
$path = Join-Path $installDir $name
if (Test-Path -LiteralPath $path) { throw "uninstaller left owned artifact $path" }
}
if (Test-Path $uninstallKey) { throw 'uninstaller left the Installed Apps registry key' }
if (Test-Path $productKey) { throw 'uninstaller left the HermesRelay product registry key' }
if (Test-Path -LiteralPath $startMenuDir) { throw "uninstaller left Start-menu artifacts at $startMenuDir" }
if (-not (Test-Path -LiteralPath $sessionSentinel -PathType Leaf)) {
throw 'uninstaller removed preserved profile session data'
}
if ((Get-Content -LiteralPath $sessionSentinel -Raw) -notmatch 'preserve-me') {
throw 'installer lifecycle modified preserved profile session data'
}
# Compare the raw registry value so expandable entries such as
# %USERPROFILE% are not resolved against the isolated smoke profile.
$userPathAfter = Normalize-UserPath (Get-RawUserPath)
if ($userPathAfter -ne $userPathBefore) {
throw "uninstaller did not restore user PATH (before='$userPathBefore', after='$userPathAfter')"
}
$startupAfter = (Get-ItemProperty -Path $startupKey -Name HermesRelayTray -ErrorAction SilentlyContinue).HermesRelayTray
if ($startupAfter -ne $startupBefore) {
throw "installer lifecycle changed the pre-existing tray startup preference"
}
Write-Host "packaged installer lifecycle smoke OK version=$versionOutput install=$installDir"
} finally {
Get-Process -Name 'hermes-relay-tray' -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $uninstaller) {
Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait | Out-Null
}
$env:USERPROFILE = $oldUserProfile
$env:HOME = $oldHomeEnv
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
if ($hadUserPath) {
$environmentKey.SetValue('Path', $originalUserPath, $originalUserPathKind)
} else {
$environmentKey.DeleteValue('Path', $false)
}
$environmentKey.Dispose()
if (Test-Path -LiteralPath $smokeRoot) {
Remove-Item -LiteralPath $smokeRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
- name: Upload Windows tray release asset
uses: actions/upload-artifact@v4
+58 -1
View File
@@ -8,9 +8,66 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **Issue area labels require maintainer review.** The unreliable keyword-based auto-labeling workflow no longer assigns ownership from ambiguous issue text.
- **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.
## [1.6.3] - 2026-08-11
### Fixed
- **Relay diagnostics distinguish a prior clean stop from a crash.** Doctor and `/relay/info` expose only bounded clean, unclean, or unknown gateway-exit state with an optional suspected out-of-memory hint, without returning raw log evidence.
- **Relay reconnects spread out after shared gateway restarts.** Ordinary exponential reconnect delays use full jitter while explicit reconnects and server-directed retry timing retain their exact behavior.
## [0.4.0-alpha.7] - 2026-08-11
### Fixed
- **Installer lifecycle validation uses an isolated Windows PATH fixture.** Release smoke tests now verify add/remove cleanup against a fixed registry value and restore the runner's original value afterward, independently of the temporary profile used for session-preservation checks.
## [0.4.0-alpha.6] - 2026-08-11
### Fixed
- **Installer cleanup validation compares the unexpanded Windows PATH.** Release smoke tests now read the raw user registry value, ensuring `%USERPROFILE%` entries are verified without temporary-profile expansion changing their apparent value.
## [0.4.0-alpha.5] - 2026-08-11
### Fixed
- **Installer cleanup validation handles expandable Windows PATH entries.** Release smoke tests restore the original profile environment before comparing user PATH, avoiding false failures when unchanged `%USERPROFILE%` entries are expanded inside an isolated test profile.
## [0.4.0-alpha.4] - 2026-08-11
### Fixed
- **Windows release validation waits for installer processes.** The packaged install/uninstall lifecycle smoke now captures GUI-subsystem process exit codes reliably before validating installed files, preserved sessions, registry state, and cleanup.
## [0.4.0-alpha.3] - 2026-08-11
### Added
- **Windows tray provides focused remote-access management.** The compact host-aware popup covers connection state, per-host Ask/Trusted/Full Access, pending grant dialogs, authorized-client revocation, activity, daemon controls, and settings without adding chat, terminal, plugin, voice, or session surfaces.
- **Desktop access policy is isolated per Hermes host.** `hermes-relay hosts` lists and selects local pairings and stores fail-closed access modes independently for each canonical relay URL.
- **Windows CLI installations can add or open the management UI directly.** `hermes-relay ui install|open|status` and the installed UI shim provide a supported lifecycle for optional UI setup, discovery, and activation.
### Changed
- **Daemon connectivity no longer requires a tool grant.** Ask mode can keep an authenticated daemon connected with zero desktop tools attached; Trusted enables command/file tools with task-scoped screen/input grants, while Full Access removes those task prompts only for the selected host.
- **Windows bundle updates preserve the desktop lifecycle.** The CLI and tray coordinate one verified installer launch, restore the daemon and UI after setup, and permit same-version UI add or repair without silently downgrading a newer CLI.
### Fixed
- **Background daemon start reports real readiness.** Detached startup now waits for the spawned process to authenticate and connect, and returns actionable log evidence for configuration, authentication, early-exit, and timeout failures.
- **Local and release tray builds embed the packaged UI.** Development installs use Tauri's production protocol instead of attempting to load a missing localhost development server, and release CI exercises a silent install/uninstall lifecycle.
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
## [1.6.2] - 2026-08-11
### Fixed
- **Paired sessions use recognizable device identities.** Relay sessions preserve a client-provided hostname as the primary name, retain model and platform details, and enrich valid reconnects without requiring users to pair again.
- **Long-lived session expiry is readable.** The Dashboard presents paired-session lifetime in days or weeks with the exact local deadline available in the detail view instead of accumulating hundreds of hours.
## [Android 1.8.1] - 2026-08-09
### Fixed
+22 -19
View File
@@ -1,35 +1,38 @@
# Hermes-Relay-CLI v__VERSION__
# Hermes-Relay CLI v__VERSION__
**Release Date:** 2026-07-13
**Release Date:** 2026-08-11
This alpha makes the desktop direction explicit: Hermes-Relay is a real CLI/TUI with an optional Windows right-click systray—not a second desktop application. The old Tauri/WebView dashboard and its embedded windows are gone. The installed CLI remains the single source of behavior for pairing, TUI, daemon management, grants, audit, diagnostics, chat, voice, and tools.
This alpha replaces the right-click-only Windows tray with the compact **Hermes-Relay CLI UI** popup while keeping Hermes-Relay's desktop boundary narrow. Chat, the remote TUI, plugins, voice, and agent sessions remain CLI or upstream desktop concerns.
**Experimental phase.** Assets are 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 optional native systray is Windows-only.
**Experimental 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 tray is Windows-only.
## What's changed
### Added
- **Persistent desktop-use control.** `hermes-relay computer-use status|enable|disable|cancel` stores one local preference, reports daemon privilege and active/pending grants, and can end an active task-scoped grant without relying on a GUI.
- **Headless grant review.** `hermes-relay grants` lists pending local computer-use requests and supports interactive review plus explicit `approve`, `reject`, and JSON forms for scripts.
- **Typed Relay chat option.** `chat --relay-chat` sends `chat.send` over WSS and renders typed `stream.event` v1 assistant, tool, artifact, memory, skill, and error lifecycles while preserving the existing gateway path as the default.
- **Release-parity verification.** One version contract now keeps the npm package, compiled CLI, Rust tray, lockfile, and installer metadata aligned. The Windows verification target covers TypeScript, compiled-binary smoke tests, Rust formatting/lint/check/tests, and installer packaging.
- **Compact Windows management tray.** The popup provides connection status, host selection, per-host access, pending approval dialogs, recent activity, daemon controls, startup settings, and authorized-client revocation.
- **Host-aware desktop access.** `hermes-relay hosts` lists and selects paired Hermes instances and stores independent Ask, Trusted, or Full Access policy for each canonical relay URL.
- **In-window grant decisions.** New computer-use requests bring the tray forward and show the requesting host, scope, reason, and duration with explicit Approve and Reject actions.
- **Supported UI lifecycle from the CLI.** `hermes-relay ui install|open|status` lets a CLI-only Windows installation add, reveal, or inspect the optional management UI without rerunning setup by hand.
### Changed
- **Menu-only Windows systray.** The optional tray is a small native Rust process with no application window, WebView, overlay, embedded terminal, chat view, voice view, or settings dashboard. Interactive actions open the installed CLI in a normal terminal.
- **State- and privilege-aware daemon control.** The menu reports PID-backed daemon state and User/Administrator privilege, disables invalid lifecycle actions, and requests UAC only when **Start/Restart daemon as Administrator…** is explicitly chosen. The tray itself remains unprivileged.
- **Visible desktop-use safety.** The tray shows enablement, active grant mode and expiry, warns when an Administrator control grant is active, raises a native alert for pending approvals, opens CLI grant review, and provides immediate cancellation and emergency stop.
- **Per-user Windows installation.** The default PowerShell installer downloads the checksum-verified NSIS package, installs the CLI and optional tray under `~/.hermes/bin`, adds Start-menu shortcuts and user PATH, and can start the tray at sign-in. CLI-only installation remains available with `HERMES_RELAY_INSTALL_SURFACE=cli`.
- **Daemon startup is connectivity-first.** Ask mode can keep an authenticated daemon connected with zero desktop tools attached, so starting the daemon does not itself grant authority.
- **Full Access is explicit and host-scoped.** Trusted hosts may use command and file tools while screen/input remains task-granted. Full Access also removes task prompts for screen, input, and file patches for that host, while authentication, audit, revocation, emergency stop, and UAC boundaries remain enforced.
- **Host changes apply immediately.** Selecting a different host or changing its access mode restarts an already-running daemon and the UI verifies that the daemon URL matches the selected host before showing it as connected.
- **PowerShell remains first-class.** Agents should prefer the dedicated `desktop_powershell` RPC for native Windows work; `desktop_terminal` remains cmd-compatible for existing callers.
- **CLI and UI updates share one verified installer.** Bundle updates coordinate shutdown and restart, allow same-version UI repair, and refuse accidental downgrade unless explicitly forced.
### Fixed
- **Installed-binary diagnostics.** `hermes-relay doctor` reports the physical Bun-compiled executable instead of a virtual embedded-module path, so PATH and install-directory checks describe the binary that actually launched.
- **Release guardrails.** CLI tag automation rejects version drift, tags not contained in `main`, oversized tray binaries, or a tray process that creates an application window.
- **Detached daemon start reports real readiness.** `daemon start` waits for the spawned PID to authenticate and connect, and reports configuration, authentication, early-exit, and timeout diagnostics instead of returning a false success.
- **Normal tray operation no longer requires opening a CLI for grants.** Pending requests are resolved directly in the focused management dialog.
- **Release checks match the management tray.** CI builds the React assets, validates Tauri metadata, and smoke-tests the packaged tray without obsolete menu-only size or window assertions.
- **Installed tray builds no longer depend on a localhost development server.** Local and packaged builds embed their UI assets, eliminating the `127.0.0.1 refused to connect` failure.
## Install
**Windows CLI + optional systray (PowerShell):**
**Windows CLI + management tray (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
@@ -53,11 +56,11 @@ Pin this release with `HERMES_RELAY_VERSION=__TAG__`.
```text
hermes-relay --version
hermes-relay pair --remote ws://<host>:8767 --grant-tools
hermes-relay hosts list --json
hermes-relay daemon start
hermes-relay daemon status
hermes-relay daemon status --json
```
On Windows, open **Hermes Relay Systray** from the Start menu and right-click its notification-area icon. No separate desktop window is installed.
On Windows, click the Hermes-Relay CLI UI notification-area icon to open the management popup directly above it.
See the [CLI and systray guide](https://hermes-relay.dev/docs/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
See the [CLI and tray guide](https://hermes-relay.dev/docs/desktop/) for installation, access modes, grants, and troubleshooting.
+4 -4
View File
@@ -1,8 +1,8 @@
# Hermes-Relay-Server v__VERSION__
**Release Date:** August 8, 2026
**Release Date:** August 11, 2026
This patch makes the optional Dashboard plugin's Android setup handoff reliable for hosted Hermes connections.
This patch improves gateway recovery diagnostics and prevents clients from reconnecting in lockstep after a shared restart.
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
@@ -10,8 +10,8 @@ Standard chat, session history, and Vanilla Hermes voice remain upstream-owned a
### Fixed
- **Canonical hosted-Hermes setup handoff.** The Dashboard plugin supplies the verified Dashboard address Android needs to continue through the official system-browser authentication flow.
- **Contained dialog focus behavior.** Mobile setup dialogs retain their own focus and keyboard handling without disrupting the surrounding Dashboard.
- **Bounded prior-exit diagnostics.** Relay Doctor and `/relay/info` distinguish a clean stop, an unclean exit, and unknown history, with an optional suspected out-of-memory hint. Raw logs are never returned through the API.
- **Desynchronized recovery.** Ordinary exponential reconnect delays use full jitter so multiple Relay clients do not retry in lockstep after the gateway restarts. Explicit reconnects and server-directed retry timing remain unchanged.
## Install / update
+2 -2
View File
@@ -186,7 +186,7 @@ tracked independently so community corrections remain easy to contribute.
## Hands on any machine — the Hermes-Relay CLI&nbsp;<sub>(alpha)</sub>
> **Alpha.** Self-contained CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64 — no Node required. Windows also has an optional native, menu-only systray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
> **Alpha.** Self-contained CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64 — no Node required. Windows also has an optional compact management tray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
@@ -202,7 +202,7 @@ hermes-relay update # self-update via GitHub Releases
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on the `desktop-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=desktop), with historical releases still visible under `cli-v*`.
On Windows, the default installer adds the optional right-click-only systray: no dashboard or app window, just TUI launch, User/Administrator-aware daemon controls, pairing, local grant review, audit, diagnostics, logs, desktop-use status/cancellation, sign-in startup, and emergency stop.
On Windows, the default installer adds the optional compact **Hermes-Relay CLI UI** tray popup for host selection and pairing, connection and daemon state, per-host Ask/Trusted/Full Access, local grant dialogs, authorized-client revocation, activity, settings, and emergency stop. It is a management surface only—chat, TUI, plugins, voice, and agent sessions remain CLI/upstream concerns.
- **Docs:** [CLI guide](https://hermes-relay.dev/docs/desktop/) · [`desktop/README.md`](desktop/README.md)
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
+6 -3
View File
@@ -119,9 +119,9 @@ artifacts.
### CLI / tray versioning
`desktop/package.json` is the Desktop/CLI release track's source of truth. Its version
must match the generated CLI and native Windows systray metadata. The systray is
a menu-only controller for the installed CLI; it has no application window,
WebView, embedded terminal, or separate desktop product surface. The public
must match the generated CLI and Windows tray metadata. The tray is a compact
management popup over the installed CLI and shared state; it has no chat,
embedded terminal, plugins, voice, or separate desktop product surface. The public
release remains one `Hermes-Relay-Desktop` track containing CLI binaries plus the
optional Windows installer.
@@ -132,6 +132,9 @@ optional Windows installer.
| `desktop/src/version.ts` | compiled CLI runtime version |
| `desktop/tray/Cargo.toml` | native systray package version |
| `desktop/tray/Cargo.lock` | locked systray package version |
| `desktop/tray/tauri.conf.json` | tray application and bundle version |
| `desktop/tray/package.json` | tray UI package version |
| `desktop/tray/package-lock.json` | locked tray UI package version |
Prepare a new CLI version on `dev` without creating a tag or npm-generated
commit:
+23
View File
@@ -6,6 +6,29 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Structured desktop hardware capabilities
Desktop command, PowerShell, process, and job tools currently execute with the
desktop daemon's OS-user authority. Add typed hardware operations for reliable
schemas, audit detail, and task-scoped approval, but do not present hardware
toggles as isolation while an enabled general shell can reach the same device.
- Define per-host capabilities for commands, files, processes, clipboard,
screen, input, connected devices, microphone, and camera. Disabled must win,
hardware-sensitive capabilities must default off, and unavailable backends
must appear unavailable rather than as inert toggles.
- Add a **Structured only** access profile that withholds shell/process escape
hatches so individual capability toggles become enforceable boundaries.
- Implement connected-device support first with typed, serial-bound ADB
operations (`list`, `shell`, `push`, `pull`, `install`, and bounded logcat)
instead of a generic device-exec wrapper.
- Add microphone and camera only with backend readiness detection, bounded local
grants, active-use indicators, audit events, and immediate cancellation.
- Reconcile legacy `desktop_screenshot` with the task-granted computer screenshot
path so screen capture follows one policy.
---
## Android Plugin Studio protocol follow-ups
The first live declarative Plugin lane is host-local: Relay tools create bounded
+2 -2
View File
@@ -370,8 +370,8 @@ dependencies {
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
// Renders real composables on the JVM at an exact canvas — no device, no
// status bar, no clipping. See StoreScreenshotTest.
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.70.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.70.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.71.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.71.0")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.auth
import android.content.Context
import android.provider.Settings
import android.util.Log
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
@@ -631,7 +632,7 @@ class AuthManager(
val now = System.currentTimeMillis() / 1000L
val defaults = PairedSession(
token = token,
deviceName = android.os.Build.MODEL,
deviceName = relayDeviceName(),
expiresAt = null,
grants = emptyMap(),
transportHint = null,
@@ -651,7 +652,7 @@ class AuthManager(
val transportHint = obj["transport_hint"]?.jsonPrimitive?.contentOrNull
val firstSeen = obj["first_seen"]?.jsonPrimitive?.longOrNull ?: now
val deviceName = obj["device_name"]?.jsonPrimitive?.contentOrNull
?: android.os.Build.MODEL
?: relayDeviceName()
PairedSession(
token = token,
@@ -750,6 +751,26 @@ class AuthManager(
})
}
private fun JsonObjectBuilder.putRelayDeviceIdentity() {
val model = android.os.Build.MODEL.orEmpty().ifBlank { "Android device" }
val deviceName = relayDeviceName()
put("device_name", deviceName)
put("device_hostname", deviceName)
put("device_model", model)
put("device_platform", "Android ${android.os.Build.VERSION.RELEASE}")
put("client_surface", "android")
put("device_form_factor", "phone")
}
private fun relayDeviceName(): String {
val configured = runCatching {
Settings.Global.getString(context.contentResolver, "device_name")
}.getOrNull()?.trim().orEmpty()
return configured.ifBlank {
android.os.Build.MODEL.orEmpty().ifBlank { "Android device" }
}
}
/**
* Send auth envelope when connection is established.
*
@@ -784,7 +805,7 @@ class AuthManager(
put("refresh_token", refreshToken)
}
put("device_id", deviceId)
put("device_name", android.os.Build.MODEL)
putRelayDeviceIdentity()
putRelayClientSupports()
}
}
@@ -800,7 +821,7 @@ class AuthManager(
buildJsonObject {
put("pairing_code", codeToSend)
put("device_id", deviceId)
put("device_name", android.os.Build.MODEL)
putRelayDeviceIdentity()
putRelayClientSupports()
pendingTtlSeconds?.let { put("ttl_seconds", it) }
pendingGrants?.let { grants ->
@@ -1066,7 +1087,7 @@ class AuthManager(
val paired = PairedSession(
token = token,
deviceName = android.os.Build.MODEL,
deviceName = relayDeviceName(),
expiresAt = expiresAt,
grants = grantsMap,
transportHint = transportHint,
@@ -74,6 +74,14 @@ data class PairedDeviceInfo(
val deviceName: String = "",
@SerialName("device_id")
val deviceId: String = "",
@SerialName("device_model")
val deviceModel: String = "",
@SerialName("device_platform")
val devicePlatform: String = "",
@SerialName("client_surface")
val clientSurface: String = "",
@SerialName("device_form_factor")
val deviceFormFactor: String = "",
@SerialName("created_at")
val createdAt: Double? = null,
@SerialName("last_seen")
@@ -366,6 +366,8 @@ data class ToolCall(
* header can render without a separate lane registry.
*/
val taskLabel: String? = null,
/** Live upstream child id used by subagent.steer while this lane runs. */
val subagentId: String? = null,
/** Deterministic non-low output risk reported by upstream for this call. */
val outputRisk: String? = null,
/** Human-readable deterministic findings; rendered as untrusted metadata. */
@@ -19,6 +19,7 @@ import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.EndpointResolver
import com.hermesandroid.relay.network.shared.EndpointSurface
import com.hermesandroid.relay.network.shared.fullJitterDelayMs
import com.hermesandroid.relay.network.shutdownOffMainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -139,6 +140,8 @@ class ConnectionManager(
* non-null — the manager falls back to the single-URL path.
*/
private val deviceIdProvider: (suspend () -> String?)? = null,
/** Random source for ordinary reconnect full-jitter; exact backoffs never use it. */
private val reconnectJitterUnit: () -> Double = { kotlin.random.Random.nextDouble() },
) {
private val supervisorJob = SupervisorJob()
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
@@ -1213,8 +1216,9 @@ class ConnectionManager(
SLOW_POLL_BACKOFF_MS
}
else -> {
val ms = (BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
val capMs = (BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
.coerceAtMost(MAX_BACKOFF_MS)
val ms = fullJitterDelayMs(capMs, reconnectJitterUnit())
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
@@ -0,0 +1,13 @@
package com.hermesandroid.relay.network.shared
import kotlin.random.Random
/** Full-jitter retry delay in the inclusive range 0..[capMs]. */
internal fun fullJitterDelayMs(
capMs: Long,
unit: Double = Random.nextDouble(),
): Long {
if (capMs <= 0L) return 0L
val boundedUnit = unit.coerceIn(0.0, Math.nextDown(1.0))
return (boundedUnit * (capMs + 1.0)).toLong().coerceAtMost(capMs)
}
@@ -90,12 +90,9 @@ class ChatHandler {
// Fallback form (no relay): `MEDIA:/absolute/path` — relay wasn't
// reachable when the tool fired, so we render an "unavailable"
// placeholder instead of attempting a fetch.
private val mediaRelayRegex = Regex("""MEDIA:hermes-relay://([A-Za-z0-9_-]+)""")
// `/.+?` (not `/\S+`) so absolute paths containing spaces — e.g.
// `MEDIA:/mnt/media/Coralee Adshade/undressher.jpg` — still match. The
// trailing `\s*$` trims any trailing whitespace; non-greedy keeps the
// capture to the path. OkHttp re-encodes the space for /media/by-path.
private val mediaBarePathRegex = Regex("""^\s*MEDIA:(/.+?)\s*$""")
private val mediaRelayPayloadRegex = Regex("""^hermes-relay://([A-Za-z0-9_-]+)$""")
private val mediaMarkerPrefixRegex = Regex("MEDIA:")
private val windowsAbsoluteMediaPathRegex = Regex("""^[A-Za-z]:[\\/].+""")
// Rich card marker — single line, full JSON object payload.
//
// Agents emit:
@@ -188,6 +185,7 @@ class ChatHandler {
* post-stream finalize reconciliation pass.
*/
private var mediaLineBuffer = StringBuilder()
private var mediaFenceDelimiter: String? = null
private val dispatchedMediaMarkers = mutableSetOf<String>()
/**
@@ -1176,12 +1174,14 @@ class ChatHandler {
// Drop any pending line buffers / dedupe state so a fresh session
// doesn't inherit leftovers from the previous one.
mediaLineBuffer.clear()
mediaFenceDelimiter = null
dispatchedMediaMarkers.clear()
annotationLineBuffer.clear()
activeAnnotationTools.clear()
cardLineBuffer.clear()
dispatchedCardMarkers.clear()
subagentLabels.clear()
subagentIds.clear()
}
/**
@@ -1771,7 +1771,7 @@ class ChatHandler {
for (line in text.lines()) {
val t = line.trim()
if (t.isEmpty()) continue
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
if (parseMediaMarkerLine(t).isNotEmpty()) continue
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
if (cardMarkerRegex.containsMatchIn(t)) continue
if (sb.isNotEmpty()) sb.append('\n')
@@ -1788,6 +1788,51 @@ class ChatHandler {
data class BarePath(val path: String) : MediaMarkerHit
}
/**
* Parse one marker-only line using upstream-compatible wrappers and
* boundaries. Prose and malformed examples remain ordinary text; a whole
* inline-code or emphasis wrapper is accepted, as are adjacent markers,
* sentence-final punctuation, POSIX paths, and Windows absolute paths.
*/
private fun parseMediaMarkerLine(line: String): List<MediaMarkerHit> {
val trimmed = line.trim()
if (trimmed.isEmpty() || trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
return emptyList()
}
val starts = mediaMarkerPrefixRegex.findAll(trimmed).map { it.range.first }.toList()
if (starts.isEmpty()) return emptyList()
val prefix = trimmed.substring(0, starts.first())
if (prefix.any { !it.isWhitespace() && it !in "`*_~" }) return emptyList()
val hits = ArrayList<MediaMarkerHit>(starts.size)
for ((index, start) in starts.withIndex()) {
val payloadStart = start + "MEDIA:".length
val payloadEnd = starts.getOrNull(index + 1) ?: trimmed.length
val payload = trimmed.substring(payloadStart, payloadEnd)
.trim()
.trimEnd { it in "`*_~.,;:)}]" }
.trim()
if (payload.isEmpty()) return emptyList()
val relay = mediaRelayPayloadRegex.matchEntire(payload)
when {
relay != null -> hits += MediaMarkerHit.RelayToken(relay.groupValues[1])
payload.startsWith("/") || windowsAbsoluteMediaPathRegex.matches(payload) ->
hits += MediaMarkerHit.BarePath(payload)
else -> return emptyList()
}
}
return hits
}
private fun fenceDelimiter(line: String): String? {
val trimmed = line.trimStart()
return when {
trimmed.startsWith("```") -> "```"
trimmed.startsWith("~~~") -> "~~~"
else -> null
}
}
/**
* Scan loaded (non-streaming) message content line-by-line for media
* markers, append hits to [out], and return the content with matched
@@ -1800,24 +1845,20 @@ class ChatHandler {
out: MutableList<Pair<String, MediaMarkerHit>>,
): String {
var cleaned = content
var openFence: String? = null
for (rawLine in content.lines()) {
val trimmed = rawLine.trim()
if (trimmed.isEmpty()) continue
val relayMatch = mediaRelayRegex.find(trimmed)
if (relayMatch != null) {
out.add(messageId to MediaMarkerHit.RelayToken(relayMatch.groupValues[1]))
cleaned = cleaned
.replace("\n$rawLine\n", "\n")
.replace("\n$rawLine", "")
.replace("$rawLine\n", "")
.replace(rawLine, "")
val delimiter = fenceDelimiter(rawLine)
if (delimiter != null) {
openFence = if (openFence == delimiter) null else if (openFence == null) delimiter else openFence
continue
}
if (openFence != null) continue
val bareMatch = mediaBarePathRegex.find(trimmed)
if (bareMatch != null) {
out.add(messageId to MediaMarkerHit.BarePath(bareMatch.groupValues[1]))
val hits = parseMediaMarkerLine(trimmed)
if (hits.isNotEmpty()) {
hits.forEach { out.add(messageId to it) }
cleaned = cleaned
.replace("\n$rawLine\n", "\n")
.replace("\n$rawLine", "")
@@ -2265,6 +2306,18 @@ class ChatHandler {
val trimmed = line.trim()
if (trimmed.isEmpty()) continue
fenceDelimiter(line)?.let { delimiter ->
mediaFenceDelimiter = if (mediaFenceDelimiter == delimiter) {
null
} else if (mediaFenceDelimiter == null) {
delimiter
} else {
mediaFenceDelimiter
}
continue
}
if (mediaFenceDelimiter != null) continue
if (tryDispatchMediaMarker(messageId, trimmed)) {
stripLineFromContent(messageId, trimmed)
}
@@ -2394,29 +2447,26 @@ class ChatHandler {
* Returns true when a marker was matched so the caller can strip the line.
*/
private fun tryDispatchMediaMarker(messageId: String, line: String): Boolean {
val relayMatch = mediaRelayRegex.find(line)
if (relayMatch != null) {
val token = relayMatch.groupValues[1]
val dedupeKey = "$messageId:relay:$token"
if (dispatchedMediaMarkers.add(dedupeKey)) {
Log.d(TAG, "Media marker (relay): token=$token")
onMediaAttachmentRequested(messageId, token)
val hits = parseMediaMarkerLine(line)
for (hit in hits) {
when (hit) {
is MediaMarkerHit.RelayToken -> {
val dedupeKey = "$messageId:relay:${hit.token}"
if (dispatchedMediaMarkers.add(dedupeKey)) {
Log.d(TAG, "Media marker (relay): token=${hit.token}")
onMediaAttachmentRequested(messageId, hit.token)
}
}
is MediaMarkerHit.BarePath -> {
val dedupeKey = "$messageId:bare:${hit.path}"
if (dispatchedMediaMarkers.add(dedupeKey)) {
Log.d(TAG, "Media marker (bare-path): ${hit.path}")
onMediaBarePathRequested(messageId, hit.path)
}
}
}
return true
}
val bareMatch = mediaBarePathRegex.find(line)
if (bareMatch != null) {
val path = bareMatch.groupValues[1]
val dedupeKey = "$messageId:bare:$path"
if (dispatchedMediaMarkers.add(dedupeKey)) {
Log.d(TAG, "Media marker (bare-path, unavailable): $path")
onMediaBarePathRequested(messageId, path)
}
return true
}
return false
return hits.isNotEmpty()
}
/**
@@ -2555,10 +2605,11 @@ class ChatHandler {
if (mediaLineBuffer.isNotEmpty()) {
val remaining = mediaLineBuffer.toString().trim()
mediaLineBuffer.clear()
if (remaining.isNotEmpty() && tryDispatchMediaMarker(messageId, remaining)) {
if (mediaFenceDelimiter == null && remaining.isNotEmpty() && tryDispatchMediaMarker(messageId, remaining)) {
stripLineFromContent(messageId, remaining)
}
}
mediaFenceDelimiter = null
// Post-stream reconciliation: re-scan the final content for markers
// that raced with stripLineFromContent during streaming.
@@ -2567,12 +2618,16 @@ class ChatHandler {
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
var cleaned = msg.content
var changed = false
var openFence: String? = null
for (rawLine in msg.content.lines()) {
val trimmed = rawLine.trim()
if (trimmed.isEmpty()) continue
if (mediaRelayRegex.containsMatchIn(trimmed) ||
mediaBarePathRegex.containsMatchIn(trimmed)
) {
val delimiter = fenceDelimiter(rawLine)
if (delimiter != null) {
openFence = if (openFence == delimiter) null else if (openFence == null) delimiter else openFence
continue
}
if (openFence == null && parseMediaMarkerLine(trimmed).isNotEmpty()) {
tryDispatchMediaMarker(messageId, trimmed)
cleaned = cleaned
.replace("\n$rawLine\n", "\n")
@@ -2903,6 +2958,7 @@ class ChatHandler {
* [onStreamComplete] / [clearMessages].
*/
private val subagentLabels = mutableMapOf<Int, String>()
private val subagentIds = mutableMapOf<Int, String>()
/**
* Apply one gateway `subagent.*` lifecycle event to the streaming
@@ -2918,6 +2974,9 @@ class ChatHandler {
when (event.phase) {
GatewaySubagentEvent.Phase.START -> {
if (label != null) subagentLabels[event.taskIndex] = label
event.subagentId?.takeIf(String::isNotBlank)?.let {
subagentIds[event.taskIndex] = it
}
}
GatewaySubagentEvent.Phase.TOOL -> {
@@ -2932,6 +2991,8 @@ class ChatHandler {
isComplete = false,
taskIndex = event.taskIndex,
taskLabel = laneLabel,
subagentId = event.subagentId?.takeIf(String::isNotBlank)
?: subagentIds[event.taskIndex],
)
_messages.update { messages ->
val target = messages.findLast {
@@ -2971,6 +3032,7 @@ class ChatHandler {
// "interrupted" lanes never finished — not a success either.
val failed = event.status == "failed" || event.status == "interrupted"
val laneLabel = subagentLabels.remove(event.taskIndex) ?: label
val subagentId = subagentIds.remove(event.taskIndex) ?: event.subagentId
val summaryId = "subagent-${event.taskIndex}-${syntheticToolSeq++}"
_messages.update { messages ->
messages.map { msg ->
@@ -3003,6 +3065,7 @@ class ChatHandler {
completedAt = System.currentTimeMillis(),
taskIndex = event.taskIndex,
taskLabel = laneLabel,
subagentId = subagentId,
)
}
msg.copy(toolCalls = withSummary)
@@ -3260,6 +3323,7 @@ class ChatHandler {
}
}
subagentLabels.clear()
subagentIds.clear()
}
fun onStreamError(message: String) {
@@ -3289,6 +3353,7 @@ class ChatHandler {
}
}
subagentLabels.clear()
subagentIds.clear()
}
fun onThinkingDelta(messageId: String, delta: String) {
@@ -500,6 +500,7 @@ class DashboardApiClient(
name: String,
cloneFromDefault: Boolean = true,
description: String? = null,
mcpServers: List<String> = emptyList(),
): Result<JsonObject> =
postJsonObject(
path = "/api/profiles",
@@ -507,9 +508,16 @@ class DashboardApiClient(
put("name", name)
put("clone_from_default", cloneFromDefault)
if (!description.isNullOrBlank()) put("description", description)
if (mcpServers.isNotEmpty()) {
put("mcp_servers", JsonArray(mcpServers.map(::JsonPrimitive)))
}
},
)
/** Create a host-owned Hermes backup, distinct from Android settings export. */
suspend fun createServerBackup(): Result<JsonObject> =
postJsonObject("/api/ops/backup")
suspend fun setProfileDescription(name: String, description: String): Result<JsonObject> =
putJsonObject(
path = "/api/profiles/${pathSegment(name)}/description",
@@ -605,15 +613,16 @@ class DashboardApiClient(
)
}
suspend fun getCustomEndpoints(): Result<DashboardCustomEndpoints> =
getJsonObject("/api/providers/custom-endpoints")
suspend fun getCustomEndpoints(profile: String? = null): Result<DashboardCustomEndpoints> =
getJsonObject("/api/providers/custom-endpoints${profileQuery(profile)}")
.mapCatching(::parseCustomEndpoints)
suspend fun saveCustomEndpoint(
draft: DashboardCustomEndpointDraft,
profile: String? = null,
): Result<DashboardCustomEndpoints> =
postJsonObject(
"/api/providers/custom-endpoints",
"/api/providers/custom-endpoints${profileQuery(profile)}",
customEndpointPayload(draft),
).mapCatching(::parseCustomEndpoints)
@@ -634,13 +643,19 @@ class DashboardApiClient(
suspend fun activateCustomEndpoint(
id: String,
profile: String? = null,
): Result<JsonObject> =
postJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}/activate")
postJsonObject(
"/api/providers/custom-endpoints/${pathSegment(id)}/activate${profileQuery(profile)}",
)
suspend fun deleteCustomEndpoint(
id: String,
profile: String? = null,
): Result<DashboardCustomEndpoints> =
deleteJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}")
deleteJsonObject(
"/api/providers/custom-endpoints/${pathSegment(id)}${profileQuery(profile)}",
)
.mapCatching(::parseCustomEndpoints)
suspend fun installMcpCatalogEntry(
@@ -752,6 +767,27 @@ class DashboardApiClient(
Result.success(sessions.values.take(limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)))
}
/**
* Read the bounded, authoritative session window across every profile.
* Every usable row must retain its owning profile; rows without one are
* skipped rather than risking a cross-profile transcript or mutation.
*/
suspend fun listAllProfileSessions(
limit: Int = SESSION_LIST_WINDOW_LIMIT,
): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
val boundedLimit = limit.coerceIn(1, SESSION_LIST_WINDOW_LIMIT)
getJson(
"/api/profiles/sessions?limit=$boundedLimit&offset=0&order=recent" +
"&min_messages=1&archived=include&profile=all",
).mapCatching { root ->
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
(parsed.sessions ?: parsed.items ?: parsed.data ?: emptyList())
.filter { it.id.isNotBlank() && !it.profile.isNullOrBlank() }
.distinctBy { "${it.profile}:${it.id}" }
.take(boundedLimit)
}
}
/**
* A session's message history, scoped to its owning profile via the dashboard
* `GET /api/sessions/{id}/messages?profile=`. Required twin of [listSessions]:
@@ -18,10 +18,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import com.hermesandroid.relay.network.shared.fullJitterDelayMs
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
@@ -86,6 +88,8 @@ class GatewayChatClient(
private val promptSubmitTimeoutMs: Long = PROMPT_SUBMIT_REQUEST_TIMEOUT_MS,
/** Test seam — idle-progress watchdog base. Production keeps [TURN_TIMEOUT_MS]. */
private val turnIdleTimeoutMs: Long = TURN_TIMEOUT_MS,
/** Random source for ordinary reconnect full-jitter. */
private val reconnectJitterUnit: () -> Double = { kotlin.random.Random.nextDouble() },
) {
/** Existing upstream rich-chat vocabulary; do not invent a Relay-only source. */
private val sessionSource = "webui"
@@ -1486,6 +1490,43 @@ class GatewayChatClient(
},
)
/**
* React to the newest message for a role without guessing a transcript row
* 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> {
require(role == "user" || role == "assistant") { "unsupported reaction role" }
val sid = liveSessionId
?: return Result.failure(GatewayRpcException("no live session"))
return rpc(
"message.react",
buildJsonObject {
put("session_id", sid)
put("newest_role", role)
if (emoji == null) put("emoji", JsonNull) else put("emoji", emoji)
put("author", "user")
},
)
}
/** Redirect one running child agent without interrupting the parent turn. */
suspend fun steerSubagent(subagentId: String, text: String): Result<JsonObject> {
val sessionId = liveSessionId
?: return Result.failure(IllegalStateException("No live gateway session"))
if (subagentId.isBlank() || text.isBlank()) {
return Result.failure(IllegalArgumentException("Subagent and instruction are required"))
}
return rpc(
"subagent.steer",
buildJsonObject {
put("session_id", sessionId)
put("subagent_id", subagentId)
put("text", text.trim())
},
)
}
/** Fetch the session/global reasoning effort and display mode. */
suspend fun getReasoningSettings(): Result<GatewayReasoningSettings> {
if (webSocket == null || readySignal?.isCompleted != true) {
@@ -2094,6 +2135,42 @@ class GatewayChatClient(
return
}
// Upstream emits session.reclaimed process-wide, so it is identified
// by payload rather than params.session_id. Retire only an exact live
// runtime we own; preserve the durable id so the next send resumes it.
if (type == "session.reclaimed") {
val reclaimedLiveId = payload?.stringField("session_id")
val reclaimedStoredId = payload?.stringField("stored_session_id")
val reason = payload?.stringField("reason")
val supportedReason = reason in setOf("idle_timeout", "lru_evict", "ws_orphan_reap")
if (!reclaimedLiveId.isNullOrBlank() && supportedReason) {
val background = backgroundTurns.remove(reclaimedLiveId)
if (background != null) {
callbackDispatcher {
unmatchedTurnCompleteListener?.invoke(
GatewayBackgroundTurnCompletion(
storedSessionId = reclaimedStoredId?.takeIf(String::isNotBlank)
?: background.storedSessionId,
liveSessionId = reclaimedLiveId,
profile = background.profile,
expectedAssistantText = null,
),
)
}
}
if (reclaimedLiveId == liveSessionId) {
liveSessionId = null
reclaimedStoredId?.takeIf(String::isNotBlank)?.let { storedSessionId = it }
val turn = activeTurn
if (turn != null && !turn.ended) {
activeTurn = null
turn.failFromTransport("Gateway reclaimed the inactive session")
}
}
}
return
}
// read_terminal is a renderer query, not a user decision. Android has
// no xterm pane on the Gateway chat surface, so mirror upstream
// desktop's no-live-pane behavior and answer with empty text instead
@@ -2412,7 +2489,7 @@ class GatewayChatClient(
Log.i(TAG, "Gateway socket rejoined for ${backgroundTurns.size} detached turn(s)")
return
}
delay(backoffMs)
delay(fullJitterDelayMs(backoffMs, reconnectJitterUnit()))
backoffMs = (backoffMs * 2).coerceAtMost(5_000L)
}
}
@@ -2505,7 +2582,7 @@ class GatewayChatClient(
)
return
}
delay(backoffMs)
delay(fullJitterDelayMs(backoffMs, reconnectJitterUnit()))
backoffMs = (backoffMs * 2).coerceAtMost(5_000L)
}
if (activeTurn === turn) activeTurn = null
@@ -207,7 +207,11 @@ class GatewayEventMapper(
}
else -> syntheticToolId(name)
}
val argsPreview = payload.string("args_text")
val argsPreview = payload?.get("args")
?.takeUnless { it is JsonPrimitive && it.contentOrNull.isNullOrBlank() }
?.toString()
?.takeIf { it.isNotBlank() && it != "null" }
?: payload.string("args_text")
?.takeIf { it.isNotBlank() }
?: payload.string("context")?.takeIf { it.isNotBlank() }
callbacks.onToolCallStart(toolId, name, argsPreview)
@@ -294,6 +298,7 @@ class GatewayEventMapper(
// text; thinking/progress carry text only.
preview = payload.string("tool_preview") ?: payload.string("text"),
durationSeconds = payload.double("duration_seconds"),
subagentId = payload.string("subagent_id"),
),
)
}
@@ -270,6 +270,7 @@ data class GatewaySubagentEvent(
val toolName: String? = null,
val preview: String? = null,
val durationSeconds: Double? = null,
val subagentId: String? = null,
) {
enum class Phase { START, THINKING, TOOL, PROGRESS, COMPLETE }
}
@@ -142,6 +142,8 @@ data class SessionItem(
val preview: String? = null,
val model: String? = null,
val source: String? = null,
/** Owning profile on the cross-profile `/api/profiles/sessions` endpoint. */
val profile: String? = null,
@SerialName("started_at")
@Serializable(with = FlexibleTimestampSerializer::class)
val startedAt: Double? = null,
@@ -64,6 +64,10 @@ data class SlashCommand(
* instead of plain text.
*/
val source: String? = null,
/** Bounded server-recorded skill usage; zero for non-skill commands. */
val usageRank: Int = 0,
/** Sanitized server skill origin such as local or bundled. */
val origin: String? = null,
) {
companion object {
const val SOURCE_SERVER = "server"
@@ -46,6 +46,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.Composable
@@ -113,6 +114,10 @@ fun MessageBubble(
onQuoteMessage: ((ChatMessage) -> Unit)? = null,
/** Navigate a rendered quote chip to its original message id. */
onNavigateToMessage: ((String) -> Unit)? = null,
/** Open an upstream @session:<profile>/<id> reference in app. */
onSessionReference: ((SessionReference) -> Unit)? = null,
/** React to the newest message for this role; null removes the reaction. */
onReact: ((String?) -> Unit)? = null,
/**
* Reads a completed assistant response through the active voice renderer.
* Null hides the entry; the owning screen uses that to limit the action to
@@ -170,6 +175,7 @@ fun MessageBubble(
) {
val isUser = message.role == MessageRole.USER
val isSystem = message.role == MessageRole.SYSTEM
val sessionReferences = remember(message.content) { parseSessionReferences(message.content) }
// Phone/voice-origin action bubble marker.
//
@@ -449,7 +455,7 @@ fun MessageBubble(
val showEditAction = onEditMessage != null && isUser
val showSpeakAction = shouldShowSpeakResponseAction(message, onSpeakMessage != null)
val showStopSpeakingAction = shouldShowStopSpeakingAction(message, onStopSpeaking != null)
if (onQuoteMessage != null || showEditAction || showSpeakAction || showStopSpeakingAction) {
if (onQuoteMessage != null || onReact != null || showEditAction || showSpeakAction || showStopSpeakingAction) {
DropdownMenu(
expanded = showMessageActions,
onDismissRequest = { showMessageActions = false },
@@ -639,6 +645,35 @@ 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(
onClick = { onSessionReference(reference) },
modifier = Modifier.padding(top = 2.dp),
) {
Text("Open ${reference.label}")
}
}
}
// Inline generated images (assistant only) — rendered OUTSIDE
// the SelectionContainer (they're not selectable text). Remote
@@ -99,6 +99,11 @@ internal enum class SessionDrawerFilter {
internal const val SESSION_DRAWER_LIST_TAG = "session-drawer-list"
internal const val UNPINNED_STAR_ALPHA = 0.45f
data class ProfileSessionRow(
val profile: String,
val session: ChatSession,
)
internal fun sessionPinIcon(pinned: Boolean) =
if (pinned) Icons.Filled.Star else Icons.Outlined.StarBorder
@@ -149,6 +154,10 @@ fun SessionDrawerContent(
hiddenSources: Set<String> = emptySet(),
/** Toggle a source's visibility (persisted). Null hides the source filter. */
onToggleSourceHidden: ((String, Boolean) -> Unit)? = null,
allProfileSessions: List<ProfileSessionRow> = emptyList(),
allProfileSessionsLoading: Boolean = false,
onRefreshAllProfiles: (() -> Unit)? = null,
onSelectProfileSession: ((String, String) -> Unit)? = null,
) {
var renameDialogSession by remember { mutableStateOf<ChatSession?>(null) }
var newThreadDialog by remember { mutableStateOf(false) }
@@ -157,6 +166,7 @@ fun SessionDrawerContent(
var query by remember { mutableStateOf("") }
var searchExpanded by remember { mutableStateOf(false) }
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
var allProfilesOpen by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
val trimmedQuery = query.trim()
// Threads affordance shows when the capability is active OR there's already at least one
@@ -355,6 +365,16 @@ fun SessionDrawerContent(
overflow = TextOverflow.Ellipsis,
)
}
if (onRefreshAllProfiles != null && onSelectProfileSession != null) {
TextButton(
onClick = {
allProfilesOpen = true
onRefreshAllProfiles()
},
) {
Text(stringResource(R.string.drawer_all_profiles))
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -620,6 +640,72 @@ fun SessionDrawerContent(
}
)
}
if (allProfilesOpen) {
var allQuery by remember { mutableStateOf("") }
val needle = allQuery.trim()
val rows = allProfileSessions.filter { row ->
needle.isBlank() ||
row.profile.contains(needle, ignoreCase = true) ||
row.session.title.orEmpty().contains(needle, ignoreCase = true) ||
row.session.sessionId.contains(needle, ignoreCase = true)
}
AlertDialog(
onDismissRequest = { allProfilesOpen = false },
title = { Text(stringResource(R.string.drawer_all_profiles)) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = allQuery,
onValueChange = { allQuery = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
)
Spacer(modifier = Modifier.height(8.dp))
when {
allProfileSessionsLoading && allProfileSessions.isEmpty() ->
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
rows.isEmpty() -> Text(
stringResource(R.string.drawer_no_profile_sessions),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(modifier = Modifier.height(420.dp)) {
items(rows, key = { "${it.profile}:${it.session.sessionId}" }) { row ->
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
allProfilesOpen = false
onSelectProfileSession?.invoke(row.profile, row.session.sessionId)
}
.padding(vertical = 10.dp, horizontal = 4.dp),
) {
Text(
row.session.title?.takeIf { it.isNotBlank() }
?: stringResource(R.string.drawer_untitled),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
row.profile,
style = relayMetadataStyle(),
color = RelayRefresh.Relay,
)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = { allProfilesOpen = false }) {
Text(stringResource(R.string.drawer_close))
}
},
)
}
}
@Composable
@@ -0,0 +1,41 @@
package com.hermesandroid.relay.ui.components
data class SessionReference(val profile: String, val sessionId: String) {
val label: String
get() = "$profile · ${sessionId.takeLast(10)}"
}
private val SESSION_REFERENCE = Regex("@session:([A-Za-z0-9_.-]{1,64})/([A-Za-z0-9_.:-]{1,160})")
/** Find session references outside fenced and inline code. */
internal fun parseSessionReferences(markdown: String): List<SessionReference> {
val visible = buildString(markdown.length) {
var fenced = false
markdown.lineSequence().forEach { line ->
if (line.trimStart().startsWith("```")) {
fenced = !fenced
appendLine()
} else if (fenced) {
appendLine()
} else {
var inline = false
line.forEach { char ->
if (char == '`') inline = !inline
append(if (inline || char == '`') ' ' else char)
}
appendLine()
}
}
}
return SESSION_REFERENCE.findAll(visible)
.map {
SessionReference(
it.groupValues[1],
it.groupValues[2].trimEnd('.', ',', ';', '!', '?', ')', ']', '}'),
)
}
.filter { it.sessionId.isNotBlank() }
.distinct()
.take(16)
.toList()
}
@@ -25,8 +25,11 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -73,11 +76,13 @@ fun SubagentLane(
taskIndex: Int,
calls: List<ToolCall>,
modifier: Modifier = Modifier,
onSteer: ((subagentId: String, instruction: String) -> Unit)? = null,
) {
val anyRunning = calls.any { !it.isComplete }
val allComplete = calls.isNotEmpty() && calls.all { it.isComplete }
val anyFailed = calls.any { it.isComplete && it.success == false }
val runningCount = calls.count { !it.isComplete }
val steerableId = calls.firstNotNullOfOrNull { it.subagentId?.takeIf(String::isNotBlank) }
val laneLabel = calls.firstNotNullOfOrNull { call ->
call.taskLabel?.takeIf { it.isNotBlank() }
@@ -106,6 +111,8 @@ fun SubagentLane(
val failureLabel = stringResource(R.string.cd_subagent_failed)
var expanded by remember { mutableStateOf(!allComplete) }
var showSteerDialog by remember { mutableStateOf(false) }
var steerText by remember { mutableStateOf("") }
// Auto-collapse when the last child completes — same pattern as
// ToolProgressCard's LaunchedEffect(toolCall.isComplete).
@@ -162,6 +169,12 @@ fun SubagentLane(
modifier = Modifier.weight(1f),
)
if (anyRunning && steerableId != null && onSteer != null) {
TextButton(onClick = { showSteerDialog = true }) {
Text("Redirect")
}
}
Text(
text = statusMeta,
style = relayMetadataStyle(),
@@ -202,4 +215,33 @@ fun SubagentLane(
}
}
}
if (showSteerDialog && steerableId != null && onSteer != null) {
AlertDialog(
onDismissRequest = { showSteerDialog = false },
title = { Text("Redirect subagent") },
text = {
OutlinedTextField(
value = steerText,
onValueChange = { steerText = it },
label = { Text("New instruction") },
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
val instruction = steerText.trim()
showSteerDialog = false
steerText = ""
onSteer(steerableId, instruction)
},
enabled = steerText.isNotBlank(),
) { Text("Redirect") }
},
dismissButton = {
TextButton(onClick = { showSteerDialog = false }) { Text("Cancel") }
},
)
}
}
@@ -231,6 +231,7 @@ import com.hermesandroid.relay.ui.components.ThinkingIndicatorStyle
import com.hermesandroid.relay.ui.components.ThinkingMatrixColor
import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern
import com.hermesandroid.relay.ui.components.SessionDrawerContent
import com.hermesandroid.relay.ui.components.ProfileSessionRow
import com.hermesandroid.relay.ui.components.ProfileDisplayManagerDialog
import com.hermesandroid.relay.ui.components.ProfileShelf
import com.hermesandroid.relay.ui.components.ProfileSwitcherSheet
@@ -743,6 +744,13 @@ fun ChatScreen(
val messages by chatViewModel.messages.collectAsState()
val messageReactionsSupported by chatViewModel.messageReactionsSupported.collectAsState()
val newestReactableMessageKeys = remember(messages) {
setOfNotNull(
messages.lastOrNull { it.role == MessageRole.USER }?.uiKey,
messages.lastOrNull { it.role == MessageRole.ASSISTANT }?.uiKey,
)
}
val isStreaming by chatViewModel.isStreaming.collectAsState()
// Keep the screen on for the two "actively engaged, hands-off-keyboard"
// cases: voice mode is a call-like continuous session (mirrors Assistant/
@@ -1128,6 +1136,8 @@ 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(
@@ -2106,6 +2116,57 @@ fun ChatScreen(
onToggleSourceHidden = { source, hidden ->
connectionViewModel.setSourceHidden(source, hidden)
},
allProfileSessions = allProfileSessions,
allProfileSessionsLoading = allProfileSessionsLoading,
onRefreshAllProfiles = {
if (!allProfileSessionsLoading) scope.launch {
allProfileSessionsLoading = true
val result = connectionViewModel.listAllProfileSessions()
result?.fold(
onSuccess = { items ->
allProfileSessions = items.mapNotNull { item ->
val owner = item.profile?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
ProfileSessionRow(
profile = owner,
session = com.hermesandroid.relay.data.ChatSession(
sessionId = item.id,
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
pinned = item.pinned,
archived = item.archived,
),
)
}
},
onFailure = { error ->
snackbarHostState.showSnackbar(
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
)
},
)
allProfileSessionsLoading = false
}
},
onSelectProfileSession = { profileName, sessionId ->
val target = agentProfiles.firstOrNull {
it.name.equals(profileName, ignoreCase = true)
}
if (target != null || profileName.equals("default", ignoreCase = true)) {
connectionViewModel.selectProfile(target)
chatViewModel.activateGatewayProfile(target)
chatViewModel.switchSession(sessionId)
scope.launch { drawerState.close() }
} else {
scope.launch {
snackbarHostState.showSnackbar("Profile $profileName is not available.")
}
}
},
)
}
) {
@@ -3023,6 +3084,33 @@ fun ChatScreen(
},
onCardAction = handleCardAction,
onCardInput = handleCardInput,
onSessionReference = { reference ->
val target = agentProfiles.firstOrNull {
it.name.equals(reference.profile, ignoreCase = true)
}
if (target != null) {
connectionViewModel.selectProfile(target)
chatViewModel.activateGatewayProfile(target)
chatViewModel.switchSession(reference.sessionId)
} else {
scope.launch {
snackbarHostState.showSnackbar(
message = "Profile ${reference.profile} is not available.",
duration = SnackbarDuration.Short,
)
}
}
},
onReact = if (
isGatewayTransport &&
messageReactionsSupported &&
!message.isStreaming &&
message.uiKey in newestReactableMessageKeys
) {
{ emoji -> chatViewModel.reactToNewest(message.role, emoji) }
} else {
null
},
onEditMessage = if (
isGatewayTransport &&
!isStreaming &&
@@ -3156,6 +3244,7 @@ fun ChatScreen(
SubagentLane(
taskIndex = taskIndex,
calls = laneGroups.getValue(taskIndex),
onSteer = chatViewModel::steerSubagent,
)
}
}
@@ -148,6 +148,10 @@ private enum class DashboardManagementSection(val path: String) {
Catalog("/api/mcp/catalog"),
CustomEndpoints("/api/providers/custom-endpoints"),
Profiles("/api/profiles"),
Memory("/api/memory"),
Learning("/api/learning/graph"),
Channels("/api/messaging/platforms"),
Operations("/api/status"),
Models("/api/model/info"),
Keys("/api/env"),
Config("/api/config/schema");
@@ -160,6 +164,10 @@ private enum class DashboardManagementSection(val path: String) {
Catalog -> stringResource(R.string.dashboard_tab_catalog)
CustomEndpoints -> stringResource(R.string.dashboard_tab_custom_endpoints)
Profiles -> stringResource(R.string.dashboard_tab_profiles)
Memory -> stringResource(R.string.dashboard_tab_memory)
Learning -> stringResource(R.string.dashboard_tab_learning)
Channels -> stringResource(R.string.dashboard_tab_channels)
Operations -> stringResource(R.string.dashboard_tab_operations)
Models -> stringResource(R.string.dashboard_tab_models)
Keys -> stringResource(R.string.dashboard_tab_keys)
Config -> stringResource(R.string.dashboard_tab_config)
@@ -174,6 +182,10 @@ private enum class DashboardManagementSection(val path: String) {
Catalog -> stringResource(R.string.dashboard_tab_catalog_lower)
CustomEndpoints -> stringResource(R.string.dashboard_tab_custom_endpoints_lower)
Profiles -> stringResource(R.string.dashboard_tab_profiles_lower)
Memory -> stringResource(R.string.dashboard_tab_memory_lower)
Learning -> stringResource(R.string.dashboard_tab_learning_lower)
Channels -> stringResource(R.string.dashboard_tab_channels_lower)
Operations -> stringResource(R.string.dashboard_tab_operations_lower)
Models -> stringResource(R.string.dashboard_tab_models_lower)
Keys -> stringResource(R.string.dashboard_tab_keys_lower)
Config -> stringResource(R.string.dashboard_tab_config_lower)
@@ -183,7 +195,15 @@ private enum class DashboardManagementSection(val path: String) {
private val managementSections: List<DashboardManagementSection> = DashboardManagementSection.entries
internal fun dashboardSectionRequestPath(path: String, profile: String?): String {
if (profile.isNullOrBlank() || path !in setOf("/api/mcp/servers", "/api/mcp/catalog")) return path
if (
profile.isNullOrBlank() ||
path !in setOf(
"/api/mcp/servers",
"/api/mcp/catalog",
"/api/providers/custom-endpoints",
"/api/learning/graph",
)
) return path
val encoded = java.net.URLEncoder.encode(profile, Charsets.UTF_8.name()).replace("+", "%20")
return "$path?profile=$encoded"
}
@@ -225,7 +245,11 @@ internal fun scopeDashboardManageItems(
profile: String?,
items: List<DashboardSummaryItem>,
): List<DashboardSummaryItem> =
if (sectionPath == "/api/mcp/servers" || sectionPath == "/api/mcp/catalog") {
if (
sectionPath == "/api/mcp/servers" ||
sectionPath == "/api/mcp/catalog" ||
sectionPath == "/api/providers/custom-endpoints"
) {
items.map { it.copy(profile = profile) }
} else {
items
@@ -350,6 +374,7 @@ private enum class DashboardSectionAction {
BrowseSkillsHub,
UpdateSkillsHub,
AddCustomEndpoint,
CreateServerBackup,
}
/** Editor session for a profile's SOUL.md — content is the FULL file from GET. */
@@ -754,7 +779,12 @@ fun DashboardManagementScreen(
}
}
fun submitCreateProfile(name: String, description: String, cloneFromDefault: Boolean) {
fun submitCreateProfile(
name: String,
description: String,
cloneFromDefault: Boolean,
mcpServers: List<String>,
) {
if (dashboardUrl.isBlank() || actionInFlight) return
val actionPayloadKey = payloadKey
actionInFlight = true
@@ -766,6 +796,7 @@ fun DashboardManagementScreen(
name = name,
cloneFromDefault = cloneFromDefault,
description = description.takeIf { it.isNotBlank() },
mcpServers = mcpServers,
)
}
} catch (e: Exception) {
@@ -785,6 +816,28 @@ fun DashboardManagementScreen(
}
}
fun runServerBackup() {
if (dashboardUrl.isBlank() || actionInFlight) return
actionInFlight = true
actionMessage = null
scope.launch {
val result = try {
withDashboardClient(clientFactory) { client -> client.createServerBackup() }
} catch (e: Exception) {
Result.failure(e)
}
actionMessage = result.fold(
onSuccess = { root ->
root.stringField("message")
?: root.stringField("filename")?.let { "Server backup ready: $it" }
?: "Server backup created."
},
onFailure = { error -> error.message ?: "Server backup failed." },
)
actionInFlight = false
}
}
LaunchedEffect(dashboardUrl, selectedTab, reloadNonce, activeConnection?.id, effectiveProfileName) {
val forceCurrent = forceReloadKey == payloadKey
loadDashboardSection(
@@ -950,6 +1003,7 @@ fun DashboardManagementScreen(
if (showCreateProfile) {
var newProfileName by remember { mutableStateOf("") }
var newProfileDescription by remember { mutableStateOf("") }
var newProfileMcpServers by remember { mutableStateOf("") }
var cloneFromDefault by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = { showCreateProfile = false },
@@ -969,6 +1023,13 @@ fun DashboardManagementScreen(
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.dashboard_description_optional)) },
)
OutlinedTextField(
value = newProfileMcpServers,
onValueChange = { newProfileMcpServers = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.dashboard_profile_mcp_servers_optional)) },
supportingText = { Text(stringResource(R.string.dashboard_profile_mcp_servers_help)) },
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -991,6 +1052,12 @@ fun DashboardManagementScreen(
name = newProfileName.trim(),
description = newProfileDescription.trim(),
cloneFromDefault = cloneFromDefault,
mcpServers = newProfileMcpServers
.split(',', '\n')
.map(String::trim)
.filter(String::isNotBlank)
.distinct()
.take(64),
)
},
enabled = newProfileName.isNotBlank() && !actionInFlight,
@@ -1091,6 +1158,7 @@ fun DashboardManagementScreen(
if (showCustomEndpointEditor) {
CustomEndpointDialog(
existing = customEndpointEditor,
profile = effectiveProfileName,
clientFactory = clientFactory,
onSaved = {
showCustomEndpointEditor = false
@@ -1325,6 +1393,8 @@ fun DashboardManagementScreen(
customEndpointEditor = null
showCustomEndpointEditor = true
}
DashboardSectionAction.CreateServerBackup ->
runServerBackup()
}
},
mcpOAuthSupported = mcpOAuthStartAllowed,
@@ -1377,6 +1447,26 @@ private fun manageTileSpec(section: DashboardManagementSection): ManageTileSpec
title = stringResource(R.string.dashboard_tile_profiles_title),
subtitle = stringResource(R.string.dashboard_tile_profiles_sub),
)
DashboardManagementSection.Memory -> ManageTileSpec(
icon = Icons.Filled.AutoAwesome,
title = stringResource(R.string.dashboard_tile_memory_title),
subtitle = stringResource(R.string.dashboard_tile_memory_sub),
)
DashboardManagementSection.Learning -> ManageTileSpec(
icon = Icons.Filled.AutoAwesome,
title = stringResource(R.string.dashboard_tile_learning_title),
subtitle = stringResource(R.string.dashboard_tile_learning_sub),
)
DashboardManagementSection.Channels -> ManageTileSpec(
icon = Icons.Filled.Link,
title = stringResource(R.string.dashboard_tile_channels_title),
subtitle = stringResource(R.string.dashboard_tile_channels_sub),
)
DashboardManagementSection.Operations -> ManageTileSpec(
icon = Icons.Filled.Tune,
title = stringResource(R.string.dashboard_tile_operations_title),
subtitle = stringResource(R.string.dashboard_tile_operations_sub),
)
DashboardManagementSection.Skills -> ManageTileSpec(
icon = Icons.Filled.AutoAwesome,
title = stringResource(R.string.dashboard_tile_skills_title),
@@ -2205,6 +2295,7 @@ private fun LoadedBody(
val actionLabelBrowseHub = stringResource(R.string.dashboard_section_action_browse_hub)
val actionLabelUpdateInstalled = stringResource(R.string.dashboard_section_action_update_installed)
val actionLabelAddEndpoint = stringResource(R.string.dashboard_custom_endpoint_add)
val actionLabelServerBackup = stringResource(R.string.dashboard_section_action_server_backup)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
@@ -2232,6 +2323,9 @@ private fun LoadedBody(
DashboardManagementSection.CustomEndpoints -> listOf(
DashboardSectionAction.AddCustomEndpoint to actionLabelAddEndpoint,
)
DashboardManagementSection.Operations -> listOf(
DashboardSectionAction.CreateServerBackup to actionLabelServerBackup,
)
else -> emptyList()
}
if (sectionActions.isNotEmpty()) {
@@ -3479,6 +3573,7 @@ private fun McpOAuthDialog(
@Composable
private fun CustomEndpointDialog(
existing: DashboardSummaryItem?,
profile: String?,
clientFactory: () -> DashboardApiClient,
onSaved: () -> Unit,
onDismiss: () -> Unit,
@@ -3586,7 +3681,9 @@ private fun CustomEndpointDialog(
onClick = {
busy = true
scope.launch {
val result = withDashboardClient(clientFactory) { it.saveCustomEndpoint(draft()) }
val result = withDashboardClient(clientFactory) {
it.saveCustomEndpoint(draft(), profile = profile)
}
result.fold(onSuccess = { onSaved() }, onFailure = {
busy = false
message = it.message ?: context.getString(R.string.dashboard_custom_endpoint_save_failed)
@@ -3696,6 +3793,16 @@ private fun summarize(
?.map { (name, value) -> summarizeObjectItem(value, name) }
?: listOf(summarizeObjectItem(root, "Profile"))
}
DashboardManagementSection.Memory -> summarizeKeyValueOrList(root, "Memory")
DashboardManagementSection.Learning ->
root.arrayItems("nodes", "items")
?.mapIndexed { index, item -> summarizeObjectItem(item, "Node ${index + 1}") }
?: summarizeKeyValueOrList(root, "Learning")
DashboardManagementSection.Channels ->
root.arrayItems("platforms", "channels", "items")
?.mapIndexed { index, item -> summarizeObjectItem(item, "Channel ${index + 1}") }
?: summarizeKeyValueOrList(root, "Channel")
DashboardManagementSection.Operations -> summarizeKeyValueOrList(root, "Status")
DashboardManagementSection.Models -> summarizeKeyValueOrList(root, "Model")
DashboardManagementSection.Keys -> summarizeEnvVars(root)
DashboardManagementSection.Config -> summarizeKeyValueOrList(root, "Config")
@@ -3736,7 +3843,7 @@ private fun summarizeEnvVars(root: JsonElement): List<DashboardSummaryItem> {
}.sortedWith(compareByDescending<DashboardSummaryItem> { it.meta?.startsWith("set") == true }.thenBy { it.title })
}
private fun summarizeObjectItem(
internal fun summarizeObjectItem(
element: JsonElement,
fallbackTitle: String,
): DashboardSummaryItem {
@@ -3768,6 +3875,9 @@ private fun summarizeObjectItem(
obj.booleanField("installed")?.let { if (it) "installed" else "not installed" },
obj.booleanField("needs_install")?.let { if (it) "bootstrap install" else null },
status,
obj.stringField("last_status")?.let { "last: $it" },
obj.stringField("last_error")?.let { "error: $it" },
obj.stringField("last_delivery_error")?.let { "delivery: $it" },
obj.stringField("provider"),
obj.stringField("transport"),
obj.stringField("auth_type"),
@@ -3801,6 +3911,7 @@ internal fun dashboardActionsFor(obj: JsonObject): List<DashboardItemAction> {
val hasSkillUsage = obj["usage"] != null || obj.stringField("category") != null
val enabled = obj.booleanField("enabled")
val paused = obj.booleanField("paused")
val completedCron = obj.stringField("state").equals("completed", ignoreCase = true)
return when {
isCatalogEntry -> buildList {
@@ -3810,12 +3921,14 @@ internal fun dashboardActionsFor(obj: JsonObject): List<DashboardItemAction> {
}
hasSchedule -> buildList {
add(DashboardItemAction("Runs", DashboardActionKind.ViewCronRuns))
if (paused == true) {
add(DashboardItemAction("Resume", DashboardActionKind.ResumeCron))
} else {
add(DashboardItemAction("Pause", DashboardActionKind.PauseCron))
if (!completedCron) {
if (paused == true) {
add(DashboardItemAction("Resume", DashboardActionKind.ResumeCron))
} else {
add(DashboardItemAction("Pause", DashboardActionKind.PauseCron))
}
add(DashboardItemAction("Run now", DashboardActionKind.TriggerCron))
}
add(DashboardItemAction("Run now", DashboardActionKind.TriggerCron))
add(DashboardItemAction("Delete", DashboardActionKind.DeleteCron, destructive = true))
}
hasTransport -> buildList {
@@ -4107,9 +4220,10 @@ private suspend fun DashboardApiClient.runDashboardAction(
DashboardActionKind.ViewProfileSoul -> getProfileSoul(id)
DashboardActionKind.ActivateProfile -> setActiveProfile(id)
DashboardActionKind.DeleteProfile -> deleteProfile(id)
DashboardActionKind.ActivateCustomEndpoint -> activateCustomEndpoint(id)
DashboardActionKind.ActivateCustomEndpoint ->
activateCustomEndpoint(id, profile = item.profile)
DashboardActionKind.DeleteCustomEndpoint ->
deleteCustomEndpoint(id).map { JsonObject(emptyMap()) }
deleteCustomEndpoint(id, profile = item.profile).map { JsonObject(emptyMap()) }
DashboardActionKind.RevealEnvKey -> revealEnvVar(id)
DashboardActionKind.ClearEnvKey -> deleteEnvVar(id)
// Input-backed kinds are intercepted at the onAction layer and routed
@@ -572,6 +572,18 @@ private fun DeviceCard(
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
val deviceDetail = listOf(device.deviceModel, device.devicePlatform)
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
.joinToString(" · ")
if (deviceDetail.isNotBlank()) {
Text(
text = deviceDetail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (device.deviceId.isNotBlank()) {
Text(
text = device.deviceId,
@@ -134,6 +134,7 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import okhttp3.sse.EventSource
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@@ -1229,10 +1230,16 @@ class ChatViewModel : ViewModel() {
// Pill update is the confirmation — no system bubble.
// Surface only a server warning, ephemerally.
warning?.let { _transientNotice.tryEmit(it) }
gateway.modelOptions().onSuccess {
_modelProviders.value = it.providers
_gatewayCurrentModel.value = it.currentModel
_gatewayCurrentProvider.value = it.currentProvider
// Current upstream may defer the switch until the turn
// boundary. Keep the optimistic requested identity; an
// immediate model.options read still reports the old
// model and would visibly snap the picker backward.
if (!isDeferredConfigResult(result)) {
gateway.modelOptions().onSuccess {
_modelProviders.value = it.providers
_gatewayCurrentModel.value = it.currentModel
_gatewayCurrentProvider.value = it.currentProvider
}
}
},
onFailure = { e ->
@@ -1616,6 +1623,9 @@ class ChatViewModel : ViewModel() {
val backgroundProcessesLoading: StateFlow<Boolean> = gatewayProcessController.loading
val stoppingProcessIds: StateFlow<Set<String>> = gatewayProcessController.stoppingProcessIds
private val _messageReactionsSupported = MutableStateFlow(true)
val messageReactionsSupported: StateFlow<Boolean> = _messageReactionsSupported.asStateFlow()
fun refreshBackgroundProcesses() {
gatewayProcessController.refresh()
}
@@ -1642,6 +1652,7 @@ class ChatViewModel : ViewModel() {
gatewayClient = client
if (changed) {
resetApprovalModeState()
_messageReactionsSupported.value = true
gatewayProcessSource = client?.let(::GatewayChatProcessSource)
gatewayProcessController.bind(
newSource = gatewayProcessSource,
@@ -2311,6 +2322,45 @@ class ChatViewModel : ViewModel() {
private val _transientNotice = MutableSharedFlow<String>(extraBufferCapacity = 8)
val transientNotice: SharedFlow<String> = _transientNotice.asSharedFlow()
fun reactToNewest(role: MessageRole, emoji: String?) {
val gateway = gatewayClient ?: return
if (role != MessageRole.USER && role != MessageRole.ASSISTANT) return
viewModelScope.launch {
gateway.reactToNewest(role.name.lowercase(), emoji).fold(
onSuccess = {
_transientNotice.tryEmit(if (emoji == null) "Reaction removed." else "Reaction added.")
},
onFailure = { error ->
if ((error as? GatewayRpcException)?.code == -32601) {
_messageReactionsSupported.value = false
_transientNotice.tryEmit("Message reactions aren't supported by this gateway.")
} else {
_transientNotice.tryEmit("Couldn't update reaction: ${error.message ?: "unknown error"}")
}
},
)
}
}
fun steerSubagent(subagentId: String, instruction: String) {
val gateway = gatewayClient ?: return
if (subagentId.isBlank() || instruction.isBlank()) return
viewModelScope.launch {
gateway.steerSubagent(subagentId, instruction).fold(
onSuccess = { result ->
val status = result.stringValue("status") ?: "queued"
_transientNotice.tryEmit(
if (status == "rejected") "Subagent redirect was rejected."
else "Subagent redirect queued.",
)
},
onFailure = { error ->
_transientNotice.tryEmit("Couldn't redirect subagent: ${error.message ?: "unknown error"}")
},
)
}
}
/**
* Composer prefill requests from server command dispatch
* (`{type:"prefill"}` — e.g. `/undo`). ChatScreen collects and sets the
@@ -8626,6 +8676,19 @@ internal fun parseCommandsCatalog(catalog: JsonObject): List<SlashCommand> {
}
val seen = mutableSetOf<String>()
val out = mutableListOf<SlashCommand>()
val skillMetadata = (catalog["skills"] as? JsonObject)
?.entries
?.take(MAX_CATALOG_SKILLS)
?.associate { (rawName, element) ->
val normalized = if (rawName.startsWith("/")) rawName.lowercase() else "/${rawName.lowercase()}"
val metadata = element as? JsonObject
val usage = (metadata?.get("usage") as? JsonPrimitive)?.intOrNull
?.coerceIn(0, MAX_SKILL_USAGE) ?: 0
val origin = metadata?.stringValue("origin")
?.trim()?.take(MAX_SKILL_ORIGIN_CHARS)?.takeIf { it.isNotBlank() }
normalized to (usage to origin)
}
.orEmpty()
(catalog["pairs"] as? JsonArray)?.forEach { pairElement ->
val pair = pairElement as? JsonArray ?: return@forEach
val rawName = (pair.getOrNull(0) as? JsonPrimitive)?.contentOrNull?.trim()
@@ -8634,16 +8697,35 @@ internal fun parseCommandsCatalog(catalog: JsonObject): List<SlashCommand> {
if (isUnsupportedMobileCommand(name, pair)) return@forEach
if (!seen.add(name.lowercase())) return@forEach
val description = (pair.getOrNull(1) as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
val skill = skillMetadata[name.lowercase()]
out += SlashCommand(
command = name,
description = description.ifBlank { "Server command" },
category = categoryByName[name.lowercase()] ?: SlashCommand.CATEGORY_SERVER,
source = SlashCommand.SOURCE_SERVER,
usageRank = skill?.first ?: 0,
origin = skill?.second,
)
}
return out
// Preserve every non-skill command's exact catalog position. Rank only
// entries occupying skill slots, so built-ins and quick commands do not
// jump around when usage changes.
val rankedSkills = out.filter { skillMetadata.containsKey(it.command.lowercase()) }
.sortedWith(compareByDescending<SlashCommand> { it.usageRank }.thenBy { it.command })
.iterator()
return out.map { command ->
if (skillMetadata.containsKey(command.command.lowercase())) rankedSkills.next() else command
}
}
private const val MAX_CATALOG_SKILLS = 512
private const val MAX_SKILL_USAGE = 1_000_000_000
private const val MAX_SKILL_ORIGIN_CHARS = 32
internal fun isDeferredConfigResult(result: JsonObject): Boolean =
(result["deferred"] as? JsonPrimitive)?.contentOrNull
?.trim()?.lowercase() in setOf("true", "1", "yes")
/** Dashboard `/api/config` response -> the same personality catalog as API mode. */
internal fun parseDashboardPersonalityConfig(
root: JsonObject,
@@ -1493,6 +1493,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
suspend fun listProfileScopedSessions(limit: Int = 200): Result<List<SessionItem>>? =
profileController.listProfileScopedSessions(limit)
suspend fun listAllProfileSessions(limit: Int = 200): Result<List<SessionItem>>? =
profileController.listAllProfileSessions(limit)
suspend fun loadProfileScopedMessages(
sessionId: String,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
@@ -344,6 +344,12 @@ class ProfileController(
.listSessions(profile = profileName, limit = limit, archived = "include")
}
suspend fun listAllProfileSessions(limit: Int = 200): Result<List<SessionItem>>? {
val connectionId = activeConnectionId.value ?: return null
val dashboardUrl = activeDashboardUrlProvider() ?: return null
return dashboardClientFactory(connectionId, dashboardUrl).listAllProfileSessions(limit)
}
/**
* A session's transcript, scoped to the active profile via the dashboard
* `/api/sessions/{id}/messages?profile=`. Returns `null` off the dashboard
@@ -3825,4 +3825,26 @@
<string name="pet_creator_step_review">Revisar e importar</string>
<string name="pet_creator_review_desc">Inspecione primeiro a imagem ou o ZIP retornado. A importação copia os arquivos validados para o armazenamento do app; ela não os publica.</string>
<string name="pet_creator_import">Importar pet concluído</string>
<string name="dashboard_tab_memory">Memória</string>
<string name="dashboard_tab_learning">Aprendizado</string>
<string name="dashboard_tab_channels">Canais</string>
<string name="dashboard_tab_operations">Operações</string>
<string name="dashboard_tab_memory_lower">memória</string>
<string name="dashboard_tab_learning_lower">aprendizado</string>
<string name="dashboard_tab_channels_lower">canais</string>
<string name="dashboard_tab_operations_lower">operações</string>
<string name="dashboard_section_action_server_backup">Criar backup do servidor</string>
<string name="dashboard_profile_mcp_servers_optional">Servidores MCP (opcional)</string>
<string name="dashboard_profile_mcp_servers_help">Nomes de servidor separados por vírgulas ou linhas. As credenciais permanecem no servidor.</string>
<string name="dashboard_tile_memory_title">Memória</string>
<string name="dashboard_tile_memory_sub">Status do provedor e configuração de memória do servidor</string>
<string name="dashboard_tile_learning_title">Grafo de aprendizado</string>
<string name="dashboard_tile_learning_sub">Inspecione os nós aprendidos do perfil ativo</string>
<string name="dashboard_tile_channels_title">Canais</string>
<string name="dashboard_tile_channels_sub">Status das plataformas de mensagens, incluindo WhatsApp</string>
<string name="dashboard_tile_operations_title">Operações do servidor</string>
<string name="dashboard_tile_operations_sub">Integridade do host e backup geral do servidor</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_close">Fechar</string>
</resources>
@@ -3913,4 +3913,26 @@
<string name="pet_creator_step_review">检查并导入</string>
<string name="pet_creator_review_desc">请先检查返回的图片或 ZIP。导入会将经过验证的文件复制到应用专属存储空间,不会发布它们。</string>
<string name="pet_creator_import">导入完成的宠物</string>
<string name="dashboard_tab_memory">记忆</string>
<string name="dashboard_tab_learning">学习</string>
<string name="dashboard_tab_channels">频道</string>
<string name="dashboard_tab_operations">运维</string>
<string name="dashboard_tab_memory_lower">记忆</string>
<string name="dashboard_tab_learning_lower">学习</string>
<string name="dashboard_tab_channels_lower">频道</string>
<string name="dashboard_tab_operations_lower">运维</string>
<string name="dashboard_section_action_server_backup">创建服务器备份</string>
<string name="dashboard_profile_mcp_servers_optional">MCP 服务器(可选)</string>
<string name="dashboard_profile_mcp_servers_help">用逗号或换行分隔服务器名称。凭据仍由服务器保管。</string>
<string name="dashboard_tile_memory_title">记忆</string>
<string name="dashboard_tile_memory_sub">提供商状态和服务器端记忆配置</string>
<string name="dashboard_tile_learning_title">学习图谱</string>
<string name="dashboard_tile_learning_sub">查看当前配置文件的已学习节点</string>
<string name="dashboard_tile_channels_title">频道</string>
<string name="dashboard_tile_channels_sub">消息平台状态,包括 WhatsApp</string>
<string name="dashboard_tile_operations_title">服务器运维</string>
<string name="dashboard_tile_operations_sub">主机健康状况和服务器整体备份</string>
<string name="drawer_all_profiles">所有配置文件</string>
<string name="drawer_no_profile_sessions">没有匹配的配置文件会话。</string>
<string name="drawer_close">关闭</string>
</resources>
+22
View File
@@ -3985,4 +3985,26 @@
<string name="pet_creator_step_review">Prüfen und importieren</string>
<string name="pet_creator_review_desc">Prüfen Sie zuerst das zurückgegebene Bild oder ZIP. Beim Import werden die validierten Dateien in den App-Speicher kopiert; sie werden nicht veröffentlicht.</string>
<string name="pet_creator_import">Fertiges Pet importieren</string>
<string name="dashboard_tab_memory">Speicher</string>
<string name="dashboard_tab_learning">Lernen</string>
<string name="dashboard_tab_channels">Kanäle</string>
<string name="dashboard_tab_operations">Betrieb</string>
<string name="dashboard_tab_memory_lower">Speicher</string>
<string name="dashboard_tab_learning_lower">Lernen</string>
<string name="dashboard_tab_channels_lower">Kanäle</string>
<string name="dashboard_tab_operations_lower">Betrieb</string>
<string name="dashboard_section_action_server_backup">Server-Sicherung erstellen</string>
<string name="dashboard_profile_mcp_servers_optional">MCP-Server (optional)</string>
<string name="dashboard_profile_mcp_servers_help">Komma- oder zeilengetrennte Servernamen. Anmeldedaten verbleiben auf dem Server.</string>
<string name="dashboard_tile_memory_title">Speicher</string>
<string name="dashboard_tile_memory_sub">Anbieterstatus und servereigene Speicherkonfiguration</string>
<string name="dashboard_tile_learning_title">Lerngraph</string>
<string name="dashboard_tile_learning_sub">Gelernte Knoten des aktiven Profils prüfen</string>
<string name="dashboard_tile_channels_title">Kanäle</string>
<string name="dashboard_tile_channels_sub">Status der Nachrichtenplattformen einschließlich WhatsApp</string>
<string name="dashboard_tile_operations_title">Serverbetrieb</string>
<string name="dashboard_tile_operations_sub">Hostzustand und serverweite Sicherung</string>
<string name="drawer_all_profiles">Alle Profile</string>
<string name="drawer_no_profile_sessions">Keine passenden Profilsitzungen.</string>
<string name="drawer_close">Schließen</string>
</resources>
+22
View File
@@ -3670,4 +3670,26 @@
<string name="pet_creator_step_review">Revisar e importar</string>
<string name="pet_creator_review_desc">Inspecciona primero la imagen o el ZIP recibido. Al importar se copian los archivos validados al almacenamiento de la aplicación; no se publican.</string>
<string name="pet_creator_import">Importar mascota terminada</string>
<string name="dashboard_tab_memory">Memoria</string>
<string name="dashboard_tab_learning">Aprendizaje</string>
<string name="dashboard_tab_channels">Canales</string>
<string name="dashboard_tab_operations">Operaciones</string>
<string name="dashboard_tab_memory_lower">memoria</string>
<string name="dashboard_tab_learning_lower">aprendizaje</string>
<string name="dashboard_tab_channels_lower">canales</string>
<string name="dashboard_tab_operations_lower">operaciones</string>
<string name="dashboard_section_action_server_backup">Crear copia del servidor</string>
<string name="dashboard_profile_mcp_servers_optional">Servidores MCP (opcional)</string>
<string name="dashboard_profile_mcp_servers_help">Nombres de servidor separados por comas o líneas. Las credenciales permanecen en el servidor.</string>
<string name="dashboard_tile_memory_title">Memoria</string>
<string name="dashboard_tile_memory_sub">Estado del proveedor y configuración de memoria del servidor</string>
<string name="dashboard_tile_learning_title">Grafo de aprendizaje</string>
<string name="dashboard_tile_learning_sub">Inspecciona los nodos aprendidos del perfil activo</string>
<string name="dashboard_tile_channels_title">Canales</string>
<string name="dashboard_tile_channels_sub">Estado de plataformas de mensajería, incluido WhatsApp</string>
<string name="dashboard_tile_operations_title">Operaciones del servidor</string>
<string name="dashboard_tile_operations_sub">Estado del host y copia de seguridad del servidor</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_close">Cerrar</string>
</resources>
+22
View File
@@ -3984,4 +3984,26 @@
<string name="pet_creator_step_review">確認してインポート</string>
<string name="pet_creator_review_desc">返された画像またはZIPを最初に確認してください。インポートすると検証済みファイルがアプリ専用ストレージにコピーされますが、公開はされません。</string>
<string name="pet_creator_import">完成したペットをインポート</string>
<string name="dashboard_tab_memory">メモリ</string>
<string name="dashboard_tab_learning">学習</string>
<string name="dashboard_tab_channels">チャンネル</string>
<string name="dashboard_tab_operations">運用</string>
<string name="dashboard_tab_memory_lower">メモリ</string>
<string name="dashboard_tab_learning_lower">学習</string>
<string name="dashboard_tab_channels_lower">チャンネル</string>
<string name="dashboard_tab_operations_lower">運用</string>
<string name="dashboard_section_action_server_backup">サーバーバックアップを作成</string>
<string name="dashboard_profile_mcp_servers_optional">MCP サーバー(任意)</string>
<string name="dashboard_profile_mcp_servers_help">サーバー名をカンマまたは改行で区切ります。認証情報はサーバー側に保持されます。</string>
<string name="dashboard_tile_memory_title">メモリ</string>
<string name="dashboard_tile_memory_sub">プロバイダーの状態とサーバー側のメモリ設定</string>
<string name="dashboard_tile_learning_title">学習グラフ</string>
<string name="dashboard_tile_learning_sub">アクティブなプロファイルの学習済みノードを確認</string>
<string name="dashboard_tile_channels_title">チャンネル</string>
<string name="dashboard_tile_channels_sub">WhatsApp を含むメッセージプラットフォームの状態</string>
<string name="dashboard_tile_operations_title">サーバー運用</string>
<string name="dashboard_tile_operations_sub">ホストの状態とサーバー全体のバックアップ</string>
<string name="drawer_all_profiles">すべてのプロファイル</string>
<string name="drawer_no_profile_sessions">一致するプロファイルセッションはありません。</string>
<string name="drawer_close">閉じる</string>
</resources>
+22
View File
@@ -3706,4 +3706,26 @@
<string name="pet_creator_step_review">Проверить и импортировать</string>
<string name="pet_creator_review_desc">Сначала проверьте полученное изображение или ZIP. При импорте проверенные файлы копируются в хранилище приложения и не публикуются.</string>
<string name="pet_creator_import">Импортировать готового питомца</string>
<string name="dashboard_tab_memory">Память</string>
<string name="dashboard_tab_learning">Обучение</string>
<string name="dashboard_tab_channels">Каналы</string>
<string name="dashboard_tab_operations">Операции</string>
<string name="dashboard_tab_memory_lower">память</string>
<string name="dashboard_tab_learning_lower">обучение</string>
<string name="dashboard_tab_channels_lower">каналы</string>
<string name="dashboard_tab_operations_lower">операции</string>
<string name="dashboard_section_action_server_backup">Создать резервную копию сервера</string>
<string name="dashboard_profile_mcp_servers_optional">Серверы MCP (необязательно)</string>
<string name="dashboard_profile_mcp_servers_help">Имена серверов через запятую или с новой строки. Учётные данные остаются на сервере.</string>
<string name="dashboard_tile_memory_title">Память</string>
<string name="dashboard_tile_memory_sub">Состояние провайдера и серверная настройка памяти</string>
<string name="dashboard_tile_learning_title">Граф обучения</string>
<string name="dashboard_tile_learning_sub">Просмотр изученных узлов активного профиля</string>
<string name="dashboard_tile_channels_title">Каналы</string>
<string name="dashboard_tile_channels_sub">Состояние платформ сообщений, включая WhatsApp</string>
<string name="dashboard_tile_operations_title">Операции сервера</string>
<string name="dashboard_tile_operations_sub">Состояние хоста и резервная копия всего сервера</string>
<string name="drawer_all_profiles">Все профили</string>
<string name="drawer_no_profile_sessions">Нет подходящих сеансов профиля.</string>
<string name="drawer_close">Закрыть</string>
</resources>
+22
View File
@@ -1862,6 +1862,10 @@
<string name="dashboard_tab_models">Models</string>
<string name="dashboard_tab_keys">Keys</string>
<string name="dashboard_tab_config">Config</string>
<string name="dashboard_tab_memory">Memory</string>
<string name="dashboard_tab_learning">Learning</string>
<string name="dashboard_tab_channels">Channels</string>
<string name="dashboard_tab_operations">Operations</string>
<!-- Lowercase tab labels for compact UI surfaces (KPI strip) -->
<string name="dashboard_tab_skills_lower">skills</string>
<string name="dashboard_tab_cron_lower">cron</string>
@@ -1872,6 +1876,10 @@
<string name="dashboard_tab_models_lower">models</string>
<string name="dashboard_tab_keys_lower">keys</string>
<string name="dashboard_tab_config_lower">config</string>
<string name="dashboard_tab_memory_lower">memory</string>
<string name="dashboard_tab_learning_lower">learning</string>
<string name="dashboard_tab_channels_lower">channels</string>
<string name="dashboard_tab_operations_lower">operations</string>
<!-- Tile titles + subtitles -->
<string name="dashboard_tile_profiles_title">Profiles</string>
@@ -1968,6 +1976,17 @@
<string name="dashboard_section_action_new_profile">New profile</string>
<string name="dashboard_section_action_browse_hub">Browse hub</string>
<string name="dashboard_section_action_update_installed">Update installed</string>
<string name="dashboard_section_action_server_backup">Create server backup</string>
<string name="dashboard_profile_mcp_servers_optional">MCP servers (optional)</string>
<string name="dashboard_profile_mcp_servers_help">Comma or line-separated server names. Credentials remain server-owned.</string>
<string name="dashboard_tile_memory_title">Memory</string>
<string name="dashboard_tile_memory_sub">Provider status and host-owned memory configuration</string>
<string name="dashboard_tile_learning_title">Learning graph</string>
<string name="dashboard_tile_learning_sub">Inspect learned nodes for the active profile</string>
<string name="dashboard_tile_channels_title">Channels</string>
<string name="dashboard_tile_channels_sub">Messaging platform status, including WhatsApp</string>
<string name="dashboard_tile_operations_title">Server operations</string>
<string name="dashboard_tile_operations_sub">Host health and server-wide backup</string>
<!-- Action labels (rendered via kind → string) -->
<string name="dashboard_action_set">Set</string>
@@ -3991,4 +4010,7 @@
<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_no_profile_sessions">No matching profile sessions.</string>
<string name="drawer_close">Close</string>
</resources>
@@ -0,0 +1,19 @@
package com.hermesandroid.relay.network.shared
import org.junit.Assert.assertEquals
import org.junit.Test
class ReconnectBackoffTest {
@Test
fun `full jitter stays within the cap`() {
assertEquals(0L, fullJitterDelayMs(1_000L, 0.0))
assertEquals(500L, fullJitterDelayMs(1_000L, 0.5))
assertEquals(1_000L, fullJitterDelayMs(1_000L, 1.0))
}
@Test
fun `non-positive caps are immediate`() {
assertEquals(0L, fullJitterDelayMs(0L, 0.5))
assertEquals(0L, fullJitterDelayMs(-1L, 0.5))
}
}
@@ -144,6 +144,43 @@ class ChatHandlerTest {
assertEquals("ABC", messages[0].content)
}
@Test
fun mediaMarkers_acceptUpstreamWrappersPunctuationAdjacentAndWindowsPaths() {
val paths = mutableListOf<String>()
handler.onMediaBarePathRequested = { _, path -> paths += path }
handler.onTextDelta("assist-1", "`MEDIA:/tmp/report.csv.`\n")
handler.onTextDelta(
"assist-1",
"**MEDIA:C:\\exports\\shot.png**, MEDIA:/tmp/other report.pdf\n",
)
assertEquals(
listOf(
"/tmp/report.csv",
"C:\\exports\\shot.png",
"/tmp/other report.pdf",
),
paths,
)
assertEquals("", handler.messages.value.single().content)
}
@Test
fun mediaMarkers_preserveFencedAndProseExamples() {
val paths = mutableListOf<String>()
handler.onMediaBarePathRequested = { _, path -> paths += path }
handler.onTextDelta(
"assist-1",
"```text\nMEDIA:/tmp/example.png\n```\nExample: `MEDIA:/tmp/example.pdf`\n",
)
assertTrue(paths.isEmpty())
assertTrue(handler.messages.value.single().content.contains("MEDIA:/tmp/example.png"))
assertTrue(handler.messages.value.single().content.contains("MEDIA:/tmp/example.pdf"))
}
@Test
fun onTextDelta_setsStreamingFlag() {
handler.onTextDelta("assist-1", "delta")
@@ -690,24 +690,30 @@ class DashboardApiClientTest {
apiKey = "never-persist-this",
)
val listed = client.getCustomEndpoints().getOrThrow()
client.saveCustomEndpoint(draft).getOrThrow()
val listed = client.getCustomEndpoints("work profile").getOrThrow()
client.saveCustomEndpoint(draft, "work profile").getOrThrow()
val validation = client.validateCustomEndpoint(draft).getOrThrow()
client.activateCustomEndpoint("local").getOrThrow()
client.deleteCustomEndpoint("local").getOrThrow()
client.activateCustomEndpoint("local", "work profile").getOrThrow()
client.deleteCustomEndpoint("local", "work profile").getOrThrow()
assertEquals("local", listed.currentProvider)
assertTrue(listed.endpoints.single().hasApiKey)
assertEquals(listOf("qwen"), validation.models)
assertEquals("/api/providers/custom-endpoints", server.takeRequest().path)
assertEquals("/api/providers/custom-endpoints?profile=work%20profile", server.takeRequest().path)
val save = server.takeRequest()
assertEquals("/api/providers/custom-endpoints", save.path)
assertEquals("/api/providers/custom-endpoints?profile=work%20profile", save.path)
val saveBody = save.body.readUtf8()
assertTrue(saveBody.contains("never-persist-this"))
assertTrue(saveBody.contains(""""models":["qwen","qwen-vl"]"""))
assertEquals("/api/providers/custom-endpoints/validate", server.takeRequest().path)
assertEquals("/api/providers/custom-endpoints/local/activate", server.takeRequest().path)
assertEquals("/api/providers/custom-endpoints/local", server.takeRequest().path)
assertEquals(
"/api/providers/custom-endpoints/local/activate?profile=work%20profile",
server.takeRequest().path,
)
assertEquals(
"/api/providers/custom-endpoints/local?profile=work%20profile",
server.takeRequest().path,
)
}
@Test
@@ -733,6 +739,30 @@ class DashboardApiClientTest {
assertEquals("/api/profiles/old%20profile", delete.path)
}
@Test
fun profileCreationCarriesMcpServersAndServerBackupIsDistinct() = runTest {
repeat(2) {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok":true}"""),
)
}
val client = DashboardApiClient(baseUrl = server.url("/").toString())
client.createProfile(
name = "research",
description = "Deep work",
mcpServers = listOf("github", "memory"),
).getOrThrow()
client.createServerBackup().getOrThrow()
val create = server.takeRequest()
assertEquals("/api/profiles", create.path)
assertTrue(create.body.readUtf8().contains(""""mcp_servers":["github","memory"]"""))
val backup = server.takeRequest()
assertEquals("POST", backup.method)
assertEquals("/api/ops/backup", backup.path)
}
@Test
fun getActiveProfileScope_distinguishesStickyDefaultFromDashboardProcess() = runTest {
server.enqueue(
@@ -834,6 +864,28 @@ class DashboardApiClientTest {
assertEquals("Review title fallbacks", sessions[1].preview)
}
@Test
fun listAllProfileSessions_preservesOwnerAndCompositeIdentity() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
"""{"sessions":[{"id":"same","title":"A","profile":"default"},{"id":"same","title":"B","profile":"work"},{"id":"unsafe"}],"total":3}""",
),
)
val sessions = DashboardApiClient(baseUrl = server.url("/").toString())
.listAllProfileSessions()
.getOrThrow()
val url = server.takeRequest().requestUrl!!
assertEquals("/api/profiles/sessions", url.encodedPath)
assertEquals("all", url.queryParameter("profile"))
assertEquals("include", url.queryParameter("archived"))
assertEquals(listOf("default", "work"), sessions.map { it.profile })
assertEquals(listOf("A", "B"), sessions.map { it.title })
}
@Test
fun listSessions_pagesAtUpstreamMaximumWhilePreservingTwoHundredRowWindow() = runTest {
val firstPage = (0 until 100).joinToString(",") { "{\"id\":\"sess-$it\"}" }
@@ -251,6 +251,8 @@ class GatewayClientHarness(
put("status", steerStatus)
put("text", (params["text"] as? JsonPrimitive)?.contentOrNull ?: "")
}
"subagent.steer" -> buildJsonObject { put("status", steerStatus) }
"message.react" -> buildJsonObject { put("row_id", 17) }
"session.compress" -> compressPayload
"slash.exec" -> buildJsonObject {
put("output", "legacy compression started")
@@ -677,6 +679,55 @@ class GatewayChatClientTest {
assertTrue(r.preflightFailures.isEmpty())
}
@Test
fun `global reclaim settles active turn once and next send resumes durable session`() {
val first = Recorder()
client.sendTurn(
sessionId = null,
text = "first",
newSessionTitle = "first",
callbacks = first.callbacks,
onPreflightFailure = { first.preflightFailures += it },
)
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val reclaim = harness.eventFrame(
"session.reclaimed",
buildJsonObject {
put("session_id", "live-1")
put("stored_session_id", "20260612_120000_abc123")
put("reason", "idle_timeout")
},
null,
)
serverWs.send(reclaim)
serverWs.send(reclaim)
assertTrue("reclaimed turn never settled", first.completeLatch.await(5, TimeUnit.SECONDS))
waitUntil { first.errors.size == 1 }
val second = Recorder()
client.sendTurn(
sessionId = "20260612_120000_abc123",
text = "continue",
newSessionTitle = null,
callbacks = second.callbacks,
onPreflightFailure = { second.preflightFailures += it },
)
harness.awaitRpc("session.resume")
harness.awaitRpcCount("prompt.submit", 2)
serverWs.send(
harness.eventFrame(
"message.complete",
buildJsonObject { put("text", "resumed") },
"live-resumed",
),
)
assertTrue("resumed turn never completed", second.completeLatch.await(5, TimeUnit.SECONDS))
assertTrue(second.errors.isEmpty())
}
@Test
fun `unsolicited assistant turn for resumed session streams without sendTurn`() = runBlocking {
val r = Recorder()
@@ -1303,6 +1354,22 @@ class GatewayChatClientTest {
assertTrue(harness.rpcLog.none { it.first == "session.steer" })
}
@Test
fun `subagent redirect carries exact parent and child identity`() = runBlocking {
val recorder = Recorder()
client.sendTurn(null, "delegate", null, recorder.callbacks) { recorder.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val result = client.steerSubagent("child-17", "focus on Android")
assertEquals("queued", (result.getOrThrow()["status"] as? JsonPrimitive)?.contentOrNull)
val params = harness.awaitRpc("subagent.steer")
assertEquals("live-1", (params["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("child-17", (params["subagent_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("focus on Android", (params["text"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `compress session uses dedicated rpc and parses authoritative messages`() {
val r = Recorder()
@@ -1753,6 +1820,23 @@ class GatewayChatClientTest {
// --- Pet thumbnails ---
@Test
fun `message reaction targets newest role without guessing a row id`() {
client.sendTurn("stored-1", "hi", null, Recorder().callbacks) {}
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
val result = runBlocking { client.reactToNewest("assistant", "👍") }
assertTrue(result.isSuccess)
val params = harness.awaitRpc("message.react")
assertEquals("live-resumed", (params["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("assistant", (params["newest_role"] as? JsonPrimitive)?.contentOrNull)
assertEquals("👍", (params["emoji"] as? JsonPrimitive)?.contentOrNull)
assertEquals("user", (params["author"] as? JsonPrimitive)?.contentOrNull)
assertFalse("row_id" in params)
}
@Test
fun `pet thumbnail connects on demand and sends upstream params`() {
client.sessionProfileProvider = { "work" }
@@ -483,6 +483,23 @@ class GatewayEventMapperTest {
assertEquals(listOf("t2" to "completed skill"), standard.toolDones)
}
@Test
fun `structured tool arguments win over legacy previews`() {
val recorder = Recorder()
val mapper = mapperWith(recorder)
mapper.onEvent(
"tool.start",
obj(
"""{"tool_id":"t3","name":"terminal","args":{"command":"echo live","timeout":30},"args_text":"stale","context":"older"}""",
),
)
assertEquals(
listOf("t3" to "{\"command\":\"echo live\",\"timeout\":30}"),
recorder.toolStartArgs,
)
}
@Test
fun `tool complete with error routes to failed`() {
val r = Recorder()
@@ -574,7 +591,7 @@ class GatewayEventMapperTest {
fun `subagent lifecycle maps phases and fields`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("subagent.start", obj("""{"goal":"research topic","task_index":1,"task_count":3}"""))
mapper.onEvent("subagent.start", obj("""{"goal":"research topic","task_index":1,"task_count":3,"subagent_id":"child-17"}"""))
mapper.onEvent("subagent.thinking", obj("""{"goal":"research topic","task_index":1,"task_count":3,"text":"hmm"}"""))
mapper.onEvent(
"subagent.tool",
@@ -600,6 +617,7 @@ class GatewayEventMapperTest {
assertEquals(1, start.taskIndex)
assertEquals(3, start.taskCount)
assertEquals("research topic", start.goal)
assertEquals("child-17", start.subagentId)
assertEquals("hmm", r.subagentEvents[1].preview)
val tool = r.subagentEvents[2]
assertEquals("web_search", tool.toolName)
@@ -0,0 +1,26 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Test
class SessionReferenceTest {
@Test
fun `references outside code are bounded and parsed`() {
val text = """
Open @session:research/20260811_abc123.
`@session:hidden/inline`
```text
@session:hidden/fenced
```
Also @session:default/session-2
""".trimIndent()
assertEquals(
listOf(
SessionReference("research", "20260811_abc123"),
SessionReference("default", "session-2"),
),
parseSessionReferences(text),
)
}
}
@@ -63,21 +63,48 @@ class DashboardManageParityTest {
}
@Test
fun mcpManagePlumbing_usesEffectiveProfileForListAndRowActions() {
fun completedOneShotCron_showsOutcomeAndOnlyOffersRunsAndDelete() {
val root = Json.parseToJsonElement(
"""{"id":"once","name":"One shot","schedule":"@once","state":"completed","enabled":false,"last_status":"error","last_error":"model timeout","last_delivery_error":"phone offline"}""",
).jsonObject
val row = summarizeObjectItem(root, "once")
assertTrue(row.meta.orEmpty().contains("last: error"))
assertTrue(row.meta.orEmpty().contains("error: model timeout"))
assertTrue(row.meta.orEmpty().contains("delivery: phone offline"))
assertEquals(
listOf(DashboardActionKind.ViewCronRuns, DashboardActionKind.DeleteCron),
row.actions.map(DashboardItemAction::kind),
)
}
@Test
fun profileScopedManagePlumbing_usesEffectiveProfileForListAndRowActions() {
assertEquals(
"/api/mcp/servers?profile=work%20profile",
dashboardSectionRequestPath("/api/mcp/servers", "work profile"),
)
assertEquals(
"/api/providers/custom-endpoints",
"/api/providers/custom-endpoints?profile=work%20profile",
dashboardSectionRequestPath("/api/providers/custom-endpoints", "work profile"),
)
assertEquals(
"/api/learning/graph?profile=work%20profile",
dashboardSectionRequestPath("/api/learning/graph", "work profile"),
)
val scoped = scopeDashboardManageItems(
"/api/mcp/servers",
"work profile",
listOf(DashboardSummaryItem(id = "hosted", title = "Hosted")),
)
assertEquals("work profile", scoped.single().profile)
val endpoint = scopeDashboardManageItems(
"/api/providers/custom-endpoints",
"work profile",
listOf(DashboardSummaryItem(id = "local", title = "Local")),
)
assertEquals("work profile", endpoint.single().profile)
}
@Test
@@ -58,6 +58,48 @@ class ChatViewModelCommandCatalogTest {
)
}
@Test
fun parseCommandsCatalog_ranksOnlySkillSlotsAndBoundsMetadata() {
val catalog = buildJsonObject {
put(
"pairs",
JsonArray(
listOf(
commandPair("status", "Show status"),
commandPair("research", "Research"),
commandPair("help", "Show help"),
commandPair("work", "Worktree"),
),
),
)
put("skills", buildJsonObject {
put("/research", buildJsonObject {
put("usage", 60)
put("origin", "bundled")
})
put("/work", buildJsonObject {
put("usage", 172)
put("origin", "local")
})
})
}
val commands = parseCommandsCatalog(catalog)
assertEquals(listOf("/status", "/work", "/help", "/research"), commands.map { it.command })
assertEquals(172, commands[1].usageRank)
assertEquals("local", commands[1].origin)
assertEquals(listOf("/status", "/help"), commands.filter { it.usageRank == 0 }.map { it.command })
}
@Test
fun deferredConfigResult_acceptsCurrentBooleanShapes() {
assertTrue(isDeferredConfigResult(buildJsonObject { put("deferred", true) }))
assertTrue(isDeferredConfigResult(buildJsonObject { put("deferred", "1") }))
assertFalse(isDeferredConfigResult(buildJsonObject { put("deferred", false) }))
assertFalse(isDeferredConfigResult(buildJsonObject { }))
}
@Test
fun parseDashboardPersonalityConfig_readsWrappedAndDirectConfig() {
val config = buildJsonObject {
+72 -39
View File
@@ -1,19 +1,20 @@
# hermes-relay-cli
Cross-platform CLI/TUI and optional menu-only Windows systray for [Hermes-Relay](https://github.com/Codename-11/hermes-relay).
Cross-platform CLI/TUI and optional compact Windows management tray for [Hermes-Relay](https://github.com/Codename-11/hermes-relay).
These are the only Hermes-Relay desktop deliverables: the cross-platform CLI
and its optional Windows systray. Hermes-Relay does not ship a separate
full desktop chat or management client; that product surface belongs to
[hermes-desktop](https://github.com/NousResearch/hermes-agent). The systray has
no application window: right-clicking its icon exposes a small native menu that
invokes the installed CLI for TUI, pairing, daemon control, grants, audit, and logs.
full desktop chat client; that product surface belongs to
[hermes-desktop](https://github.com/NousResearch/hermes-agent). The tray opens a
compact management popup for hosts, connection state, access, grants, authorized
clients, activity, and settings. It deliberately contains no chat, embedded
terminal, plugins, voice, or agent-session UI.
The agent brain (LLM + tools + sessions + memory) runs on your Hermes host. The
CLI is the product: bare `hermes-relay` opens the remote Hermes TUI, while
subcommands provide scriptable chat, pairing, sessions, daemon management,
grants, diagnostics, and desktop-tool routing. The optional systray is only a
Windows convenience launcher and controller for those commands.
Windows management surface over those same commands and state files.
> **What this is not:** A local Hermes install. Point it at an existing Hermes-Relay server (`ws://host:8767`). For the full TUI with Ink, see the sibling package [`ui-tui`](../../hermes-agent-tui-smoke/ui-tui) in the hermes-agent fork.
@@ -26,26 +27,53 @@ binary and one set of state files; the tray does not bundle a private sidecar.
| Surface | Intended use | Default experience |
|---------|--------------|--------------------|
| CLI/TUI | Primary desktop experience | Interactive remote Hermes TUI plus scriptable commands and JSON output |
| Windows systray | Optional convenience | Right-click menu for TUI, daemon, pairing, grants, audit, logs, and emergency stop |
| Daemon | Headless operation | Background desktop-tool router controlled by the CLI or tray menu |
| Windows tray | Optional management | Compact host, connection, access, grant, authorized-client, and settings popup |
| Daemon | Headless operation | Background connection and desktop-tool router controlled by the CLI or tray |
Left-clicking the tray icon intentionally does nothing. Right-clicking opens its
only interface. Actions that need input open the real CLI in a terminal rather
than embedding another terminal or management window.
Clicking the tray icon toggles the management popup. Paired Hermes instances are
shown as **Hosts**; clients authenticated to the selected host appear separately
under Settings and can be deauthorized there. **Pair another host...** is the
last host-selector option, or the selector's only action when no hosts exist.
The menu cross-checks the daemon heartbeat and PID, labels the account as
The tray cross-checks the daemon heartbeat and PID, labels the account as
**User** or **Administrator**, and disables lifecycle actions that do not apply
to the current state. **Start/Restart daemon as Administrator...** uses the
standard Windows UAC prompt; the tray itself stays unelevated. The menu also
shows pending-grant counts, exposes CLI diagnostics, can toggle tray startup at
sign-in, and states explicitly that exiting the tray leaves the daemon running.
standard Windows UAC prompt; the tray itself stays unelevated. Settings can
check the Desktop release channel and self-update the CLI and management UI
together. 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.
Access is selected per host. **Ask** keeps the connection available but attaches
no desktop tools. **Trusted** enables command and file tools while screen/input
still uses task grants. **Full Access** also allows screen, keyboard, and mouse
without task grants for that host; authentication, audit, deauthorization,
emergency stop, and UAC boundaries still apply.
The tray's **Activity** section is a live, local view of recent remote actions
and management events such as daemon, host-access, grant, client, and update
changes.
It groups events into commands, files, screen, and input; highlights failures,
aborts, and non-zero process exits; and keeps request context collapsed until
explicitly expanded. Events record handler duration and request ID where
available. The compact Overview still shows only the three newest events.
Settings also keeps activity compact: it previews the three newest events and
opens a dedicated Activity detail page for wrapped filters and expandable
records. Handler failures and aborts are **Issues**; non-zero process exits are
shown separately because probing commands may legitimately use them. **Clear**
removes both current and rotated local audit history after confirmation.
Clicking a card under **Hosts** opens that host's detail page; it does not change
the active connection. The detail page can set a local display name and has an
explicit connect action. Local names are stored in `desktop-control.json` and
do not rename the remote Hermes instance.
## Install
### GitHub Release install (recommended, no Node required)
```powershell
# Windows CLI + optional menu-only systray (default)
# Windows CLI + optional management tray (default)
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
```
@@ -60,9 +88,10 @@ curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/deskt
```
Windows downloads and verifies `hermes-relay-windows-x64-setup.exe`. The installer
places the CLI and systray together, adds `~/.hermes/bin` to the user PATH, and
lets the systray start at sign-in. CLI-only installs download the same prebuilt
single-file CLI binary without the systray. Pin a release with
places the CLI and management UI together, adds `~/.hermes/bin` to the user PATH, and
lets the UI start at sign-in. CLI-only installs download the same prebuilt
single-file CLI binary without the UI; add it later with `hermes-relay ui install`.
Pin a release with
`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.18`; CLI-only installs can override the
install directory with `HERMES_RELAY_INSTALL_DIR=...`.
@@ -112,6 +141,7 @@ on Windows because Windows locks its executable.
For tray development on Windows:
```sh
npm --prefix tray ci
npm run tray:dev
npm run tray:fmt
npm run tray:lint
@@ -195,7 +225,7 @@ hermes-pair
# -> prints "Copy/paste pairing invite" with hermes-relay://pair?payload=...
```
Right-click the tray and choose **Pair or re-pair...**, or use the CLI directly:
Open the host selector and choose **Pair another host...**, or use the CLI directly:
```sh
hermes-relay pair --pair-qr 'hermes-relay://pair?payload=...' --grant-tools
@@ -227,26 +257,27 @@ Herm uses `bun add -g herm-tui` when Bun is available and falls back to
`npm install -g herm-tui`; launch uses the installed `herm` binary or
`bunx herm-tui` / `npx --yes herm-tui` when available.
### Pair + grant tools in one shot (CLI-only daemon bring-up)
### Host access and daemon bring-up
If you plan to run `daemon` (headless tool serving), tack `--grant-tools` onto `pair` to capture the per-URL desktop-tool consent in the same step. That removes the historical `pair` → `shell` (consent prompt) → `daemon` dance:
Starting the daemon no longer grants tools and no longer requires a tool grant.
With a paired host in **Ask**, it connects in locked mode with zero desktop tools.
Select a host policy from the tray or use the CLI:
```sh
hermes-relay pair --remote ws://192.168.1.100:8767 --grant-tools
# ...prompts for code, then prompts for tool consent, stamps it on the stored session.
hermes-relay daemon
# ...starts headless; consent gate already satisfied.
hermes-relay hosts list --json
hermes-relay hosts select ws://192.168.1.100:8767
hermes-relay hosts access trusted --remote ws://192.168.1.100:8767
hermes-relay daemon start
```
For non-interactive provisioning (CI, install scripts, automated boxes) use `--auto-grant-tools` — same effect, no prompt:
Full Access requires explicit confirmation for non-interactive use:
```sh
HERMES_RELAY_CODE=F3W7EY hermes-relay pair \
--remote ws://192.168.1.100:8767 --auto-grant-tools --non-interactive
hermes-relay hosts access full-access \
--remote ws://192.168.1.100:8767 --yes
```
The two flags are deliberately separate so consent is never implicit — `--grant-tools` means "ask me", `--auto-grant-tools` means "I've already decided". Plain `pair` (no flag) leaves consent untouched, matching the original behavior.
Pairing still supports `--grant-tools` and `--auto-grant-tools` for backward-compatible CLI provisioning. New management flows should use `hosts access` so the policy is explicit and host-scoped.
## Usage
@@ -262,6 +293,8 @@ hermes-relay computer-use Enable, inspect, disable, or cancel desktop use
hermes-relay status Show stored sessions + grants + TTL
hermes-relay tools List tools available on the server
hermes-relay devices List / revoke / extend server-side paired devices
hermes-relay hosts List / select hosts and set per-host access
hermes-relay daemon Start / stop / restart the background connection
hermes-relay --help Full help
```
@@ -345,17 +378,17 @@ hermes-relay computer-use cancel
hermes-relay computer-use disable
```
The preference is stored in `~/.hermes/desktop-settings.json` and applies to
The legacy preference is stored in `~/.hermes/desktop-settings.json` and applies to
future chat, shell, daemon, tray restart, and UAC elevation flows. The explicit
`--experimental-computer-use` and `--no-computer-use` flags remain one-process
overrides. The Windows tray exposes the same enable/disable control, active
grant mode and expiry, and a cancel action.
overrides. The per-host policy is stored separately in
`~/.hermes/desktop-host-access.json`; Full Access enables this surface for its
host without an expiring task grant.
They still require normal desktop-tool consent. Observe grants allow screenshots;
In Ask or Trusted mode, observe grants allow screenshots;
assist/control grants require explicit local approval before host input can run.
The daemon writes pending requests to `~/.hermes/grant-bridge`; review them with
`hermes-relay grants` or the systray's **Review pending grants...** action. The
tray raises a native security alert when a new approval request appears. Grants
`hermes-relay grants` or the tray's focused approval dialog. Grants
expire automatically after at most one hour, and disabling desktop use,
**Cancel active desktop grant**, or **Emergency stop daemon** ends local input
authority. An Administrator daemon displays a prominent warning while an
@@ -480,7 +513,7 @@ Desktop-tool activity (4 most recent)
2s ago desktop_search ● ok pattern=TODO
```
Read from a local log (`~/.hermes/desktop-audit.jsonl`) the tool router writes whenever the agent runs a `desktop_*` tool — no network, no auth, works whether the relay is local or remote.
Read from a local log (`~/.hermes/desktop-audit.jsonl`) the tool router writes whenever the agent runs a `desktop_*` tool — no network, no auth, works whether the relay is local or remote. New entries include a stable `tool.completed` kind, category, handler duration, request ID, and process exit code when the handler exposes one. Older log entries remain readable.
### Relay — inspect the server
@@ -560,7 +593,7 @@ Precedence for credentials: `--token` → `HERMES_RELAY_TOKEN` → `--code` →
What's shipped on the `desktop-v*` track: remote chat + tool-event rendering,
one-time pairing, the interactive PTY/TUI shell, client-side tool routing,
auto-reconnect with TOFU cert pinning, server-side session management, the
headless daemon, local diagnostics, and the optional menu-only Windows systray.
headless daemon, local diagnostics, and the optional Windows management tray.
What's next (see [ROADMAP.md](../ROADMAP.md#desktop-track) for the full track):
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@hermes-relay/cli",
"version": "0.4.0-alpha.2",
"version": "0.4.0-alpha.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hermes-relay/cli",
"version": "0.4.0-alpha.2",
"version": "0.4.0-alpha.7",
"license": "MIT",
"bin": {
"hermes-relay": "bin/hermes-relay.js"
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@hermes-relay/cli",
"version": "0.4.0-alpha.2",
"version": "0.4.0-alpha.7",
"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": {
@@ -35,11 +35,12 @@
"build:bin:mac-arm": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-darwin-arm64 src/cli.ts --outfile dist/bin/hermes-relay-darwin-arm64",
"build:sums": "cd dist/bin && sha256sum hermes-relay-* > SHA256SUMS.txt",
"tray:dev": "node scripts/run-tray-dev.mjs",
"tray:ui:build": "npm --prefix tray run build",
"tray:fmt": "cargo fmt --manifest-path tray/Cargo.toml -- --check",
"tray:lint": "cargo clippy --manifest-path tray/Cargo.toml --all-targets --all-features -- -D warnings",
"tray:check": "cargo check --manifest-path tray/Cargo.toml",
"tray:test": "cargo test --manifest-path tray/Cargo.toml",
"tray:build:exe": "cargo build --release --manifest-path tray/Cargo.toml",
"tray:lint": "npm run tray:ui:build && cargo clippy --manifest-path tray/Cargo.toml --all-targets --all-features -- -D warnings",
"tray:check": "npm run tray:ui:build && cargo check --manifest-path tray/Cargo.toml",
"tray:test": "npm run tray:ui:build && cargo test --manifest-path tray/Cargo.toml",
"tray:build:exe": "cargo build --release --features custom-protocol --manifest-path tray/Cargo.toml",
"tray:build": "node scripts/build-tray-installer.mjs",
"smoke": "npm run build:bin:win && node -e \"const{execFileSync}=require('child_process');const bin='./dist/bin/hermes-relay-win-x64.exe';const pkg=require('./package.json');for(const a of [['--version'],['--help'],['doctor'],['workspace']]){const out=execFileSync(bin,a,{encoding:'utf8'});if(!out||out.length<10)throw new Error('smoke FAIL: '+bin+' '+a.join(' ')+' produced no output');console.log('smoke OK: '+a.join(' ')+' ('+out.split('\\n')[0]+')')}const updOut=execFileSync(bin,['update','--check','--json'],{encoding:'utf8'});const parsed=JSON.parse(updOut);if(parsed.current!==pkg.version)throw new Error('smoke FAIL: update --check --json current='+parsed.current+' != package.json version='+pkg.version);console.log('smoke OK: update --check --json (current='+parsed.current+', up_to_date='+parsed.up_to_date+')')\"",
"verify": "node scripts/verify.mjs",
+18 -2
View File
@@ -30,11 +30,21 @@ function npmRun(script) {
}
}
function npmPrefixRun(prefix, script) {
const npmExecPath = process.env.npm_execpath
if (npmExecPath) {
run(process.execPath, [npmExecPath, '--prefix', prefix, 'run', script], `npm --prefix ${prefix} run ${script}`)
} else {
run(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', 'npm.cmd', '--prefix', prefix, 'run', script], `npm --prefix ${prefix} run ${script}`)
}
}
function findMakensis() {
const candidates = [
process.env.MAKENSIS,
join(process.env.ProgramFiles ?? '', 'NSIS', 'makensis.exe'),
join(process.env['ProgramFiles(x86)'] ?? '', 'NSIS', 'makensis.exe')
join(process.env['ProgramFiles(x86)'] ?? '', 'NSIS', 'makensis.exe'),
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'NSIS', 'makensis.exe')
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
@@ -46,7 +56,12 @@ function findMakensis() {
}
npmRun('build:bin:win')
run('cargo', ['build', '--release', '--manifest-path', 'tray/Cargo.toml'], 'tray release build')
npmPrefixRun('tray', 'build')
run(
'cargo',
['build', '--release', '--features', 'custom-protocol', '--manifest-path', 'tray/Cargo.toml'],
'tray release build'
)
const cliExe = join(desktopRoot, 'dist', 'bin', 'hermes-relay-win-x64.exe')
const trayExe = join(desktopRoot, 'tray', 'target', 'release', 'hermes-relay-tray.exe')
@@ -68,6 +83,7 @@ run(
`/DTRAY_EXE=${trayExe}`,
`/DOUT_FILE=${output}`,
`/DPATH_HELPER=${join(desktopRoot, 'tray', 'installer', 'path.ps1')}`,
`/DUI_SHIM=${join(desktopRoot, 'tray', 'installer', 'hermes-relay-ui.cmd')}`,
`/DICON_FILE=${join(desktopRoot, 'tray', 'icons', 'icon.ico')}`,
join(desktopRoot, 'tray', 'installer', 'hermes-relay.nsi')
],
+21 -2
View File
@@ -24,7 +24,10 @@ const paths = {
packageLock: join(desktopRoot, 'package-lock.json'),
generatedVersion: join(desktopRoot, 'src', 'version.ts'),
cargoToml: join(desktopRoot, 'tray', 'Cargo.toml'),
cargoLock: join(desktopRoot, 'tray', 'Cargo.lock')
cargoLock: join(desktopRoot, 'tray', 'Cargo.lock'),
tauriConfig: join(desktopRoot, 'tray', 'tauri.conf.json'),
trayPackageJson: join(desktopRoot, 'tray', 'package.json'),
trayPackageLock: join(desktopRoot, 'tray', 'package-lock.json')
}
const packageJson = JSON.parse(readFileSync(paths.packageJson, 'utf8'))
@@ -80,12 +83,24 @@ if (write) {
/(\[\[package\]\]\s*\r?\nname\s*=\s*"hermes-relay-tray"\s*\r?\nversion\s*=\s*")[^"]+("\s*$)/m,
`$1${version}$2`
)
const tauriConfig = JSON.parse(readFileSync(paths.tauriConfig, 'utf8'))
tauriConfig.version = version
writeFileSync(paths.tauriConfig, JSON.stringify(tauriConfig, null, 2) + '\n')
for (const packagePath of [paths.trayPackageJson, paths.trayPackageLock]) {
const metadata = JSON.parse(readFileSync(packagePath, 'utf8'))
metadata.version = version
if (metadata.packages?.['']) metadata.packages[''].version = version
writeFileSync(packagePath, JSON.stringify(metadata, null, 2) + '\n')
}
}
const packageLock = JSON.parse(readFileSync(paths.packageLock, 'utf8'))
const generatedVersionText = readFileSync(paths.generatedVersion, 'utf8')
const cargoTomlText = readFileSync(paths.cargoToml, 'utf8')
const cargoLockText = readFileSync(paths.cargoLock, 'utf8')
const tauriConfig = JSON.parse(readFileSync(paths.tauriConfig, 'utf8'))
const trayPackageJson = JSON.parse(readFileSync(paths.trayPackageJson, 'utf8'))
const trayPackageLock = JSON.parse(readFileSync(paths.trayPackageLock, 'utf8'))
const generatedVersion = generatedVersionText.match(/export const VERSION\s*=\s*["']([^"']+)["']/)?.[1]
const cargoVersion = cargoTomlText.match(/\[package\][\s\S]*?^version\s*=\s*"([^"]+)"/m)?.[1]
const cargoLockVersion = cargoLockText.match(
@@ -97,7 +112,11 @@ const locations = [
['package-lock workspace root', packageLock.packages?.['']?.version],
['generated src/version.ts', generatedVersion],
['Cargo package', cargoVersion],
['Cargo lock package', cargoLockVersion]
['Cargo lock package', cargoLockVersion],
['Tauri config', tauriConfig.version],
['tray package', trayPackageJson.version],
['tray package-lock root', trayPackageLock.version],
['tray package-lock workspace root', trayPackageLock.packages?.['']?.version]
]
const mismatches = locations.filter(([, value]) => value !== version)
+14 -1
View File
@@ -9,7 +9,20 @@ import { spawnSync } from 'node:child_process'
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
if (process.platform !== 'win32') throw new Error('the optional systray is Windows-only')
const build = spawnSync('cargo', ['build', '--release', '--manifest-path', 'tray/Cargo.toml'], {
const npmExecPath = process.env.npm_execpath
const npmCommand = npmExecPath ? process.execPath : (process.env.ComSpec ?? 'cmd.exe')
const npmArgs = npmExecPath
? [npmExecPath, '--prefix', 'tray', 'run', 'build']
: ['/d', '/s', '/c', 'npm.cmd', '--prefix', 'tray', 'run', 'build']
const uiBuild = spawnSync(npmCommand, npmArgs, {
cwd: desktopRoot,
stdio: 'inherit'
})
if (uiBuild.error || uiBuild.status !== 0) process.exit(uiBuild.status ?? 1)
// Match the shipped build. Without custom-protocol Tauri follows devUrl and an
// installed tray tries to load 127.0.0.1:1420 instead of its embedded UI.
const build = spawnSync('cargo', ['build', '--release', '--features', 'custom-protocol', '--manifest-path', 'tray/Cargo.toml'], {
cwd: desktopRoot,
stdio: 'inherit'
})
+29 -3
View File
@@ -2,7 +2,7 @@
#
# irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
#
# Downloads the CLI + optional menu-only systray installer by default - no Node.js required.
# Downloads the CLI + optional compact management tray installer by default - no Node.js required.
# Install only the CLI binary instead:
# $env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm ... | iex
# Pin a specific release:
@@ -23,8 +23,11 @@ $ErrorActionPreference = 'Stop'
$repo = if ($env:HERMES_RELAY_REPO) { $env:HERMES_RELAY_REPO } else { 'Codename-11/hermes-relay' }
$version = if ($env:HERMES_RELAY_VERSION) { $env:HERMES_RELAY_VERSION } else { 'latest' }
$dir = if ($env:HERMES_RELAY_INSTALL_DIR) { $env:HERMES_RELAY_INSTALL_DIR } else { Join-Path $HOME '.hermes\bin' }
$surface = if ($env:HERMES_RELAY_INSTALL_SURFACE) { $env:HERMES_RELAY_INSTALL_SURFACE.ToLowerInvariant() } else { 'tray' }
$registeredDir = if ($surface -eq 'tray' -and -not $env:HERMES_RELAY_INSTALL_DIR) {
(Get-ItemProperty -Path 'HKCU:\Software\HermesRelay' -Name InstallDir -ErrorAction SilentlyContinue).InstallDir
} else { $null }
$dir = if ($env:HERMES_RELAY_INSTALL_DIR) { $env:HERMES_RELAY_INSTALL_DIR } elseif ($registeredDir) { $registeredDir } else { Join-Path $HOME '.hermes\bin' }
$aliasMode = if ($env:HERMES_RELAY_HERMES_ALIAS) { $env:HERMES_RELAY_HERMES_ALIAS.ToLowerInvariant() } else { 'skip' }
function Say($msg) { Write-Host " $msg" }
@@ -172,6 +175,16 @@ Say ""
if ($surface -eq 'tray') {
$tmp = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ("hermes-relay-" + [Guid]::NewGuid()))
$daemonWasRunning = $false
$existingCli = Join-Path $dir 'hermes-relay.exe'
if (Test-Path -LiteralPath $existingCli) {
try {
& $existingCli daemon status --json *> $null
$daemonWasRunning = $LASTEXITCODE -eq 0
} catch {
$daemonWasRunning = $false
}
}
try {
Say '-> downloading tray installer...'
$installer = Join-Path $tmp $asset
@@ -204,17 +217,29 @@ if ($surface -eq 'tray') {
if ($env:HERMES_RELAY_TRAY_SILENT -eq '1') {
$installerArgs += '/S'
}
# NSIS requires /D to be the final argument. Keeping the existing registered
# install directory avoids splitting an upgrade across two PATH locations.
$installerArgs += "/D=$dir"
Say '-> launching installer...'
$proc = Start-Process -FilePath $installer -ArgumentList $installerArgs -Wait -PassThru
if ($proc.ExitCode -ne 0) {
Die "tray installer exited with code $($proc.ExitCode)"
}
if ($daemonWasRunning) {
Say '-> restarting the Relay daemon with the updated CLI...'
$restart = Start-Process -FilePath (Join-Path $dir 'hermes-relay.exe') -ArgumentList @('daemon', 'start') -WindowStyle Hidden -Wait -PassThru
if ($restart.ExitCode -ne 0) {
Say " WARN: the updated CLI installed, but the daemon restart exited with code $($restart.ExitCode)"
Say ' Run: hermes-relay daemon start'
}
}
} finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
Say ''
Say 'Installed the Hermes Relay CLI and optional menu-only systray. Right-click the tray icon to manage the daemon or open the real CLI/TUI.'
Say 'Installed Hermes-Relay CLI and the optional Hermes-Relay CLI UI. Click the tray icon to manage hosts, access, grants, and the daemon; use the CLI for chat and TUI workflows.'
Say 'Open it later with: hermes-relay ui'
Say 'For CLI-only installs, rerun with:'
Say " `$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/$repo/main/desktop/scripts/install.ps1 | iex"
return
@@ -348,6 +373,7 @@ if ($installedVersion) {
}
Say ' hermes-relay --help'
Say ' hermes-relay pair --remote ws://<host>:8767'
Say ' hermes-relay ui install # add the optional Windows management UI later'
Say ''
Say 'Optional Orca/upstream-style alias:'
Say " `$env:HERMES_RELAY_HERMES_ALIAS='enable'; irm https://raw.githubusercontent.com/$repo/main/desktop/scripts/hermes-alias.ps1 | iex"
+1 -3
View File
@@ -90,11 +90,9 @@ os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$os-$arch" in
linux-x86_64) asset="hermes-relay-linux-x64" ;;
linux-aarch64) asset="hermes-relay-linux-arm64" ;;
linux-arm64) asset="hermes-relay-linux-arm64" ;;
darwin-x86_64) asset="hermes-relay-darwin-x64" ;;
darwin-arm64) asset="hermes-relay-darwin-arm64" ;;
*) die "unsupported platform: $os/$arch (supported: linux-x64/arm64, darwin-x64/arm64)" ;;
*) die "unsupported platform: $os/$arch (published binaries: linux-x64, darwin-x64/arm64; Windows uses install.ps1)" ;;
esac
# Resolve "latest" to a concrete tag. GitHub's /releases/latest/download/ URL
+32 -6
View File
@@ -2,7 +2,8 @@
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import { createConnection } from 'node:net'
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
@@ -24,9 +25,34 @@ if (npmExecPath) {
run('npm.cmd', ['run', 'build:bin:win'])
}
run('cargo', ['run', '--manifest-path', 'tray/Cargo.toml'], {
env: {
...process.env,
HERMES_RELAY_CLI_PATH: join(desktopRoot, 'dist', 'bin', 'hermes-relay-win-x64.exe')
const npmCommand = npmExecPath ? process.execPath : 'npm.cmd'
const npmArgs = npmExecPath
? [npmExecPath, '--prefix', 'tray', 'run', 'dev']
: ['--prefix', 'tray', 'run', 'dev']
const vite = spawn(npmCommand, npmArgs, { cwd: desktopRoot, stdio: 'inherit' })
async function waitForVite() {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
const ready = await new Promise(resolve => {
const socket = createConnection({ host: '127.0.0.1', port: 1420 })
socket.once('connect', () => { socket.destroy(); resolve(true) })
socket.once('error', () => resolve(false))
})
if (ready) return
await new Promise(resolve => setTimeout(resolve, 100))
}
})
throw new Error('tray Vite server did not start on 127.0.0.1:1420')
}
try {
await waitForVite()
run('cargo', ['run', '--manifest-path', 'tray/Cargo.toml'], {
env: {
...process.env,
HERMES_RELAY_CLI_PATH: join(desktopRoot, 'dist', 'bin', 'hermes-relay-win-x64.exe')
}
})
} finally {
vite.kill()
}
+10
View File
@@ -57,6 +57,16 @@ Say ''
# -- Tier 1: binary + PATH --------------------------------------------------
# Bundle installs own Start-menu, registry, PATH-helper, and uninstall entries.
# Delegate to their registered per-user uninstaller before applying the same
# conservative data-preservation policy below.
$bundleUninstaller = Join-Path $dir 'uninstall-hermes-relay.exe'
if (Test-Path -LiteralPath $bundleUninstaller) {
Say '-> removing the installed CLI + UI bundle...'
$bundle = Start-Process -FilePath $bundleUninstaller -ArgumentList '/S' -Wait -PassThru
if ($bundle.ExitCode -ne 0) { Die "bundle uninstaller exited with code $($bundle.ExitCode)" }
}
$trayTarget = Join-Path $dir 'hermes-relay-tray.exe'
if (Test-Path -LiteralPath $trayTarget) {
Get-Process -Name 'hermes-relay-tray' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
+13
View File
@@ -10,6 +10,7 @@ import { daemonCommand } from './commands/daemon.js'
import { devicesCommand } from './commands/devices.js'
import { doctorCommand } from './commands/doctor.js'
import { grantsCommand } from './commands/grants.js'
import { hostsCommand } from './commands/hosts.js'
import { pairCommand } from './commands/pair.js'
import { pasteCommand } from './commands/paste.js'
import { pluginsCommand } from './commands/plugins.js'
@@ -19,6 +20,7 @@ import { shellCommand } from './commands/shell.js'
import { statusCommand } from './commands/status.js'
import { toolsCommand } from './commands/tools.js'
import { updateCommand } from './commands/update.js'
import { uiCommand } from './commands/ui.js'
import { voiceCommand } from './commands/voice.js'
import { workspaceCommand } from './commands/workspace.js'
import { renderLogo } from './lib/logo.js'
@@ -72,6 +74,9 @@ const BOOLEAN_FLAGS = new Set([
'no-voice',
'no-open',
'check',
'installer',
'download-only',
'force',
'yes',
'new',
'watch-editor',
@@ -141,6 +146,7 @@ const KNOWN_COMMANDS = new Set([
'devices',
'doctor',
'grants',
'hosts',
'paste',
'pair',
'relay',
@@ -150,6 +156,7 @@ const KNOWN_COMMANDS = new Set([
'status',
'tools',
'update',
'ui',
'voice',
'workspace',
'help'
@@ -174,7 +181,9 @@ Usage:
hermes-relay daemon [start|stop|restart|status] Headless tool router — 'start' runs it in the background
hermes-relay doctor Diagnostic report: version, paths, sessions, daemon status
hermes-relay grants Review pending local computer-use grants
hermes-relay hosts List/select paired hosts and set local access policy
hermes-relay update Check for and install the latest desktop-v* release
hermes-relay ui [open|status|install] Open or install the optional Windows management UI
hermes-relay voice Show native Hermes voice config (STT/TTS/realtime providers)
hermes-relay voice mode Push-to-talk in a browser tab (proxied through this CLI)
hermes-relay workspace Print local workspace context (cwd, git, editor, shell) — --json for scripting
@@ -325,6 +334,8 @@ export async function main(argv = process.argv): Promise<number> {
return doctorCommand(args)
case 'grants':
return grantsCommand(args)
case 'hosts':
return hostsCommand(args)
case 'pair':
return pairCommand(args)
case 'paste':
@@ -343,6 +354,8 @@ export async function main(argv = process.argv): Promise<number> {
return toolsCommand(args)
case 'update':
return updateCommand(args)
case 'ui':
return uiCommand(args)
case 'voice':
return voiceCommand(args)
case 'workspace':
+3 -1
View File
@@ -13,6 +13,7 @@ import {
type AttachPayload
} from '../chatAttach.js'
import type { ParsedArgs } from '../cli.js'
import { desktopRelayIdentity } from '../deviceIdentity.js'
import { resolveCredentials } from '../credentials.js'
import { GatewayClient } from '../gatewayClient.js'
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
@@ -115,7 +116,7 @@ async function connectAndAuth(args: ParsedArgs): Promise<AuthedRelay> {
const cfg: ConstructorParameters<typeof RelayTransport>[0] = {
url,
deviceName: `hermes-relay-cli (${process.platform})`
...desktopRelayIdentity()
}
if (creds.pairingCode) {
cfg.pairingCode = creds.pairingCode
@@ -591,6 +592,7 @@ export async function chatCommand(args: ParsedArgs): Promise<number> {
const advertisedTools = advertisedDesktopTools({ computerUse: computerUseEnabled })
toolRouter = new DesktopToolRouter({
consentGranted: true,
hostUrl: url,
handlers: desktopHandlers({ computerUse: computerUseEnabled }),
advertisedTools: [...advertisedTools]
})
+3 -3
View File
@@ -1,7 +1,7 @@
import { createInterface } from 'node:readline/promises'
import type { ParsedArgs } from '../cli.js'
import { readDaemonStatus, isPidAlive } from '../lib/daemonStatus.js'
import { readDaemonStatus, isDaemonProcessAlive } from '../lib/daemonStatus.js'
import {
readDesktopUseSettings,
requestComputerGrantCancellation,
@@ -58,7 +58,7 @@ async function statusPayload(): Promise<Record<string, unknown>> {
readDaemonStatus(),
listPendingGrantRequests()
])
const daemonAlive = !!daemon && isPidAlive(daemon.pid)
const daemonAlive = !!daemon && isDaemonProcessAlive(daemon)
const activeGrant = daemonAlive && daemon?.computer_grant?.active === true
? daemon.computer_grant
: null
@@ -120,7 +120,7 @@ export async function computerUseCommand(args: ParsedArgs): Promise<number> {
if (subcommand === 'cancel') {
const daemon = await readDaemonStatus()
if (!daemon || !isPidAlive(daemon.pid) || daemon.computer_grant?.active !== true) {
if (!daemon || !isDaemonProcessAlive(daemon) || daemon.computer_grant?.active !== true) {
process.stdout.write(t.muted('No active desktop-use grant is reported.') + '\n')
return 0
}
+211 -59
View File
@@ -7,10 +7,8 @@
// any time of day, not just when they have a shell attached.
//
// Design contract:
// - Fails closed if no stored session or desktop-tool consent isn't already true.
// A headless binary must never be the thing that grants tool access;
// the user must have previously consented via interactive `shell`/`chat`
// or `pair --grant-tools`.
// - Connects in locked mode when a paired host has not granted desktop tools.
// Starting the connectivity daemon is safe and must not itself grant access.
// - Inherits RelayTransport's reconnect state machine as-is. No custom
// retry loop here — the transport's exp-backoff-to-30s + auth-resolve
// semantics are already daemon-appropriate. Terminal auth failures
@@ -32,15 +30,17 @@
// - --log-file <path>: for now, redirect stderr if you need a file.
import { spawn } from 'node:child_process'
import { openSync, promises as fs } from 'node:fs'
import { closeSync, openSync, promises as fs } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import type { ParsedArgs } from '../cli.js'
import { desktopRelayIdentity } from '../deviceIdentity.js'
import { GatewayClient } from '../gatewayClient.js'
import type { GatewayEvent, SessionCreateResponse } from '../gatewayTypes.js'
import {
clearDaemonStatus,
isDaemonProcessAlive,
isPidAlive,
readDaemonStatus,
writeDaemonStatus,
@@ -49,6 +49,7 @@ import {
type DaemonStatus
} from '../lib/daemonStatus.js'
import { rpcErrorMessage, asRpcResult } from '../lib/rpc.js'
import { effectiveHostAccessMode, getHostAccessMode } from '../lib/hostAccessPolicy.js'
import { theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
@@ -78,6 +79,8 @@ const VOICE_DISCOVERY_FILE = 'desktop-voice.json'
/** Refresh the status-file `updated_at` on this cadence so `--status` can tell
* a live daemon from a crashed one whose file lingers. */
const STATUS_HEARTBEAT_MS = 30_000
const DETACHED_START_TIMEOUT_MS = 20_000
const DETACHED_START_POLL_MS = 100
const DAEMON_USAGE: UsageSpec = {
name: 'daemon',
@@ -167,7 +170,7 @@ async function printDaemonStatus(args: ParsedArgs): Promise<number> {
)
return 1
}
const alive = isPidAlive(status.pid)
const alive = isDaemonProcessAlive(status)
if (args.flags.json) {
process.stdout.write(JSON.stringify({ ...status, alive }, null, 2) + '\n')
return alive ? 0 : 1
@@ -225,6 +228,100 @@ function daemonLogPath(): string {
return path.join(os.homedir(), '.hermes', 'daemon.log')
}
type DetachedStartupResult =
| { outcome: 'ready'; status: DaemonStatus }
| { outcome: 'failed'; detail: string | null }
| { outcome: 'timeout'; status: DaemonStatus | null }
interface DetachedStartupProbe {
readStatus: () => Promise<DaemonStatus | null>
isAlive: (pid: number) => boolean
readFailure: () => Promise<string | null>
now: () => number
sleep: (ms: number) => Promise<void>
}
function daemonStatusIsReady(status: DaemonStatus | null, pid: number): boolean {
return status?.pid === pid && status.state === 'connected'
}
/** Wait until the child proves it crossed the configuration, consent, and
* authentication gates. A PID match is required so a stale status file from
* an earlier daemon can never make a new start look successful. */
async function waitForDetachedStartup(
pid: number,
timeoutMs: number,
probe: DetachedStartupProbe
): Promise<DetachedStartupResult> {
const deadline = probe.now() + timeoutMs
let lastStatus: DaemonStatus | null = null
while (probe.now() < deadline) {
lastStatus = await probe.readStatus()
if (lastStatus && daemonStatusIsReady(lastStatus, pid)) {
return { outcome: 'ready', status: lastStatus }
}
const failure = await probe.readFailure()
if (failure) return { outcome: 'failed', detail: failure }
if (!probe.isAlive(pid)) return { outcome: 'failed', detail: null }
await probe.sleep(DETACHED_START_POLL_MS)
}
lastStatus = await probe.readStatus()
if (lastStatus && daemonStatusIsReady(lastStatus, pid)) {
return { outcome: 'ready', status: lastStatus }
}
const failure = await probe.readFailure()
if (failure || !probe.isAlive(pid)) {
return { outcome: 'failed', detail: failure }
}
return {
outcome: 'timeout',
status: lastStatus?.pid === pid ? lastStatus : null
}
}
/** Read only log bytes written by this start attempt. Detached daemons use
* JSON logs by default, but keep a conservative human-log fallback for an
* explicit --log-human invocation. */
async function readDetachedStartupFailure(logPath: string, offset: number): Promise<string | null> {
try {
const handle = await fs.open(logPath, 'r')
try {
const stat = await handle.stat()
if (stat.size <= offset) return null
const length = Math.min(stat.size - offset, 64 * 1024)
const start = stat.size - length
const buffer = Buffer.alloc(length)
await handle.read(buffer, 0, length, start)
const lines = buffer.toString('utf8').split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i -= 1) {
const line = lines[i] ?? ''
try {
const record = JSON.parse(line) as Record<string, unknown>
if (record.level !== 'error') continue
const event = typeof record.event === 'string' ? record.event : 'startup_error'
const detail =
typeof record.message === 'string'
? record.message
: typeof record.reason === 'string'
? record.reason
: null
return detail ? `${event}: ${detail}` : event
} catch {
if (/\bERROR\b/i.test(line)) return line.slice(0, 500)
}
}
} finally {
await handle.close()
}
} catch {
/* best-effort diagnostic; process liveness remains authoritative */
}
return null
}
/** Rebuild the child argv for the foreground daemon from this invocation's
* flags, so `daemon start --remote … --experimental-computer-use` forwards. */
function buildDaemonChildArgs(args: ParsedArgs): string[] {
@@ -262,12 +359,16 @@ async function startDetachedDaemon(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const existing = await readDaemonStatus()
if (existing && isPidAlive(existing.pid)) {
if (existing && isDaemonProcessAlive(existing)) {
process.stderr.write(
t.warnLine(`daemon already running (pid ${existing.pid}) — stop it first: hermes-relay daemon stop`) + '\n'
)
return 1
}
// Remove stale state before spawning. Besides keeping status truthful while
// the new child starts, this prevents an unlikely recycled PID from matching
// a previous daemon's connected record.
await clearDaemonStatus()
const logPath = daemonLogPath()
try {
@@ -275,6 +376,12 @@ async function startDetachedDaemon(args: ParsedArgs): Promise<number> {
} catch {
/* best-effort */
}
let logOffset = 0
try {
logOffset = (await fs.stat(logPath)).size
} catch {
/* new log file */
}
const logFd = openSync(logPath, 'a')
// Compiled binary: the entry is embedded, so exe + args is enough. Running
@@ -284,13 +391,59 @@ async function startDetachedDaemon(args: ParsedArgs): Promise<number> {
const execIsNode = /node(\.exe)?$/i.test(path.basename(process.execPath))
const spawnArgs = execIsNode ? [process.argv[1] ?? '', ...childArgs] : childArgs
const child = spawn(process.execPath, spawnArgs, {
detached: true,
stdio: ['ignore', logFd, logFd],
windowsHide: true
let child
try {
child = spawn(process.execPath, spawnArgs, {
detached: true,
stdio: ['ignore', logFd, logFd],
windowsHide: true
})
} catch (error) {
closeSync(logFd)
process.stderr.write(t.err(`failed to start daemon: ${(error as Error).message}`) + '\n')
process.stderr.write(t.muted(` logs: ${logPath}`) + '\n')
return 1
}
closeSync(logFd)
let spawnError: Error | null = null
child.once('error', error => {
spawnError = error
})
if (typeof child.pid !== 'number') {
process.stderr.write(t.err('failed to start daemon: the child process returned no pid') + '\n')
process.stderr.write(t.muted(` logs: ${logPath}`) + '\n')
return 1
}
child.unref()
const result = await waitForDetachedStartup(child.pid, DETACHED_START_TIMEOUT_MS, {
readStatus: readDaemonStatus,
isAlive: pid => !spawnError && isPidAlive(pid),
readFailure: async () =>
spawnError
? `process error: ${spawnError.message}`
: readDetachedStartupFailure(logPath, logOffset),
now: Date.now,
sleep: ms => new Promise(resolve => setTimeout(resolve, ms))
})
if (result.outcome === 'failed') {
process.stderr.write(t.err('daemon failed before it became ready') + '\n')
if (result.detail) process.stderr.write(t.err(` ${result.detail}`) + '\n')
process.stderr.write(t.muted(` logs: ${logPath}`) + '\n')
return 1
}
if (result.outcome === 'timeout') {
const state = result.status?.state ?? 'no matching status'
process.stderr.write(
t.err(`daemon did not become ready within ${DETACHED_START_TIMEOUT_MS / 1000} seconds (${state})`) + '\n'
)
process.stderr.write(t.muted(` logs: ${logPath}`) + '\n')
process.stderr.write(t.muted(' status: hermes-relay daemon status') + '\n')
return 1
}
process.stdout.write(t.okLine(`daemon started in the background (pid ${child.pid})`) + '\n')
process.stdout.write(t.muted(` logs: ${logPath}`) + '\n')
process.stdout.write(t.muted(' status: hermes-relay daemon status') + '\n')
@@ -306,7 +459,7 @@ async function stopDaemon(args: ParsedArgs): Promise<number> {
process.stdout.write(t.muted('No daemon status file — nothing to stop.') + '\n')
return 1
}
if (!isPidAlive(status.pid)) {
if (!isDaemonProcessAlive(status)) {
await clearDaemonStatus()
process.stdout.write(t.muted(`Daemon (pid ${status.pid}) is already gone — cleared stale status.`) + '\n')
return 0
@@ -327,7 +480,7 @@ async function stopDaemon(args: ParsedArgs): Promise<number> {
async function restartDaemon(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const existing = await readDaemonStatus()
if (existing && isPidAlive(existing.pid)) {
if (existing && isDaemonProcessAlive(existing)) {
try {
process.kill(existing.pid)
} catch (error) {
@@ -338,10 +491,10 @@ async function restartDaemon(args: ParsedArgs): Promise<number> {
}
const deadline = Date.now() + 5_000
while (isPidAlive(existing.pid) && Date.now() < deadline) {
while (isDaemonProcessAlive(existing) && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 50))
}
if (isPidAlive(existing.pid)) {
if (isDaemonProcessAlive(existing)) {
process.stderr.write(t.err(`daemon pid ${existing.pid} did not stop within 5 seconds`) + '\n')
return 1
}
@@ -416,37 +569,16 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
return 1
}
// Consent gate: the daemon must not grant tool access on its own. The
// interactive `shell` command is the canonical place consent is captured,
// and it writes `toolsConsented: true` onto the stored session. If the
// token came from --token but we have no stored record, assume the user
// knows what they're doing ONLY if they also pass --allow-tools; otherwise
// bail.
const tokenFromStored = !argToken && !envToken
// Access gates control which tools are attached, not whether the daemon may
// connect. This lets the tray manage a healthy locked daemon without making
// connectivity itself a privileged operation.
const consented = stored?.toolsConsented === true
const allowToolsFlag = !!args.flags['allow-tools']
if (tokenFromStored && !consented) {
log.error({
event: 'consent_missing',
url,
message:
'tools not consented for this URL. Re-pair with `hermes-relay pair --remote <url> --grant-tools` ' +
'(prompts on TTY) or `--auto-grant-tools` (no prompt). `hermes-relay shell` also works. ' +
'`--allow-tools` overrides this gate when invoking with --token directly.'
})
return 1
}
if (!tokenFromStored && !consented && !allowToolsFlag) {
log.error({
event: 'consent_missing',
url,
message:
'no stored consent for this URL. Pass --allow-tools to override, or pair first with ' +
'`hermes-relay pair --remote <url> --grant-tools`.'
})
return 1
}
const accessMode = effectiveHostAccessMode(
await getHostAccessMode(url),
stored?.toolsConsented === true
)
const toolsEnabled = consented || allowToolsFlag || accessMode !== 'ask'
log.info({
event: 'starting',
@@ -459,9 +591,12 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
// Observable status file — `hermes-relay daemon --status` reads this.
const nowSec = () => Math.floor(Date.now() / 1000)
const identity = currentProcessIdentity()
const computerUseEnabled = shouldAdvertiseComputerUse(args.flags)
const computerUseEnabled = toolsEnabled && (
accessMode === 'full_access' || shouldAdvertiseComputerUse(args.flags)
)
const status: DaemonStatus = {
pid: process.pid,
process_name: path.basename(process.execPath),
url,
state: 'starting',
started_at: nowSec(),
@@ -470,6 +605,8 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
username: identity.username,
privilege: identity.privilege,
computer_use_enabled: computerUseEnabled,
access_mode: accessMode,
tools_enabled: toolsEnabled,
computer_grant: { active: false, mode: 'none', expires_at: null }
}
const updateStatus = (partial: Partial<DaemonStatus> & { state?: DaemonState }) => {
@@ -481,7 +618,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
const relay = new RelayTransport({
url,
sessionToken: token,
deviceName: `hermes-relay-cli daemon (${process.platform})`
...desktopRelayIdentity()
})
// Lifecycle wiring — every event the transport emits that a daemon
@@ -544,9 +681,12 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
configureComputerUseRuntime({
url,
computerUseConsented: computerUseEnabled,
consentSource: consented ? 'stored' : 'override'
consentSource: consented ? 'stored' : toolsEnabled ? 'override' : 'none',
accessMode
})
const advertisedTools = advertisedDesktopTools({ computerUse: computerUseEnabled })
const advertisedTools = toolsEnabled
? advertisedDesktopTools({ computerUse: computerUseEnabled })
: []
const toDaemonGrantStatus = (grant: ComputerGrant | null): DaemonComputerGrantStatus => ({
active: grant !== null,
mode: grant?.mode ?? 'none',
@@ -577,21 +717,27 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
}, 500)
grantControlInterval.unref?.()
const router = new DesktopToolRouter({
consentGranted: true,
interactive,
handlers: desktopHandlers({ computerUse: computerUseEnabled }),
advertisedTools: [...advertisedTools]
})
router.attach(relay)
const router = toolsEnabled
? new DesktopToolRouter({
consentGranted: true,
interactive,
hostUrl: url,
handlers: desktopHandlers({ computerUse: computerUseEnabled }),
advertisedTools: [...advertisedTools]
})
: null
router?.attach(relay)
log.info({
event: 'ready',
event: toolsEnabled ? 'ready' : 'ready_locked',
advertised_tools: [...advertisedTools],
experimental_computer_use: computerUseEnabled,
interactive
})
updateStatus({ advertised_tools: [...advertisedTools].length, last_event: 'ready' })
updateStatus({
advertised_tools: [...advertisedTools].length,
last_event: toolsEnabled ? 'ready' : 'ready_locked'
})
// Keep the status file's updated_at fresh so `--status` can distinguish a
// live daemon from a crashed one whose file lingers (belt-and-suspenders
@@ -665,7 +811,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
/* ignore */
}
try {
router.detach()
router?.detach()
} catch {
/* ignore */
}
@@ -691,7 +837,13 @@ export default daemonCommand
// Small utility function re-exported for tests that need to stub the logger.
export type { LogFields }
export { makeLogger as __makeLoggerForTests, rpcErrorMessage as __rpcErrorMessageForTests }
export {
daemonStatusIsReady as __daemonStatusIsReadyForTests,
makeLogger as __makeLoggerForTests,
readDetachedStartupFailure as __readDetachedStartupFailureForTests,
rpcErrorMessage as __rpcErrorMessageForTests,
waitForDetachedStartup as __waitForDetachedStartupForTests
}
// ── Voice-server helpers ───────────────────────────────────────────────
+8
View File
@@ -67,6 +67,10 @@ interface ServerSession {
expires_at?: number | null
grants?: Record<string, number | null>
transport_hint?: string
client_surface?: string
device_form_factor?: string
device_model?: string
device_platform?: string
is_current?: boolean
}
@@ -214,6 +218,9 @@ async function listDevices(args: ParsedArgs): Promise<number> {
return [
s.token_prefix,
nameCell,
[s.device_model, s.device_platform, s.client_surface]
.filter(value => typeof value === 'string' && value.trim())
.join(' · ') || '—',
lastSeen,
humanExpiry(s.expires_at ?? null),
s.transport_hint ?? '—',
@@ -227,6 +234,7 @@ async function listDevices(args: ParsedArgs): Promise<number> {
[
{ header: 'PREFIX' },
{ header: 'DEVICE' },
{ header: 'DETAILS' },
{ header: 'LAST SEEN' },
{ header: 'EXPIRES' },
{ header: 'TRANSPORT' },
+189
View File
@@ -0,0 +1,189 @@
import type { ParsedArgs } from '../cli.js'
import { getActiveDesktopRelayUrl, getDesktopHostAliases, setActiveDesktopRelayUrl, setDesktopHostAlias } from '../desktopConfig.js'
import {
effectiveHostAccessMode,
getHostAccessMode,
isHostAccessMode,
setHostAccessMode,
type HostAccessMode
} from '../lib/hostAccessPolicy.js'
import { theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec, unknownSubcommand } from '../lib/usage.js'
import { getSession, listSessions, saveSession } from '../remoteSessions.js'
const HOSTS_USAGE: UsageSpec = {
name: 'hosts',
summary: 'manage Hermes hosts paired with this PC',
usage: [
'hosts [list] [--json]',
'hosts select <relay-url>',
'hosts rename <relay-url> <name>',
'hosts access <ask|trusted|full-access> [--remote <url>] [--yes]'
],
subcommands: [
{ verb: 'list', desc: 'List locally paired Hermes hosts (default)' },
{ verb: 'select <url>', desc: 'Choose the host used by the tray and daemon' },
{ verb: 'rename <url> <name>', desc: 'Set a local display name for a paired host' },
{ verb: 'access <mode>', desc: 'Set this PC access policy for one host' }
],
flags: [
{ flag: '--remote <url>', desc: 'Host targeted by the access command' },
{ flag: '--json', desc: 'Emit machine-readable host state' },
{ flag: '--yes', desc: 'Confirm Full Access non-interactively' }
],
examples: [
'hermes-relay hosts --json',
'hermes-relay hosts select wss://home.example:8767',
'hermes-relay hosts access full-access --remote wss://home.example:8767 --yes'
]
}
export interface LocalHostSummary {
url: string
host: string
server_version: string | null
endpoint_role: string | null
paired_at: number
is_active: boolean
access_mode: HostAccessMode
}
function hostLabel(url: string): string {
try {
return new URL(url).hostname || url
} catch {
return url
}
}
export function parseAccessMode(value: string | undefined): HostAccessMode | null {
const normalized = value?.trim().toLowerCase().replaceAll('-', '_')
return isHostAccessMode(normalized) ? normalized : null
}
async function localHosts(): Promise<LocalHostSummary[]> {
const [sessions, active, aliases] = await Promise.all([listSessions(), getActiveDesktopRelayUrl(), getDesktopHostAliases()])
return Promise.all(Object.entries(sessions).map(async ([url, session]) => ({
url,
host: aliases[url] ?? hostLabel(url),
server_version: session.serverVersion,
endpoint_role: session.endpointRole ?? null,
paired_at: session.pairedAt,
is_active: url === active,
access_mode: effectiveHostAccessMode(
await getHostAccessMode(url),
session.toolsConsented === true
)
}))).then(hosts => hosts.sort((a, b) =>
Number(b.is_active) - Number(a.is_active) || b.paired_at - a.paired_at || a.url.localeCompare(b.url)
))
}
async function renameHost(args: ParsedArgs): Promise<number> {
const url = args.positional.shift()?.trim() ?? ''
const name = args.positional.join(' ').trim()
if (!url || !(await getSession(url))) {
process.stderr.write('error: `hosts rename` requires a paired relay URL\n')
return 1
}
if (!name || name.length > 64 || /[\r\n\t]/.test(name)) {
process.stderr.write('error: host name must be 1-64 characters on one line\n')
return 2
}
await setDesktopHostAlias(url, name)
if (args.flags.json) process.stdout.write(JSON.stringify({ ok: true, url, host: name }) + '\n')
else process.stdout.write(makeTheme({ noColor: !!args.flags['no-color'] }).okLine(`renamed host to ${name}`) + '\n')
return 0
}
async function listHosts(args: ParsedArgs): Promise<number> {
const hosts = await localHosts()
if (args.flags.json) {
process.stdout.write(JSON.stringify({ hosts }, null, 2) + '\n')
return 0
}
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (hosts.length === 0) {
process.stdout.write(t.muted('No Hermes hosts are paired. Run `hermes-relay pair`.') + '\n')
return 0
}
process.stdout.write(t.bold(`Paired Hermes hosts (${hosts.length})`) + '\n')
for (const host of hosts) {
process.stdout.write(
` ${host.is_active ? '*' : ' '} ${host.host} ${host.access_mode.replace('_', '-')}\n` +
t.muted(` ${host.url}${host.endpoint_role ? ` (${host.endpoint_role})` : ''}`) + '\n'
)
}
return 0
}
async function selectHost(args: ParsedArgs): Promise<number> {
const url = args.positional[0]?.trim()
if (!url) {
process.stderr.write('error: `hosts select` requires a relay URL\n')
return 2
}
if (!(await getSession(url))) {
process.stderr.write(`error: ${url} is not paired on this PC\n`)
return 1
}
await setActiveDesktopRelayUrl(url)
if (args.flags.json) process.stdout.write(JSON.stringify({ ok: true, active_url: url }) + '\n')
else process.stdout.write(makeTheme({ noColor: !!args.flags['no-color'] }).okLine(`selected host ${hostLabel(url)}`) + '\n')
return 0
}
async function setAccess(args: ParsedArgs): Promise<number> {
const mode = parseAccessMode(args.positional[0])
if (!mode) {
process.stderr.write('error: access mode must be ask, trusted, or full-access\n')
return 2
}
const requested = typeof args.flags.remote === 'string' ? args.flags.remote.trim() : ''
const url = requested || await getActiveDesktopRelayUrl() || ''
const session = url ? await getSession(url) : null
if (!url || !session) {
process.stderr.write('error: select or pass a locally paired Hermes host\n')
return 1
}
if (mode === 'full_access' && args.flags.yes !== true) {
process.stderr.write(
'error: Full Access allows this host to use commands, files, screen, keyboard, and mouse without task grants. Pass --yes to confirm.\n'
)
return 2
}
const policy = await setHostAccessMode(url, mode)
await saveSession(url, session.token, session.serverVersion, {
pairedAt: session.pairedAt,
toolsConsented: mode !== 'ask'
})
const payload = {
ok: true,
url,
access_mode: policy.access_mode,
restart_required: true
}
if (args.flags.json) process.stdout.write(JSON.stringify(payload, null, 2) + '\n')
else {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
process.stdout.write(t.okLine(`${hostLabel(url)} access set to ${mode.replace('_', '-')}`) + '\n')
process.stdout.write(t.muted('Restart the daemon to apply this policy.') + '\n')
}
return 0
}
export async function hostsCommand(args: ParsedArgs): Promise<number> {
if (args.flags.help) {
printUsage(HOSTS_USAGE, makeTheme({ noColor: !!args.flags['no-color'] }))
return 0
}
const subcommand = args.positional.shift() ?? 'list'
if (subcommand === 'list') return listHosts(args)
if (subcommand === 'select') return selectHost(args)
if (subcommand === 'rename') return renameHost(args)
if (subcommand === 'access') return setAccess(args)
return unknownSubcommand(HOSTS_USAGE, subcommand, makeTheme({ noColor: !!args.flags['no-color'] }))
}
export default hostsCommand
+2 -1
View File
@@ -11,6 +11,7 @@
// render "Paired via LAN / Tailscale / Public" correctly).
import type { ParsedArgs } from '../cli.js'
import { desktopRelayIdentity } from '../deviceIdentity.js'
import { formatError } from '../lib/hints.js'
import { SYMBOLS, theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
@@ -191,7 +192,7 @@ export async function pairCommand(args: ParsedArgs): Promise<number> {
const relay = new RelayTransport({
url: target.url,
pairingCode: target.code,
deviceName: `hermes-relay-cli (${process.platform})`
...desktopRelayIdentity()
})
relay.start()
+3 -1
View File
@@ -48,6 +48,7 @@
import { buildConnectBanner } from '../banner.js'
import type { ParsedArgs } from '../cli.js'
import { desktopRelayIdentity } from '../deviceIdentity.js'
import { resolveCredentials } from '../credentials.js'
import { GatewayClient } from '../gatewayClient.js'
import type { GatewayEvent } from '../gatewayTypes.js'
@@ -140,7 +141,7 @@ export async function connectAndAuth(args: ParsedArgs): Promise<AuthedRelay> {
const cfg: ConstructorParameters<typeof RelayTransport>[0] = {
url,
deviceName: `hermes-relay-cli shell (${process.platform})`
...desktopRelayIdentity()
}
if (creds.pairingCode) {
cfg.pairingCode = creds.pairingCode
@@ -417,6 +418,7 @@ export async function shellCommand(args: ParsedArgs): Promise<number> {
const advertisedTools = advertisedDesktopTools({ computerUse: computerUseEnabled })
toolRouter = new DesktopToolRouter({
consentGranted: true,
hostUrl: url,
handlers: desktopHandlers({ computerUse: computerUseEnabled }),
advertisedTools: [...advertisedTools]
})
+2 -1
View File
@@ -4,6 +4,7 @@
// so the user can see which toolsets are enabled before spending a prompt.
import type { ParsedArgs } from '../cli.js'
import { desktopRelayIdentity } from '../deviceIdentity.js'
import { resolveCredentials } from '../credentials.js'
import { GatewayClient } from '../gatewayClient.js'
import type { GatewayEvent, ToolsListResponse } from '../gatewayTypes.js'
@@ -101,7 +102,7 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
const relayCfg: ConstructorParameters<typeof RelayTransport>[0] = {
url,
deviceName: `hermes-relay-cli (${process.platform})`
...desktopRelayIdentity()
}
if (creds.pairingCode) {
relayCfg.pairingCode = creds.pairingCode
+73
View File
@@ -0,0 +1,73 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { basename, dirname, join, win32 } from 'node:path'
import type { ParsedArgs } from '../cli.js'
import { updateCommand } from './update.js'
export function uiExecutablePath(env = process.env, executable = process.execPath): string {
if (env.HERMES_RELAY_UI_PATH) return env.HERMES_RELAY_UI_PATH
if (env.HERMES_RELAY_INSTALL_DIR) {
const pathApi = win32.isAbsolute(env.HERMES_RELAY_INSTALL_DIR) ? win32 : { join }
return pathApi.join(env.HERMES_RELAY_INSTALL_DIR, 'hermes-relay-tray.exe')
}
const executableName = win32.basename(executable).toLowerCase()
if (executableName === 'hermes-relay.exe') {
return win32.join(win32.dirname(executable), 'hermes-relay-tray.exe')
}
if (basename(executable).toLowerCase() === 'hermes-relay') {
return join(dirname(executable), 'hermes-relay-tray.exe')
}
return join(homedir(), '.hermes', 'bin', 'hermes-relay-tray.exe')
}
function printHelp(): void {
process.stdout.write(`Usage: hermes-relay ui [open|status|install]\n\n`)
process.stdout.write(` open Open the optional Windows management UI (default)\n`)
process.stdout.write(` status Report whether the management UI is installed\n`)
process.stdout.write(` install Download, verify, and launch the CLI + UI installer\n`)
process.stdout.write(`\ninstall follows update safety rules: use --yes for non-interactive setup.\n`)
}
export async function uiCommand(args: ParsedArgs): Promise<number> {
if (process.platform !== 'win32') {
process.stderr.write('ui: the optional management UI is currently Windows-only.\n')
return 2
}
const action = args.positional[0]?.toLowerCase() ?? 'open'
if (args.flags.help) {
printHelp()
return 0
}
if (action === 'install') {
return updateCommand({ ...args, flags: { ...args.flags, installer: true } })
}
if (action !== 'open' && action !== 'status') {
process.stderr.write(`ui: unknown action '${action}'. Expected open, status, or install.\n`)
return 2
}
const path = uiExecutablePath()
const installed = existsSync(path)
if (action === 'status' || args.flags.json) {
if (args.flags.json) {
process.stdout.write(JSON.stringify({ installed, path }, null, 2) + '\n')
} else {
process.stdout.write(installed ? `Hermes-Relay CLI UI is installed at ${path}\n` : 'Hermes-Relay CLI UI is not installed.\n')
}
return installed ? 0 : 1
}
if (!installed) {
process.stderr.write('ui: Hermes-Relay CLI UI is not installed. Run `hermes-relay ui install`.\n')
return 1
}
const child = spawn(path, ['--show'], { detached: true, stdio: 'ignore' })
child.unref()
process.stdout.write('Opening Hermes-Relay CLI UI.\n')
return 0
}
export default uiCommand
+78 -8
View File
@@ -5,6 +5,7 @@
// hermes-relay update --check check only, print status, exit 0
// hermes-relay update --yes skip confirm prompt
// hermes-relay update --json machine-readable output
// hermes-relay update --installer target the Windows CLI UI installer
//
// Behaviour notes:
// - Never silently self-replaces. --yes is the only path that skips confirm.
@@ -14,15 +15,21 @@
// on next `hermes-relay` invocation (see updater.ts → finalizePendingUpdate).
import { createInterface } from 'node:readline'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import type { ParsedArgs } from '../cli.js'
import {
assetNameForPlatform,
checkForUpdate,
compareVersions,
downloadAndInstall,
type DownloadOptions,
type UpdateInfo
} from '../updater.js'
import { VERSION } from '../version.js'
import { scheduleWindowsInstaller } from '../windowsInstaller.js'
import { isDaemonProcessAlive, readDaemonStatus } from '../lib/daemonStatus.js'
function humanBytes(n: number | null): string {
if (n === null) return '?'
@@ -74,6 +81,7 @@ function renderProgressBar(bytes: number, total: number, width = 28): string {
interface JsonReport {
current: string
up_to_date: boolean
ahead_of_latest: boolean
latest_tag: string | null
latest_version: string | null
is_prerelease: boolean
@@ -81,6 +89,8 @@ interface JsonReport {
asset_name: string | null
asset_size: number | null
installed: boolean
downloaded: boolean
installer_scheduled: boolean
installed_path: string | null
needs_restart: boolean
error: string | null
@@ -90,6 +100,7 @@ function emptyReport(): JsonReport {
return {
current: VERSION,
up_to_date: true,
ahead_of_latest: false,
latest_tag: null,
latest_version: null,
is_prerelease: false,
@@ -97,6 +108,8 @@ function emptyReport(): JsonReport {
asset_name: null,
asset_size: null,
installed: false,
downloaded: false,
installer_scheduled: false,
installed_path: null,
needs_restart: false,
error: null
@@ -107,6 +120,7 @@ function reportFromInfo(info: UpdateInfo): JsonReport {
return {
current: info.current,
up_to_date: !info.is_upgrade,
ahead_of_latest: compareVersions(info.current, info.latest_version) > 0,
latest_tag: info.latest_tag,
latest_version: info.latest_version,
is_prerelease: info.is_prerelease,
@@ -114,6 +128,8 @@ function reportFromInfo(info: UpdateInfo): JsonReport {
asset_name: info.asset_name,
asset_size: info.asset_size,
installed: false,
downloaded: false,
installer_scheduled: false,
installed_path: null,
needs_restart: false,
error: null
@@ -124,12 +140,19 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
const wantJson = !!args.flags.json
const checkOnly = !!args.flags.check
const autoYes = !!args.flags.yes
const installer = !!args.flags.installer
const downloadOnly = !!args.flags['download-only']
const force = !!args.flags.force
const repo = typeof args.flags.repo === 'string' ? args.flags.repo : undefined
let info: UpdateInfo | null = null
try {
info = await checkForUpdate(repo !== undefined ? { repo } : {})
const checkOpts = {
...(repo !== undefined ? { repo } : {}),
...(installer ? { assetName: 'hermes-relay-windows-x64-setup.exe' } : {})
}
info = await checkForUpdate(checkOpts)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (wantJson) {
@@ -160,12 +183,26 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
return 0
}
if (installer && report.ahead_of_latest && !force) {
const msg = `latest UI bundle (${info.latest_version}) is older than this CLI (${VERSION}); pass --force to downgrade explicitly`
if (wantJson) {
report.error = msg
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
} else {
process.stderr.write(`update: ${msg}\n`)
}
return 2
}
if (!wantJson) {
process.stdout.write(`Current version: ${info.current}\n`)
process.stdout.write(`Checking GitHub Releases...\n`)
}
if (!info.is_upgrade) {
// The full Windows installer is also how a CLI-only installation adds the
// optional UI. Do not suppress that operation merely because the bundled
// CLI version is already current.
if (!info.is_upgrade && !installer) {
if (wantJson) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
return 0
@@ -195,7 +232,7 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
}
// Refuse to proceed if this platform has no matching asset.
const wantAsset = assetNameForPlatform()
const wantAsset = installer ? 'hermes-relay-windows-x64-setup.exe' : assetNameForPlatform()
if (!wantAsset || !info.asset_url) {
const msg = `no binary available for ${process.platform}/${process.arch}`
if (wantJson) {
@@ -241,14 +278,45 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
: undefined
try {
const downloadOpts = onProgress ? { onProgress } : {}
const downloadOpts: DownloadOptions = onProgress ? { onProgress } : {}
if (installer) {
downloadOpts.targetPath = join(
tmpdir(),
`hermes-relay-${info.latest_version}-${process.pid}-${Date.now()}-setup.exe`
)
downloadOpts.cooperative = false
}
const result = await downloadAndInstall(info, downloadOpts)
if (installer && !downloadOnly) {
if (process.platform !== 'win32') {
throw new Error('the Hermes-Relay CLI UI installer is Windows-only')
}
const daemon = await readDaemonStatus()
const installDir = process.execPath.toLowerCase().endsWith('hermes-relay.exe')
? dirname(process.execPath)
: join(homedir(), '.hermes', 'bin')
scheduleWindowsInstaller(result.installedPath, {
silent: autoYes || wantJson,
restartDaemon: daemon ? isDaemonProcessAlive(daemon) : false,
installDir,
cliPath: join(installDir, 'hermes-relay.exe'),
trayPath: join(installDir, 'hermes-relay-tray.exe')
})
}
if (useTtyProgress) process.stdout.write('\n')
if (!wantJson) {
process.stdout.write('-> verifying SHA256... ok\n')
process.stdout.write(`-> installed at ${result.installedPath}\n`)
if (result.needsRestart) {
process.stdout.write(
installer
? downloadOnly
? `-> verified installer at ${result.installedPath}\n`
: `-> verified installer; setup will open after this CLI exits\n`
: `-> installed at ${result.installedPath}\n`
)
if (installer && !downloadOnly) {
process.stdout.write(' The installer updates the CLI and lets you enable the optional tray at sign-in.\n')
} else if (result.needsRestart) {
process.stdout.write(
` On next \`hermes-relay\` invocation, the CLI will swap this in automatically.\n`
)
@@ -263,9 +331,11 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
return 0
}
report.installed = true
report.installed = !installer
report.downloaded = installer
report.installer_scheduled = installer && !downloadOnly
report.installed_path = result.installedPath
report.needs_restart = result.needsRestart
report.needs_restart = installer ? !downloadOnly : result.needsRestart
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
return 0
} catch (err) {
+2 -1
View File
@@ -24,6 +24,7 @@
// here without restructuring — the resolver and HTTP helper are reusable.
import type { ParsedArgs } from '../cli.js'
import { desktopRelayIdentity } from '../deviceIdentity.js'
import { resolveCredentials } from '../credentials.js'
import { GatewayClient } from '../gatewayClient.js'
import type {
@@ -388,7 +389,7 @@ async function connectAndAuth(args: ParsedArgs): Promise<AuthedRelay> {
const cfg: ConstructorParameters<typeof RelayTransport>[0] = {
url,
deviceName: `hermes-relay-cli voice (${process.platform})`
...desktopRelayIdentity()
}
if (creds.pairingCode) cfg.pairingCode = creds.pairingCode
if (creds.sessionToken) cfg.sessionToken = creds.sessionToken
+73 -3
View File
@@ -1,12 +1,20 @@
import { promises as fs } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
interface DesktopControlConfigFile {
interface DesktopControlConfigFile extends Record<string, unknown> {
relay_url?: unknown
host_aliases?: unknown
}
const configPath = () => join(homedir(), '.hermes', 'desktop-control.json')
let configPathOverride: string | null = null
const configPath = () => configPathOverride ?? join(homedir(), '.hermes', 'desktop-control.json')
/** Test-only path override. Pass null to restore the normal user config path. */
export function setDesktopConfigPath(path: string | null): void {
configPathOverride = path
}
export async function getActiveDesktopRelayUrl(): Promise<string | null> {
try {
@@ -18,3 +26,65 @@ export async function getActiveDesktopRelayUrl(): Promise<string | null> {
return null
}
}
export async function getDesktopHostAliases(): Promise<Record<string, string>> {
try {
const parsed = JSON.parse(await fs.readFile(configPath(), 'utf8')) as DesktopControlConfigFile
if (!parsed.host_aliases || typeof parsed.host_aliases !== 'object' || Array.isArray(parsed.host_aliases)) return {}
return Object.fromEntries(Object.entries(parsed.host_aliases as Record<string, unknown>)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim().length > 0)
.map(([url, alias]) => [url, alias.trim()]))
} catch {
return {}
}
}
export async function setDesktopHostAlias(url: string, alias: string | null): Promise<void> {
const path = configPath()
let config: DesktopControlConfigFile = {}
try {
const parsed = JSON.parse(await fs.readFile(path, 'utf8')) as unknown
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) config = parsed as DesktopControlConfigFile
} catch { /* Missing or malformed config starts clean. */ }
const aliases = config.host_aliases && typeof config.host_aliases === 'object' && !Array.isArray(config.host_aliases)
? { ...(config.host_aliases as Record<string, unknown>) }
: {}
const normalized = alias?.trim() ?? ''
if (normalized) aliases[url] = normalized
else delete aliases[url]
if (Object.keys(aliases).length) config.host_aliases = aliases
else delete config.host_aliases
await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 })
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`
await fs.writeFile(temporary, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 })
await fs.rename(temporary, path)
}
/** Persist the tray-selected host without discarding unrelated desktop-control
* settings written by older releases. A null URL clears only the selection. */
export async function setActiveDesktopRelayUrl(url: string | null): Promise<void> {
const path = configPath()
let config: DesktopControlConfigFile = {}
try {
const parsed = JSON.parse(await fs.readFile(path, 'utf8')) as unknown
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
config = parsed as DesktopControlConfigFile
}
} catch {
// Missing or malformed config starts from a safe empty object.
}
const normalized = url?.trim() ?? ''
if (normalized) {
config.relay_url = normalized
} else {
delete config.relay_url
}
await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 })
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`
await fs.writeFile(temporary, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 })
await fs.rename(temporary, path)
}
+46
View File
@@ -0,0 +1,46 @@
import { execFileSync } from 'node:child_process'
import { hostname, machine, release, type } from 'node:os'
function windowsDeviceModel(): string | null {
if (process.platform !== 'win32') return null
try {
const output = execFileSync(
'reg.exe',
[
'query',
String.raw`HKLM\HARDWARE\DESCRIPTION\System\BIOS`,
'/v',
'SystemProductName'
],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 1_000,
windowsHide: true
}
)
return output.match(/SystemProductName\s+REG_SZ\s+(.+)$/im)?.[1]?.trim() || null
} catch {
return null
}
}
/** Consistent identity metadata for every desktop relay connection surface. */
export function desktopRelayIdentity(): {
deviceName: string
deviceHostname: string
deviceModel: string
devicePlatform: string
clientSurface: string
deviceFormFactor: string
} {
const host = hostname().trim() || 'Hermes-Relay desktop'
return {
deviceName: host,
deviceHostname: host,
deviceModel: windowsDeviceModel() || machine() || process.arch,
devicePlatform: `${type()} ${release()}`.trim(),
clientSurface: 'desktop',
deviceFormFactor: 'desktop'
}
}
+28
View File
@@ -16,10 +16,19 @@ import { dirname, join } from 'node:path'
export interface AuditEntry {
/** Epoch milliseconds when the command completed. */
ts: number
/** Stable event type for consumers that do not want to infer it from fields. */
kind?: 'tool.completed'
tool: string
category?: AuditCategory
ok: boolean
aborted?: boolean
request_id?: string
/** Relay identity that issued the action, when known. */
host_url?: string
/** Handler wall time, excluding relay transport latency. */
duration_ms?: number
/** Process exit code when the handler returns one. Non-zero is attention-worthy. */
exit_code?: number
/** Truncated preview of the call args, for context. */
args_preview?: string
/** Short success summary (path / exit code / first stdout line). */
@@ -27,6 +36,8 @@ export interface AuditEntry {
error?: string
}
export type AuditCategory = 'command' | 'files' | 'screen' | 'input' | 'system' | 'other'
/** Rotate the log once it crosses ~1 MB, keeping a single `.1` backup. */
const MAX_BYTES = 1_000_000
@@ -112,3 +123,20 @@ export function summarizeResult(result: unknown): string | undefined {
}
return undefined
}
export function resultExitCode(result: unknown): number | undefined {
if (result === null || typeof result !== 'object') return undefined
const value = (result as Record<string, unknown>).exit_code
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
/** A small stable taxonomy shared by CLI audit consumers and the tray UI. */
export function categorizeTool(tool: string): AuditCategory {
const value = tool.toLowerCase()
if (value.includes('computer_screenshot') || value.includes('screen')) return 'screen'
if (value.includes('computer_') || value.includes('mouse') || value.includes('keyboard')) return 'input'
if (value.includes('file') || value.includes('directory') || value.includes('patch')) return 'files'
if (value.includes('shell') || value.includes('terminal') || value.includes('powershell') || value.includes('job')) return 'command'
if (value.includes('daemon') || value.includes('connect')) return 'system'
return 'other'
}
+46 -1
View File
@@ -9,9 +9,11 @@
import { promises as fs } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import { execFileSync } from 'node:child_process'
import type { ProcessPrivilege } from './processPrivilege.js'
import type { HostAccessMode } from './hostAccessPolicy.js'
export type DaemonState = 'starting' | 'connected' | 'reconnecting' | 'stopped'
@@ -24,6 +26,9 @@ export interface DaemonComputerGrantStatus {
export interface DaemonStatus {
pid: number
/** Executable image recorded by the daemon. Used to reject a recycled PID
* before status/stop treats an unrelated process as Hermes Relay. */
process_name?: string
url: string
state: DaemonState
/** Epoch seconds. */
@@ -38,6 +43,8 @@ export interface DaemonStatus {
username?: string
privilege?: ProcessPrivilege
computer_use_enabled?: boolean
access_mode?: HostAccessMode
tools_enabled?: boolean
computer_grant?: DaemonComputerGrantStatus
}
@@ -83,3 +90,41 @@ export function isPidAlive(pid: number): boolean {
return (e as NodeJS.ErrnoException)?.code === 'EPERM'
}
}
function normalizeProcessName(value: string): string {
return basename(value.trim()).replace(/\.exe$/i, '').toLocaleLowerCase('en-US')
}
function processNameForPid(pid: number): string | null {
try {
if (process.platform === 'win32') {
const output = execFileSync(
'tasklist.exe',
['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'],
{ encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }
)
return /^"([^"]+)"/.exec(output.trim())?.[1] ?? null
}
const output = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
return output.trim() || null
} catch {
return null
}
}
/** Validate both PID existence and executable identity. PID-only probes are
* unsafe for status/stop because operating systems can recycle a daemon PID
* for an unrelated process immediately after a crash. */
export function isDaemonProcessAlive(
status: Pick<DaemonStatus, 'pid' | 'process_name'>,
lookup: (pid: number) => string | null = processNameForPid
): boolean {
if (!isPidAlive(status.pid)) return false
const actual = lookup(status.pid)
if (!actual) return false
const expected = status.process_name ?? basename(process.execPath)
return normalizeProcessName(actual) === normalizeProcessName(expected)
}
+193
View File
@@ -0,0 +1,193 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
export const HOST_ACCESS_MODES = ['ask', 'trusted', 'full_access'] as const
export type HostAccessMode = (typeof HOST_ACCESS_MODES)[number]
export interface HostAccessPolicy {
access_mode: HostAccessMode
updated_at?: string
}
export interface HostAccessPolicyFile {
version: 1
hosts: Record<string, HostAccessPolicy>
}
const STORE_VERSION = 1 as const
export function hostAccessPolicyPath(): string {
return process.env.HERMES_RELAY_HOST_ACCESS_POLICY_PATH ??
join(homedir(), '.hermes', 'desktop-host-access.json')
}
/**
* Return the stable identity used for per-host policy. Relay URLs are limited
* to WebSocket schemes; credentials, query strings, and fragments are rejected
* because they are connection details, not host identity.
*/
export function canonicalRelayUrl(raw: string): string | null {
try {
const url = new URL(raw.trim())
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return null
if (!url.hostname || url.username || url.password || url.search || url.hash) return null
url.pathname = url.pathname.replace(/\/+$/, '') || '/'
const canonical = url.toString()
return url.pathname === '/' ? canonical.replace(/\/$/, '') : canonical
} catch {
return null
}
}
export function isHostAccessMode(value: unknown): value is HostAccessMode {
return typeof value === 'string' && HOST_ACCESS_MODES.includes(value as HostAccessMode)
}
const emptyStore = (): HostAccessPolicyFile => ({ version: STORE_VERSION, hosts: {} })
const restrictiveness = (mode: HostAccessMode): number => HOST_ACCESS_MODES.indexOf(mode)
function parsePolicy(value: unknown): HostAccessPolicy | null {
if (isHostAccessMode(value)) return { access_mode: value }
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const raw = value as Record<string, unknown>
const mode = raw.access_mode ?? raw.mode
if (!isHostAccessMode(mode)) return null
return {
access_mode: mode,
updated_at: typeof raw.updated_at === 'string' ? raw.updated_at : undefined
}
}
function mergePolicy(
hosts: Record<string, HostAccessPolicy>,
rawUrl: string,
policy: HostAccessPolicy
): void {
const url = canonicalRelayUrl(rawUrl)
if (!url) return
const current = hosts[url]
if (!current || restrictiveness(policy.access_mode) < restrictiveness(current.access_mode)) {
hosts[url] = policy
}
}
/**
* Parse the versioned store and the two pre-release shapes used by prototypes:
* a bare URL-to-policy map and trusted_hosts/full_access_hosts arrays. Unknown
* data is ignored and duplicate canonical URLs resolve to the least privilege.
*/
export function parseHostAccessPolicyFile(value: unknown): HostAccessPolicyFile {
if (!value || typeof value !== 'object' || Array.isArray(value)) return emptyStore()
const raw = value as Record<string, unknown>
const hosts: Record<string, HostAccessPolicy> = {}
const policyMap = raw.hosts && typeof raw.hosts === 'object' && !Array.isArray(raw.hosts)
? raw.hosts as Record<string, unknown>
: raw
for (const [url, value] of Object.entries(policyMap)) {
const policy = parsePolicy(value)
if (policy) mergePolicy(hosts, url, policy)
}
for (const [field, mode] of [
['trusted_hosts', 'trusted'],
['full_access_hosts', 'full_access']
] as const) {
const urls = raw[field]
if (!Array.isArray(urls)) continue
for (const url of urls) {
if (typeof url === 'string') mergePolicy(hosts, url, { access_mode: mode })
}
}
return { version: STORE_VERSION, hosts }
}
export async function readHostAccessPolicies(
filePath = hostAccessPolicyPath()
): Promise<HostAccessPolicyFile> {
try {
return parseHostAccessPolicyFile(JSON.parse(await readFile(filePath, 'utf8')))
} catch {
return emptyStore()
}
}
async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
await mkdir(dirname(filePath), { recursive: true, mode: 0o700 })
const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
try {
await writeFile(temporaryPath, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 })
await rename(temporaryPath, filePath)
} finally {
await rm(temporaryPath, { force: true }).catch(() => undefined)
}
}
export async function getHostAccessMode(
relayUrl: string,
filePath = hostAccessPolicyPath()
): Promise<HostAccessMode> {
const canonical = canonicalRelayUrl(relayUrl)
if (!canonical) return 'ask'
return (await readHostAccessPolicies(filePath)).hosts[canonical]?.access_mode ?? 'ask'
}
export async function setHostAccessMode(
relayUrl: string,
accessMode: HostAccessMode,
filePath = hostAccessPolicyPath()
): Promise<HostAccessPolicy> {
const canonical = canonicalRelayUrl(relayUrl)
if (!canonical) throw new Error('host access policy requires a valid ws:// or wss:// relay URL')
if (!isHostAccessMode(accessMode)) throw new Error(`unsupported host access mode: ${String(accessMode)}`)
const store = await readHostAccessPolicies(filePath)
const policy: HostAccessPolicy = {
access_mode: accessMode,
updated_at: new Date().toISOString()
}
store.hosts[canonical] = policy
await writeJsonAtomic(filePath, store)
return policy
}
export async function removeHostAccessPolicy(
relayUrl: string,
filePath = hostAccessPolicyPath()
): Promise<boolean> {
const canonical = canonicalRelayUrl(relayUrl)
if (!canonical) return false
const store = await readHostAccessPolicies(filePath)
if (!(canonical in store.hosts)) return false
delete store.hosts[canonical]
await writeJsonAtomic(filePath, store)
return true
}
/** Full access is the only mode that bypasses task-scoped action grants. */
export function requiresTaskGrant(mode: HostAccessMode): boolean {
return mode !== 'full_access'
}
export function hasFullAccess(mode: HostAccessMode): boolean {
return mode === 'full_access'
}
/** Preserve the pre-policy consent contract without displaying a false Ask
* state. Explicit Ask clears toolsConsented, so a remaining true value is an
* unambiguous legacy Trusted host. */
export function effectiveHostAccessMode(
storedMode: HostAccessMode,
legacyToolsConsented: boolean
): HostAccessMode {
return storedMode === 'ask' && legacyToolsConsented ? 'trusted' : storedMode
}
+2 -1
View File
@@ -142,6 +142,7 @@ export const getSession = async (url: string): Promise<RemoteSessionRecord | nul
}
export interface SaveSessionOptions {
pairedAt?: number
certPin?: string | null
grants?: Record<string, number | null> | null
ttlExpiresAt?: number | null
@@ -169,7 +170,7 @@ export const saveSession = async (
file.sessions[url] = fromRecord({
token,
serverVersion,
pairedAt: Math.floor(Date.now() / 1000),
pairedAt: options.pairedAt ?? Math.floor(Date.now() / 1000),
certPinSha256: options.certPin ?? prev?.cert_pin_sha256 ?? null,
grants: options.grants ?? prev?.grants ?? null,
ttlExpiresAt: options.ttlExpiresAt ?? prev?.ttl_expires_at ?? null,
+31 -3
View File
@@ -1,3 +1,5 @@
import type { HostAccessMode } from '../lib/hostAccessPolicy.js'
export type ComputerGrantMode = 'observe' | 'assist' | 'control'
export interface ComputerGrantScope {
@@ -19,6 +21,7 @@ export interface ComputerUseRuntime {
url: string | null
computerUseConsented: boolean
consentSource: 'stored' | 'prompted' | 'override' | 'none'
accessMode: HostAccessMode
}
let activeGrant: ComputerGrant | null = null
@@ -26,7 +29,8 @@ let grantChangeListener: ((grant: ComputerGrant | null) => void) | null = null
let runtime: ComputerUseRuntime = {
url: null,
computerUseConsented: false,
consentSource: 'none'
consentSource: 'none',
accessMode: 'ask'
}
function nowMs(): number {
@@ -97,6 +101,15 @@ export function getActiveComputerGrant(): ComputerGrant | null {
}
export function getComputerGrantSummary(): Record<string, unknown> {
if (runtime.accessMode === 'full_access') {
return {
active: true,
mode: 'full_access',
expires_at: null,
scope: null,
reason: 'This host has Full Access.'
}
}
const grant = getActiveComputerGrant()
if (!grant) {
return {
@@ -127,7 +140,9 @@ export function getComputerUseRuntimeSummary(): Record<string, unknown> {
return {
url: runtime.url,
consented: runtime.computerUseConsented,
consent_source: runtime.consentSource
consent_source: runtime.consentSource,
access_mode: runtime.accessMode,
full_access: runtime.accessMode === 'full_access'
}
}
@@ -139,6 +154,14 @@ export interface RequestComputerGrantInput {
}
export function requestComputerGrant(input: RequestComputerGrantInput): Record<string, unknown> {
if (runtime.accessMode === 'full_access') {
return {
ok: true,
full_access: true,
grant: getComputerGrantSummary(),
message: 'This host already has Full Access; no task grant is required.'
}
}
const mode = input.mode
const reason = normalizeComputerGrantReason(input.reason)
const durationSeconds = normalizeComputerGrantDurationSeconds(input.duration_seconds)
@@ -189,10 +212,15 @@ export function cancelComputerGrant(reason = 'cancelled'): Record<string, unknow
}
export function hasComputerInputGrant(): boolean {
if (runtime.accessMode === 'full_access') return true
const grant = getActiveComputerGrant()
return grant?.mode === 'assist' || grant?.mode === 'control'
}
export function hasComputerObserveGrant(): boolean {
return getActiveComputerGrant() !== null
return runtime.accessMode === 'full_access' || getActiveComputerGrant() !== null
}
export function hasFullHostAccess(): boolean {
return runtime.accessMode === 'full_access'
}
+18 -6
View File
@@ -234,6 +234,7 @@ export const computerStatusHandler: ToolHandler = async (_args, ctx) => {
const grant = getActiveComputerGrant()
const runtime = getComputerUseRuntimeSummary()
const inputBackendReady = runtime.consented === true && process.platform === 'win32'
const fullAccess = runtime.full_access === true
return {
ok: true,
...EXPERIMENTAL_META,
@@ -241,10 +242,10 @@ export const computerStatusHandler: ToolHandler = async (_args, ctx) => {
displays: await getDisplays(),
runtime,
permissions: {
screenshot: grant ? 'granted' : 'grant_required',
input: grant?.mode === 'assist' || grant?.mode === 'control'
screenshot: fullAccess ? 'full_access' : grant ? 'granted' : 'grant_required',
input: fullAccess || grant?.mode === 'assist' || grant?.mode === 'control'
? inputBackendReady
? 'granted_until_expiry'
? fullAccess ? 'full_access' : 'granted_until_expiry'
: 'grant_active_but_input_backend_unavailable'
: 'not_granted',
accessibility: 'not_implemented'
@@ -254,8 +255,8 @@ export const computerStatusHandler: ToolHandler = async (_args, ctx) => {
visible: ctx.interactive,
state: ctx.interactive ? 'cli_grant_prompt_available' : 'not_available',
message: ctx.interactive
? 'CLI grant approval prompts are available. Native overlay/tray UI is still planned.'
: 'Native overlay/tray UI is not implemented and this client is non-interactive.'
? 'Local grant approval is available through the CLI or Hermes-Relay CLI UI.'
: 'This client is non-interactive; use the Hermes-Relay CLI UI approval card or hermes-relay grants.'
},
safety: {
host_input: process.platform === 'win32' ? 'windows_only_with_local_approval' : 'unsupported_platform',
@@ -365,8 +366,19 @@ export const computerGrantRequestHandler: ToolHandler = async (args, ctx) => {
if (!mode) {
return failure('invalid_request', 'mode must be one of observe, assist, or control.')
}
const runtime = getComputerUseRuntimeSummary()
if (runtime.full_access === true) {
return {
...requestComputerGrant({
mode,
scope: args.scope,
duration_seconds: args.duration_seconds,
reason: args.reason
}),
...EXPERIMENTAL_META
}
}
if (mode !== 'observe') {
const runtime = getComputerUseRuntimeSummary()
if (runtime.consented !== true) {
return failure(
'computer_use_consent_required',
+8 -5
View File
@@ -12,6 +12,7 @@ import { promises as fs } from 'node:fs'
import * as path from 'node:path'
import { approveOrReject } from '../patchApproval.js'
import { hasFullHostAccess } from '../computerGrants.js'
import type { ToolContext, ToolHandler } from '../router.js'
const DEFAULT_MAX_BYTES = 1_000_000
@@ -247,15 +248,17 @@ export const patchHandler: ToolHandler = async (args, ctx) => {
// Interactive gate. The approver returns a decision — accepted or
// rejected with a reason — and never throws. If the user edited the
// diff we re-parse the edited version (strict; still no fuzz).
const decision = await approveOrReject(patchText, {
targetFile: abs,
interactive: ctx.interactive
})
const decision = hasFullHostAccess()
? { accepted: true }
: await approveOrReject(patchText, {
targetFile: abs,
interactive: ctx.interactive
})
if (!decision.accepted) {
const reason = decision.reason ?? 'user rejected patch'
throw new Error(`patch rejected: ${reason}`)
}
let approvalTag: 'auto' | 'user' | 'edited' = 'user'
let approvalTag: 'auto' | 'user' | 'edited' = hasFullHostAccess() ? 'auto' : 'user'
if (decision.editedPatch && decision.editedPatch !== patchText) {
try {
hunks = parseUnifiedDiff(decision.editedPatch)
+21 -1
View File
@@ -25,7 +25,13 @@ import * as os from 'node:os'
import type { RelayTransport } from '../transport/RelayTransport.js'
import { appendAudit, previewArgs, summarizeResult } from '../lib/auditLog.js'
import {
appendAudit,
categorizeTool,
previewArgs,
resultExitCode,
summarizeResult
} from '../lib/auditLog.js'
import { VERSION } from '../version.js'
import { getComputerGrantSummary, getComputerUseRuntimeSummary } from './computerGrants.js'
@@ -69,6 +75,8 @@ export type ToolHandler = (
export interface DesktopToolRouterOpts {
handlers: Record<string, ToolHandler>
/** Relay identity recorded with local audit events. */
hostUrl?: string
/** Optional override for the heartbeat `advertised_tools` list — default
* is `Object.keys(handlers)`. Useful when some handlers are stubs that
* should not be advertised. */
@@ -129,6 +137,7 @@ export class DesktopToolRouter {
private readonly advertisedTools: string[]
private readonly consentGranted: boolean
private readonly interactive: boolean
private readonly hostUrl?: string
private relay: RelayTransport | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private attached = false
@@ -143,6 +152,7 @@ export class DesktopToolRouter {
constructor(opts: DesktopToolRouterOpts) {
this.handlers = opts.handlers
this.hostUrl = opts.hostUrl
this.advertisedTools = opts.advertisedTools ?? Object.keys(opts.handlers)
this.consentGranted = opts.consentGranted ?? false
// Explicit opts.interactive wins; otherwise detect from env/TTY. We
@@ -264,6 +274,7 @@ export class DesktopToolRouter {
* paths — so the server's pending-request map never hangs. */
private async dispatch(cmd: ToolCallPayload): Promise<void> {
const { request_id, tool, args } = cmd
const startedAt = Date.now()
const handler = this.handlers[tool]
if (!handler) {
this.sendResponse({
@@ -301,9 +312,14 @@ export class DesktopToolRouter {
// Local audit trail — fire-and-forget so logging never delays the reply.
void appendAudit({
ts: Date.now(),
kind: 'tool.completed',
tool,
category: categorizeTool(tool),
ok: true,
request_id,
host_url: this.hostUrl,
duration_ms: Date.now() - startedAt,
exit_code: resultExitCode(result),
args_preview: previewArgs(args),
summary: summarizeResult(result)
})
@@ -325,10 +341,14 @@ export class DesktopToolRouter {
this.sendResponse({ request_id, ok: false, error: message })
void appendAudit({
ts: Date.now(),
kind: 'tool.completed',
tool,
category: categorizeTool(tool),
ok: false,
aborted,
request_id,
host_url: this.hostUrl,
duration_ms: Date.now() - startedAt,
args_preview: previewArgs(args),
error: message
})
+15
View File
@@ -121,6 +121,15 @@ export interface RelayTransportConfig {
sessionToken?: string
/** Human-readable label for the "Paired Devices" list. */
deviceName?: string
/** Machine hostname used as a fallback identity by newer relays. */
deviceHostname?: string
/** Hardware/model descriptor retained as secondary metadata. */
deviceModel?: string
/** OS/platform descriptor retained as secondary metadata. */
devicePlatform?: string
/** Client family and physical form-factor metadata. */
clientSurface?: string
deviceFormFactor?: string
/** Stable per-install identifier. */
deviceId?: string
/** Requested session lifetime in seconds (0 = never expire). */
@@ -492,6 +501,12 @@ export class RelayTransport extends EventEmitter implements Transport {
payload.device_name = this.cfg.deviceName
}
if (this.cfg.deviceHostname) payload.device_hostname = this.cfg.deviceHostname
if (this.cfg.deviceModel) payload.device_model = this.cfg.deviceModel
if (this.cfg.devicePlatform) payload.device_platform = this.cfg.devicePlatform
if (this.cfg.clientSurface) payload.client_surface = this.cfg.clientSurface
if (this.cfg.deviceFormFactor) payload.device_form_factor = this.cfg.deviceFormFactor
if (this.cfg.deviceId) {
payload.device_id = this.cfg.deviceId
}
+7 -3
View File
@@ -48,6 +48,7 @@ export interface UpdateInfo {
export interface DownloadOptions {
targetPath?: string
onProgress?: (bytes: number, total: number) => void
cooperative?: boolean
}
export interface DownloadResult {
@@ -192,7 +193,7 @@ async function fetchReleases(repo: string): Promise<GhRelease[]> {
return data
}
export async function checkForUpdate(opts: { repo?: string } = {}): Promise<UpdateInfo | null> {
export async function checkForUpdate(opts: { repo?: string; assetName?: string } = {}): Promise<UpdateInfo | null> {
const repo = opts.repo ?? DEFAULT_REPO
const releases = await fetchReleases(repo)
@@ -214,7 +215,7 @@ export async function checkForUpdate(opts: { repo?: string } = {}): Promise<Upda
const latestVersion = releaseVersionFromTag(pick.tag_name)
const current = VERSION
const wantAsset = assetNameForPlatform()
const wantAsset = opts.assetName ?? assetNameForPlatform()
if (!wantAsset) {
// Unsupported platform — tell the caller the state but don't crash here.
// The `update` subcommand will surface this to the user.
@@ -337,7 +338,10 @@ export async function downloadAndInstall(
throw new Error(`no asset available for ${process.platform}/${process.arch}`)
}
const targetPath = opts.targetPath ?? process.execPath
const { downloadTmp, finalStaged, cooperative } = stagingPathFor(targetPath)
const paths = stagingPathFor(targetPath)
const cooperative = opts.cooperative ?? paths.cooperative
const finalStaged = cooperative ? paths.finalStaged : targetPath
const downloadTmp = paths.downloadTmp
// Best-effort cleanup of any stale temp file from a previous aborted run.
try { await unlink(downloadTmp) } catch { /* ok */ }
+1 -1
View File
@@ -1,2 +1,2 @@
// Regenerated from package.json by gen:version script. Do not edit by hand.
export const VERSION = "0.4.0-alpha.2" as const
export const VERSION = "0.4.0-alpha.7" as const
+81
View File
@@ -0,0 +1,81 @@
import { spawn, type SpawnOptions } from 'node:child_process'
export interface WindowsInstallerLaunchPlan {
program: string
args: string[]
options: SpawnOptions
}
export interface WindowsInstallerLaunchOptions {
silent: boolean
restartDaemon?: boolean
installDir?: string
cliPath?: string
trayPath?: string
delayMs?: number
}
/**
* Schedule the NSIS bundle after the current CLI process has released its
* executable. Windows will not replace a running hermes-relay.exe, so starting
* the installer directly from `hermes-relay ui install` is inherently racy.
*/
export function windowsInstallerLaunchPlan(
installerPath: string,
options: WindowsInstallerLaunchOptions
): WindowsInstallerLaunchPlan {
const {
silent,
restartDaemon = false,
installDir = '',
cliPath = '',
trayPath = '',
delayMs = 1200
} = options
const script = [
`Start-Sleep -Milliseconds ${Math.max(0, Math.floor(delayMs))}`,
'$installer = $env:HERMES_RELAY_SETUP_PATH',
"$installerArgs = if ($env:HERMES_RELAY_SETUP_SILENT -eq '1') { @('/S') } else { @() }",
"if ($env:HERMES_RELAY_SETUP_INSTALL_DIR) { $installerArgs += ('/D=' + $env:HERMES_RELAY_SETUP_INSTALL_DIR) }",
'$process = Start-Process -FilePath $installer -ArgumentList $installerArgs -PassThru',
'$process.WaitForExit()',
"if ($process.ExitCode -eq 0 -and $env:HERMES_RELAY_SETUP_RESTART_DAEMON -eq '1') { Start-Process -FilePath $env:HERMES_RELAY_SETUP_CLI -ArgumentList @('daemon','start') -WindowStyle Hidden | Out-Null }",
"if ($process.ExitCode -eq 0 -and $env:HERMES_RELAY_SETUP_TRAY) { Start-Process -FilePath $env:HERMES_RELAY_SETUP_TRAY -ArgumentList '--show' | Out-Null }",
'Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue',
'exit $process.ExitCode'
].join('; ')
return {
program: 'powershell.exe',
args: [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-WindowStyle',
'Hidden',
'-Command',
script
],
options: {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: {
...process.env,
HERMES_RELAY_SETUP_PATH: installerPath,
HERMES_RELAY_SETUP_SILENT: silent ? '1' : '0',
HERMES_RELAY_SETUP_RESTART_DAEMON: restartDaemon ? '1' : '0',
HERMES_RELAY_SETUP_INSTALL_DIR: installDir,
HERMES_RELAY_SETUP_CLI: cliPath,
HERMES_RELAY_SETUP_TRAY: trayPath
}
}
}
}
export function scheduleWindowsInstaller(installerPath: string, options: WindowsInstallerLaunchOptions): void {
const plan = windowsInstallerLaunchPlan(installerPath, options)
const child = spawn(plan.program, plan.args, plan.options)
child.unref()
}
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { categorizeTool, resultExitCode, summarizeResult } from '../src/lib/auditLog.js'
test('audit events classify the activity surfaces used by the tray', () => {
assert.equal(categorizeTool('desktop_powershell'), 'command')
assert.equal(categorizeTool('desktop_read_file'), 'files')
assert.equal(categorizeTool('desktop_computer_screenshot'), 'screen')
assert.equal(categorizeTool('desktop_computer_input'), 'input')
assert.equal(categorizeTool('daemon.connect'), 'system')
assert.equal(categorizeTool('desktop_unknown'), 'other')
})
test('audit events preserve process exit outcome separately from dispatch success', () => {
const result = { exit_code: 17, stdout: '', stderr: 'failed' }
assert.equal(resultExitCode(result), 17)
assert.equal(summarizeResult(result), 'exit 17')
assert.equal(resultExitCode({ path: 'C:\\temp\\file.txt' }), undefined)
})
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import test from 'node:test'
import type { DaemonStatus } from '../src/lib/daemonStatus.js'
import {
__daemonStatusIsReadyForTests as daemonStatusIsReady,
__readDetachedStartupFailureForTests as readDetachedStartupFailure,
__waitForDetachedStartupForTests as waitForDetachedStartup
} from '../src/commands/daemon.js'
function status(pid: number, state: DaemonStatus['state']): DaemonStatus {
return {
pid,
url: 'wss://relay.example.test',
state,
started_at: 1,
updated_at: 1
}
}
test('detached startup ignores stale status and succeeds only for the child pid', async () => {
let now = 0
const statuses = [status(41, 'connected'), status(42, 'starting'), status(42, 'connected')]
let read = 0
const result = await waitForDetachedStartup(42, 1_000, {
readStatus: async () => statuses[Math.min(read++, statuses.length - 1)] ?? null,
isAlive: () => true,
readFailure: async () => null,
now: () => now,
sleep: async ms => {
now += ms
}
})
assert.equal(result.outcome, 'ready')
assert.equal(result.outcome === 'ready' ? result.status.pid : null, 42)
})
test('detached startup reports log evidence before generic child-exit failure', async () => {
let now = 0
const result = await waitForDetachedStartup(42, 1_000, {
readStatus: async () => null,
isAlive: () => false,
readFailure: async () => 'consent_missing: pair with --grant-tools',
now: () => now,
sleep: async ms => {
now += ms
}
})
assert.deepEqual(result, {
outcome: 'failed',
detail: 'consent_missing: pair with --grant-tools'
})
})
test('detached startup times out with the matching in-progress status', async () => {
let now = 0
const starting = status(42, 'reconnecting')
const result = await waitForDetachedStartup(42, 200, {
readStatus: async () => starting,
isAlive: () => true,
readFailure: async () => null,
now: () => now,
sleep: async ms => {
now += ms
}
})
assert.equal(result.outcome, 'timeout')
assert.equal(result.outcome === 'timeout' ? result.status?.state : null, 'reconnecting')
})
test('connected status is not ready when it belongs to another process', () => {
assert.equal(daemonStatusIsReady(status(41, 'connected'), 42), false)
assert.equal(daemonStatusIsReady(status(42, 'starting'), 42), false)
assert.equal(daemonStatusIsReady(status(42, 'connected'), 42), true)
})
test('startup log diagnostics only inspect bytes appended for this attempt', async t => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'hermes-daemon-start-'))
t.after(() => fs.rm(dir, { recursive: true, force: true }))
const logPath = path.join(dir, 'daemon.log')
const oldLine = JSON.stringify({ level: 'error', event: 'old_failure', message: 'ignore me' }) + '\n'
await fs.writeFile(logPath, oldLine)
const offset = Buffer.byteLength(oldLine)
await fs.appendFile(
logPath,
JSON.stringify({ level: 'error', event: 'consent_missing', message: 'pair first' }) + '\n'
)
assert.equal(
await readDetachedStartupFailure(logPath, offset),
'consent_missing: pair first'
)
assert.equal(await readDetachedStartupFailure(logPath, (await fs.stat(logPath)).size), null)
})
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { isDaemonProcessAlive } from '../src/lib/daemonStatus.js'
test('daemon liveness rejects a recycled pid owned by another executable', () => {
const status = { pid: process.pid, process_name: 'hermes-relay.exe' }
assert.equal(isDaemonProcessAlive(status, () => 'svchost.exe'), false)
})
test('daemon liveness accepts the recorded executable case-insensitively', () => {
const status = { pid: process.pid, process_name: 'HERMES-RELAY.EXE' }
assert.equal(isDaemonProcessAlive(status, () => 'hermes-relay.exe'), true)
})
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, test } from 'node:test'
import {
getActiveDesktopRelayUrl,
getDesktopHostAliases,
setActiveDesktopRelayUrl,
setDesktopHostAlias,
setDesktopConfigPath
} from '../src/desktopConfig.js'
const temporaryRoots: string[] = []
afterEach(async () => {
setDesktopConfigPath(null)
await Promise.all(temporaryRoots.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
test('host aliases are local, trimmed, removable, and preserve selection', async () => {
const path = await temporaryConfig()
await setActiveDesktopRelayUrl('wss://home.example.test:8767')
await setDesktopHostAlias('wss://home.example.test:8767', ' Office Hermes ')
assert.deepEqual(await getDesktopHostAliases(), { 'wss://home.example.test:8767': 'Office Hermes' })
assert.equal(await getActiveDesktopRelayUrl(), 'wss://home.example.test:8767')
await setDesktopHostAlias('wss://home.example.test:8767', null)
assert.deepEqual(await getDesktopHostAliases(), {})
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), { relay_url: 'wss://home.example.test:8767' })
})
async function temporaryConfig(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'hermes-desktop-config-'))
temporaryRoots.push(root)
const path = join(root, 'nested', 'desktop-control.json')
await mkdir(join(root, 'nested'), { recursive: true })
setDesktopConfigPath(path)
return path
}
test('active host selection is persisted and can be cleared', async () => {
const path = await temporaryConfig()
await setActiveDesktopRelayUrl(' wss://home.example.test:8767 ')
assert.equal(await getActiveDesktopRelayUrl(), 'wss://home.example.test:8767')
await setActiveDesktopRelayUrl(null)
assert.equal(await getActiveDesktopRelayUrl(), null)
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {})
})
test('host selection preserves unrelated desktop settings', async () => {
const path = await temporaryConfig()
await writeFile(path, JSON.stringify({ overlay: { visible: true }, relay_url: 'ws://old' }))
await setActiveDesktopRelayUrl('ws://new')
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
overlay: { visible: true },
relay_url: 'ws://new'
})
})
+40 -2
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
@@ -12,6 +12,7 @@ import {
setDesktopUseEnabled
} from '../src/lib/desktopUseSettings.js'
import { shouldAdvertiseComputerUse } from '../src/tools/handlerSet.js'
import { patchHandler } from '../src/tools/handlers/fs.js'
import {
cancelComputerGrant,
configureComputerUseRuntime,
@@ -54,7 +55,7 @@ test('persistent desktop-use preference participates in advertisement precedence
test('grant changes publish immediately for daemon status and tray cancellation', () => {
const changes: Array<ComputerGrant | null> = []
configureComputerUseRuntime({ computerUseConsented: true })
configureComputerUseRuntime({ computerUseConsented: true, accessMode: 'ask' })
const restore = setComputerGrantChangeListener(grant => changes.push(grant))
try {
requestComputerGrant({ mode: 'control', duration_seconds: 60, reason: 'status test' })
@@ -67,3 +68,40 @@ test('grant changes publish immediately for daemon status and tray cancellation'
cancelComputerGrant('test cleanup')
}
})
test('full access bypasses expiring computer grants for the selected host', () => {
configureComputerUseRuntime({
url: 'wss://trusted.example.test',
computerUseConsented: true,
consentSource: 'stored',
accessMode: 'full_access'
})
const result = requestComputerGrant({ mode: 'control', reason: 'remote operation' })
assert.equal(result.ok, true)
assert.equal(result.full_access, true)
assert.equal((result.grant as { mode: string }).mode, 'full_access')
configureComputerUseRuntime({ accessMode: 'ask' })
})
test('full access applies file patches without an interactive task prompt', async () => {
const dir = await mkdtemp(join(tmpdir(), 'hermes-full-access-patch-'))
const file = join(dir, 'note.txt')
try {
await writeFile(file, 'before\n')
configureComputerUseRuntime({
url: 'wss://trusted.example.test',
computerUseConsented: true,
consentSource: 'stored',
accessMode: 'full_access'
})
const result = await patchHandler(
{ path: file, patch: '@@ -1,1 +1,1 @@\n-before\n+after' },
{ cwd: dir, abortSignal: new AbortController().signal, interactive: false }
) as { approved: string }
assert.equal(result.approved, 'auto')
assert.equal(await readFile(file, 'utf8'), 'after\n')
} finally {
configureComputerUseRuntime({ accessMode: 'ask' })
await rm(dir, { recursive: true, force: true })
}
})
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import { hostname } from 'node:os'
import test from 'node:test'
import { desktopRelayIdentity } from '../src/deviceIdentity.js'
test('desktop relay identity uses hostname as the primary paired-device name', () => {
const identity = desktopRelayIdentity()
assert.equal(identity.deviceName, hostname().trim() || 'Hermes-Relay desktop')
assert.equal(identity.deviceHostname, identity.deviceName)
assert.equal(identity.clientSurface, 'desktop')
assert.equal(identity.deviceFormFactor, 'desktop')
assert.ok(identity.deviceModel.length > 0)
assert.ok(identity.devicePlatform.length > 0)
})
+129
View File
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import {
canonicalRelayUrl,
effectiveHostAccessMode,
getHostAccessMode,
hasFullAccess,
parseHostAccessPolicyFile,
readHostAccessPolicies,
removeHostAccessPolicy,
requiresTaskGrant,
setHostAccessMode
} from '../src/lib/hostAccessPolicy.js'
test('canonical relay identity normalizes host casing, default ports, and trailing slashes', () => {
assert.equal(canonicalRelayUrl(' WSS://Relay.Example:443/ '), 'wss://relay.example')
assert.equal(canonicalRelayUrl('ws://Relay.Example:80/bridge///'), 'ws://relay.example/bridge')
assert.equal(canonicalRelayUrl('https://relay.example'), null)
assert.equal(canonicalRelayUrl('wss://user@relay.example'), null)
assert.equal(canonicalRelayUrl('wss://relay.example/?token=secret'), null)
})
test('missing, malformed, and unknown host policy defaults to ask', async () => {
const dir = await mkdtemp(join(tmpdir(), 'hermes-host-access-'))
const filePath = join(dir, 'desktop-host-access.json')
try {
assert.equal(await getHostAccessMode('wss://relay.example', filePath), 'ask')
await writeFile(filePath, '{broken')
assert.equal(await getHostAccessMode('wss://relay.example', filePath), 'ask')
assert.equal(await getHostAccessMode('not a relay', filePath), 'ask')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('policy changes persist atomically under canonical host keys', async () => {
const dir = await mkdtemp(join(tmpdir(), 'hermes-host-access-'))
const filePath = join(dir, '.hermes', 'desktop-host-access.json')
try {
await setHostAccessMode('WSS://Relay.Example:443/', 'full_access', filePath)
assert.equal(await getHostAccessMode('wss://relay.example', filePath), 'full_access')
const onDisk = JSON.parse(await readFile(filePath, 'utf8')) as {
version: number
hosts: Record<string, { access_mode: string }>
}
assert.equal(onDisk.version, 1)
assert.deepEqual(Object.keys(onDisk.hosts), ['wss://relay.example'])
assert.equal(onDisk.hosts['wss://relay.example']?.access_mode, 'full_access')
assert.equal(await removeHostAccessPolicy('wss://RELAY.example/', filePath), true)
assert.equal(await getHostAccessMode('wss://relay.example', filePath), 'ask')
assert.equal(await removeHostAccessPolicy('wss://relay.example', filePath), false)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('legacy maps and arrays migrate safely without privilege escalation', () => {
const parsed = parseHostAccessPolicyFile({
'WSS://relay.example/': 'ask',
hosts: {
'wss://RELAY.example:443': { mode: 'full_access', updated_at: '2026-08-11T00:00:00Z' },
'ws://trusted.example:80/': { access_mode: 'trusted' },
'https://wrong-scheme.example': { access_mode: 'full_access' },
'wss://unknown.example': { access_mode: 'superuser' }
},
trusted_hosts: ['wss://array.example/'],
full_access_hosts: ['WSS://ARRAY.example:443', 42]
})
assert.equal(parsed.hosts['wss://relay.example']?.access_mode, 'full_access')
assert.equal(parsed.hosts['ws://trusted.example']?.access_mode, 'trusted')
assert.equal(parsed.hosts['wss://array.example']?.access_mode, 'trusted')
assert.equal(parsed.hosts['https://wrong-scheme.example'], undefined)
assert.equal(parsed.hosts['wss://unknown.example'], undefined)
const bareMap = parseHostAccessPolicyFile({
'WSS://legacy.example:443/': 'trusted'
})
assert.equal(bareMap.hosts['wss://legacy.example']?.access_mode, 'trusted')
})
test('duplicate canonical entries choose the most restrictive valid policy', () => {
const parsed = parseHostAccessPolicyFile({
hosts: {
'wss://relay.example': { access_mode: 'full_access' },
'WSS://RELAY.EXAMPLE:443/': { access_mode: 'ask' }
}
})
assert.equal(parsed.hosts['wss://relay.example']?.access_mode, 'ask')
})
test('routing helpers bypass task grants only for full access', () => {
assert.equal(requiresTaskGrant('ask'), true)
assert.equal(requiresTaskGrant('trusted'), true)
assert.equal(requiresTaskGrant('full_access'), false)
assert.equal(hasFullAccess('ask'), false)
assert.equal(hasFullAccess('full_access'), true)
})
test('legacy tool consent migrates visibly to trusted until Ask is explicit', () => {
assert.equal(effectiveHostAccessMode('ask', true), 'trusted')
assert.equal(effectiveHostAccessMode('ask', false), 'ask')
assert.equal(effectiveHostAccessMode('full_access', true), 'full_access')
})
test('read normalizes stored keys and ignores invalid records', async () => {
const dir = await mkdtemp(join(tmpdir(), 'hermes-host-access-'))
const filePath = join(dir, 'desktop-host-access.json')
try {
await writeFile(filePath, JSON.stringify({
version: 99,
hosts: {
'WSS://Relay.Example/': { access_mode: 'trusted' },
garbage: { access_mode: 'full_access' }
}
}))
const store = await readHostAccessPolicies(filePath)
assert.deepEqual(Object.keys(store.hosts), ['wss://relay.example'])
assert.equal(store.hosts['wss://relay.example']?.access_mode, 'trusted')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, test } from 'node:test'
import type { ParsedArgs } from '../src/cli.js'
import { hostsCommand, parseAccessMode } from '../src/commands/hosts.js'
import { getActiveDesktopRelayUrl, getDesktopHostAliases, setDesktopConfigPath } from '../src/desktopConfig.js'
import { getHostAccessMode } from '../src/lib/hostAccessPolicy.js'
import { getSession, saveSession, setStorePath } from '../src/remoteSessions.js'
const temporaryRoots: string[] = []
const originalPolicyPath = process.env.HERMES_RELAY_HOST_ACCESS_POLICY_PATH
afterEach(async () => {
setStorePath(null)
setDesktopConfigPath(null)
if (originalPolicyPath === undefined) delete process.env.HERMES_RELAY_HOST_ACCESS_POLICY_PATH
else process.env.HERMES_RELAY_HOST_ACCESS_POLICY_PATH = originalPolicyPath
await Promise.all(temporaryRoots.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
async function setup(): Promise<{ root: string; url: string }> {
const root = await mkdtemp(join(tmpdir(), 'hermes-hosts-command-'))
temporaryRoots.push(root)
setStorePath(join(root, 'sessions.json'))
setDesktopConfigPath(join(root, 'desktop-control.json'))
process.env.HERMES_RELAY_HOST_ACCESS_POLICY_PATH = join(root, 'host-access.json')
const url = 'wss://home.example.test:8767'
await saveSession(url, 'secret-token', '1.2.3', { endpointRole: 'tailscale' })
return { root, url }
}
function args(positional: string[], flags: Record<string, string | true> = {}): ParsedArgs {
return { command: 'hosts', positional: [...positional], flags }
}
test('access mode parser accepts the user-facing full-access spelling', () => {
assert.equal(parseAccessMode('ask'), 'ask')
assert.equal(parseAccessMode('trusted'), 'trusted')
assert.equal(parseAccessMode('full-access'), 'full_access')
assert.equal(parseAccessMode('full_access'), 'full_access')
assert.equal(parseAccessMode('always'), null)
})
test('select and access commands update shared host state', async () => {
const { root, url } = await setup()
assert.equal(await hostsCommand(args(['select', url], { 'no-color': true })), 0)
assert.equal(await getActiveDesktopRelayUrl(), url)
const pairedAt = (await getSession(url))?.pairedAt
assert.equal(
await hostsCommand(args(['access', 'full-access'], { remote: url, yes: true, 'no-color': true })),
0
)
assert.equal(await getHostAccessMode(url), 'full_access')
assert.equal((await getSession(url))?.toolsConsented, true)
assert.equal((await getSession(url))?.pairedAt, pairedAt)
const stored = JSON.parse(await readFile(join(root, 'host-access.json'), 'utf8')) as {
hosts: Record<string, { access_mode: string }>
}
assert.equal(stored.hosts[url]?.access_mode, 'full_access')
})
test('full access requires an explicit confirmation flag', async () => {
const { url } = await setup()
assert.equal(await hostsCommand(args(['access', 'full-access'], { remote: url })), 2)
assert.equal(await getHostAccessMode(url), 'ask')
})
test('rename stores a local display name for a paired host', async () => {
const { url } = await setup()
assert.equal(await hostsCommand(args(['rename', url, 'Office', 'Hermes'], { 'no-color': true })), 0)
assert.deepEqual(await getDesktopHostAliases(), { [url]: 'Office Hermes' })
})
+106
View File
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'
import { join, win32 } from 'node:path'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import test from 'node:test'
import { uiExecutablePath } from '../src/commands/ui.js'
import { windowsInstallerLaunchPlan } from '../src/windowsInstaller.js'
test('UI executable follows an explicit install directory', () => {
assert.equal(
uiExecutablePath({ HERMES_RELAY_INSTALL_DIR: 'C:\\Hermes' }, 'C:\\runtime\\node.exe'),
win32.join('C:\\Hermes', 'hermes-relay-tray.exe')
)
})
test('compiled CLI resolves a colocated UI executable', () => {
assert.equal(
uiExecutablePath({}, 'C:\\Hermes\\hermes-relay.exe'),
win32.join('C:\\Hermes', 'hermes-relay-tray.exe')
)
})
test('installer launch is delayed until the running CLI can exit', () => {
const plan = windowsInstallerLaunchPlan('C:\\Temp\\hermes setup.exe', {
silent: true,
installDir: 'C:\\Hermes custom'
})
assert.equal(plan.program, 'powershell.exe')
assert.equal(plan.options.env?.HERMES_RELAY_SETUP_PATH, 'C:\\Temp\\hermes setup.exe')
assert.equal(plan.options.env?.HERMES_RELAY_SETUP_SILENT, '1')
assert.equal(plan.options.env?.HERMES_RELAY_SETUP_INSTALL_DIR, 'C:\\Hermes custom')
assert.match(plan.args.join(' '), /Start-Sleep/)
assert.match(plan.args.join(' '), /\/D=/)
assert.match(plan.args.join(' '), /Remove-Item -LiteralPath \$installer/)
assert.equal(plan.options.detached, true)
})
test('PowerShell launches an installer whose path contains spaces via environment transport', {
skip: process.platform !== 'win32'
}, async () => {
const scratch = await mkdtemp(join(tmpdir(), 'hermes ui installer '))
const fakeInstaller = join(scratch, 'fake setup.exe')
await copyFile(join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'whoami.exe'), fakeInstaller)
try {
const plan = windowsInstallerLaunchPlan(fakeInstaller, { silent: false, delayMs: 0 })
const result = spawnSync(plan.program, plan.args, { ...plan.options, detached: false, stdio: 'pipe' })
assert.equal(result.status, 0, result.stderr?.toString())
} finally {
await rm(scratch, { recursive: true, force: true })
}
})
test('NSIS bundle installs and removes the stable UI shim', async () => {
const script = await readFile(new URL('../tray/installer/hermes-relay.nsi', import.meta.url), 'utf8')
assert.match(script, /File \/oname=hermes-relay-ui\.cmd "\$\{UI_SHIM\}"/)
assert.match(script, /Hermes-Relay CLI UI\.lnk" "\$INSTDIR\\hermes-relay-ui\.cmd"/)
assert.match(script, /Delete "\$INSTDIR\\hermes-relay-ui\.cmd"/)
})
test('NSIS bundle cleans cooperative-update and local-development backups', async () => {
const script = await readFile(new URL('../tray/installer/hermes-relay.nsi', import.meta.url), 'utf8')
for (const artifact of [
'hermes-relay.new.exe',
'hermes-relay.old.exe',
'hermes-relay.exe.bak',
'hermes-relay-tray.exe.bak'
]) {
assert.ok(
script.includes(`Delete "$INSTDIR\\${artifact}"`),
`installer should remove ${artifact}`
)
}
})
test('local tray installation embeds production assets instead of loading devUrl', async () => {
const script = await readFile(new URL('../scripts/dev-install-tray.mjs', import.meta.url), 'utf8')
assert.match(script, /'--features', 'custom-protocol'/)
})
test('CLI opens the GUI process without forcing a hidden Windows startup state', async () => {
const source = await readFile(new URL('../src/commands/ui.ts', import.meta.url), 'utf8')
assert.match(source, /spawn\(path, \['--show'\]/)
assert.doesNotMatch(source, /windowsHide:\s*true/)
})
test('POSIX installer only advertises artifacts produced by the release workflow', async () => {
const script = await readFile(new URL('../scripts/install.sh', import.meta.url), 'utf8')
assert.doesNotMatch(script, /hermes-relay-linux-arm64/)
assert.match(script, /hermes-relay-linux-x64/)
})
test('PowerShell uninstaller delegates bundle cleanup to the NSIS uninstaller', async () => {
const script = await readFile(new URL('../scripts/uninstall.ps1', import.meta.url), 'utf8')
assert.match(script, /uninstall-hermes-relay\.exe/)
assert.match(script, /Start-Process -FilePath \$bundleUninstaller -ArgumentList '\/S' -Wait/)
})
test('PowerShell tray bootstrap preserves the install directory and daemon lifecycle', async () => {
const script = await readFile(new URL('../scripts/install.ps1', import.meta.url), 'utf8')
assert.match(script, /HKCU:\\Software\\HermesRelay/)
assert.match(script, /\$installerArgs \+= "\/D=\$dir"/)
assert.match(script, /daemon status --json/)
assert.match(script, /daemon', 'start/)
})
+105
View File
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { updateCommand } from '../src/commands/update.js'
import { checkForUpdate, downloadAndInstall } from '../src/updater.js'
import { VERSION } from '../src/version.js'
async function captureStdout(run: () => Promise<number>): Promise<{ code: number; output: string }> {
const originalWrite = process.stdout.write
let output = ''
process.stdout.write = ((chunk: string | Uint8Array) => {
output += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
return true
}) as typeof process.stdout.write
try {
return { code: await run(), output }
} finally {
process.stdout.write = originalWrite
}
}
test('desktop installer selection downloads the requested asset to an exact verified path', async () => {
const originalFetch = globalThis.fetch
const scratch = await mkdtemp(join(tmpdir(), 'hermes-installer-update-'))
const target = join(scratch, 'hermes-relay-setup.exe')
const assetName = 'hermes-relay-windows-x64-setup.exe'
const bytes = Buffer.from('verified desktop installer fixture')
const checksum = createHash('sha256').update(bytes).digest('hex')
globalThis.fetch = async (input) => {
const url = String(input)
if (url.endsWith('/releases')) {
return new Response(JSON.stringify([{
tag_name: 'desktop-v9.0.0',
prerelease: false,
published_at: '2026-08-11T00:00:00Z',
assets: [{ name: assetName, browser_download_url: 'https://download.test/setup.exe', size: bytes.length }]
}]), { status: 200, headers: { 'content-type': 'application/json' } })
}
if (url.endsWith('/SHA256SUMS.txt')) {
return new Response(`${checksum} ${assetName}\n`, { status: 200 })
}
if (url === 'https://download.test/setup.exe') {
return new Response(bytes, { status: 200, headers: { 'content-length': String(bytes.length) } })
}
return new Response('not found', { status: 404 })
}
try {
const info = await checkForUpdate({ repo: 'example/hermes-relay', assetName })
assert.ok(info)
assert.equal(info.asset_name, assetName)
assert.equal(info.asset_url, 'https://download.test/setup.exe')
const result = await downloadAndInstall(info, { targetPath: target, cooperative: false })
assert.deepEqual(result, { installedPath: target, needsRestart: false })
assert.deepEqual(await readFile(target), bytes)
} finally {
globalThis.fetch = originalFetch
await rm(scratch, { recursive: true, force: true })
}
})
test('installer check reports a newer local preview without treating it as an error', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify([{
tag_name: 'desktop-v0.4.0-alpha.2',
prerelease: true,
published_at: '2026-07-14T01:13:36Z',
assets: [{
name: 'hermes-relay-windows-x64-setup.exe',
browser_download_url: 'https://download.test/setup.exe',
size: 27_335_311
}]
}]), { status: 200, headers: { 'content-type': 'application/json' } })
try {
const check = await captureStdout(() => updateCommand({
command: 'update',
positional: [],
flags: { installer: true, check: true, json: true, repo: 'example/hermes-relay' }
}))
assert.equal(check.code, 0)
const report = JSON.parse(check.output)
assert.equal(report.current, VERSION)
assert.equal(report.latest_version, '0.4.0-alpha.2')
assert.equal(report.up_to_date, true)
assert.equal(report.ahead_of_latest, true)
assert.equal(report.error, null)
const install = await captureStdout(() => updateCommand({
command: 'update',
positional: [],
flags: { installer: true, yes: true, json: true, repo: 'example/hermes-relay' }
}))
assert.equal(install.code, 2)
assert.match(JSON.parse(install.output).error, /older than this CLI/)
} finally {
globalThis.fetch = originalFetch
}
})
+5
View File
@@ -0,0 +1,5 @@
dist/
target/
gen/
.playwright-cli/
output/
+3891 -32
View File
File diff suppressed because it is too large Load Diff
+13 -6
View File
@@ -1,7 +1,7 @@
[package]
name = "hermes-relay-tray"
version = "0.4.0-alpha.2"
description = "Optional menu-only Windows systray for the Hermes Relay CLI"
version = "0.4.0-alpha.7"
description = "Compact Windows management UI for Hermes-Relay CLI"
authors = ["Axiom Labs"]
edition = "2021"
rust-version = "1.77.2"
@@ -12,19 +12,26 @@ name = "hermes-relay-tray"
path = "src/main.rs"
[dependencies]
image = { version = "0.25.10", default-features = false, features = ["png"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tray-icon = { version = "0.24.1", default-features = false }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
[features]
custom-protocol = ["tauri/custom-protocol"]
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61.3", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_Security",
"Win32_System_Com",
"Win32_System_Ole",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_System_Variant",
"Win32_UI_Accessibility",
"Win32_UI_HiDpi",
"Win32_UI_WindowsAndMessaging"
] }
[build-dependencies]
winres = "0.1.12"
tauri-build = { version = "2", features = [] }
+1 -26
View File
@@ -1,28 +1,3 @@
#[cfg(windows)]
fn main() {
use winres::{VersionInfo, WindowsResource};
let version = std::env::var("CARGO_PKG_VERSION").expect("package version");
let mut parts = version
.split(['.', '-'])
.take(4)
.map(|part| part.parse::<u64>().unwrap_or(0))
.collect::<Vec<_>>();
parts.resize(4, 0);
let numeric_version = (parts[0] << 48) | (parts[1] << 32) | (parts[2] << 16) | parts[3];
let mut resource = WindowsResource::new();
resource
.set_icon("icons/icon.ico")
.set("FileDescription", "Hermes Relay menu-only Windows systray")
.set("ProductName", "Hermes Relay CLI + Systray")
.set("OriginalFilename", "hermes-relay-tray.exe")
.set("FileVersion", &version)
.set("ProductVersion", &version)
.set_version_info(VersionInfo::FILEVERSION, numeric_version)
.set_version_info(VersionInfo::PRODUCTVERSION, numeric_version);
resource.compile().expect("compile Windows resources");
tauri_build::build();
}
#[cfg(not(windows))]
fn main() {}

Some files were not shown because too many files have changed in this diff Show More