Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4605b87c10 | ||
|
|
6a91d6ee7e | ||
|
|
95a2813efe | ||
|
|
2ecf521c8c | ||
|
|
7752c5c404 | ||
|
|
e1d3764cd2 | ||
|
|
e34171b5ad | ||
|
|
8040cac39a | ||
|
|
aab70520ca | ||
|
|
5a0cd8123c | ||
|
|
4370d9a925 | ||
|
|
726308d2ef | ||
|
|
1acc3a4c80 | ||
|
|
11ccbd6e5c | ||
|
|
d13af35357 | ||
|
|
e3752d43f3 | ||
|
|
97293b62c3 | ||
|
|
454e770648 | ||
|
|
e18572e8e3 | ||
|
|
83f69725b9 | ||
|
|
72aa7c3046 | ||
|
|
3995c64493 | ||
|
|
ce538ced3d | ||
|
|
352bc5b439 | ||
|
|
9ec163b27b | ||
|
|
7a3efa2c4d | ||
|
|
c28be7c92e | ||
|
|
8d1758ec8b | ||
|
|
ce8b8702c3 | ||
|
|
ca0a9eb524 | ||
|
|
b91d8c9a09 | ||
|
|
bebd327816 | ||
|
|
8fa97e0b46 | ||
|
|
45dc82e573 | ||
|
|
48b23f4d9e | ||
|
|
f708ef3353 | ||
|
|
3224595a46 | ||
|
|
c0453f040d | ||
|
|
33a0ea3216 |
@@ -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' }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ tracked independently so community corrections remain easy to contribute.
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+126
-12
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
+60
-8
@@ -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" }
|
||||
|
||||
+19
-1
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+29
-2
@@ -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
|
||||
|
||||
+42
@@ -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
@@ -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):
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
dist/
|
||||
target/
|
||||
gen/
|
||||
.playwright-cli/
|
||||
output/
|
||||
Generated
+3891
-32
File diff suppressed because it is too large
Load Diff
+13
-6
@@ -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
@@ -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
Reference in New Issue
Block a user