hermes-agent's native installer imports the plugin directory as
hermes_plugins.hermes_relay — no top-level 'plugin' package exists there,
so every absolute 'from plugin.X' import crashed 'hermes relay start'
with ModuleNotFoundError: No module named 'plugin'.
- Convert all runtime absolute plugin.* imports to package-relative form
(relay voice/realtime chain, tailscale CLI, pair, enhancements, tools).
- android_tool's direct-script fallback now imports the sibling module
bare instead of via 'plugin.tools.'.
- dashboard/plugin_api.py is exec'd standalone by the dashboard web
server (spec_from_file_location, no parent package), so relative
imports can't work there: add a _plugin_module() bootstrap that
imports through the real parent package when one exists, and
otherwise synthesizes it (bare ModuleType with __path__ at the plugin
dir under a stable sys.modules alias) without exec'ing
plugin/__init__.py side effects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six unrelated polish items that collectively make the ADR 24
multi-endpoint flow legible and hard to misconfigure:
1. Connections list subtitle shows role *names* not count.
Previously "2 endpoints" — accurate but opaque. Now
"Active: LAN • LAN + Public" so the user sees *which* roles the
QR carries at a glance, matching the Settings → Connection Card
1.5 info density.
2. Re-pair hint on single-endpoint connections.
When the active card has exactly one endpoint stored (legacy
single-URL pair), an inline tertiary-container strip suggests
re-pairing with mode=auto. Inline Re-pair button wired to the
existing onRepair callback.
3. Active-endpoint chip in the Chat top bar.
Compact tappable chip next to the ambient-mode button surfacing
the currently-resolved role (LAN/Tailscale/Public/Custom VPN).
Tap jumps to Connections so the user can probe/override without
leaving chat. Hidden when no endpoint is resolved (single-
endpoint legacy pairings).
4. Loose resolver probe timing (2s → 4s, 30s → 60s cache).
ADR 24 speced 2s probe + 30s cache. LTE handoff and slow hotel
Wi-Fi routinely tripped the 2s false-negative. NetworkCallback
still invalidates the cache on real network changes so the
longer cache is functionally equivalent but burns less battery.
5. PairDialog proxy-fronted consent gate.
The Advanced API-server override warning was informational only —
the dialog still auto-minted a QR the phone would fail to use.
Now when the pinned host trips the proxy heuristic the auto-mint
pauses and the dialog shows "Mint anyway / Clear override"
inline. Consent is per-host: changing host resets
proxyConfirmed so a new FQDN triggers a fresh confirm.
6. Tailscale Funnel auto-detect for the public candidate.
plugin/relay/tailscale.py adds funnel_url(port) that probes
``tailscale serve status --json`` for AllowFunnel flags and
returns ``https://<hostname>/`` when the relay port is
funneled. plugin/pair.py build_endpoint_candidates calls it as
a fallback when mode=auto|public is picked without an explicit
--public-url. Removes the "pin public URL on Remote Access tab"
step when Funnel is already publishing. Soft-fail on every
error path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds explicit "promote this role to priority 0" control so operators
can force a specific endpoint path (Tailscale, public, custom VPN)
during testing or when the natural LAN → Tailscale → Public order
isn't what they want.
Surfaces:
- CLI: `hermes-pair --mode auto --prefer tailscale`
- Skill: documented in skills/devops/hermes-relay-pair/SKILL.md
- Dashboard Remote Access tab: "Prefer role:" dropdown on the
Endpoint Preview card; consumed on "Regenerate QR".
Semantics:
- Open-vocab role string (not a closed enum) — any role emitted by
build_endpoint_candidates can be named. Matching is
case-insensitive + whitespace-trimmed; stored verbatim.
- Promoted role becomes priority 0, others shifted down one.
- Unknown role → stderr warning + natural order (fail-soft so
operators see what's actually in the candidate list).
- Already-priority-0 role → no-op.
Tests: 6 new BuildEndpointCandidatesPreferTests covering all
semantics. Full suite 77 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the loop on the half-wired in-app fallback pairing flow that's been
sitting since Phase 3 landed, and adds per-channel grant revoke to the
Paired Devices screen so users can yank one channel without nuking the
whole session.
## Manual pair fallback — host side (plugin/pair.py)
New `--register-code <CODE>` flag composes with the existing TTL / grants /
transport-hint flags. Skips the QR rendering pipeline entirely and just
pre-registers the supplied code with the local relay over the same loopback
`/pairing/register` endpoint the QR flow already uses.
- `normalize_pairing_code()` — 6-char A-Z/0-9 validator with clear errors
- `register_code_command()` — probes relay, pre-registers, prints "tap
Connect" instructions
- `pair_command()` short-circuits to the new path when --register-code is set
Composes with all existing flags:
hermes-pair --register-code ABCD12 --ttl 30d --grants chat:never,bridge:7d
Exit codes: 0 success, 1 relay unreachable / rejection, 2 arg validation.
Tests: plugin/tests/test_register_code.py — 25 stdlib unittest cases across
3 classes (validator branches, command happy + error paths, wire-shape
assertions via patched urlopen). All pass in 0.004s.
## Manual pair fallback — phone side UX polish
ConnectionSettingsScreen Card 3 (was the bare "code + copy + regenerate"
stub) is now a numbered three-step walkthrough:
1. Copy the code (with copy + regenerate icon buttons inline)
2. Run `hermes-pair --register-code <CODE>` (rendered in a tappable
monospace span — one-tap copies the full command)
3. Real "Connect" button that fires applyServerIssuedCodeAndReset →
disconnectRelay → connectRelay, with in-flight spinner state +
LocalSnackbarHost result feedback via classifyError("pair", ...)
Below the steps: an expandable "How does this work?" explainer covering
when this is the right flow (no camera / SSH-only / single-device pair),
how the code mechanism works end-to-end, and the reminder that bridge
control is gated by the master toggle on the Bridge tab — not by this
code. New private ManualPairStep composable for the numbered step badges.
## Per-channel grant revoke — Paired Devices screen
Each device card's GrantChip now has an inline x icon. Tapping it opens
an AlertDialog confirming "Revoke <channel> access for <device>?" with a
reminder that the session itself stays paired and other channels keep
their existing expiry.
New ConnectionViewModel.revokeChannelGrant(tokenPrefix, channel) which:
- Reads cached PairedDeviceInfo grants
- Rebuilds the full grants map converting absolute-epoch back to seconds-
from-now (clamped to >= 1 because the relay-side _materialize_grants
interprets 0 as "never expire")
- Replaces the target channel with 1L (~instantly expired by the time
the PATCH lands)
- Calls relayHttpClient.extendSession(tokenPrefix, ttlSeconds=null,
grants=rebuilt)
- Snackbar + device list refresh on success
GrantChip rewritten to show relative TTL ("never" / "in 6d" / "in 23h" /
"expired") instead of absolute short date. Used a small clickable Box
instead of IconButton for the chip x because IconButton's 48dp minimum
inflates the FlowRow visually.
The full-session "Revoke" button is unchanged — per-channel chips are
additive, not replacements.
## Docs
- skills/devops/hermes-relay-pair/SKILL.md — Manual fallback section
between Procedure and Pitfalls with workflow + composition rules
- skills/devops/hermes-relay-self-setup/SKILL.md — "If you can't scan
a QR" subsection in section D
- user-docs/reference/configuration.md — expanded Manual pairing code
bullet into a 3-step walkthrough
- user-docs/guide/getting-started.md — camera-unavailable tip callout
- README.md — one-line bullet under the install section
- DEVLOG.md — full entries for both halves under 2026-04-12
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The signed payload is long enough (~500-800 bytes with HMAC signature)
that error="m" was producing a high QR version number and a visibly
oversized terminal render. Dropping to error="l" saves 2-3 QR versions
(~16-24 fewer modules across), which is the single biggest lever left
after compact=True + border=1. Applied to both terminal and PNG paths
so they stay consistent.
Scannability is unaffected on a phone camera at normal distances —
error="l" still has 7% redundancy, well above the noise floor for a
direct-scan use case where the QR is rendered cleanly on screen.
Shrinks the hermes-pair / /hermes-relay-pair QR so it fits a normal
terminal window. Was already using compact=True (half-blocks); adds
border=1 to drop the default 4-module quiet zone. Content, signing,
and scan reliability unchanged.
Full overhaul of the pairing model + session security based on Bailey's
design request. Everything lives in files we own; no upstream patches.
## Why
The existing model was minimal: one-shot pairing codes → fixed-30-day
session tokens → no channel separation → EncryptedSharedPreferences
storage. After the inbound media work exposed the "phone rate-limited
to death on relay restart" gap, the entire pairing story was ready
for a pass. Bailey asked for: user-chosen TTL at pair time (including
"never"), per-channel grants, secure-by-default transport UX, device
revocation UI, Android Keystore storage, TOFU cert pinning, QR signing,
Phase 3 bidirectional pairing foundation, and Tailscale detection.
Never expire is explicitly always selectable per Bailey's direction:
dont force check, just allow based on user intent. User agency over
policy.
## Server - plugin/relay/
- auth.py: Session gains grants + transport_hint + first_seen; math.inf
for never-expire (serializes as null); PairingMetadata dataclass
threaded through PairingManager.register_code + consume_code (returns
metadata or None, not bool); SessionManager create_session accepts
ttl_seconds + grants + transport_hint; _materialize_grants clamps
per-channel grants to session lifetime; list_sessions + find_by_prefix
+ revoke for the new routes; RateLimiter.clear_all_blocks() called
unconditionally when /pairing/register succeeds (fixes the stale
block prevents re-pair bug). Default caps: terminal 30d, bridge 7d.
- server.py: /pairing/register accepts ttl_seconds/grants/transport_hint
metadata (host wins over phone); new /pairing/approve (Phase 3 stub);
new GET /sessions (tokens masked to 8-char prefix, is_current flag);
new DELETE /sessions/{prefix} (200/404/409, self-revoke flagged);
_authenticate threads pairing metadata into new session;
_detect_transport_hint sniffs SSL from request transport; auth.ok
payload carries expires_at + grants + transport_hint (inf to null).
- qr_sign.py NEW: HMAC-SHA256 over canonicalized JSON. Canonical form
strips sig so signing is idempotent. load_or_create_secret reads or
creates ~/.hermes/hermes-relay-qr-secret (32 bytes, 0o600).
allow_nan=False so signing math.inf crashes loudly.
- pair.py + cli.py: new --ttl and --grants flags. parse_duration for
1d/7d/30d/90d/1y/never + loose patterns. parse_grants for
terminal=7d,bridge=1d. build_payload(sign=True) auto-bumps hermes 1
to 2 when v2 fields present and attaches HMAC signature.
read_relay_config detects TLS via RELAY_SSL_CERT. render_text_block
shows Pair for 30 days / indefinitely plus grant labels.
- __version__ stays at 0.2.0 per Bailey: we never released 0.2.0.
- Tests: 4 new test modules covering qr_sign, session_grants,
sessions_routes, rate_limit_clear. Existing media tests still pass.
## Phone - app/src/main/kotlin/.../
- auth/SessionTokenStore.kt NEW: interface + KeystoreTokenStore
(StrongBox-preferred) + LegacyEncryptedPrefsTokenStore fallback.
tryCreate swallows exceptions. One-shot lossless migration.
hasHardwareBackedStorage flag surfaced in UI.
- auth/CertPinStore.kt NEW: TOFU cert pinning. SHA-256 SPKI fingerprints
per host:port in DataStore. recordPinIfAbsent on first successful wss
handshake; buildPinnerSnapshot produces an OkHttp CertificatePinner.
removePinFor called by applyServerIssuedCodeAndReset on QR re-pair.
- auth/PairedSession.kt NEW: PairedSession state class + PairedDeviceInfo
wire model.
- auth/AuthManager.kt: lazy-init SessionTokenStore picker with
migration; currentPairedSession StateFlow; parses expires_at/grants/
transport_hint from auth.ok; authenticate(ttlSeconds) injects
ttl_seconds + grants into pairing-mode auth envelope;
applyServerIssuedCodeAndReset wipes TOFU pin for target host.
- data/PairingPreferences.kt NEW: DataStore keys pair_ttl_seconds,
insecure_ack_seen, insecure_reason, tofu_pins.
- util/TailscaleDetector.kt NEW: NetworkInterface scan for tailscale0
plus 100.64.0.0/10 CGNAT plus relay URL host check. Informational
only.
- ui/components/SessionTtlPickerDialog.kt NEW: radio list
1d/7d/30d/90d/1y/Never. Defaults: QR wins, wss/Tailscale 30d,
ws 7d, unknown 30d. Never warning inline, NOT gated.
- ui/components/TransportSecurityBadge.kt NEW: three states, three
sizes.
- ui/components/InsecureConnectionAckDialog.kt NEW: first-time consent
with reason picker. Reason persists for display, not gating.
- ui/screens/PairedDevicesScreen.kt NEW: full list with transport
badge + expiry + grant chips + revoke button. Self-revoke wipes
local state + redirects to pair.
- network/RelayHttpClient.kt: new listSessions and
revokeSession(tokenPrefix) methods. 404 handled as endpoint not yet
implemented for forward-compat.
- network/ConnectionManager.kt: optional CertPinStore param. Rebuilds
OkHttpClient on every connect with fresh CertificatePinner snapshot.
- ui/components/QrPairingScanner.kt: HermesPairingPayload gains sig
field and defaults; RelayPairing gains ttlSeconds/grants/
transportHint. parseHermesPairingQr no longer rejects on version
mismatch.
- viewmodel/ConnectionViewModel.kt: wires AuthManager before
ConnectionManager so pin store is available. Exposes
currentPairedSession, pairedDevices, isTailscaleDetected,
insecureAckSeen, insecureReason.
- ui/RelayApp.kt: new Screen.PairedDevices route.
- ui/screens/SettingsScreen.kt: TransportSecurityBadge + Tailscale
chip + hardware storage chip + Paired Devices row in Connection
card. Insecure toggle routes through ack dialog. QR scan handoff
opens SessionTtlPickerDialog.
- ui/components/ConnectionInfoSheet.kt: SessionInfoSheet shows
Expires / Channel grants / Transport / Key storage rows.
## Docs
DEVLOG entry, ADR 15, spec.md section 3.3/3.3.1/3.4 rewrites, relay
route tables in docs + user-docs, Paired Devices subsection in
configuration.md, pair flow extension in getting-started.md, CLAUDE.md
Current State + Key Files + Integration Points.
## Known gaps filed for follow-up
- Phone-side QR signature verification (parsing works; full verify
needs secret distribution path)
- Bidirectional pairing full UX tied to Phase 3 bridge
- Per-device role model (admin vs user)
- Grant renewal UI on Paired Devices
- Build verification: no gradle run this session; Bailey deploys from
Android Studio
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reworks the relay pairing flow so it matches the API-side flow we already
had: the operator runs `hermes pair` on the host (the trust anchor), the
relay pre-registers a fresh pairing code via a new loopback-only endpoint,
and the QR payload carries both API credentials AND relay credentials.
The phone scans once and is fully configured for chat + terminal.
## Wire format
HermesPairingPayload now has an optional nested `relay` object:
{
"hermes": 1,
"host": "<api-ip>", "port": 8642, "key": "<bearer>", "tls": false,
"relay": { "url": "ws://<ip>:8767", "code": "ABCD12" }
}
Old API-only QRs still parse cleanly (kotlinx.serialization
`ignoreUnknownKeys = true`, nullable field). The `relay` block is only
present when `hermes pair` found a running relay and pre-registration
succeeded.
## Server side
- `POST /pairing/register` on the relay: accepts `{"code":"…"}`, calls
`PairingManager.register_code()`, returns `{"ok":true}` on success.
Gated to `127.0.0.1` / `::1` — a remote LAN attacker cannot inject
pairing codes; only host-shell processes can.
- `plugin/relay/config.py` — `PAIRING_ALPHABET` broadened from the
no-ambiguous-chars 32-char set to the full 36-char `A-Z0-9` set so it
matches `AuthManager.PAIRING_CODE_CHARS` on the app side. The earlier
restriction silently rejected valid codes containing `0/O/1/I`.
## `hermes pair` changes
- Probes `http://127.0.0.1:<relay_port>/health` on startup.
- If the relay is up: mints a 6-char code, POSTs `/pairing/register`,
embeds `relay.{url,code}` in the QR payload.
- If not: emits an `[info]` line pointing at `hermes relay start` and
renders an API-only QR (backward compat).
- New `--no-relay` flag to force API-only even when a relay is running.
- Text block now has a "Relay (terminal + bridge)" section alongside
"Server" so manual entry is possible when QR scanning fails.
- Warning is now gated to "any credential present" rather than just
"API key present" — both types trigger the "don't share screenshots"
notice.
## App side
- `HermesPairingPayload` gains optional `RelayPairing(url, code)`.
- `AuthManager.applyServerIssuedCode(code)` stores a one-shot override
that trumps the locally-generated code on the next `authenticate()`
call. Cleared automatically on `auth.ok` so subsequent reconnects use
the long-lived session token. Local generation stays as the fallback
and as the Phase-3 bridge direction trust anchor (phone issues code,
host approves).
- `OnboardingScreen` — `onComplete` signature grew a `relayPairingCode`
param; `ConnectPage` gets an `onRelayPairingDetected` callback that
propagates scanned relay fields up to the parent. Applied to
AuthManager in `RelayApp.kt` before nav transition.
- `SettingsScreen` — scanner handler auto-configures the relay URL,
flips insecure mode on for `ws://` URLs, and calls
`applyServerIssuedCode()`. Toast message distinguishes "API + relay
configured" from "API only".
- `ConnectionManager.connect()` — URL normalizer appends `/ws` if the
path is empty, so a bare `ws://host:port` still upgrades cleanly.
Pairs with the server-side `/` → `/ws` alias added earlier.
## Docs
README, docs/{spec,decisions,relay-server}.md,
user-docs/{guide/getting-started,reference/relay-server}.md, and DEVLOG
all updated to describe the new single-scan flow + `/pairing/register`
endpoint + `--no-relay` flag + alphabet change.
Verified locally:
gradlew :app:assembleDebug — BUILD SUCCESSFUL
python -m py_compile plugin/relay/server.py plugin/pair.py
python -m plugin.pair --help / python -m plugin.relay --help
adb install -r app-debug.apk — Success
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename "Hermes Relay" -> "Hermes-Relay" across docs, app strings,
scripts, workflows, plugin, and relay server (~57 files)
- Redesign user-docs landing: install section moved above features
via home-hero-after slot (InstallSection.vue), hero image replaced
with phone-framed demo video that crossfades to brand logo after
12s of playback (HeroDemo.vue). Fix dead /guide/getting-started
links in raw HTML hrefs that VitePress base-rewriting skipped.
- Add chat demo to README, docs landing hero, and Getting Started's
Verify Connection section. Re-encode 20MB / 102fps source capture
to 1.95MB / 30fps with extracted poster frame for instant LCP.
- Add smooth auto-scroll for streaming chat with Settings toggle --
follows new tokens at the bottom, pauses when user scrolls up.
- Migrate SettingsScreen clipboard from deprecated LocalClipboardManager
to LocalClipboard suspend API (Compose 1.7+).
- Migrate scripts/screenshots from monolithic .bat to thin wrapper
+ screenshots.py for Play Store screenshot capture.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the standalone bash skill with a plugin-owned CLI sub-command
and adds a curl|bash installer so users can go from zero to connected
without cloning the repo.
Plugin migration:
- plugin/pair.py — pure-Python pairing logic (config reading, LAN IP
detection, QR rendering, text fallback). Cross-platform socket-based
LAN detection replaces Linux-only 'ip route'. Config fallback chain:
config.yaml → ~/.hermes/.env → env vars → defaults
- plugin/cli.py — registers 'hermes pair' via v0.8.0 register_cli API
with --png, --no-qr, --host, --port flags
- plugin/__init__.py — calls ctx.register_cli_command() wrapped in
try/except so the 14 android_* tools still register on v0.7.0
- Pure-Python QR rendering via segno — no qrencode binary dependency
- Text block always shown alongside QR so pairing works inside Hermes
Rich TUI and over limited SSH sessions where QR blocks garble
- Plugin version bumped to 0.3.0; segno>=1.6.0 added to requirements,
setup.py, pyproject.toml
One-line installer:
- install.sh at repo root: curl|bash entry point that clones the plugin,
installs Python deps, and prints next steps. Supports HERMES_HOME and
HERMES_RELAY_BRANCH env overrides. Uses trap cleanup for temp dirs.
Replaces the old plugin/install.sh (its URL comment already pointed
to the root location)
Docs restructure:
- README 'Install' → 'Quick Start' with the one-liner front and center
- Homepage (user-docs/index.md) gets an 'Install in 30 seconds' block
below the feature cards with scoped CSS
- Guide landing page leads with Quick Install
- Getting Started restructured into 3-step Quick Start flow
- CLAUDE.md key files table updated with plugin/cli.py and plugin/pair.py
Deprecation:
- skills/hermes-pairing-qr/SKILL.md gets deprecation frontmatter +
notice in body pointing to 'hermes pair'
- skills/hermes-pairing-qr/hermes-pair prints a deprecation warning
to stderr but stays functional for v0.7.0 users
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>