Specialize pending image_generate tool parts with a theme-aware procedural
diffusion placeholder and accessibility announcement. Vanilla Hermes already
emits the generic tool lifecycle, so this stays client-only.
Forge: AXI-129
Make the Hermes dashboard the standard connection path with API fallback and optional Relay pairing. Redesign onboarding and connection management, add startup preference and route/security details, reconcile pinned profile identity and hostname discovery, and update tests, docs, and localized resources.
Merges the CLI/TUI-first desktop architecture, native menu-only Windows systray, desktop-use safety controls, release hardening, and refreshed public documentation into dev.
Salvages and integrates the user-facing localization work from PR #180 onto current dev, with complete catalogs, CI parity checks, translated entry points, and contributor guidance.
Co-authored-by: EasongChung <easong@users.noreply.github.com>
After a background result was seeded into provider history, a pure-recall
follow-up ('what did that say?') still triggered a full hermes_run_task
round-trip instead of answering from history. Cause: _native_instructions
told the provider to re-route whenever context is 'tool-derived' -- which a
delivered background result is.
Rewrite the clause to separate recall from new work: a Hermes result already
delivered earlier in the conversation is in history, so recall/quote/reference
answers directly (no re-run); call hermes_run_task again only for new,
updated, deeper, or re-verified info. Drop the blanket tool-derived re-route,
keep 'fresh data or verification you don't already have -> re-route'.
Instruction-only. test_provider_native_instructions_include_recent_context
extended to assert the recall carve-out; route + promotion suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
After a background result falls back to relay TTS (the provider deferred
instead of reading the answer), the provider's conversation history retained
only its own deferral -- so a follow-up ('what did that say?', 'can't you see
we ran the task?') had no record of the result and failed or re-ran. A
provider-VOICED delivery already becomes a history item; only fallbacks left
the gap. The existing native_pending_delivery_note is a one-shot correction
attached to the next Hermes-routed response and is skipped by follow-ups that
don't route through that branch.
Add RealtimeAgentConnection.append_context_item(role, text): a silent
conversation.item.create (assistant->'text', user/system->'input_text', no
response.create) implemented for xAI + OpenAI. On both fallback paths
(_finish_forced_summary_provider_response validator fallback,
_speak_fallback_answer provider-death/request-failed) the broker seeds the
delivered answer as an assistant turn, so any later follow-up finds it in
history durably, independent of routing. Best-effort (dead socket no-ops);
fallback-only, so a provider-voiced success is never double-recorded. Kept
the pending note as a belt-and-suspenders correction. Logs result_seeded /
result_seed_failed with a preview.
104 realtime tests green; test_filler_summary_triggers_fallback_delivery
extended to assert the seeded assistant history item; append_context_item
added to all connection fakes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background-result delivery outcomes could not be told apart from the flight
recorder: the fallback event carried provider_text_preview, but the SUCCESS
paths (forced_summary_streaming early-commit, forced_summary_delivered
end-validated) logged only char counts and the pre-run acknowledgement
(hermes_forced_preamble.finished) logged only metadata. So a clean delivery
could not be confirmed verbatim, and a fallback could not be distinguished
from a validator false positive.
Add a _compact_status_text (<=120 char) preview of the actually-spoken text
to three existing _log payloads: transcript_preview (preamble),
prefix_preview (committed early-commit prefix), provider_text_preview
(end-validated delivery). Reuses the fallback path's existing compaction; no
new session state; bounded by the 14-day run-dir retention sweep.
Live payoff: confirmed think-fast spoke a genuine deferral ('One moment...
I'll let you know') rather than reading the answer -- a real fallback, not a
validator miss -- and the behaviour is model-agnostic across grok variants.
104 realtime tests green; test_realtime_summary_validation extended to assert
the delivered text rides prefix_preview / provider_text_preview.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the owner's background-tasks-as-first-class-chat vision (titles,
kickoff/result chat entries, expand-to-detail, persisted results,
in-session provider context for follow-ups, concurrent multi-task) plus
today's live findings: first confirmed provider-voiced delivery (but
inconsistent on grok-voice-latest), think-fast untested until forced, a
duplicate-status bug, and a status-speak logging gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fallback-TTS delivery played audibly while the overlay sat on
"Thinking" — no waveform, no live text. Two client gates: the overlay
dropped every hermes-sourced voice.response.delta (a rule for mid-run
chatter, written before fallback TTS became a first-class delivery
mouth), and that handler was the only path flipping Thinking->Speaking,
so the Speaking-gated waveform envelope never fed.
- Relay: delivery responses now tagged on the wire (delivery:
fallback/respeak/visual_only on started+delta events).
- App: hermes-sourced deltas with a delivery tag render (run chatter
stays suppressed), and arriving output audio flips Thinking->Speaking
so the waveform tracks any spoken response regardless of source.
116 realtime tests green with delivery-tag assertions on both fallback
paths; sideload debug build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Run-dir retention: sweep session JSONL logs + wav taps past
realtime_voice.run_retention_days (default 14, 0 disables) at
session-log creation — transcripts no longer accumulate indefinitely.
- Wav render tap is debug-only (debug_audio_tap, default off): artifact
deleted after PCM streams, voice.response.done.audio_path blank.
- Delivery-outcome rollup: python -m plugin.relay.realtime_agent.report
tallies provider-spoken vs fallback deliveries with reasons; new
forced_summary_delivered marker makes end-validated deliveries countable.
- Models: OpenAI realtime default gpt-realtime-2 -> gpt-realtime-2.1
(2.1-mini + rollback 2 selectable); xAI exposes the versioned
grok-voice-think-fast-1.0 pin alongside the grok-voice-latest alias.
- Both providers surface the RESOLVED model id from session.created
echoes (provider_model_resolved) so live rounds stay attributable
across provider-side alias flips.
191 realtime/voice tests green, including new hygiene + report suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
grok-voice-latest now resolves to the new reasoning flagship
grok-voice-think-fast-1.0 (fast-1.0 deprecated); we default to the alias
everywhere, so live-round verdicts may predate the model change. Adds
re-baseline, lifecycle re-probe, and new-voices TODO items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The provider-voiced delivery rework moved speak_verbatim from a synchronous
TTS render onto the async provider pipeline, opening failure windows the old
path couldn't have:
1. Foreground request_response was bare — a dead provider socket lost the
answer and wedged native_forced_summary_active. Now falls back to the new
shared _speak_fallback_answer relay-TTS mouth.
2. The delivery-confirm alarm only covered attached-background deliveries;
foreground and deferred-resume injections now spawn it too.
3. A new user utterance mid-delivery wiped forced-summary state with no
cancel and no record. New _preempt_pending_forced_summary cancels the
stale response and lands a never-spoken answer as text.
4. Blocklist phrases present in the authoritative answer no longer flag a
faithful exact reading; only model-added phrases count.
5. Structured JSON answers route to the summary prompt — no meaningful
word-for-word reading exists for them.
Injection failure on the attached background path also now falls back to
spoken TTS immediately instead of waiting for the text-only alarm.
102 realtime tests green, five new covering each failure scenario.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stop sending top-level messages/attachments fields the native upstream
session-chat and runs handlers never parse (silent data loss). Synthetic
phone-local history (voice intents, card dispatches, realtime voice
turns) now rides channels upstream actually consumes: tool-call pairs
render as a plain-text digest folded into the per-turn ephemeral system
prompt (system_message on sessions, instructions on runs, the system
message on completions); plain text turns splice into completions
messages and runs conversation_history. Attachments with no supported
channel are returned as ChatPayloadResult.droppedAttachments and logged
(the ChatViewModel user notice already existed) — never silently
discarded. Docs truth-up: HERMES-WEBAPI-REFERENCE session-chat body now
documents the native contract; decisions.md card-dispatch ADR gains an
HRUI-001 update note.
Verified: 18/18 HermesChatPayloadsTest unit tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
speak_verbatim no longer renders through relay TTS directly: every spoken
result_delivery mode now delivers through the realtime provider so
background/foreground Hermes answers keep the session's voice and tone.
Exact instructs a word-for-word reading of the authoritative answer
(_forced_hermes_exact_prompt); Summary keeps the natural-summary prompt.
The forced-summary validator, relay-TTS fallback, and delivery-confirm
alarm backstop both, so an off-script response degrades to the previous
TTS-direct behavior instead of losing the answer.
_speak_result_verbatim and the voice.response.verbatim_delivery event are
removed; foreground, background, and deferred-resume paths share the
injection pipeline. Voice Settings' delivery-mode info dialog, docs, and
the stale CHANGELOG keepalive bullet (superseded by idle-close recovery)
are updated to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HRUI-016 (desktop half): RelayTransport.request() bounded every RPC by
the single env-tunable HERMES_RELAY_RPC_TIMEOUT_MS (default 120s), so a
legitimately long prompt.submit ack rejected mid-turn; the chat/voice
turn promises additionally wall-clock capped healthy turns at 10m/5m.
- RelayTransport.request() (and the Transport interface + GatewayClient
pass-through) accept an optional per-call timeoutMs; the env-var
default still covers every other call.
- Export PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000 (mirrors upstream
apps/desktop/src/hermes.ts, commit 164144183) and pass it at both
prompt.submit call sites (chat.ts runOneTurn, voiceServer.ts) — the
only two under desktop/src/.
- Convert the 10-min chat and 5-min voice turn caps into idle-progress
watchdogs: the timer re-arms on every gateway event and only fires
after that long with NO events at all, so streaming turns are never
wall-clock capped.
Verified with npm run build (strict tsc).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HRUI-016: upstream treats gateway prompt.submit as a long-running RPC —
the ack is effectively fire-and-forget (turn completion arrives via
stream events, not the RPC return) and can trail a MoA/deep-reasoning/
tool-heavy turn by minutes. Bounding it by the generic 15s rpc timeout
false-failed running turns into the SSE preflight fallback, resubmitting
the same prompt as a duplicate turn.
- Add PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000 (mirrors upstream
apps/desktop/src/hermes.ts, commit 164144183; matches the backend
agent.gateway_timeout = 1800s ceiling) and pass it at the
prompt.submit call site.
- Guard the submit-failure path: once this turn's own events are
flowing (or it already ended), a slow/lost/socket-severed ack no
longer fires onPreflightFailure — recovery stays with the idle
watchdog and mid-turn rejoin. session.info is excluded from the
"turn started" signal (connection-level, turn-independent).
- The 180s turn watchdog was already idle-progress (reset on every
gateway event) — semantics unchanged, docs clarified.
- Expose rpc/submit/idle timeouts as constructor test seams (same
pattern as midTurnRejoinWindowMs); harness gains a withheld-ack seam.
4 new JVM tests: slow-ack survives past the generic timeout with no
fallback, late ack timeout after completion does not resubmit, idle
watchdog stays quiet while events trickle, and still fires on silence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Update every place that described the bootstrap as a sessions/skills
fallback to reflect the retirement split:
- plugin/doctor.py + plugin/compat.py docstrings, compat status
recommendation, compat status text, and the legacy-bootstrap doctor
check now state that compat covers only session search, memory, legacy
skill detail/toggle, config, available-models, and slash middleware.
- TODO.md bootstrap-injection entry records the sessions + skills-list
retirement as done (2026-07-08) and lists the still-gapped surfaces.
- CLAUDE.md bootstrap-maintenance bullet, compatibility-endpoints table,
Key Files row, and Integration Points row updated to match.
- docs/upstream-surface-matrix.md, docs/upstream-integration-sync.md,
docs/upstream-contributions.md, docs/decisions.md ADR 16 removal path,
docs/HERMES-WEBAPI-REFERENCE.md, and docs/remote-access.md no longer
claim the bootstrap injects sessions or the legacy skills list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove the bootstrap handlers for surfaces current hermes-agent serves
natively: sessions CRUD/messages/fork (/api/sessions*, upstream PR #33134)
and the legacy read-only GET /api/skills list (superseded by /v1/skills +
/v1/toolsets, PR #33016). No pre-#33134 fallback remains; older core builds
degrade via the client capability probe to /v1/chat/completions or /v1/runs.
The bootstrap now injects only genuine compatibility gaps with no native
API-server replacement: GET /api/sessions/search, memory CRUD, legacy skill
detail (/api/skills/{name}) + the 501 toggle stub, config, available-models,
and the slash-command middleware. Registration stays method/path-aware so
native routes still win if any remaining surface lands in core.
Tests assert the split both ways: retired surfaces are never injected, kept
surfaces are, and native routes still win for kept surfaces.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Probe run 4 (valid): three session.update pings at 240/480/720s, each
acknowledged by the server, and the conversation still timed out at
exactly 900.0s. Combined with the silent-append runs: xAI's inactivity
timer counts only real conversation items. Remaining designs recorded
(scheduled reopen vs silent auto-reopen-on-next-turn with Hermes-session
context reseed; POC doc recommends the latter).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream pinned all aiohttp paths to the patched 3.14.1 line
(CVE-2026-34993, CVE-2026-47265, and the earlier 2026 advisories). Raise
the relay floor to match in plugin/requirements.txt, pyproject.toml, and
the relay_server compat shim requirements, and update the version table
in docs/spec.md. README/AGENTS mention aiohttp without a version string,
so they need no change.
Refs HRUI-015.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream flipped the default of dashboard GET /api/model/options (and the
gateway model.options RPC) to configured-providers-only; unconfigured
provider skeleton rows now require an explicit include_unconfigured opt-in.
Relay Android called the route bare and parseModelOptions dropped
empty-models rows, so on new upstream every provider awaiting an API key
silently vanished from the Manage model picker along with its Keys-setup
affordance.
- DashboardApiClient.getModelOptions() always sends include_unconfigured=1
(cached and refresh=1 paths); old upstream ignores the extra param.
- parseModelOptions keeps empty-models skeleton rows, resolves the provider
id from the canonical slug first (what /api/model/set expects), defaults
authenticated by model presence when picker hints are absent, and carries
the upstream warning as a setupHint.
- ModelPickerDialog renders the setup hint (e.g. "paste X_API_KEY to
activate") under empty skeleton providers, keeping the Keys guidance.
- Gateway-WS model.options callers audited: the only call site feeds the
in-chat picker, which intentionally stays on the configured subset.
Verified: :app:testGooglePlayDebugUnitTest — DashboardApiClientTest (42)
and new ModelOptionsParserTest (5, incl. old-upstream back-compat fixture)
all pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirrors upstream hermes-agent media-delivery hardening
(gateway/platforms/base.py validate_media_delivery_path): even with
RELAY_MEDIA_STRICT_SANDBOX off, /media/by-path now refuses to serve
credential/system paths — ~/.hermes/.env, auth.json, config.yaml, OAuth
token stores, pairing/, mcp-tokens/, ~/.ssh and the other home credential
dirs, /etc and other system prefixes — plus the relay-specific
hermes-relay-qr-secret and hermes-relay-sessions.json secret stores.
The check runs after realpath resolution (a symlink can't launder a
denied target) and before the existence check (no 403-vs-404 existence
oracle for credential probes). Ordinary files keep serving in permissive
mode; strict-sandbox mode is unchanged except the denylist now outranks
the allowlist there too.
Refs HRUI-014.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live round-5 finding: a background task finishing while the user was
speaking delivered over them and ended their recording. The relay knows
the user is talking (live input_audio.append chunks now stamp
native_last_input_audio_at); _await_floor_idle_for_result additionally
requires the user quiet >= 1.5s before consuming the floor, bounded by
the existing wait deadline. Covers summary, fallback, and queued-start
transition deliveries. TODO logs the client half (don't end an active
recording on incoming audio), the end-of-response audio tail cut repro,
fallback path-speech polish, and the 4/4 grok delivery-instruction
failure stat elevating verbatim delivery to likely default.
Tests: input-quiet gate holds/proceeds cases; affected suites 42/42.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first session_update keepalive run died inside the initial 240s,
before any ping was sent (the pinger exits silently when the event stream
ends) — an unstamped clean stream end made that indistinguishable from a
keepalive failure. Stamp it so early deaths read as invalid runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Empirical (relay host, live xAI, 2026-07-08): the 960s repro died at
exactly 900.0s, and the silent-PCM keepalive run ALSO died at exactly
900.0s — uncommitted input_audio_buffer.append does NOT reset xAI's
conversation-inactivity timer. POC doc revised; the silent-append
keepalive stays as harmless scaffolding until a working ping lands. Next
candidate: a session.update re-send (connection.configure()), now
available as --keepalive-mode session_update; if that fails too, the
remaining option is a scheduled provider-socket reopen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live round-4 finding: the fallback spoke the answer correctly, but the
provider never sees fallback/text-only deliveries — its conversation
history still read "running in background", so on the user's next turn it
claimed the task was still running.
Out-of-band deliveries (forced-summary fallback, text-only emit,
delivered-or-alarm force emit, respeak) now set a pending delivery note;
the next normal user turn's response carries it via per-response
instructions (composed WITH the session instructions, which per-response
instructions otherwise replace), then clears it: the task has ALREADY
COMPLETED, the answer was already spoken, don't re-deliver.
e2e filler test extended: after the fallback, the correction note must be
pending and carry the delivered answer. Realtime batch 85/85 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live round-3 regression: the forced summary for a completed answer came
back as ANOTHER queue acknowledgement ("It's queued and will start
automatically once the Minnesota check finishes. I'll let you know…") and
early-commit approved it at 45 chars — substring matching let "will START
automatically" count as evidence for an answer containing "starting", the
audio played, the response was marked delivered, and the real answer never
spoke (so the delivered-or-alarm stayed silent too).
- _summary_overlap_hits: WHOLE-WORD evidence matching (substring was the
hole); overlap check now counts hits
- early commit requires >= 2 whole-word hits (irreversible once audio
plays, so the early bar is higher than end-of-response validation's 1)
- blocklist gains the queue/deferral class a final answer must never
contain: "i'll let you know", "it's queued", "is queued", "queued and
will", "in the queue"
- _start_next_queued_run gains an explicit phase-1 wait for the summary
injection to BEGIN (correct ordering previously held only by task
scheduling luck) before the existing wait-for-finish
- the queued-start spoken transition now waits for floor idle so it can't
overlap a fallback TTS render of the previous result
Tests: live regression strings pinned (queue-speak flagged end-of-response,
substring non-overlap, single-weak-hit no-commit); realtime batch 85/85
green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Client half of the background-run A-E batch:
- chip shows "+N queued" (hermes.run.queued + queued_count on promoted)
- tapping the settled (DONE) chip asks the relay to respeak the last
delivered answer (hermes.result.respeak); chip stays up while it plays;
taps on live phases no-op
- the chip now renders in compact (non-focus) voice mode too — it
previously existed only in the focus layout, so a running task had no
visible presence there
- exiting voice mode with a live background run posts a chat system
notice ("Background voice task still running (+N queued) — Hermes will
report back") via VoiceViewModel.chatNoticeSink, wired in RelayApp to
the shared ChatHandler
- `_thinking` drafting deltas drive a "Drafting the answer…" chip status
line (never a tool pill)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background-run A-E batch (relay half):
- forced summaries STREAM: buffered only until the prefix (>=40 chars)
clears the bad-phrase check and shows content overlap with the Hermes
answer (_maybe_commit_forced_summary_early), then flush + live stream —
removes the "silence, then the whole answer in one burst" delivery gap;
committed responses skip end-of-response validation (audio already
played)
- positive validation: _summary_overlaps_answer requires the summary to
share content tokens with the answer (vacuous for bare confirmations);
no_answer_overlap joins the bad-summary reasons
- delivered-or-alarm: _confirm_background_delivery force-emits the answer
as text (+ delivery_unconfirmed log) when no spoken delivery lands
within 30s — a background answer can never be silently lost
- respeak: hermes.result.respeak client message replays
last_background_result via relay TTS (DONE-chip tap client-side)
- task queue: a long second ask is queued (FIFO, cap 3, status "queued")
instead of refused; starts automatically when the current run's task
completes (_start_next_queued_run waits for the summary to settle, runs
the task as durable with a spoken transition); cancel clears the queue;
hermes.run.queued event + queued_count on promoted /
background_completed / get_status; queue-full keeps the busy answer
- _thinking drafted text is the answer of last resort when the
response-delta path yields empty (answer_from_thinking)
- fast lane reuses one Hermes side-session per voice session
(fast_lane_session_id) instead of one session per quick ask
- idle probe injects the relay xAI OAuth token like the broker does
(_probe_provider_options) so it runs on the relay host
Tests: 93 green across the realtime batch — 8 new overlap/early-commit
cases, 4 new queue/side-session cases, and a route-level misbehaving-
provider e2e (filler summary -> fallback carries the real answer; the
filler never reaches the client). The second-ask contract changed from
already_running to queued; existing tests updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chip was nulled at the first summary-audio byte — it disappeared
exactly when the waveform/spinner returned to speak the answer, reading
as the background task being lost (second live e2e finding, same day).
- new BackgroundRunPhase.DONE: on first summary audio (or the 20s
no-audio delivery watchdog) the chip settles to "Background task
finished." — solid dot (no pulse), elapsed ticker frozen — lingers
DONE_CHIP_LINGER_MS (10s), then auto-dismisses
- the chip ✕ on a DONE chip is a LOCAL dismiss, never a relay cancel
(a late cancel used to overwrite a delivered answer); TalkBack label
flips to "Dismiss"
- a newly promoted run replaces a lingering DONE chip and cancels its
auto-dismiss timer so it can't clear the new chip's later DONE early
- progress / tool / reconnect handlers treat DONE like DELIVERING:
a settled chip cannot be reanimated by stray late events
:app:assembleSideloadDebug green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live e2e forensics (session event log): the gateway streams drafting text
as a `_thinking` pseudo-tool (deltas only, never tool.completed) -> the
client rendered it as a forever-"running" pill -> the user cancelled an
ALREADY-COMPLETED run -> the unguarded cancel marked it cancelled and the
completed answer was never spoken. Independently the model read the full
32-char run id aloud, claimed to "queue" a request (no queue exists), and
one delivery spoke "One moment while I look that up" filler the summary
validator didn't recognize.
- client: `_`-prefixed tool names are internal (upstream hidden-tool
convention) — hermes.tool.delta/.started no longer create ToolCall pills
for them; text still feeds the detailed thinking trace
- relay: response.cancel only cancels a Hermes run that is actually in
flight; late cancel still stops speech but cannot flip a completed run
to "cancelled" or emit hermes.run.cancelled for it
- relay: run/session ids removed from every model-visible payload
(interim ack, forced-summary metadata); "never say run IDs, session
IDs, or other identifiers aloud" added to interim-ack, handoff, and
summary instructions (get_status/cancel default to the active run)
- relay: "there is no task queue" added to handoff/busy instructions
- relay: _bad_forced_summary_reason gains deferral-filler phrases (one
moment / report back / looking into / i'll look / as soon as i have);
summary prompt reworded to speak the answer NOW
- relay: pre-Hermes status lead no longer carries the previous run's
run_id/tool-count into a new run's first progress event
Tests: new plugin/tests/test_realtime_summary_validation.py (5, pinning
the exact observed filler), cancel route test updated to the
no-active-run contract; realtime batch 69/69 green;
:app:compileSideloadDebugKotlin green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background-run v2 item 1. A second hermes_run_task while a detached
(promoted/durable) run holds the single background slot used to get an
unconditional busy answer — even for a two-second lookup. The broker now
first tries the request INLINE on a separate ephemeral Hermes session
(session_id=None) within the normal grace window:
- completes inside grace -> the tool result is returned (fast_lane: true)
and spoken as usual
- grace elapses, a known-long tool starts (_long_tool_hints), the call
asks mode=background, or promotion is off -> the attempt is abandoned
(stream cancelled client-side) and the reworded busy answer falls
through unchanged
- gate requires the in-flight run to actually be detached
(hermes_run_tier promoted/durable)
The fast lane keeps every observation in locals and touches NONE of the
session's hermes_* run state — run_id, status, progress counters, and the
chip stay owned by the in-flight run — and emits no client events of its
own (bounded by grace, so no chip is needed; one would fight the detached
run's). Session-log events: voice.hermes_fast_lane.completed / .abandoned
/ .error.
Tests: plugin/tests/test_realtime_fast_lane.py (7 — inline answer + state
non-interference, grace/long-tool fall-throughs incl. client-side
cancellation, background-mode/promotion-off/foreground-tier skips, error
reporting). test_second_run_task_answers_busy_without_orphaning_first
updated to per-stream cancellation tracking: the abandoned fast-lane
stream is the designed fall-through; the first run's stream must stay
uncancelled. Full realtime batch 64/64 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The connectionId namespacing wiring already shipped in 0aa1b38 (2026-06-21):
RelayApp's (connection, profile) effect calls setVoicePrefsConnection before
onProfileChanged, and applyVoicePrefsScope pushes both into
VoicePreferencesRepository.setActiveScope. The deferred-list entry and the
"null until an integration wires this" KDoc paragraph described the
pre-wiring state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Typing + Send in offline Demo mode did nothing (sendMessage early-returned
on the null API client), which read as broken. sendMessage now intercepts
while isDemoMode: echoes the user bubble and appends
DemoContent.composerReply — an honest "offline demo, tap Connect in the
banner" assistant notice. Both bubbles are clientOnly, so demo-exit's
clearMessages() wipes them with the rest of the transcript.
Wired via setDemoModeWiring unconditionally in RelayApp: in demo there is
no API client, so the client-gated chat init never runs and ChatViewModel's
own handler stays null — the wiring supplies both the demo flag and the
shared ChatHandler. UUID-based ids so rapid sends can't collide on
LazyColumn keys.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sendChatStream / sendCompletionsStream / sendRunStream built their Request
before any try/catch or listener existed, so a malformed apiServerUrl
(hand-edited connection, corrupt settings import) made
Request.Builder.url(String) throw IllegalArgumentException synchronously
out of the ViewModel — the last open group in the #131 "Invalid URL host"
crash-class audit.
- authRequestOrNull() chokepoint backed by top-level buildApiRequestOrNull
(mirrors ConnectionManager's buildRelayRequestOrNull so the guard is
unit-testable without instantiating the client)
- a bad URL fails the turn through the normal onError channel with a
human message and returns an inert EventSource; no side effects fire
before the guard
- tests: valid/malformed URL cases in HermesApiClientTest
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream verification (clone @ 5057f03bf): delegate_task(background=true)
never dispatches async on the api_server surface — every api_server route
binds async_delivery=False and tools/delegate_tool.py downgrades the batch
to synchronous execution (upstream issue #10760). All standard voice turns
ride SSE/api_server, so the background-delegation nudge could not work as
designed and was reverted (45c7ef4); the speak-delegated-result-on-overlay
follow-up is closed on the same finding (no delayed completion turn exists
on that surface). TODO records the verified mechanism with source
locations; the CHANGELOG entry is withdrawn; DEVLOG item corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TODO: mark the durability + provenance-chip item shipped (gateway drain +
marker->badge + orphan dedupe), scope the remaining app-restart
persistence question, and mark the standard-voice delegate_task nudge
shipped with its on-device verify steps. CHANGELOG: user-facing entries
for the sync drain/badge and the background-delegation nudge under 1.4.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a line to the ephemeral voice interface context telling the model the
user is waiting in a live voice session: clearly-long requests (builds,
research, multi-step tool work) should be delegated via
delegate_task(background=true) with a spoken "started it in the
background" acknowledgement, while quick questions keep answering
directly. Rides the per-turn SSE system_message — nothing is persisted
and text chat is unaffected. Deliberately hedged: false-positive
delegation is worse UX than a long turn (SSE recovery + the turn-complete
notification already make those survivable).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Provider-answered realtime voice turns are folded into the Hermes session
as synthetic messages on the next chat/run request — but the gateway
prompt.submit can't carry them, and on a gateway-primary phone "wait for
the next SSE turn" meant never: the agent never learned what was said in
voice.
- force a gateway turn with unsynced synthetic sync messages (voice
intents / card dispatches / realtime turns) onto the sessions SSE route
so the traces land; guarded to an existing session id + the sessions
fallback + the default profile (a non-default profile's gateway session
is invisible to the shared api_server surface — the POST would 404 and
fail the user's turn)
- mark traces synced based on the route the turn actually DISPATCHED on
(effectiveEndpoint), fixing a latent duplicate re-send for voice turns
forced onto SSE by their interface context
- RealtimeTurnSyncBuilder.stripProvenanceMarker(): recognize the synced
"[Realtime Agent provider-native voice turn: ...]" marker in loaded
history, strip the bracket noise, and restore the quiet "Realtime
Agent" badge live turns get
- drop the superseded local clientOnly bubble when its synced copy loads
from the server (the exchange rendered twice otherwise); unsynced
traces stay preserved — they are still the only record of the turn
- tests: 4 new ChatHandlerTest load-path cases, 4 new
RealtimeTurnSyncBuilderTest marker cases (incl. builder round-trip)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
xAI closes a realtime conversation after 900s of inactivity; with manual
turn-taking (turn_detection: None) the provider socket sees nothing while
the user is silent, so an open-but-quiet voice session — most commonly a
background-run wait — died with a raw provider error (observed live
2026-07-08).
- add _provider_keepalive_loop: per-connection broker task appends ~100ms
of silent, never-committed PCM after RELAY_VOICE_PROVIDER_KEEPALIVE_MS
of quiet (default 240s => 3 pings per 900s window; 0 disables); runs
through detached periods; append-only so it can never race or clobber
a user utterance
- stamp provider activity in two places only: client input_audio.append
and once per provider event in _pump_provider_events
- classify residual provider idle-closes (_is_provider_idle_timeout) into
a human-readable "voice session expired" error instead of raw provider
text
- extend realtime-provider-idle-probe.py with --keepalive-ms for the
relay-host repro (--windows 960) and fix verification
- revise ADR 33 Phase 0: xAI is needs-keepalive beyond ~900s (POC doc
revision 2026-07-08 + decisions.md note)
- tests: 11 new in plugin/tests/test_realtime_keepalive.py; existing 54
realtime tests green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The forced-Hermes preamble, background-task handoff ack, and
completed-background-task summary were all injected via
conversation.item.create role=user — the model's history contained
fake turns like "the user" saying "Hermes has already handled the
user's previous voice request...".
response.create supports a per-response instructions field that
overrides the session prompt for one response only, with no
conversation item created at all. Confirmed supported by both
providers (OpenAI docs; xAI's Voice Agent API docs show the same
shape) — conversation:"none" (OpenAI-only true out-of-band) is
deliberately not used since the spoken summary should remain real
history for follow-up turns to reference.
request_response() gained an optional instructions kwarg on both
provider adapters; the 4 broker-authored injection sites switched
from send_text(prompt) to request_response(instructions=prompt).
The one genuine passthrough (real client-supplied text) is
untouched.
Background-run chip pinned a finished tool's status line until the
next unrelated event overwrote it (no hermes.tool.completed/failed
handler); CompactTranscriptRow rendered the reply above the tool
calls that produced it. Also add KeepScreenOnWhile so voice mode
holds the screen on for the whole session and chat holds it only
while a reply streams, matching call/video-playback conventions
instead of relying on the OS default throughout.
Leads TODO with the current state so work can resume cleanly after a session
compact: the prepped android-v1.4.0 / plugin-v1.4.0 release act (sign-off →
notes → merge/tag → discard the 1.3.0 Play draft), the two open voice bugs
(tool-completion handler + PCM click, both needing a logcat repro), and the
Mizu triage queue. Trimmed the now-shipped duplicate-toast voice item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relay URL guards (rest of the #131 relay class):
- RelayVoiceClient validates its base in resolveHttpBase() → null on a
malformed URL, so all 7 voice endpoints fail via the existing Result.failure
guards instead of a throwing .url() on the IO dispatcher.
- RelayHttpClient's two string-URL sites (fetchMedia, listSessions) now use
toHttpUrlOrNull() → Result.failure. RelayProfileInspectorClient was already
guarded (toHttpUrl + catch everywhere).
Voice error-recovery UX (from the on-device realtime test):
- VoiceModeOverlay no longer pipes errorEvents to the app-wide bottom snackbar
while it's up — the inline top banner is the single surface, killing the
duplicate bottom toast on a failed/timed-out turn.
- clearError() now resets Error→Idle so a dismissed/retried failure lands
usable; the banner gained a Dismiss beside Retry (was retry-only, which
trapped the user).
Also diagnosed in TODO (need a repro-with-logs before fixing): realtime
tool-call spinners run forever (no hermes.tool.completed/failed handler in
VoiceViewModel though the relay forwards them); tap/static click between
sentences in realtime PCM playback.
Build + install + launch-clean verified on device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Play crash on 1.2.6 (Galaxy S25 Ultra / Android 16): IllegalArgumentException
from okhttp3.HttpUrl$Builder.parse via ConnectionManager.doConnectInternal →
Request.Builder.url(). doConnectInternal runs on a background coroutine, so a
malformed relay host (from a corrupt/edited pairing payload) made OkHttp's url()
throw uncaught → app crash. This is the relay-socket half of the #131 "Invalid
URL host" class the TODO flagged (the #131 fix only covered Manage/voice HTTP).
- Extracted a pure buildRelayRequestOrNull() (try/catch → null on
IllegalArgumentException). doConnectInternal treats null as a connection
failure: "Invalid relay URL" diagnostic + Disconnected + close-replaced-socket
+ backed-off reconnect — the same path onFailure uses. No happy-path change.
- ConnectionManagerUrlGuardTest: valid ws/wss build; empty-host / space-in-host
return null. Green via :app:testSideloadDebugUnitTest; APK rebuilt + installed
+ launched clean on device.
TODO #131 audit: ConnectionManager marked fixed; RelayHttpClient /
RelayProfileInspectorClient / RelayVoiceClient remain for a defense-in-depth pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Android → 1.4.0 (versionCode 22); plugin → 1.4.0 (all metadata in sync).
- CHANGELOG [Unreleased] → [1.4.0] - 2026-07-07.
- DEVLOG entry for the CI-path-coverage + Android-14 crash-safety work.
Prep only — in-app What's New / RELEASE_NOTES / Play notes, the dev→main
merge, and the android-v1.4.0 / plugin-v1.4.0 tags are the release act,
pending an on-device smoke test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Built against SDK 35, Kotlin's removeFirst()/removeLast() resolve to Java 21's
List methods that don't exist below Android 15, crashing older devices.
- Replaced all 5 app-code removeFirst() calls (all on kotlin ArrayDeque, so
members not the flagged MutableList extension — already safe, but converted
per Google's guidance and for future-proofing) with removeAt(0). All sites
are size-guarded, so behavior is identical.
- Pinned com.google.crypto.tink:tink-android:1.16.0 ahead of the transitive
version security-crypto pulls, whose HybridConfig.<clinit> tripped the same
Play pre-launch check. Our EncryptedSharedPreferences use is AEAD-only, so
HybridConfig is almost certainly never loaded — this clears the static Play
warning. Untestable without a build; on-device auth smoke-test queued in TODO.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The path list omitted doctor.py, compat.py, config.py, profiles.py, and 5
other top-level modules, so changes to them alone never ran plugin CI (my
doctor.py fix only got covered because it also touched plugin/tests/**). A
plugin/*.py glob covers every current and future top-level module.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gateway plugin loader dedups discovered plugins by manifest name, so a
second directory declaring `name: hermes-relay` (a backup copy left by an
older installer, or a stray extra install) could win the dedup and make the
gateway load stale code — silently ignoring every later deploy. This was the
root cause of the 2026-06-29 phone-platform round-trip failure.
- doctor: new `_duplicate_plugin_dirs()` + `plugin-name-unique` check warns
when >1 directory under the plugins dir declares the same plugin name
(deduped by resolved real target); report gains `duplicate_dirs`/`plugins_dir`.
- install.sh: sweep the plugins dir after symlinking and remove any other
entry declaring `name: hermes-relay`, so a stale duplicate can't linger.
Tests: 4 new doctor cases (14/14 green). install.sh grep-match validated in
isolation. Live-host verify queued in TODO.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Remove 11 shipped-and-released [x] records from "User-Added" (session
delete, voice override, analytics/diagnostics, connections reframe,
profile lock, etc.) — they live in DEVLOG; keep the one open [ ] item.
- Collapse the dot-matrix "thinking indicator" section: base + presets +
colors shipped in android-v1.3.0; keep only the two real remainders
(OS reduce-motion/TalkBack; optional avatar-style promotion).
- Fix the "Research / open questions" bootstrap notes: PR #8556 was closed
as superseded; native upstream now covers sessions via #33134 and
skill/toolset discovery via /v1/skills + /v1/toolsets (#33016). Bootstrap
shrinks per surface, not one big delete. Stage 2 slash-preprocessor is
unblocked (was "blocked on #8556").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump to 1.3.0 (code 21); cut the CHANGELOG [1.3.0] block; refresh
RELEASE_NOTES, whats_new, changelog.json, and Play notes; write
plugin-v1.3.0 release notes (with the #165 native-install known-issue
callout — that fix ships in v1.3.1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background-run progress events (tool.started/tool.delta/run.progress and the
shared emitStatus path) forced VoiceState.Thinking on every tick, pinning the
overlay in spinner+Stop and routing mic taps to the interrupt branch — the
relay's floor was free but the client never returned to Idle, so conversation
couldn't continue during a promoted run. Those paths now update only the
background chip while a run is active; inline (grace-window) turns keep
today's behavior.
Exit and Stop no longer kill a background task: exitVoiceMode detaches (the
relay delivers the result on the next session or as a proactive notification)
and interruptSpeaking silences audio only — the chip's ✕ remains the one
explicit cancel. hermes.run.cancelled in the chat sync now only replaces the
bubble with "Cancelled." when it holds no real content; a delivered answer
keeps its text and gets the Stopped badge, fixing the completed-task-shown-
as-cancelled transcript.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aborting an in-flight answer recovery (session/profile switch, new
chat/thread, connection switch) called cancelAnswerRecovery(settleUi=false)
with no live stream, killing the poller but leaving ChatHandler._isStreaming
true and the "Reconnecting to your answer…" turn status frozen forever.
cancelAnswerRecovery now always settles the handler when a poller was
running: a silent clearStreamingStatus() on the abandon paths (no error
badge, per-message flags untouched so cancelStream's Stopped-badge findLast
still works) and the existing placeholder finalize on the new-send path.
The poller also anchored on the pending user message by trimmed-text
indexOfLast, so a short repeated prompt ("yes"/"continue") whose send never
reached the server could match a stale identical earlier row and adopt a
different turn's static (instantly "stable") answer. Recovery now captures
how many user rows existed before the send and requires the pending send to
be the (N+1)-th user row AND match its content; when it can't be established
it never adopts and fails fast to the error UI after confirming across two
polls, instead of polling to the 30-minute cap.
Also adds the clearPendingAsk(deny) parity the sibling stream-error branch
performs to the recovery give-up path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
plugin/tests/test_native_layout_imports.py (the AST guard against absolute
plugin.* imports + the native-loader smoke test) was never in ci-plugin.yml's
pytest file list, so a reintroduced absolute import would pass CI. Add it to
the invocation and switch the dependency step to `pip install -e .` so the
smoke test's full relay import chain (requests/httpx/segno/websocket-client)
resolves under CI's clean subprocess.
Also document, on _plugin_module()'s dotted-__package__ branch, the assumption
that our parent package is the plugin package (true for both real layouts:
plugin.dashboard and hermes_plugins.hermes_relay.dashboard).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
install.sh already autodetects the venv layout (classic venv, uv-managed
.venv, Docker), but the systemd unit template and the pair/status/relay/
tailscale shims still hardcoded the classic ~/.hermes/hermes-agent/venv
interpreter. On a uv-managed host the install reported success while
ExecStart pointed at a nonexistent python -> 203/EXEC, dead relay, broken
shims.
Rewrite the unit's ExecStart/PATH/VIRTUAL_ENV to the detected venv dir at
install time (sed over the committed classic-default template), and make
every generated shim try the detected interpreter first. HERMES_VENV_PY
still wins; the classic + uv layouts remain runtime fallbacks so a relocated
venv self-heals without a reinstall.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review-nit cleanup across the merged branches (KDoc placement, prefill KDoc
accuracy, synthetic test IP, RELEASE.md artifact wording, security.md plain-ws
gating description), ServerAddressTest + IssueReportAndDiagnosticsTest added to
the focused Android CI slice, and consolidated CHANGELOG/DEVLOG/TODO entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Onboarding slide content overflowed below the fold with no scroll
affordance on short viewports or raised font scale (#145).
- OnboardingPage: outer Column now verticalScroll(rememberScrollState())
per page; Arrangement.Center dropped (the pager's centering Box handles
short content and Center conflicts under verticalScroll).
- Hero adapts via BoxWithConstraints: 232dp -> 160dp below 620dp available
height, hidden below 480dp, so typical devices don't need to scroll;
scrolling remains the safety net for font-scale/foldable extremes.
- OnboardingScreen: bottom nav (pinned outside the pager) tightens its
48dp bottom padding + indicator spacer under compact screen heights.
- OnboardingCompactScreenshotTest: Roborazzi renders at w320dp-h480dp @
1.5x font scale and w360dp-h600dp, scrolling to the last body line to
prove reachability; no golden PNGs, store screenshots untouched.
Fixes#145
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- "No reachable endpoint" subsection: what the diagnostic means and how to
check each saved route from the phone (LAN vs Tailscale, port 8642).
- Callout: never use localhost/127.0.0.1 as the server address on a phone.
- Tailscale checklist, including that the relay Tailscale helper serves
the relay + API ports but not the dashboard :9119 (Manage needs it
reachable separately).
- "Long turns with local models" entry: mid-turn stream drops recover the
finished answer automatically; screen-on/plugged-in reduces drops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pure helper returning an advisory string when a typed server address
parses to a loopback / any-interface host (localhost, 127.x.x.x, ::1,
0.0.0.0) - such an address points at the phone itself and can never reach
the server. Accepts scheme-less input via the existing parseUserInput
normalization; bare "::1" is handled explicitly since it never parses
without brackets. Not wired into any UI yet - wiring is a queued
follow-up owned by the connection-flow workstream.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Routine Info-severity probe lines ("Testing API connection") were one tap
away from becoming "[Bug]:"-titled GitHub issues with an unedited
boilerplate body (#155, #154, #146). Now:
- Info entries require a free-text "What were you expecting to happen?"
answer before the GitHub link is offered; the answer replaces the
boilerplate "What happened" line (secret-redacted via the shared
DiagnosticsLog path). Error entries keep the direct flow.
- Info/Warning entries prefill as "[Diagnostic]: <title>" with the existing
"question" label; Error entries keep "[Bug]:" + "bug".
- The "Connection mode" line now carries the actual route role
(entry.endpointRole, else inferred from the entry URL, else "unknown")
instead of the literal "LAN / Tailscale / public TLS / other" template.
Prefill logic extracted to the pure DiagnosticIssuePrefill object so the
title/label/body contract is unit-testable without Compose.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slow local models + delegating skills outlive the phone's SSE socket
(screen-off / Doze / Wi-Fi power-save — not an OkHttp timeout). Upstream
api_server keeps running the turn after the SSE writer dies and persists
the final answer, but the client finalized the turn as an error after one
immediate history reload that raced the still-running run — stranding the
"Still working…" placeholder forever.
Client-side recovery, standard-path safe (no server/plugin changes):
- ChatStreamRecovery: poll `/api/sessions/{id}/messages` after a
transport-class (IOException-family) drop on the SESSIONS endpoint —
5s cadence with exponential backoff to 30s, capped at 30 minutes.
Finish = a new non-empty assistant message postdating the pending user
message, stable across two consecutive polls; intermediate persisted
rows reconcile progressively. A reachable transcript that lacks the
pending user message fails fast (the run never started).
- Recovered turns finalize with normal-completion side effects
(turn-complete notification, queued-send drain, session-list refresh)
via finalizeTurnSideEffects, extracted from onCompleteCb and shared.
Cap expiry falls back to the existing error UI.
- Exactly one poller per turn; aborted on new send, user Stop, session
or profile or connection switch, new chat/thread, and VM clear; a late
onComplete cancels it (double-finalize guard).
- Gateway/runs/completions error paths are unchanged; user cancel keeps
the existing intentionallyCancelled discipline.
- HermesApiClient: shared streamFailureMessage() + TRANSPORT_ERROR_PREFIX
so transport failures are distinguishable from server-reported errors.
- UI: streaming placeholder reads "Reconnecting to your answer…" during
recovery; input caption mirrors it via turnStatus; one diagnostics
Warning entry when recovery starts. ChatHandler.onStreamError now also
clears the stale turn-status caption.
Tests: virtual-time poller coverage (backoff cadence, stability window,
fail-fast, cap expiry, cancel) plus Robolectric + MockWebServer
end-to-end coverage (mid-turn socket kill -> polls -> placeholder
completes with the recovered answer; user cancel and new send abort the
poller; cap expiry surfaces the error UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The immutable nousresearch/hermes-agent image (/opt/hermes/.venv) only
supports the native 'hermes plugins install' path; install.sh's
editable/systemd path is not applicable there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two layers so #165's failure class can't regress silently:
- AST guard over every runtime module under plugin/ asserting no
absolute plugin.* imports remain (tests exempt).
- Native-layout smoke: copies the plugin tree to a tempdir under a
different package name and, in a subprocess with top-level 'plugin'
imports blocked, loads it exactly like upstream's
PluginManager._load_directory_module, then imports the relay server
chain, tailscale CLI, pair, doctor, and enhancements.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
install.sh hardcoded ~/.hermes/hermes-agent/venv/bin/python and died
mid-run on the official Docker image (#165). Now auto-detects, in order:
the classic venv, a uv-managed .venv, and /opt/hermes/.venv (official
Docker image). The Docker layout is immutable — no clone, no user
systemd, site-packages reset on pull — so the installer refuses it
early with a steer to 'hermes plugins install
Codename-11/hermes-relay/plugin' and exits nonzero. An explicit
HERMES_VENV_PY still forces through.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'hermes relay doctor' previously passed on installs where 'hermes relay
start' crashed at import time (#165) because no check ever imported the
relay code. Add a relay-import-chain check that imports
<plugin pkg>.relay.server under the CURRENT package layout and reports
an actionable error (update the plugin / reinstall) when it fails.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Four assets per android-v* release confused new users: GitHub sorts
assets alphabetically so the non-installable googlePlay .aab listed
first, and the two parity/testing artifacts had no meaning to
non-developers (#144, follow-up from #65).
- release-android.yml: still builds all four artifacts, but attaches
only the sideload APK, the googlePlay AAB, and SHA256SUMS.txt;
checksums now cover exactly the attached files. The parity twins
remain reproducible from the tag via CI. The sideload APK filename
is unchanged — the in-app UpdateChecker matches ".apk"+"sideload"
in asset names.
- RELEASE_NOTES.md: replaced the 4-row download table with a lead
install callout (sideload APK / Google Play) plus an explicit note
that the .aab is a Play Console upload bundle, not tap-installable.
- RELEASE.md §2: codified the Download-block format and the 2-asset
policy as the required RELEASE_NOTES format for future releases.
Closes#144
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Multi-agent code-verified triage: 5 issues already fixed in released tags
(closure-only), 4 workstreams of real fixes (SSE stream recovery, native-install
imports, diagnostics report de-noising, onboarding scroll), release-asset and
docs-freshness passes, and release sequencing around the pending plugin-v1.3.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With timer-driven spoken progress off by default, the voice overlay's
background-run chip is the primary in-between signal — but it was a static
string. Make it live using the hermes.run.progress extras the relay already
sends (active_tool_name / completed_tool_count / elapsed_ms, now parsed into
RealtimeVoiceEvent):
- BackgroundRunState gains statusLine / completedToolCount / startedAtMs /
phase (RUNNING / RECONNECTING / DELIVERING). Tool-start + progress events
drive the live line; handoff labels flip the chip to RECONNECTING during a
mid-run socket drop ("Reconnecting — your task is still running") and back
on "Voice reconnected"; background_completed shows a DELIVERING chip until
the first summary audio (20s watchdog covers visual-only delivery); a new
turn mid-run sets "Still working on the earlier task…".
- BackgroundRunChip composable: pulsing dot (tertiary while reconnecting),
phase-aware title, live detail line (step · N steps · m:ss ticker), and a
cancel ✕ wired to the existing response.cancel path (chip flips to
"Cancelling…"; hermes.run.cancelled clears it). Remembers the last non-null
state so the exit fade doesn't snap empty.
Client-only; no relay changes. assembleSideloadDebug + lintSideloadDebug
green. Ambient visibility outside voice mode deferred to coordinate with the
connection-management status strip (TODO).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FCQnygBdT2VfEsa3bufJnX
- A failed realtime-agent resume used to park forever on "waiting for route
change" while the relay held the detached session (and any background run's
result) open for minutes. The client now also retries the resume every 10s
for up to 5 minutes; a successful socket open clears the retry state, and
the route-change watcher stays as the fast path on an actual network switch.
- entering voice mode prewarms the persistent Realtime Agent session (session
POST + relay websocket + provider connect) so the first utterance skips that
setup. Prewarm sends no input and skips all turn-scoped side effects (no
Thinking state, no chat placeholder, guards disarmed); the first real
utterance rides submitRealtimeTurn like any follow-up turn, and a failed
warm-up is silent — the next turn simply opens fresh.
Verified: assembleSideloadDebug + lintSideloadDebug green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FCQnygBdT2VfEsa3bufJnX
Robustness batch from an architecture review of the realtime background-run
path (the interim-ack + later-injection shape matches current realtime-API
practice; these are the lifecycle/UX gaps around it):
- Deliver-on-reattach: a result completing while the phone is detached is held
(pending_background_result) instead of spoken into the bounded replay ring
(a long summary could evict its own head); resume injects it after replay.
If the session dies first, _close_native_session pushes the result through a
new proactive_push hook (ProactiveChannel.push, buffered while offline) —
including the close-races-completion case.
- Busy answer: a second hermes_run_task while one is in flight returns a
speakable already_running result instead of overwriting hermes_task and
cancelling the first run's delivery (silent orphan).
- Adaptive promotion: known-long tools flag the run at tool-start
(RELAY_VOICE_LONG_TOOL_HINTS); the grace wait races that signal and promotes
after a 1.5s quick-finish window, so obviously-long runs hand off early
while fast long-class calls stay Tier A.
- Milestone speech: timer-driven spoken progress is per-session config and OFF
by default (realtime_voice_progress_spoken_after_ms: 0); handoff/completion/
failure speech and the visual progress events are unchanged.
- Plumbing: done-callbacks retrieve+log unexpected hermes_task /
background_delivery_task failures; provider websockets get an explicit
heartbeat=20s with a connect-bounded (not total) timeout.
Tests: 4 new promotion tests (deferred-injection-on-resume, busy second task,
long-tool promotes before an 8s grace, config default 0); the spoken-status
routes test opts in via the new per-session knob. 65 realtime tests green via
python -m unittest. Pre-existing xai-oauth fixture failure recorded in TODO.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FCQnygBdT2VfEsa3bufJnX
After a promoted/background Hermes run finished, the spoken summary was never
heard. The re-injection called cancel_response() with no active provider
response, xAI replied "Cancellation failed: no active response found", the relay
forwarded it as a fatal voice.error, and the client closed the whole realtime
session (error toast + Retry) right before the reply.
- broker.py: _inject_background_summary gains cancel_current; _deliver_background
_result passes cancel_current=not floor_idle, so the needless cancel (and its
benign error) is avoided when the floor is already idle. _pump_provider_events
classifies benign provider notices via _is_benign_provider_error() and logs a
non-fatal voice.realtime_agent.provider_notice instead of a fatal voice.error.
- RelayVoiceClient.kt: a voice.error matching a transient provider notice is
logged and ignored instead of tearing down the session (defense in depth).
Test: benign provider ERROR is not forwarded as voice.error while a fatal one
still is. 61 realtime-broker tests green via python -m unittest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Minor bump from 1.2.1 (still the latest plugin tag) to cover the accumulated
unreleased plugin work: the proactive phone platform (agent->phone push, agent
Threads, two-way reply, outbound buffering), the /phone/threads + update-check
routes, the per-profile enable helper, and the realtime-voice background-run
resilience fix. Version synced across pyproject.toml, plugin/relay/__init__.py,
plugin.yaml, and the dashboard manifest/package files; CHANGELOG [Unreleased]
gains the realtime-voice fix bullet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A promoted/durable realtime-voice run (ADR 33) lost its result on a brief network
drop: the relay closed the detached session 30s after any disconnect
(_RESUME_TTL_SECONDS), cancelling background delivery and orphaning the run long
before a minutes-long run finishes. The relay already replays unacked events on
resume, so the session just needs to survive that long.
- Keep a detached session alive while a background run is in flight: the resume
window stretches from 30s to a background cap (default 6 min,
RELAY_VOICE_BACKGROUND_DETACHED_MAX_MS); a poll loop closes only after the run
finishes plus a grace. Resume then replays the recorded result.
- Bound a run at a hard cap (default 5 min, RELAY_VOICE_BACKGROUND_RUN_MAX_MS): a
hung tool is cancelled and surfaced as a background_completed error.
- Cancel the orphaned hermes_task on session close so a hung tool can't keep
running; guard _inject_background_summary's send_text so a dead provider socket
can't raise unhandled in the delivery task.
Tests: two new promotion tests (detached session survives a shrunken base TTL and
records the result for replay; a hung run times out + is cancelled). 60
realtime-broker tests green via python -m unittest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Markdown h1..h6 were unset in markdownTypography(), falling through to the
mikepenz M3 display roles (h1=displayLarge, 57sp in this app's scale) so a single
`#` dwarfed the 14sp body in a ~272dp bubble. Set an explicit chat-tuned ramp
(h1 20sp -> h6 13sp, derived from bodyLarge/bodyMedium so the font-picker still
applies), unify paragraph/text/bullet/ordered/list to 14sp, size inline+fenced
code to 13sp with tracking 0, italicize/mute blockquotes, and give links a
primary accent + underline via textLink.
Also: gate the per-bubble timestamp to isLastInGroup (alpha 0.5 -> 0.6), break
same-author grouping on a >5min gap (GROUP_GAP_MS), fire a haptic when the
long-press action menu opens, and show the streaming dots only before the first
token. Deferred follow-ups recorded in TODO.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the two-connection rework: connection status lives in the chat
header subtitle + the bottom RelayStatusStrip cue, so the top-strip machinery is
unreachable. Removed:
- ConnectionHandoffBanner (wrapper, no callers) + ConnectionStatusBanner (the
take-space/compact top render) + PulsingSyncIcon, and their orphaned imports.
- ConnectionStatusSurface enum + presentationSurface() (RelayUiState) and its
ConnectionStatusSurfaceTest.
- Refreshed the stale KDoc refs in RelayStatusStrip.
Kept: ConnectionStatusToast, deliberately parked as a general-purpose toast
primitive (only surface with a live multi-step stepper), plus the shared
ConnectionStepRow/StepGlyph helpers and the ConnectionStatusSnapshot/
ConnectionHandoffStatus models (still feed the VM's globalConnectionStatus and
the bottom-strip cue). compileSideloadDebugKotlin green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks connection-status UI (superseding the persistence-tiered top strip) after
on-device iteration surfaced that "connection" is really two independent connections,
and conflating them was the design error:
- Chat/agent (gateway/API) -> the chat header SUBTITLE (WhatsApp-style): the model
line swaps to Reconnecting.../Connecting.../Disconnected and crossfades back to the
model on recovery. Adds reconnecting-vs-connecting wording (everConnected).
- Relay socket (:8767, bridge/terminal/relay-voice) -> the bottom RelayStatusStrip
"Reconnecting..." cue only. It never blocks chat, so it stays ambient.
- No top-of-screen surface at all (removed the Passive/Banner top strip). Route
change is ambient-only (the bottom strip's route label).
Also in this pass:
- fix: a benign background->foreground re-handshake is now fully silent. New
postResumeQuiet window gates the bottom-strip cue -- the health "Connecting" path
used to leak it there, flashing then clearing with no resolution.
- Merge the three positive handoff branches into one, deciding "Connected to Hermes"
vs "Connection changed - LAN -> Tailscale" at the actual connect via
lastConnectedRole, so a flap or a swap can't emit a redundant pair.
- Permanent client-side logging (tag ConnectionVM, pairs with ConnectionManager) for
every relay state/role transition + handoff record.
Diagnosed via a both-sides trace (client log + read-only relay journalctl): the
flapping was a Samsung adaptive-Wi-Fi teardown, not an app bug. The old
ConnectionStatusSurface/presentationSurface tiering + ConnectionStatusBanner top
render are now unused (kept for now, tracked in TODO); ConnectionStatusToast is
parked as a general toast primitive. Lint green; ConnectionStatusSurfaceTest green
(3 unrelated DataStore-rename failures are pre-existing Windows-local flakiness).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Routine reconnects no longer shove the whole screen down. The connection-
status surface is now tiered by how long a state persists, so the most
frequent event (a reconnect) is the least disruptive:
- ConnectionStatusSurface { None, Float, Banner } + presentationSurface():
active (in-flight reconnect/checking) -> None; success (reconnected /
route switched) -> Float; sustained Warning/Error -> Banner. Maps 1:1
onto the existing handoff producers.
- RelayStatusStrip gains a `reconnecting` cue (amber, softly pulsing
"Reconnecting..."), so a routine in-progress reconnect surfaces only in
the always-visible bottom strip -- zero layout shift for the common case.
- RelayApp re-routes the two existing render sites off presentationSurface():
take-space ConnectionStatusBanner fires only for Banner, floating
ConnectionStatusToast only for Float. No component rewrites.
Prior split routed all non-error states to the take-space banner, and the
"error -> float" branch was dead (buildGlobalConnectionStatus never emits
Error). The post-resume suppression still fully silences benign resumes.
Adds ConnectionStatusSurfaceTest (pure mapping guard). Lint + unit test green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The back arrow on Manage (DashboardManagementScreen) and Bridge did nothing.
They navigated to Chat via `navigate(Chat){ popUpTo(startDestination){saveState};
launchSingleTop; restoreState }`, but Chat IS the start destination — navigating
to the start with restoreState restored an equivalent stack and nothing moved
(a silent no-op). Navigating to Manage (not the start) worked, which is why only
the "back to Chat" paths were dead.
Fix: use the standard `popBackStack()` for the back arrows (Manage's onBack,
Bridge's onNavigateToChat in both flavors) — reliably returns to the screen the
user opened it from, matching every other sub-screen. bridgeReturnAction's
navigate-to-remembered-route drops saveState/restoreState so returning to Chat
works too. Renamed DashboardManagementScreen.onNavigateToChat → onBack and
relabeled the icon "Back to chat" → "Back".
Verified on-device (sideload debug on SM-S938U): the Manage back arrow now
navigates (previously inert). :app:assembleSideloadDebug BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two connection-management improvements from the sleep/backgrounding audit.
Slow-poll tier (ConnectionManager). Against a paired-but-dead server the
exponential backoff otherwise capped at ~16s and retried forever — steady
battery + log noise for no benefit. After SLOW_POLL_AFTER_ATTEMPTS (20, ~5 min
of continuous failure) the reconnect drops to a 5-min poll. A network change
re-resolves + reconnects immediately regardless, and reconnectAttempt resets to
0 on a successful onOpen, so recovery is never gated on the slow interval.
Doze battery-optimization exemption (sideload). A specialUse foreground service
holds the process up but does NOT exempt from Doze's network deferral, so the
"Persistent connection" keep-alive still drops the socket in deep sleep. New
util/BatteryOptimizations (isIgnoringBatteryOptimizations + launchRequest) and a
Settings → Quick Controls nudge — shown only when keep-alive is on and the app
isn't exempt — offer the one-tap system request. REQUEST_IGNORE_BATTERY_
OPTIMIZATIONS is declared in the sideload manifest only (Google Play restricts
it), and the nudge is gated on BuildFlavor.isSideload; it re-checks on ON_RESUME
so it clears once granted.
Verified: :app:assembleSideloadDebug + :app:lintSideloadDebug BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two connection-management fixes surfaced by the live relay logs.
1. bridge.status auth race (the relay was rejecting connections). The live
logs showed repeated "Auth failed … expected system/auth, got
bridge/bridge.status" — BridgeStatusReporter's immediate/30s tick could beat
the auth handshake on a fresh or reconnecting socket, so the relay rejected
the whole connection and the phone reconnected. ConnectionManager.send() now
holds every non-`system/auth` frame until `auth.ok` arrives (flag reset on
each connect/close/failure/disconnect); dropping a periodic frame is harmless
since the next tick re-sends once authenticated.
2. Resume re-probe debounce. revalidate() cleared the probe cache + re-probed
every route (+ Probing badge flash) on every ON_RESUME, including sub-second
app switches. New revalidateOnResume(awayMs) skips the heavy re-probe when
the app was away briefly and the API connection is already healthy; RelayApp
measures the away duration from ON_PAUSE→ON_RESUME. Network *changes* are
handled by the ConnectivityObserver/callbacks, not revalidate(), so a brief
skip can't miss a Wi-Fi↔cellular flip. Network-change callers of revalidate()
are unchanged.
Verified: :app:lintSideloadDebug BUILD SUCCESSFUL. (Auth-gate confirmed against
live journalctl on the relay host: the exact rejection pattern this removes.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On first load the background session-list load runs before the server is
reachable; its ConnectException was classified to "Connection refused / The
server isn't accepting connections" and shown as the global bottom snackbar —
a scary, redundant pop-up duplicating what the themed connection banner +
startup sphere already surface.
Add `isConnectivityError(Throwable)` (connection-refused / host-unresolved /
timeout) to RelayErrorClassifier, and gate `ChatViewModel.emitError` so the
bootstrap contexts (load_sessions, create_session) skip the snackbar for
connectivity-class failures while still recording them to diagnostics.
Actionable failures (auth rejected, server error) and interactive contexts
(send_message, …) still surface normally.
Adds classifier unit tests (connectivity vs non-connectivity). Verified via
:app:testSideloadDebugUnitTest (RelayErrorClassifierTest) — BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the app-side of update discovery (the dashboard/CLI shipped in efa51dc).
- plugin/relay/server.py: GET /relay/update-check on :8767 — the app-facing
twin of the dashboard route (bearer for the app, loopback for diag).
Reuses plugin/update_check.check() in an executor (off the event loop),
caches the GitHub result 1h, degrades softly offline.
- RelayHttpClient.fetchUpdateCheck() → ConnectionViewModel.relayUpdateInfo
(refreshed on each auth.ok) → an AboutScreen "Relay" row showing the
connected relay's version + a soft "newer release available" nudge with a
copy-the-fix command. Fail-soft: older relay (404) → no row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The session file (~/.hermes/hermes-relay-sessions.json) already survives a
git-pull + restart update — it lives outside the updated tree — and a 180-day
trusted-device refresh token recovers a session without a QR when the short
session lapses. But _load_from_disk wrote a `version` field it never read and
handled a missing file silently, so a future on-disk schema change could
silently drop every record (forcing a mass re-pair), and an update that lost
the file gave no diagnostic.
Make the load path version-aware and drop-averse: a newer-versioned file (e.g.
after a relay downgrade) loads best-effort rather than being discarded, only
records that genuinely fail to parse are skipped, and the on-disk version is
logged. A missing session file is now logged explicitly so an update that lost
it is diagnosable instead of silently forcing re-pairs.
Adds version/visibility tests (newer-version file still loads sessions +
trusted devices; versionless file loads; missing file is logged). 22 tests in
test_session_persistence pass; 79 across the adjacent auth/session suites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the ConnectionsScene mock to the new scannable connection list — each
card carries an Active badge + the capability timeline summary — replacing the
old single crammed active-card frame. Re-render host-side (Roborazzi, 1080x2160)
and re-export the Play graphic (phone-screenshots/7.png). Validation passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the overloaded Connections screen into a scannable list (level 1) and a
new tabbed ConnectionDetailScreen (level 2: Overview / Routes / Advanced /
Security). Each list card shows label + an Active badge + a one-line status +
the capability timeline summary; tapping drills into the detail, whose overflow
menu owns rename / re-pair / revoke / remove. Adds Screen.ConnectionDetail and
wires it in the NavHost; relocates the ADR-24 route block into a reusable
ActiveCardRoutesSection. Relay sessions get a clear home in the Security tab.
Make the non-error status banner take-space, animated (3-line per-step stepper:
spinner -> green check -> red x), and dismissible (close x + swipe-up, wired to
the existing dismissedStatusKey). Rename the reconnect handoff from the
misleading "Connection changed" to "Reconnecting", and suppress the transient
banner during a foreground-resume grace window so a same-connection
re-handshake after returning to the app doesn't flash a misleading status.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes two productization gaps from the phone Threads work, reusing the
existing update mechanisms (no new updater).
Profiles (install-once / enable-per-profile / pair-once):
- plugin/profiles.py + `hermes relay profiles list|enable [--all|NAME]`
enumerate the default config + every profiles/<name>/config.yaml and
bulk-enable hermes-relay in plugins.enabled (backing up each rewritten
file to .bak, skipping already-enabled configs). Pairing is unaffected
— one relay, pair once.
Update discovery:
- plugin/update_check.py + `hermes relay update-check` + a dashboard
"Plugin version" card compare plugin.relay.__version__ against the
latest plugin-v* GitHub release and surface the right command
(hermes-relay-update vs `hermes plugins update hermes-relay`). Dashboard
route GET /api/plugins/hermes-relay/update-check caches the GitHub
fetch 1h and degrades softly offline.
Docs: configuration.md gains "Profiles & the relay" + "Keeping the relay
plugin updated".
Tests: +39 (profiles discover/state/enable; semver/tag-pick/command-
detect/build-result; dashboard update-check available/up-to-date/error).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Silences upstream's first-message onboarding notice ("📬 No home channel
is set for Phone … /sethome"), which fired on every new phone Thread's
first message. The nudge (gateway/run.py) checks the PHONE_HOME_CHANNEL
env var directly, not the adapter's seeded config; a single paired phone
has exactly one logical home, so /sethome is friction with no decision
behind it.
- phone_platform.py: register_phone_platform pre-fills
PHONE_HOME_CHANNEL=phone when enabled and unset/blank (respects an
explicit operator override) — presence is what the notice checks.
- dashboard: "Home channel" card on Relay → Management saves a display
name via host PUT /api/env (PHONE_HOME_CHANNEL_NAME); backed by a new
loopback GET /phone/config reading the adapter's env resolution.
Rebuilt dist/index.js.
- user-docs: brief "Phone Threads — Beta" config subsection (opt-in,
auto-home-channel, rename).
Tests: +4 home-channel-default (test_phone_platform), +2 /phone/config
(test_plugin_api) — 54 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the Roborazzi harness for the navbar change: drop RelayModeStrip from the shared StoreCockpit (fixes the chat / landing / gallery scenes) and give ManageScene faithful Manage chrome (back arrow + Terminal/Settings/Refresh, no context meter, no hamburger). Re-render the store sources (assets/screenshots/*.png, 1080x2160) and re-export the Play phone screenshots. RelayModeStrip/RelayPrimaryMode stay defined in RelayCockpitChrome — only the harness stopped rendering them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The top-bar connection chip was replaced by the AgentInfoSheet's Connection section; update the 'Switching connection' steps to match (tap the agent name/avatar -> Connection radio list, shown for 2+ connections, disabled while a reply streams).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the app to the new relay route so Thread reply routing is complete,
not just for threads created this session:
- RelayHttpClient.fetchPhoneThreads() — GET /phone/threads (bearer), parses
[{session_id, chat_id, title}]; fail-soft (404/older relay -> empty).
- ConnectionViewModel: fetch on every auth.ok (relay connect/reconnect) and
expose phoneThreadChatIds; ChatViewModel.seedThreadChatIds merges it into
the reply-routing map, authoritative over the in-memory learned map.
- RelayApp collects the flow into the chat layer.
So a Thread the app didn't create, or any Thread after a restart, now routes
its replies to the right conversation instead of falling back to the home
channel. Degrades gracefully if the relay lacks the route.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A phone Thread is a source=phone gateway session keyed by a chat_id, but
/api/sessions returns only the opaque timestamp id + source — never chat_id
or session_key. So the Android client can't map a session to its Thread to
route a reply. The relay (our plugin, co-hosted with the gateway, same user)
reads the gateway session store read-only and surfaces session_id <-> chat_id
via GET /phone/threads, filling the gap without a fork or touching the
standard path.
- plugin/relay/session_store.py: read_phone_threads() globs ~/.hermes/state.db
+ profiles/*/state.db, mode=ro (safe alongside the running gateway/WAL),
returns [{session_id, chat_id, title}] for source=phone rows, deduped;
skips blank chat_id + older schemas without the column.
- server.py: handle_phone_threads — bearer for the app, loopback for diag
(same gate as /context/injected); registered at GET /phone/threads.
- 5 unittest cases (read, skip-no-chat_id, root+profile dedup, old schema,
missing home).
Client prefers this over its in-memory learned map; redundant once upstream
adds chat_id to /api/sessions (a later PR).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persistent connection is a connection-level control flipped frequently, not chat-specific, so move the Quick Controls card out of Chat settings to the Settings root (beside the Active Agent / Profile lock cards) and add a Turn-complete alerts toggle. Both wire to the existing ConnectionViewModel flows (gatewayKeepAlive / notifyTurnComplete) - no new prefs. Removes the card from ChatSettingsScreen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Belt-and-suspenders on top of the network-layer reconnect-loop fix: the LaunchedEffect that re-acquires the gateway chat client now waits a 750ms settle window before rebuilding when only the resolved API route changed, so any residual sub-second LAN<->Tailscale flip can't shutdown the warm gateway socket (idle) or retarget mid-turn (burning MAX_TURN_REJOINS). First acquisition and non-url key changes are not delayed, so cold-start connect latency is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Thread-name override was in-memory only, so a named Thread reverted to
the gateway's auto-title after an app restart. Persist it: ThreadNameStore
(DataStore, sessionId -> name) saved on create, loaded on launch, re-applied
to ChatHandler on the names flow + on initialize() so a handler created
after the load still picks it up. ChatHandler.setUserThreadNames now merges
(doesn't clear) so a just-created name isn't clobbered by a stale load.
Wired ChatViewModel <-> ConnectionViewModel via RelayApp (onSaveThreadName +
a threadNames collector), since ChatViewModel has no Context.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the mode-strip removal: drop RelayModeStrip from DashboardManagementScreen, BridgeScreen, and BridgeCoreScreen (Chat lost it in the prior commit). Each now reaches Chat via a TopAppBar back arrow (Up -> onNavigateToChat, matching the old strip's Chat tab); system Back still pops to Settings. Manage/Bridge stay reachable from Settings -> Hermes management / Bridge. RelayModeStrip/RelayPrimaryMode remain defined in RelayCockpitChrome for the store screenshot harness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chat is the home screen; the triplicated mode strip spent a chrome band on the most-used surface. Manage and Bridge are already reachable from Settings (Settings -> Hermes management / Bridge), and Terminal + Settings remain quick icons in the chat top app bar, so the strip is redundant here. Removes the RelayModeStrip usage + its now-unused imports from ChatScreen. The component stays defined in RelayCockpitChrome for the other screens + the store screenshot harness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The non-error connection-status banner rendered as a take-space banner above the Scaffold with fade-only AnimatedVisibility, so its height popped in/out and the content column hard-snapped to fit. Route it through the existing floating-overlay Column alongside the error toast, reusing the house slideInVertically+fadeIn / slideOutVertically+fadeOut spec. An overlay occupies zero layout space, so content no longer reflows on appear/disappear, and it drops out of the Scaffold status-bar inset accounting (matching the comment that already described it as a floating overlay).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A "Session sources" sub-section under Chat settings lists the common
external gateways (Discord/Telegram/Cron/Webhook/Web) with a show/hide
toggle each, editing the same persisted hiddenSources set the drawer
source filter uses — so you can hide a source even before it appears in
the session list. cron + webhook hidden by default; your chats + Threads
always show.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Desktop-style per-gateway attribution in the session drawer:
- A colored source chip on each row (Discord/Telegram/Cron/Webhook/Web/…);
the app's own chats (tui/api_server) and phone Threads are unbadged
(Threads keep their thread-spool).
- A source filter dropdown in the drawer header (show/hide sources),
persisted in SessionSourcePrefs; default hides the noisy cron + webhook
lanes so the drawer shows your chats + Threads. The filter row is now
horizontally scrollable so it never crowds.
- "Beta" chip on the Threads filter chip + the best-path Threads capability
row (full release gated on live /api/ws-in-foreground + unread + the
chat_id upstream fix + outbox).
Setting persisted via DataStore (cron+webhook hidden by default); editable
from the drawer dropdown. A dedicated Chat-settings section (to toggle
sources not currently present) is a follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gateway's async auto-titler (agent.title_generator) overwrote the name
the user gave a new Thread. Make the user's name authoritative: ChatHandler
holds a sessionId -> name override applied in updateSessions, so the
auto-title can't clobber it; switchToCreatedThread records it on create
(plus a best-effort server renameSession for other surfaces).
In-memory for now (sticks within a session); cross-restart persistence is a
follow-up, folded into the drawer source-attribution work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-device, the create-flow + in-thread reply were broken: a phone session's
id is a timestamp and /api/sessions exposes neither chat_id nor session_key
(only source). My code assumed the id encoded chat_id (…:dm:<chat_id>), so:
- switchToCreatedThread never matched -> never switched -> currentSessionId
stayed null -> the reply couldn't inject (notified) and the rename never
ran (auto-title instead of the user's name);
- existing-thread replies routed by a null chat_id.
Fixes (client-side; no API change):
- Find a created thread by session-list DIFF (the new source=phone session
not present before the send), not by parsing chat_id from the id.
- Keep an in-memory sessionId -> chat_id map, learned at creation and from
incoming phone.messages, to route replies to the right thread.
- Inject by source + learned chat_id; cover the pre-switch window via the
creatingThread draft so the first reply shows inline, not as a notification.
Limitation (TODO): a thread the app didn't create this session has an unknown
chat_id until a message arrives while viewing it; proper fix is exposing
chat_id on /api/sessions upstream.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With agent replies now rendering inline in the open Thread, the flat
HermesInboxScreen is redundant surface. Retire it:
- Delete HermesInboxScreen + its Screen.HermesInbox route + nav entry.
- Re-point the notification tap and Settings "View messages" to Chat
(the message lives there as a Thread).
- Rename the surface "Hermes messages" -> "Threads" (the settings screen,
the Settings entry, the notification channel) and reframe its copy
toward conversations-in-Chat.
- ProactiveInboxStore is now a viewer-less write-only log (fully
retireable — TODO); doc updated.
The opt-in setting (the enable gate + notification permission) stays — it's
still needed; only the inbox *view* is gone. No separate admin view: thread
management folds into the drawer + Settings + the desktop `relay queue`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent's reply to a Thread message only surfaced as a notification +
the legacy Hermes Messages inbox, never in the open Thread conversation.
ProactiveMessageHandler.dispatch now first tries injectIntoThread: an
inbound phone.message whose chat_id matches the Thread currently open in
Chat (or a pending "+ New Thread" draft) is rendered inline as an
ASSISTANT bubble (ChatHandler.addAgentThreadMessage — clientOnly,
idempotent on message_id) and the notification + inbox entry are skipped
(you're already looking at it). Falls back to home-channel match when a
side has no parseable chat_id, so the single home thread keeps working.
Non-matching / no-thread-open messages still notify + inbox as before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Elevate the proactive agent->phone inbox into first-class "Threads":
source=phone gateway sessions rendered in the one Chat surface (sessions
tagged by source), not a separate tab. See docs/decisions.md ADR 12.
App:
- Drawer source tags + custom thread-spool glyph + Threads filter, gated
on relay pairing + "Let Hermes message me" (slice 1).
- Open a Thread from its server history (free via the existing path);
composer reply in a source=phone session routes over proactive.reply
with per-message delivery status Sending->Delivered/Failed (slices 2,4).
- "+ New Thread" (named, Discord-style): mint a chat_id, open it on the
first message, poll-and-switch to the real session; multi-thread replies
route by chat_id parsed from the session id (slice 8, user-initiated).
- Threads capability row in the best-path UI (slice 5).
Relay:
- proactive.reply.ack (settles the reply bubble) + a proactive.cancel WS
verb; 25/25 proactive tests green (slice 7).
Built + installed sideload debug; on-device behavior unverified. Verifies
+ deferred tails in DEVLOG/TODO. "Delivered" needs the relay running the
updated proactive.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Host-side UI for the queued state added in ebb4f04 (the queue exists while the
phone is OFFLINE, so it belongs on a surface that's online while the phone is off).
- `relay queue` lists what's waiting for an offline phone (id · age · preview);
`relay queue --clear` cancels all, `relay queue --cancel <id>` cancels one.
Over the new loopback GET/DELETE /phone/outbound; loopback-only (403 off-host
→ explained, same as relay info/security). --json for scripting.
- cli.ts: `clear` added to BOOLEAN_FLAGS so `--clear` doesn't swallow a following
arg; `--cancel <id>` stays a value-flag. relay help line updated.
- Docs: CLAUDE.md Key Files, CHANGELOG (relay bullet + offline-reply reliability
note on the two-way entry), TODO (host-side CLI surfacing marked done; phone-side
threaded pending-reply UI remains).
tsc build clean; `relay --help` renders the new subcommand.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the gap the device test exposed: an agent answer pushed while the phone
had dropped its (connection-scoped) subscription used to 503 and be lost.
- ProactiveChannel.push() now queues on no-subscriber (bounded deque, drop-oldest,
24h staleness TTL) and returns {delivered:false, queued:true, buffered:N} instead
of raising 503. _flush_outbound delivers the backlog FIFO on the next
proactive.subscribe; stale entries are pruned, and a socket that dies mid-flush
re-buffers the remainder.
- Observe/cancel: peek_outbound()/cancel_outbound() + loopback routes
GET /phone/outbound (count + summaries) and DELETE /phone/outbound[?message_id=]
(cancel all / one) — the enabling layer for a host-side "queued + cancel" UI.
- Adapter send() unchanged (a 200 queued reads as success). Handler's no-subscriber
503 path removed (now queues); ProactiveError is left only for a live write fail.
- Tests: 54 proactive+phone pass (flush-on-subscribe FIFO, bounded drop-oldest,
stale-drop, cancel one/all, close clears).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverts the temporary file-based [phone-diag] scaffolding (connect/loop/register
tracing) added to localize the two-way-reply failure. The genuine fixes stay:
PhoneAdapter.connect(is_reconnect=...) (3018a18), and the registration `except`
upgraded from silent debug to a visible warning.
- DEVLOG: record the device round-trip + both faults — the connect() signature
TypeError, and a stale duplicate plugin copy in the user-plugins dir winning the
loader's name-dedup so the gateway loaded old code and ignored every deploy.
- TODO: mark Phase 2c device-verified; add the installer-purge-stale-backups fix
and the usability roadmap (outbound buffering first; threaded agent surface per
the "separate lanes, unified surface" decision; per-thread chat_id; status; title).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gateway journal suppresses plugin logs at every level, so the WARNING
trace was invisible. Switch the diagnostics to append to ~/.hermes/phone-diag.log
so we can see exactly which hop fires: registration, connect() entry/abort,
reply-loop start. TEMPORARY — reverted once the connect gap is fixed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TEMPORARY diagnostics (to be reverted) to localize why the gateway's phone
adapter never polls /phone/replies despite registration+env_enablement working
in isolation. Plugin INFO is filtered from the gateway journal, so these trace
at WARNING: registration outcome (was silently swallowed), connect() entry, and
reply-loop start. Also upgrades the previously-silent registration except from
DEBUG to WARNING — a real improvement worth keeping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gateway's platform supervisor calls adapter.connect(is_reconnect=...)
(BasePlatformAdapter.connect contract, gateway/run.py:3195). PhoneAdapter.connect
was declared connect(self), so every gateway connect raised TypeError, the
adapter never came up, and the Phase 2c inbound reply long-poll
(_run_reply_loop, spawned only inside connect()) never started. Net: the phone's
replies reached the relay and sat buffered forever (0 GET /phone/replies polls);
the agent never saw them and never answered back.
The 2026-06-29 device round-trip surfaced it: agent->phone push worked
(relay->phone), the reply buffered in the relay (proactive <<< ... buffered=1),
but the gateway never drained it.
- Add `*, is_reconnect: bool = False` to PhoneAdapter.connect (matches the base
contract + the ntfy template it was modeled on). Behavior otherwise unchanged.
- Add ConnectContractTests regression guard (inspect-based — the live adapter
binds to the gateway base class that's absent in CI, which is the blind spot
that let this ship).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SessionManager persists sessions to ~/.hermes/hermes-relay-sessions.json
(server.py:88-90, persistence_path from RelayConfig.from_env), and a
trusted-device refresh token recovers a lost/revoked/reset session
without a new QR scan. Paired phones therefore survive a relay restart;
only the in-memory live-connection presence clears (reconnect is
automatic).
Fixes the stale "in-memory; wiped on restart → must re-pair" assertion in:
- CLAUDE.md (Server Deployment key conventions)
- docs/path-architecture.html (auth model table)
- skills/devops/hermes-relay-status/SKILL.md (presence vs pairing)
- ConnectionViewModel.kt / RelayHttpClient.kt (stale comment mechanism;
fetch-gating logic unchanged — a URL can still outlive a pairing via
expiry/revoke/never-paired)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2c leg 1 — the app side of two-way phone messaging. The user can now
answer a proactive message; the reply rides the existing relay WS as a
`proactive.reply` envelope.
- ProactiveMessageNotifier: inline Reply action via RemoteInput + a MUTABLE
getBroadcast PendingIntent (FLAG_MUTABLE guarded for API<31) carrying the
message's chat_id/message_id; confirmReply() re-posts the slot to clear the
system reply spinner (or tell the user to open the app if disconnected).
- New ProactiveReplyReceiver (BroadcastReceiver, exported=false in manifest):
reads the typed text + extras, sends `proactive.reply` via a static
ChannelMultiplexer slot (mirror of HermesNotificationCompanion), then
confirms. Best-effort drop when offline.
- ProactiveInboxEntry gains chatId (back-compat default); the inbox sink +
ProactiveMessage carry it through so a reply continues the right thread.
- HermesInboxScreen: per-card expandable reply box -> sendProactiveReply.
Chat visuals untouched.
- ConnectionViewModel: sendProactiveReply(text, chatId, replyTo) + wire the
receiver's static multiplexer alongside the notification companion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2c leg 3 — wires the phone platform's INBOUND path so a reply continues
the agent conversation (was push-only; connect() was a no-op).
- connect() starts a background long-poll loop over the relay's loopback
GET /phone/replies (mirror of the bundled adapters' receive-loop, e.g. ntfy
_run_stream): self._running-guarded, backoff on transient errors.
- Each reply -> build_source(role_authorized=True) + MessageEvent(reply_to)
-> handle_message(); the agent answers via the existing send(). The reply
continues the originating thread (chat_id keys the session, reply_to anchors).
- role_authorized=True: the relay pairing/session-token layer already
authenticated the device, so replies pass the gateway's default-deny user
allowlist without forcing PHONE_ALLOW_ALL_USERS.
- disconnect() cancels the loop + closes the client.
- Pure gateway-independent helpers (_replies_url_and_headers, _normalize_reply)
+ path/timing constants; extend test_phone_platform.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2c leg 2 — the relay side of two-way phone messaging. The phone can now
send its answer back; the relay buffers it for the gateway adapter to drain.
- ProactiveChannel.handle learns inbound `proactive.reply` ({text, chat_id,
reply_to, message_id, ts}); empty-text replies dropped, ids/ts synthesized.
- Replies buffered in a bounded deque (drop-oldest) + asyncio.Event; new
take_replies(timeout) long-poll drain. close() clears the buffer.
- New loopback-only GET /phone/replies in server.py (mirror of the outbound
POST /phone/message hop), clamped long-poll window.
- proactive.reply added to the frozen wire-envelope docstring.
- Extend test_proactive_channel.py: buffer/drain, empty-text drop, late-reply
wake, timeout->[], bounded drop-oldest, close clears.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The blend refresh added a 28dp assistant-avatar gutter to the left of every
assistant group, duplicating the profile avatar already in the top bar and
eating ~36dp of bubble width on a phone. Remove the gutter (and the now-unused
AssistantAvatar composable) so bubbles reclaim that width; identity is shown
once, in the top bar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Loose ends from shipping + live-testing the phone-as-Hermes-platform feature:
- TODO: add Phase 2c (two-way reply — proactive is push-only today: no
RemoteInput on the notification, one-way ProactiveChannel), and upgrade the
cron/standalone line to a FIX (hermes send --to phone returns "Unknown
platform: phone"; the kind=standalone plugin isn't discovered by the
non-gateway send path — only the running gateway loads its register()).
- CLAUDE.md: add a "Don't touch production / remote hosts" rule — automation
and orchestrated agents must never SSH/deploy/restart a live host; server
deployment + on-device testing stay owner-driven.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Advertise proactive phone messaging to the agent via the relay context seam
(PHONE_ENABLED-gated system-prompt block).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a relay-owned system-prompt context block (via the existing
RELAY_AGENT_CONTEXT_ENABLED seam) telling the agent it can reach the user
with send_message target=phone. Without this the capability worked but was
not proactively surfaced: platform_hint only injects for the inbound
platform (never, for a push-only platform) and the send_message schema's
target examples (upstream core, no-fork) don't list phone.
Gated on PHONE_ENABLED (only advertised when the platform is actually on)
plus a per-block opt-out RELAY_CONTEXT_PHONE_PLATFORM (default on); the
block is auditable/removable like media-sensitivity (GET /context/injected).
56 tests pass; existing context-injection tests unchanged (block off by
default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 — the other two config-driven surfacings. The inbox is now the
always-present durable log: ProactiveMessageHandler records every received
message there, then adds the surface its `surfacing` hint selects —
notification (default), inbox-only, or session injection.
- ProactiveInboxRepository (DataStore, newest-first, deduped, capped 100)
+ HermesInboxScreen (self-contained cards, not the chat-ux MessageBubble).
- Session injection: ChatHandler.addProactiveMessage appends a SYSTEM-role
clientOnly bubble (kept out of the voice TTS observer so it can't trigger
uncontrolled speech; Phase 3 owns TTS-on-voice) preserved across history
reconcile; ChatViewModel.injectProactiveMessage is the small entry point;
the toSession sink is wired once at the RelayApp root.
- Notification tap + a "View messages" button open the inbox.
No chat visuals or voice internals touched. lint :app:lintSideloadDebug
BUILD SUCCESSFUL; no warnings in the new files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the off-by-default opt-in for proactive messages: a dedicated
ProactiveSettingsScreen ("Hermes messages") with the enablement switch, a
POST_NOTIFICATIONS request on enable, a not-paired hint, and in-app docs of
the server-side PHONE_ENABLED requirement. Wired as a Settings category row
+ Screen.ProactiveSettings nav route. ConnectionViewModel.setProactiveEnabled
persists the flag; subscribe/unsubscribe is driven by the existing collector.
Completes the Phase 1 spine: send_message target=phone -> phone notification,
surfacing only when the server enables PHONE_ENABLED, the user opts in, and
the phone is paired.
Verified: ./gradlew :app:lintSideloadDebug BUILD SUCCESSFUL over the combined
1c+1d app spine; no warnings in the new proactive files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the app-side receive path for agent-initiated messages: a "proactive"
branch in ChannelMultiplexer routes phone.message to a new
ProactiveMessageHandler (sibling of BridgeCommandHandler) that raises a
heads-up notification via ProactiveMessageNotifier (modeled on
TurnCompleteNotifier; stacks per message_id).
Receiving is gated off by default: KEY_PROACTIVE_ENABLED ("Let Hermes
message me", data/ProactivePrefs.kt) controls whether ConnectionViewModel
sends proactive.subscribe. Subscribe is re-sent on every auth.ok via a new
additive AuthManager.authOkEvents flow (sourced through _authManagerFlow so
it survives connection switches), guaranteeing it never races the auth
handshake and re-establishes the per-socket subscription after reconnects.
The Settings switch + notification-permission prompt land in Phase 1d.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add ProactiveChannel — the server->app mirror of the bridge handler. It
latches the phone WS on proactive.subscribe and pushes phone.message
envelopes via push(); best-effort, no awaited reply. Wire it onto
RelayServer (dispatch branch, disconnect detach, shutdown close) and add
a loopback-only POST /phone/message that the phone platform adapter calls.
Delivery is opt-in on both ends: the relay can only push when the app has
subscribed (user toggle), and the adapter is gated on PHONE_ENABLED.
503 when no phone subscribed, 502 on write failure.
11 channel unit tests; bridge+proactive+phone suites green together (40).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Theme.kt's non-default font-scale branch passed the static `Typography`
constant (hardwired SansSerif) instead of the live `typography` built from
the selected font, so the font choice was silently ignored whenever the
in-app font-size slider was moved off 1.0×. Use the live `typography` in
both branches.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a push-only BasePlatformAdapter (PhoneAdapter) registered via the
upstream platform-plugin API so the agent can proactively message the
paired device: send_message target=phone and cron deliver=phone. send()
POSTs loopback to the relay (/phone/message); the relay forwards over the
existing phone WSS. Off by default behind PHONE_ENABLED; no fork.
Modeled on the bundled ntfy adapter, including a standalone_sender_fn so
out-of-process cron delivery works. Plugin stays kind=standalone;
registration wired into register() and guarded for older hosts.
22 unit tests cover gating, relay-URL precedence, payload build, and the
standalone sender over a fake httpx client.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the standard-path silence watchdog to parity with hermes-desktop
voice_mode defaults:
- end-of-speech default 1250 ms (was 3000) — desktop `silenceMs`
- idle/no-speech auto-close at 12 s, cancelling WITHOUT transcribing —
desktop `idleSilenceMs` (replaces the old "wait forever")
- 60 s hard turn cap, transcribing whatever was captured
The existing 0.08 amplitude floor already matches desktop's 0.075 silenceLevel.
Also remove two dead client prefs surfaced by the parity audit: voice_auto_tts
(desktop has no read-every-typed-message feature — it only speaks responses
during an active voice conversation, which VoiceViewModel already does) and
voice_language (STT language is a server-side stt.*.language key, now editable
in the Server voice config card, not a transcribe param). Drops the fields,
keys, defaults, and setters.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Server voice config" card to Voice settings (standard/no-Relay path)
that loads the dashboard config schema + values, renders provider-scoped tts.*
/stt.* controls, and saves via GET -> merge -> PUT-whole — the same config.yaml
the desktop's Audio settings edit, and the same values the /api/audio/* voice
path reads. Includes the ElevenLabs voice picker (tts.elevenlabs.voice_id ->
dropdown from the server's key; graceful when available:false), the one genuine
desktop voice feature the app lacked. Writes target the launch-profile config
(profile = null) since standard voice is host-global.
The dashboard client is built per (dashboardUrl, connection) and disposed on
leave; RelayApp passes the effective dashboard URL + the VM's cached cookie
store. Also in this file: the silence-threshold slider is re-ranged to
0.75-5s/250ms steps (so the new 1.25s default lands on a stop), and the dead
"Coming soon" card (disabled Auto-TTS toggle + STT-language picker) is removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add host-side Roborazzi captures for the refresh: `s09_blend_chat`
(Hermes dark + Nous-blue light) renders a grouped user/assistant thread with
the avatar + a code block, and `s10_font_picker` frames the new Font picker to
prove Inter/Nunito load as distinct faces.
- CHANGELOG [Unreleased]: font picker (Added) + refreshed chat look (Changed).
- DEVLOG: session entry for the refresh + verification.
- TODO: park the optional /font slash command + the on-device gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Rebuild the streaming code block Discord-style: a contrasting inset surface
with a thin header (language label + a copy affordance that flips to a check),
and a horizontally-scrollable monospace body.
- Fix code/inline-code backgrounds so they contrast against the assistant bubble
(itself surfaceVariant) instead of blending in — the block uses the lowest
container, inline code a subtle raised step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refresh the chat surface toward the "Blend" look (wider bubbles + assistant
avatars + tighter rhythm):
- Assistant turns now show a small Hermes brand-mark avatar in a left gutter,
drawn once per group (like the name label); the gutter reserves its width on
every grouped message so bubbles stay aligned under the first. User bubbles
stay right-aligned with no gutter.
- Raise the compact-phone bubble cap (300 -> 340dp) and trim the chat list inset
so long turns fill more of the row instead of wrapping early.
- Tighten the bubble content padding for a denser, Discord-like vertical rhythm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an app-wide, user-selectable typeface system as a parallel axis to the
existing theme/font-scale prefs:
- Bundle Inter (default) and Nunito as OFL/SIL variable fonts in res/font,
weight-instanced via FontVariation; commit their OFL licenses under licenses/.
- New AppFont registry (Inter / Nunito / System) -> FontFamily + label + preview.
- Parametrize Typography via appTypography(body); HermesRelayTheme rebuilds it
from the selected AppFont so the whole app re-themes live, no restart. Code and
metadata styles stay monospaced.
- Persist the choice in DataStore (ConnectionViewModel.appFont / setAppFont) and
thread appFontId through the Compose theme root in RelayApp.
- Add a Font picker to Appearance settings, each option previewed in its own face.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standard-path voice config was unreachable: the Manage Config tab GETs
/api/config/schema and renders it read-only via summarizeKeyValueOrList, so it
only ever showed two rows ("fields", "category_order") and never the tts.*/stt.*
values, and DashboardApiClient had no config-write path at all.
Add the client + pure helpers the voice-config editor needs:
- DashboardApiClient: getConfig(), getConfigSchema(), updateConfig(config,
profile) (PUT /api/config, whole-tree per upstream save_config), and
getElevenLabsVoices() (GET /api/audio/elevenlabs/voices) + ElevenLabsVoices
models / parser.
- DashboardConfigEditing.kt: schema parsing (ConfigSchemaField + types),
voiceConfigFields() (tts.*/stt.* by dot-path prefix), and immutable
dot-path read/merge (configValueAt / withConfigValue / applyConfigEdits)
enforcing the GET -> mutate -> PUT-whole invariant.
Pure-JVM + MockWebServer unit tests cover parsing, the whole-tree PUT body,
order preservation, and the no-API-key (available:false) path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Attach AcousticEchoCanceler and NoiseSuppressor to the AudioRecord capture
session when the device exposes them — parity with hermes-desktop's
getUserMedia({echoCancellation, noiseSuppression}). Both are best-effort:
devices that report isAvailable()=false capture raw exactly as before. AEC
keeps the device's own TTS playback from bleeding into the next captured
utterance during back-to-back voice turns. Effects are released with the
recorder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
defaultExoPlayer() built the TTS ExoPlayer with no audio attributes and no
focus handling, so the first clip of a cold standard-path voice session could
lose its opening syllables to the AudioTrack/HAL allocation window. Set
USAGE_MEDIA + CONTENT_TYPE_SPEECH attributes with handleAudioFocus=true so
focus is requested (warming the output path) before the first play(). This is
the standard-path twin of the relay PCM player's deep-buffer cold-start fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Compose-Canvas dot-matrix animation as an alternative to the
three-dot StreamingDots working indicator shown in a streaming reply
bubble — themed, and configurable from Chat settings.
- DotMatrixIndicator: a brand-themed dot grid driven by
rememberAmbientPhase (frame-throttled ~30fps; paints a static frame
when motion is off) rather than an always-on infinite transition.
- ThinkingMatrixPattern (Wave/Pulse/Bounce/Sparkle): Wave is a
procedural sweep; the rest are authored frame sequences (row*cols+col
indexing, the dot-anime-react convention) crossfaded between frames.
- ThinkingMatrixColor (Auto + brand accents) resolved against
LocalBrand, so accents re-theme per app theme; Auto follows the
bubble text color.
- Chat settings "Thinking indicator" selector (Dots/Matrix) with a
Matrix-only Pattern selector + color swatch row and a live preview;
backed by new ConnectionViewModel prefs. Default style: matrix.
- The style/pattern/color are provided via LocalThinkingIndicator at
the chat root, so MessageBubble stays a pure presentation swap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-ups from the live triage smoke test (#150 / #146):
- start-issue.sh: the brief's "Automated triage notes" section filtered on
author github-actions, but triage/deep-dive/follow-up comments are posted by
the Claude GitHub App (author "claude"), so the section came up empty. Switch
to an identity-proof match on the comment signatures, with the bot logins as
a fallback. Verified against #150 (captures both bot notes, excludes owner
comments).
- claude-triage.yml: bump actions/github-script@v7 → @v8 (clears the Node 20
deprecation; v8 targets Node 24).
- claude-triage.yml: tell the deep-dive to use its headings as section
separators and not add horizontal rules between sections — the first run
rendered a rule under every heading, which read heavy.
The workflow tweaks activate on the next dev → main merge; the script fix is
live from dev.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve DEVLOG.md: keep both 2026-06-28 entries (dev-loop automation above
the safe-call cleanup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two Kotlin compiler warnings surfaced by a sideload build.
UpdateAvailableBanner: subtitle is non-null in every reachable `when`
branch, so the declaration is now String (not String?) and the
subtitle Text renders unconditionally instead of via a redundant ?.let.
UpdateChecker: OkHttp 5's Response.body is non-null, so the ?. on
resp.body was dead — and dropping it would leave an Elvis-on-non-null
warning. Replaced with resp.body.string() plus an explicit isBlank()
guard, preserving the original empty-body error path.
Behavior-preserving; resolves both `w:` warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grow the Claude issue automation from a conservative classifier into a
surface-aware dev-loop, and make PR review more constructive.
claude-triage.yml (2 jobs → 4):
- auto-label now also applies an area:* surface label from keywords.
- triage-ai is no longer a pure classifier — it adds a hedged probable
cause, likely files, and a suggested direction (one ≤180-word note),
and invites the triage:deep label. Injection hardening preserved.
- deep-dive (new, opt-in via the triage:deep label) investigates the
codebase and posts a root-cause hypothesis, a fix plan, a
surface-specific verification plan, and a maintainer worktree
quick-start.
- triage-followup (new) re-reads a bug thread on reporter replies and
escalates to needs-maintainer-review + the maintainer after ~2 rounds.
Not gated on commenter write-access, so external reporters get
follow-up; bot-authored comments are excluded so it can't self-trigger.
claude-code-review.yml: keep the /code-review plugin depth, add a
constructive "Maintainer's-eye verdict" header and use_sticky_comment so
re-pushes update one comment instead of stacking.
scripts/start-issue.sh: local bridge — pulls an issue into a pre-briefed
git worktree (fix|feature|docs/issue-N-slug off origin/dev) with an
ISSUE-BRIEF.md carrying the body, the bot triage notes, and the surface's
verification commands.
docs/dev-loop.md documents the loop, the verification matrix, the label
setup, and the default-branch activation lag. .gitignore ignores the
generated ISSUE-BRIEF.md.
All jobs stay read-only against the repo; an auto-fix (contents: write)
path was intentionally left out as an injection risk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump appVersionName 1.2.5 -> 1.2.6, appVersionCode 19 -> 20.
Bundles the #133 "Untitled chats" fix + profile-scoped rename + session
drawer refresh, and the connection-status overlay -> take-space banner
(plus a general top info banner). CHANGELOG/RELEASE_NOTES/whats_new/
changelog.json/Play notes refreshed; Desktop CLI items stay in
[Unreleased] for a future cli-v* release.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The connection-status surface rendered as a floating overlay
(ConnectionStatusToast) that slid over content on every reconnect/
handoff/network change. Move the frequent transient/active/warning
states to the take-space ConnectionStatusBanner (slides content down,
above the header) and keep the floating overlay only for persistent
Error-tone status.
Also adds a general info banner: UiMessageBus + MessageBannerHost (thin,
auto-dismiss, coalesces duplicates, tap to expand recent), and routes
frequent snackbar confirmations (profiles updated, paired, copied,
profile/personality picks) to it instead of the bottom snackbar. Errors
stay on the snackbar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the session-title work: the upstream api_server titler PR and
the interim relay-side option, the resolved profile-scoped rename + its
audit result, and why client-side LLM titling was dropped (no
client-reachable LLM endpoint avoids persisting a session, so it would
spawn phantom title-generation rows in the drawer).
(Carries one incidental compileSdk-doc line that couldn't be hunk-split
from the #133 notes in the same file.)
Refs #133
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The project moved to compileSdk 37 (206d182) but the docs and the
android.suppressUnsupportedCompileSdk flags still said 36. Align them:
CLAUDE.md "Compile SDK 36" → 37, docs/spec.md adds the Compile SDK line,
and gradle.properties + quest/gradle.properties suppress flags → 37.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sessions read "Untitled" because ChatHandler.updateSessions copied the
server title verbatim, overwriting the optimistic first-message preview
with null whenever a re-list raced ahead of (or lacked) upstream's async
title write. The api_server SSE/runs/completions surfaces never auto-title
at all (only the gateway/cli path calls agent.title_generator).
- updateSessions: merge title (server wins when non-blank, else keep local)
- gateway-only post-turn title reconcile (+3s/+7s) via scheduleTitleReconcile
- serverAutoTitles flow + subtle "not auto-named here" drawer note on SSE
- profile-scope session rename: DashboardApiClient.renameSession +
patchJsonObject, ConnectionViewModel.renameProfileScopedSession,
ChatViewModel.profileSessionRenamer wired from RelayApp (write twin of
the scoped delete; the unscoped PATCH hit the shared api_server DB)
- manual refresh icon in the session drawer header
- ChatHandlerTest: clobber-guard regression cases
Refs #133
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Play Console caps the App-access reviewer-instructions field at 500
characters; the prior block was over. Replace it with a 423-char single
paragraph that still names the "Try the demo" entry points (first/Connect
screen + empty Chat after Skip) and the offline/no-login guarantee, and note
the cap in the lead-in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundles the day's Android work: the #131/#132 non-address-URL crash guard,
the offline Demo / Explore mode, and the demo-reachability + App-access polish.
- appVersionName 1.2.4 → 1.2.5, appVersionCode 18 → 19
- CHANGELOG: promote the Android items into [1.2.5]; Desktop CLI items stay
in [Unreleased] for a future cli-v* release
- Refresh RELEASE_NOTES.md, in-app whats_new.txt + changelog.json, the Play
what's-new, and the play-store-listing release-notes block
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the offline demo reachable from every first-run path, not just the
Connect surfaces, so a skipped / never-connected start (what a Play reviewer
hits) can always explore without a server.
- ChatScreen: the empty-chat "needs connection" card now offers a "Try the
demo" action under "Connect Hermes" (new optional onTryDemo param) and reads
warmer — "explore a quick demo first. You can connect anytime."
- RelayApp: wires the empty-chat card's onTryDemo to the existing enterDemo
lambda (safe — that state only shows when nothing is configured).
- Onboarding "Skip setup?" dialog: reframed from "Chat and Manage won't load"
to an inviting "explore the demo… connect anytime"; button → "Skip for now".
- docs/play-store-listing.md: tighten the App access guidance — choose
"restricted" (the option that exposes the reviewer-instructions field), name
the exact screens for "Try the demo", and add a paste-ready reviewer note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Google Play rejected v1.2.4 under "App access": a reviewer with no Hermes
server hit the empty Connect wall and bounced. The app is a client for a
user-run server, so there was no content — and no offline path — without a
connection.
Add an in-app Demo mode so anyone (reviewer or first-run user) can see the
app work with zero setup and zero network:
- "Try the demo" on the setup/Connect surface loads a canned, fictional
conversation (Markdown, a tool-progress card, a rich card) through the
REAL chat pipeline (DemoContent -> ChatHandler -> ChatViewModel -> ChatScreen),
so there is no parallel UI.
- New pure-JVM DemoMode holder owns the active flag + transcript; entering
does NOT complete onboarding.
- No network in demo: reconnectIfStale/revalidate/connectRelay and the API/
relay health probes early-return while demo is active (runs in airplane
mode); a back-nav effect clears demo on reaching a connect surface so a
stale flag can never block the real connection.
- Persistent "Demo mode - sample data, not connected" banner whose Connect
exits demo into the real wizard; Manage/Voice show a friendly demo empty
state; Bridge/Terminal keep their pair-gate screens.
Verified: :app:testSideloadDebugUnitTest (new DemoContentTest/DemoModeTest)
and :app:lintSideloadDebug both green. Not built in Studio / not on-device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A non-URL value entered into a server-URL field could force-close the app
on the Manage / sign-in screen. The auto-captured crash (#131; dup #132) was
`IllegalArgumentException: Invalid URL host: "Manage sign-in and admin screens"`
from `okhttp3.Request$Builder.url`, inside a suspend lambda with a suppressed
`Dispatchers.Main.immediate` frame — a UI/docs label pasted into the Dashboard
URL field, normalized to `http://<spaces>` at save, then handed to okhttp's
*throwing* `url(String)` inside a `withContext(IO)` lambda whose caller sat on
Main → uncaught → crash. Same family as #124->#125 and #129->#128.
Root cause is user-entered (hypothesis a): the wizard's URL validators only
checked the scheme, never whether the value parsed as a host, and the save path
normalizes but does not validate. Hypothesis b (an internal label->host leak)
is ruled out — every DashboardApiClient/HermesApiClient is built from a URL
field, never a label.
Two layers:
- Layer 1 (UX): new `util/ServerAddress.kt` validates with the same engine the
request builder uses (`toHttpUrlOrNull`). `apiUrlSchemeError` /
`optionalHttpUrlError` now reject anything that won't parse, so a non-address
shows an inline error and blocks submit.
- Layer 2 (crash guard): `DashboardApiClient` routes every request through a
private `resolveUrl()` (`toHttpUrlOrNull`) -> `Result.failure`/`false` on a
malformed base URL (~10 sites); `StandardHermesVoiceClient.transcribe`/
`synthesize` get the same guard (same dashboard URL, also built before their
try/catch). A bad value is now reported unreachable, never a Main-thread crash.
Tests: `ServerAddressTest` (pure JVM) covers the crash string, blank/whitespace/
missing-scheme/junk rejection, and bare-host/IP/localhost/host:port/http(s)
acceptance; `DashboardApiClientTest.malformedBaseUrl_returnsFailure_doesNotThrow`
asserts every verb returns `Result.failure`/`false` (no throw) for a junk base
URL. Affected `:app:testSideloadDebugUnitTest` classes green; `:app:lintSideloadDebug`
green. (Full suite has 12 unrelated pre-existing Windows DataStore-rename
failures in preferences tests.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New `claude-triage.yml` triages issues on open, in two jobs:
- auto-label: a free, deterministic github-script labeler that maps the
fixed issue-template title prefixes ([Bug]/[Feature]/[Docs]) to the
bug/enhancement/documentation labels. Applied by the Actions bot, so it
labels every issue regardless of who filed it — closing the gap where
crash-reporter issues land unlabeled because GitHub ignores the app's
`?labels=bug` deep-link param for non-collaborators.
- triage-ai: Claude (pinned to claude-sonnet-4-6, scoped to Bash(gh:*) +
read-only code tools) reads the issue, checks open and closed issues for
duplicates, ensures one correct primary label, and posts one short triage
note. Guardrails: never closes, never @-mentions, restricted label set,
treats the issue body as untrusted input.
Separate from claude.yml (the @claude responder, intentionally issues:read)
so the reactive responder's scope stays narrow. A workflow_dispatch trigger
with an issue_number input allows manual re-runs to backfill existing issues.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Crash fix (#129): currentSession() over a flaky Tailscale dashboard route
could re-throw a transient connect/abort onto the main thread and force-close
the app. Now degrades gracefully. Also ships the connection security indicator
across the chat chip, connection card, and route picker.
Android surface only (appVersionName 1.2.4, appVersionCode 18). Desktop CLI
items stay in [Unreleased] for a future cli-v* release.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An on-device crash (FATAL EXCEPTION: main, SocketTimeoutException,
Caused by SocketException "Software caused connection abort") over a
Tailscale connection. Full trace recovered from a background logcat
capture pinned it to DashboardApiClient.currentSession().
Root cause: currentSession() returns Result<DashboardAuthSession> but did
a raw okHttpClient.newCall(req).execute() with NO try/catch — the lone
outlier among the client's methods (executeJson/executeJsonElement/
audioRoutesPresent all catch). The execute() ran on Dispatchers.IO
(correct), but a transient stale-pooled-connection abort re-threw out of
withContext(IO). The caller chain — ConnectionViewModel.probeStandardVoice()
-> viewModelScope.launch (Dispatchers.Main.immediate, the Suppressed frame
in the trace) — used try/finally with no catch, so the exception was
uncaught on the main thread and killed the app. (execute() being off-main
is why StrictMode never fired; the uncaught propagation was the bug.)
Fix:
- currentSession() wraps its request in try/catch -> Result.failure on any
exception, honoring the Result contract callers rely on (mirrors
executeJson()).
- Defense-in-depth: probeStandardVoice() gains a catch (rethrowing
CancellationException) that degrades availability state instead of
letting any probe sub-call crash the Main coroutine.
Test: DashboardApiClientTest.currentSession_onConnectionAbort_returnsFailure_doesNotThrow
(MockWebServer DISCONNECT_AT_START) asserts a connection abort yields
Result.failure, not a throw. :app:testSideloadDebugUnitTest green (25/25).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the spec in docs/plans/2026-06-24-connection-security-indicator.md
(decisions: Tailscale=green, ship all surfaces, "Encrypted · <mechanism>").
Single source of truth: data/ConnectionSecurity.kt computes a per-surface +
rollup verdict (TLS / Overlay / Mixed / Plain) from the active route's
schemes; ConnectionViewModel exposes it as a StateFlow. Overlay transports
(Tailscale/WireGuard/plugin proxy) count as encrypted, not just TLS — so a
ws:// route over a tailnet reads "Encrypted · Tailscale" (green), fixing the
old badge's hardcoded "Secure — TLS" lie.
Surfaces (all read the one flow):
- Chat status chip: leading security glyph (RelayStatusStrip slot).
- Connection card: full-width badge promoted out of the Advanced fold.
- Route picker: per-route glyph on each candidate.
- New ConnectionSecuritySheet: tap any badge for the per-transport
breakdown + mechanism explainer + docs link.
Removed the duplicated, buried security computation from
ActiveConnectionSections (now delegates to the shared model).
Docs: new user-docs "Is my connection secure?" page; fixes the
Tailscale=TLS conflation in decisions.md / security.md / remote-access.md;
first user-facing mention of TOFU cert pinning.
Verified: ./gradlew :app:testSideloadDebugUnitTest (ConnectionSecurityTest
7/7) + :app:lintSideloadDebug both green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design spec for making connection security legible at a glance. Companion
to docs/plans/2026-06-18-native-secure-routes.md (which owns the routes /
plugin-proxy mechanics).
Key findings from the UI/code/docs audit:
- The security model already exists (TransportSecurityBadge tri-state,
isEncryptedOverlayRoute, ActiveCardSecurityPosture) but is buried under
Manage > Connections > Advanced and absent from every at-a-glance surface.
- The badge hardcodes "Secure - TLS" even for Tailscale/WireGuard routes
(the "TLS lie") - likely why users keep asking "is it secure?".
- Security is inherently per-surface (gateway/API/dashboard/relay schemes
are independent), so a binary verdict can't be honest - propose a
connection rollup for the glance + per-surface truth on tap.
Spec covers: corrected mechanism-first wording (TLS / Tailscale / Mixed /
Not encrypted, with overlay = secure), placement (chat status chip, header,
route picker, new detail sheet) with mockups, the secure-proxy stub status,
a documentation plan to fix the Tailscale=TLS conflation, open decisions
for review, and tiered implementation with effort sizing.
No implementation yet - placement/wording decisions pending review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connection-stability hotfix. Promotes the TLS/Tailscale connect-crash fix
(#118, #124; likely #70) from [Unreleased] to [1.2.3]. appVersionName
1.2.3 / appVersionCode 17. Desktop CLI entries stay under [Unreleased] for
their own cli-v* cut.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connecting over an encrypted link (Tailscale Serve / public HTTPS) could
hard-close the app with NetworkOnMainThreadException. HermesApiClient,
DashboardApiClient and ConnectionManager all call ConnectionPool.evictAll()
inline in shutdown(); evictAll() closes pooled sockets synchronously, and a
live https/wss keep-alive close drains a TLS close-notify through
SSLOutputStream -- a real network write StrictMode forbids on the main
thread. Several call sites reach shutdown() from a viewModelScope
(Dispatchers.Main.immediate) coroutine -- probeStandardVoice()'s finally
block on every connect, and onCleared()'s connectionManager.shutdown() --
so the process was killed on connect over TLS. (Plaintext closes write
nothing, which is why every report is on Tailscale/public TLS.)
Push the guard into the leaf: a shared shutdownOffMainThread() runs the
executor-shutdown + evictAll() on a short-lived daemon thread when called
from the main thread, and inline otherwise (preserving the blocking
awaitTermination semantics for callers already on IO). Every shutdown()
call site is now safe regardless of dispatcher; the redundant
withContext(IO)/Thread wrappers in onCleared() are removed.
Adds a Robolectric NetworkShutdownTest asserting the teardown never runs on
the main thread when invoked from the main looper, and runs inline off it.
Fixes#118, #124. Likely resolves the v1.1.0/Tailscale crash in #70.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catch up the local 1.2.2 work with origin/dev, which moved to compileSdk 37
(206d182) and added typed stream.event passthrough (PR #120). Dropped the
local markdown-renderer 0.41.0 / lifecycle 2.10.0 pins (a compileSdk-36
workaround) for compileSdk 37 + the 0.42.0 / 2.11.0 deps origin adopted.
Kept the 1.2.2 version bump (code 16) and all feature/fix work; both
2026-06-22 DEVLOG entries retained.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Iterate the clean text-flow mode (refines 1dca285) to its final shape:
- Vertically-centered sphere + text group that rises toward the top third
as the reply grows — no reserved empty "void", no gap above the composer
(replaces the earlier fixed weight split).
- Top fade-edge applies only when the flow is actually scrolled, so a reply
that fits shows its first line crisply instead of looking cut off.
- The flow now renders the recent CONVERSATION as one faded, scrollable
transcript (user turns marked "›"), so scrolling up brings history into
view; the line buffer accumulates across turns (keyed on a
conversation-stable id) and the update loop keeps watching for new turns.
- Clean mode consumes stray pointer events in its empty areas (mirrors the
voice overlay scrim) so taps/swipes don't fall through to the chat and
session drawer behind it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump appVersionName 1.2.1 -> 1.2.2, appVersionCode 15 -> 16.
Headline: multi-profile reliability — deleting a session on a non-default
profile now sticks, and a cold start opens the session drawer on the right
profile instead of flashing the default one — plus a full-screen Diagnostics
status timeline, simpler "Hermes"/"Relay" connection wording, and a roomier
clean-chat text area.
Build fix folded in: the 2026-06-22 Dependabot wave raised the compileSdk
floor to 37 on two deps, breaking the dev build on our compileSdk 36. Pinned
markdown-renderer 0.42.0 -> 0.41.0 and lifecycle 2.11.0 -> 2.10.0 (both the
last versions that build on 36, and the 1.2.1-shipped values); guard comments
added. Do not bump past these without a compileSdk bump.
Docs: CHANGELOG [1.2.2] (Desktop-CLI entries stay under [Unreleased] for their
own cli-v* cut), RELEASE_NOTES, whats_new.txt, Play default.txt, and
changelog.json (also backfilled the missing 1.2.1 entry). Verified buildable:
:app:assembleSideloadDebug green (versionCode 16 APK).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Note the session-drawer cold-start race fix (889273a) in TODO (batch
follow-ups + broader profile-isolation sweep), DEVLOG, and CHANGELOG
[Unreleased] Fixed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On cold start the session drawer (and the restored session context) could
hydrate with the SERVER-DEFAULT profile's sessions and then visibly snap to
the persisted profile a beat later. The chat client became ready — and the
first refreshSessions() fired — before the per-connection agent-profile
list arrived to resolve the persisted selection, so the first
profile-scoped read ran with a null (server-default) profile; the list
landed a tick later, re-resolved the profile, and re-fetched correctly.
Add ProfileController.selectionSettled (true once the selection has
resolved, OR no non-default profile is pending, OR the profile list has
arrived so resolution was attempted) and gate the cold-start LaunchedEffect
on it. While a non-default profile is still resolving the first load waits
on a 2.5s backstop instead of fetching; the effect re-fires the instant the
profile resolves, cancelling the wait so only the correct, profile-scoped
load lands. The backstop keeps the drawer from ever stranding empty if the
profile list never arrives. Also defers the per-profile session-context /
transcript restore in the same effect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Check off the four resolved User-Added items (clean-chat viewport,
connections reframe, diagnostics/analytics, session-delete fix), add the
batch's deferred follow-ups (build+lint+device verify, diagnostics
re-probe trigger, pass-check timing), a DEVLOG entry, and CHANGELOG
[Unreleased] entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A non-default Hermes profile keeps its sessions in that profile's own
state.db, but the delete went through the unscoped api_server
DELETE /api/sessions/{id} — which hits the shared DB, leaves the row
intact, and lets the next profile-scoped list resurrect it. Route gateway
deletes through the dashboard profile-scoped surface (the write twin of
the existing list path): add DashboardApiClient.deleteSession(id, profile),
ConnectionViewModel.deleteProfileScopedSession(), a
ChatViewModel.profileSessionDeleter hook wired in RelayApp, and a
refreshSessions() after a successful delete so a still-present row can't
linger in the drawer. Off-gateway (one shared DB, no profiles) the plain
api_server delete is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the Diagnostics modal bottom sheet with a dedicated
DiagnosticsScreen behind a new Screen.Diagnostics nav route. The screen
leads with a vertical status-check timeline (Network, API server, server
capabilities, chat transport, pairing/auth, relay, voice), each with a
green/amber/red/gray dot on a connecting rail and an inline failure
reason; checks backed by a logged error are tappable into the existing
DiagnosticDetailDialog. Checks derive read-only from existing
ConnectionViewModel flows plus the recent DiagnosticsLog (no new probing)
via a pure, testable buildStatusChecks(); the recent-activity log panel
stays below. Adds StatusCheck/CheckStatus models + a reusable
StatusCheckTimeline composable, and tidies AnalyticsScreen + StatsForNerds
visual hierarchy (no data/behavior change).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relabel the default connection path from "Vanilla Hermes" / "Standard
Hermes" to simply "Hermes", and "Hermes-Relay plugin" to "Relay plugin",
across the connections wizard, connection info/switcher sheets, voice
settings, permissions, QR scanner, and power-feature gate (28 display
strings, 10 files). Display text only — no enum names, sealed types,
when-branches, or stored route/storage values were changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the fragile screenHeightDp*0.34f cap on the clean-mode text flow
with a weight split: the centered sphere keeps weight(1f) while the flow
takes weight(1.1f), so the readable/scrollable text area grows from ~34%
to ~52% of the vertical slack. Keeps the min=96.dp floor, internal
scroll + top-fade + a11y mirror paths, and composer/exit spacing intact;
drops the now-dead LocalConfiguration import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The repo is public and distributed; several files leaked real server
identifiers. Replace them with generic placeholders across docs, scripts,
source, and test fixtures:
- real LAN IP 172.16.24.250 -> 192.168.1.100 (blessed example)
- real Tailscale IP 100.71.8.56 -> 100.64.0.1
- real hostname docker-server / tail6f460 tailnet -> hermes-host(.tailnet.ts.net)
- ssh user@host targets -> you@hermes-host
- server home path /home/bailey/ -> $HOME/
- custom voice id -> <your-voice-id>
Test fixtures changed on both input and assertion sides so suites stay
green (plugin.tests.test_pairing_mint_schema + test_voice_routes pass;
Kotlin URL-deriver/normalization fixtures consistent). No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Patch release for the Realtime Agent voice path:
- brokered Hermes turns no longer fail with session_not_found (broker
mints/reuses a valid API Server session, retries once, reads the
nested create-session response)
- realtime voice session survives long Hermes runs via heartbeat
Both fixes already merged to dev (f6b965a, d1820fb); this bumps the six
plugin version sources to 1.2.1, folds the relay fix into the [1.2.1]
CHANGELOG line, and rewrites PLUGIN_RELEASE_NOTES.md as the release body.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote CHANGELOG [Unreleased] -> [1.2.1] (Android-only; CLI + the relay
session_not_found fix stay under [Unreleased] for their own cli-v*/plugin-v*
cuts), rewrite RELEASE_NOTES.md, in-app whats_new.txt, Play release notes, and
the Play listing copy for 1.2.1. Also clarifies the per-surface CHANGELOG split
in RELEASE.md. Version source (1.2.1 / versionCode 15) was already committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tray is a visual cockpit over the CLI: it auto-starts the daemon on launch
(auto_start_daemon default), embeds Voice Mode + the TUI, and adds GUI surfaces
the headless CLI can't — a Grant Requests tab and pause / emergency-stop.
- index.md: "not a chat app" -> "not a full chat app" (it has a CLI-backed
lightweight chat); document auto-start-daemon-on-launch (distinct from
boot-persistence), Grant Requests + Voice Mode tabs, pause/emergency-stop.
- tools.md: new "Computer-use (experimental)" section covering the
enable->observe->grant flow AND how grants are approved — interactive prompt,
tray Grant Requests tab, and the headless HERMES_RELAY_GRANT_BRIDGE_DIR
file-bridge (previously undocumented).
- subcommands.md: daemon tip notes the tray auto-runs the daemon (GUI
equivalent of `daemon start`), same while-running lifetime, not boot-persist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Debug-only (FeatureFlags.isDevBuild) triggers in Developer options for the
on-device-only flows unit tests can't reach and that don't occur on demand:
- Emit sample Info/Warning/Error entries into DiagnosticsLog (exercises the
list -> detail -> Copy/Share/Create-issue flow).
- Preview the in-app update banner via UpdateDebugOverride (Available ->
Downloaded -> off), honoured by rememberUpdateAvailability ONLY in debug
builds; cleared when the previewed banner is actioned/dismissed.
- Show What's New now (ConnectionViewModel.showWhatsNewNow()).
- Force a test crash to exercise the crash-report capture + dialog.
No release-build behaviour change: the section is gated by isDevBuild and the
update override is gated by BuildConfig.DEBUG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brokered Hermes turns from the Realtime Agent no longer fail with
session_not_found when the client session id came from another namespace,
and API Server session creation now parses the nested session.id shape.
The Realtime Agent's brokered Hermes path (hermes_run_task) could fail
two ways when reaching back to the API Server:
- a caller-supplied chat_session_id from another session namespace (the
gateway/client session store) was passed straight to
/api/sessions/{id}/chat/stream and rejected with 404 session_not_found
- _create_session() only read a flat id/session_id, but the current API
Server returns the session nested under {"session": {"id": ...}}, so
creation raised "Hermes API created a session without an id"
stream_task() now tracks whether it owns the API Server session and, on a
404 session_not_found for a caller-supplied id, mints a fresh API Server
session (emitting a session.bound handoff event) and retries the turn
once — a session it created itself, or a second failure, is not retried,
so there is no loop. Valid existing API sessions are reused untouched.
_create_session() parses both the nested and legacy flat response shapes.
Adds plugin/tests/test_hermes_tool_broker.py (13) covering both parsers
and the namespace-mismatch handoff/retry against a local aiohttp fake
API Server.
Closes#101
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps appVersionName to 1.2.1 (versionCode 15).
Added:
- Profile lock (per-connection): pin to one profile and hide the rest; ProfileLockStore + ProfileController enforcement + Settings lock dialog with a not-found banner.
- In-app What's New / changelog from a bundled changelog.json; revisitable Settings entry sharing one renderer with the auto post-update dialog.
- Diagnostics detail view with Copy / Share / Create-GitHub-issue via a shared IssueReport helper (also adopted by the crash dialog); RelayErrorClassifier now records every classified error to DiagnosticsLog with a clean title + redacted stacktrace.
- Update-available banner: googlePlay uses Play In-App Update (FLEXIBLE; new app-update dep, flavor-scoped), sideload uses the GitHub checker; per-version dismissal + 6h throttle, never nags.
Fixed:
- Voice override now applies in Auto mode (effectiveRoute gate) and voice prefs are namespaced by connectionId.
- Realtime Stop halts playback immediately (suppress in-flight deltas); spoken-status throttle; client idle-watchdog relaxed on promoted/long runs.
- Hold-to-talk releases only on a real finger-up; voice overlay panel + bubbles opaque with non-wrapping labels; invalid engine/route combos gated.
- Connection status overlay terminal states auto-dismiss within ~5s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- SECURITY.md: GitHub Private Vulnerability Reporting (preferred) + security@codename-11.dev fallback; scope, response expectations, safe harbor.
- CODE_OF_CONDUCT.md: Contributor Covenant 2.1 (conduct@codename-11.dev), adopted by reference.
- Issue config: replace the public "security guidance" link with a private "Report a vulnerability" link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tray smoke step in release-cli.yml assigned `$home = ...`, but $HOME is a
read-only automatic variable in PowerShell (names are case-insensitive), so it
threw "Cannot overwrite variable HOME because it is read-only or constant",
failing the tray job and skipping Publish. First cli-v* tag surfaced it — the
CLI binaries themselves built fine. Use a distinct scratch variable; the
$env:HOME / $env:USERPROFILE environment vars stay writable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps desktop/package.json 0.3.0-alpha.18 -> 0.4.0-alpha.1 (a new minor for the
command-surface uplift; stays in the experimental alpha track) and fills
CLI_RELEASE_NOTES.md for the GitHub Release body.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the CLI up to the relay's v1.2.0 capabilities and gives it a consistent,
discoverable interface. New commands: `audit` (what the agent ran on this
machine, from a local log), `relay info/security/context` (inspect the relay
server and audit the system-prompt context it injects into the agent), `logo`,
and `daemon start/stop/status` for running the tool router in the background
(no console window, survives closing the terminal).
Every subcommand now answers `--help`; list output (devices/sessions) renders
as aligned tables with status dots; slow operations show a spinner; errors
suggest the fix; and pairing reports per-endpoint probe progress and warns
before a stored session expires. `voice` surfaces the enhanced-voice
(Gemini/xAI) block, and the desktop-tool consent prompt points at `audit`.
Adds a shared zero-dep lib/ (theme/table/spinner/hints/usage/logo/auditLog/
daemonStatus), an `npm run dev:install` local-binary helper, and refreshed
desktop user-docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The realtime voice agent killed a turn after ~90s of websocket silence
(client idle watchdog). The relay heartbeat stopped the moment
hermes_run_status left {running, waiting_for_confirmation}, so a long or
background Hermes run could starve it and trip the stall. The heartbeat
now continues while session.hermes_task is unfinished, and the spoken
progress repeat is raised 30s->90s and gated on a coarse status change so
tool-message churn no longer re-narrates.
Adds plugin/tests/test_realtime_heartbeat.py (11 cases).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The android-v* release builds the release variant (bundleRelease
assembleRelease, both flavors); PR CI only built debug, so release-only
failures (R8/minify, resource shrinking, bundletool OOM) surfaced at the tag
— e.g. the v1.2.0 OOM at -Xmx2048m. Adds a debug-signed release-build smoke
(no secrets) on dev/main pushes and the dev->main release PR, so the same
build that the tag runs is exercised before tagging.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bundled docsearch declares search-insights as an OPTIONAL peer dep with
no resolved lock entry. npm 11.9 (local) treats it as satisfiable and passes;
CI's npm rejects it ("Missing: search-insights@2.17.3 from lock file").
Pinning it as a direct devDependency gives it a resolved node_modules entry,
so `npm ci` agrees on every npm version. Validated with a clean local npm ci.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploy Docs failed `npm ci` with "Missing: search-insights@2.17.3 from lock
file". user-docs/package-lock.json is generated by npm 11, which omits the
resolved entry for the optional `search-insights` peer dep of bundled
docsearch; CI's Node 20 / npm 10 demands it. Align CI to npm 11.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The android-v* release workflow builds both flavors' AABs+APKs
(bundleRelease assembleRelease); at -Xmx2048m, packageSideloadReleaseBundle
OOMed ("Java heap space") in bundletool after the googlePlay bundle. PR CI
only builds debug, so it never hit this. 4g clears it with margin and also
helps local release builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Play Store Listing workflow now publishes the listing (screenshots,
graphics, and text) automatically when its path-scoped assets change on main,
in addition to manual workflow_dispatch. PRs and dev pushes still validate
only, and it skips gracefully (a notice, not a failure) when the
PLAY_SERVICE_ACCOUNT_JSON secret is absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Deterministic rendering" section to docs/screenshot-automation.md (run
command, how to add a view, real-screen vs curated-frame for config/data
screens, the JDK-21 and no-plugin gotchas, and the Play-listing publish flow),
plus a CLAUDE.md Key Files pointer so the harness is discoverable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New Themes feature page showing the eight-theme gallery (the same chat reskinned
by every theme), wired into the docs sidebar and the features index.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regenerate all eight phone screenshots host-side at exact 2:1; replace the
command-palette and settings scenes with App Themes and Appearance (the latter
the real AppearanceSettingsScreen, rendered 1:1). Re-export the Play graphics
and README grid; screenshots.py validate is clean (no 2:1 crop warnings).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deterministic, device-free store/docs screenshot renderer: renders real
screens/components with mock data at exactly 1080x2160 (no Play 2:1 clipping).
Drops the AGP-9-incompatible Roborazzi Gradle plugin (keeps the runtime) and
runs unit tests on JDK 21 for the markdown code-highlighter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote [Unreleased] -> [1.2.0]; backfill the agent-pet system, in-app
crash reporting, per-profile icons, clean mode, permissions screen, the
"Standard"->"Vanilla Hermes" rename, and PDF/image crash fixes that
shipped to dev without changelog bullets. appVersionCode 13 -> 14.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generation-method change over-corrected: the registration/safe-box prompt
produced 16 near-identical frames (measured interframe diff ~0.02/255), so pets
rendered static even though frameCount is 16 and the renderer cycles all of
them. Clarify across the prompt template, gotchas, and pet-spec that the cells
are an animation, NOT copies — lock only the identity/anchor (position+scale),
but the moving parts (eyes, mouth, hands, hair, accent) must visibly progress
through the full motion arc across all 16 frames; over-locking is its own
distinct failure. Also carries the chroma-key + safe-box authoring guidance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2dp "customized" ring is meant to mark the letter avatar; on an actual
profile photo it just looks like a bad outline. Suppress it whenever
LocalAgentIconPath is set (sheet header + Settings); the ring still shows for
the letter fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The profile icon only reached the per-message label; the circular header avatars
(agent sheet, chat top bar, Settings) still showed the generated letter. Add a
shared AgentAvatarFace that renders the LocalAgentIconPath image when set, else
the name's initial, and use it in all three. The chat header keeps its letter
cross-fade for the no-icon case (image short-circuits before AnimatedContent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two custom-identity features (they share ConnectionViewModel, so one commit):
Static-image avatar: "Add a pet" now accepts a single image (PNG/JPG/GIF/WebP),
detected by magic bytes, and auto-wraps it as a one-frame static pet (idle.png +
a synthesized minimal pet.json) — a custom avatar with no manifest authoring.
importZip -> importUri; importPetFromZip -> importPet.
Per-profile agent icon: a client-side twin of ProfileDisplayAliasStore. New
ProfileIconStore (own DataStore, keyed per (connection, profile), never sent to
Hermes) holds a path to an image copied into files/profile-icons/ (not a SAF
URI, so it survives without persistable permission). Wired through
ProfileController next to profileDisplayAlias, exposed on ConnectionViewModel,
provided at the app root as LocalAgentIconPath, and rendered as a small circular
Coil image beside the agent name in MessageBubble. Picker (AgentIconRow) sits
under the local-name row in ConnectionInfoSheet. Scope: small name-adjacent icon
only; the big avatar stays global. Tests for both; PetImporter image-wrap +
ProfileIconStore scoping/clear.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Testing a pet meant inducing each state by driving the agent (run a tool for
working, fail a turn for error, start voice for speaking). Add a preview under
the speed/stabilize controls (pet selected only): a ~140dp canvas rendering the
active pet, a FilterChip row for the seven sustained states, and Greet/Done
buttons that replay the one-shots. Pure UI on the existing AgentAvatar seam —
no new ViewModel/pref/renderer; it calls activeAvatar.Render(AvatarRenderState(
state=...)) with a user-picked state, so it also reflects the live speed and
stabilize settings. Working = Thinking + toolCallBurst; Greet remounts via key;
Done drives a momentary Speaking->Idle transition.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AI-generated sprite sheets keep a character's appearance consistent but not its
position/scale across cells, so the pet floats/jumps as it plays (audited: 34px
vertical drift over 16 cells, 8/16 frames touching the cell edge). Add decode-
time stabilization: scan each frame's opaque pixels (alpha bbox) and shift the
draw so the content's center sits at the cell center. Works for sheets (per
cell) and sequences (per bitmap); one-time scan on IO with a reused buffer.
Exposed as a global LocalPetStabilize (pet_stabilize pref) with a "Stabilize
frames" Switch in Appearance, default on. Keys the decode produceState so
toggling re-decodes. Fixes an installed pet at render time with no re-import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-device audit of a 4x4 pet showed the character's vertical center drifting
34px across the 16 cells with 8/16 frames touching the cell edge — the image
model kept appearance consistent but not position/scale, so the pet floats and
the next frame's edge bleeds in. The renderer slices/centers exact cells
faithfully, so this is an authoring (registration) gap, not an engine bug. Add
registration instructions to the prompt template (lock head/shoulders, same
position + scale, only small secondary motion) and the consistency caveat
(registration degrades with cell count; drop to 3x3/2x2 if a 4x4 drifts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Android: SessionPathDetails (agent sheet → Connection) gains a vertical basic→best transport ladder (Completions → Runs → Sessions → Gateway) via a new TransportTierStepper, using the same resolveChatTransportStatus as the status badge — active tier filled+highlighted, server-unsupported tiers muted, with the resolver's reason beneath. Dashboard: the Agent-context toggles now read as ON when the env is unset (matching the new config default) via a strict-bool coercion, and the label says 'On by default for relay installs'; dist rebuilt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pixelation is a resolution axis (cell px), separate from smoothness (frame
count): one frame set is contain-fit into every surface, so author for the
largest (the full-screen chat background) and small placements (voice overlay)
downscale and stay sharp. Bump the kit default to 256px cells (a 1024x1024
sheet for 4x4), note 512px is fine for a sprite sheet (one bitmap), and that
the old "<=256px" note was for frame-sequences. Updates custom-avatars.md,
pet-prompt-kit.txt, pet-spec.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A pet that feels too fast/slow needed re-authoring + re-importing to tune. Add a
global playback-speed multiplier (pet_speed pref, 0.5x-1.5x, default 1.0) as a
Slider in Appearance, shown when a pet is selected. It's provided at the app
root via a new LocalPetPlaybackSpeed composition local and read live in
PetAvatar.Render (rememberUpdatedState), so dragging it re-times the pet
instantly with no restart. Applies to every clip including one-shots and
composes with intensity (baseFps * speed * intensityFactor, clamped 1-60). The
sphere avatar ignores it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The relay plugin install is itself the opt-in, and the wrap is fail-open, auditable (chat 'Relay context (server-side)'), and reversible from the dashboard toggle — so default the master + media-sensitivity gates ON. Vanilla upstream (no plugin) is unaffected; set RELAY_AGENT_CONTEXT_ENABLED=0 to opt out. Tests updated for the new default + explicit-off coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A 2x2 (4-frame) sheet reads steppy at any fps. The renderer already slices any
N×M grid (decodeClip derives cols/rows from sheet size / cell size; drawPetFrame
indexes col=i%cols, row=i/cols), so "support 4x4" is an authoring default, not a
renderer change. Default the kit to a 4x4 grid (16 frames): prompt template,
manifest example, and pet-prompt-kit.txt now use frameCount 16 with fps matched
to the count (idle ~8 -> ~2s loop); 2x2/4 stays documented as the
easier-consistency fallback. pet-spec notes any rectangular grid works. Adds a
PetLoaderTest case for a 16-frame sheet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PetAvatar.Render's frame loop awaited withFrameNanos (one vsync) AND
delay(1000/fps) each iteration, so every frame waited ~16ms longer than its
duration; the surplus accumulated until the loop skipped a frame to catch up —
a periodic hitch, worst at low fps. Drop the delay: withFrameNanos already
paces the loop at vsync, and the accumulator advances the sprite only when a
frame's worth of real time has elapsed, so playback is smooth and intensity's
variable rate no longer causes skips.
Also document that smoothness comes from frame count (8-16), not fps, and to
match fps to count (calm states 3-4); lowered the example/kit idle+listening
fps to 4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Appearance could select avatars but not add or remove a pet — the only path
was adb push into app-scoped external storage, which scoped storage stalls on
(confirmed hanging on a Samsung device). And the avatar list loaded once at
startup, so even a pushed pet never appeared without a restart; users saw only
the Sphere.
- PetImporter (new): "Add a pet" launches a SAF .zip picker and unpacks into
pets/. Hardened with a zip-slip guard, per-file/total/count ceilings, and
post-extract validation through the same PetSpec.toAvatar the loader uses.
- PetLoader.deletePet: remove a pack by resolved manifest id, behind a confirm
dialog; falls back to the Sphere if the deleted pet was selected.
- Live refresh: an avatarsRefreshTick keys the avatar produceState in RelayApp,
so import/delete and opening Appearance re-scan pets/ without an app restart
(resolves the process-scoped-load TODO). Results surface as snackbars.
- AppearanceSettingsScreen: "Add a pet" + "Rescan" buttons and an
"Installed pets" management list with per-pet remove.
- Tests: PetImporterTest (root/nested import, no-manifest, missing-idle,
zip-slip refused) and PetLoaderTest delete cases.
Built and installed to the sideload debug build; new unit tests pass (the 12
build failures are the pre-existing DataStore/FileStorage JVM cases).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pets are pure data, so the only barrier to making one is sourcing the art.
Document an AI-generation workflow plus a machine-readable contract:
- A reference-image-first, character-agnostic prompt template
({character}/{style}/{accent}) and a per-state motion table mapping image
generation onto the agent-state vocabulary, a full 9-state manifest, and a
one-download pet-prompt-kit.txt.
- A draft-07 JSON Schema (user-docs/public/pet.schema.json) mirroring the
loader structural rules (required idle, frames-XOR-sheet, positive sheet
dims) so editors and AI agents can validate a pet.json before installing it.
- A vendor-neutral "let an AI agent build the pack" callout (Codex/Claude Code
as examples) stating the acceptance criteria and image-gen prerequisite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PdfDoc.pageCount was a lazy getter delegating to PdfRenderer.pageCount, so a LazyColumn measure pass racing DisposableEffect's onDispose { doc.close() } could call getPageCount() on an already-closed renderer -> IllegalStateException 'Document already closed' (caught in the wild by the crash reporter). Capture pageCount once at open time (a PDF's count is immutable) so it never reads the renderer after close, and skip page render when the doc is already closed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An agent referenced /mnt/.../Coralee Adshade/undressher.jpg three ways and none rendered — all because of the space in the path:
- MEDIA:/path bare marker used /\S+, which stops at the space, so the marker never matched and showed as raw text. Now /.+? (allows spaces; OkHttp re-encodes for /media/by-path).
- : the markdown angle-bracket URL form wasn't accepted — the regex kept the leading '<' and stopped at the space, failing the startsWith("/") server-local check. Regex now accepts <...> and normalizeImageSrc strips the brackets.
- : the percent-encoded space wasn't decoded, so the relay looked up a literal '%20' directory and 404'd. normalizeImageSrc now percent-decodes absolute paths (protecting a literal '+').
Verified on-device: the previously-raw MEDIA: line now renders the image. File and relay were fine; this was entirely client-side path handling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CrashReportGate consumed (read+deleted) the report on first read, so it vanished after one glance even if the user never acted on it. Switch to peek-on-read + clear-on-acknowledge: the report now survives relaunches until the user Dismisses or Reports it (Copy keeps it available), so a crash you saw but didn't report isn't lost.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RelayServerImage crashed on app open with 'kotlin.Result cannot be cast to byte[]': the resolver returned Result<ByteArray> from a suspend fun, and runCatching { fetch() } nested Result-in-Result, which Kotlin's value-class Result collapses incorrectly at runtime. Replace the suspend fetch path's kotlin.Result with a purpose-built ServerImageResult sealed type (Success/Failure) so the resolver boundary never returns kotlin.Result from a suspend function.
Caught in the wild by the new in-app crash reporter (Galaxy S25 Ultra, SDK 36).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Issue-form field-id prefill (template=bug_report.yml&<id>=...) is a GitHub public-preview feature and silently did not apply — only the title carried. Switch to the stable classic ?title=&body=&labels=bug route (blank_issues_enabled is true), with a markdown body that mirrors the form's sections (Affected area / What happened / Environment / Crash) plus a sanitization reminder, so the auto-captured report reliably prefills.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add privacy-respecting crash capture (no Firebase): an uncaught handler persists a structured report then re-raises so the system dialog and Play Android vitals still collect it. On next launch a show-once dialog offers Copy + a pre-filled GitHub bug_report.yml issue with device/version/trace.
Harden QrPairingScanner camera init — try/catch around ProcessCameraProvider.get() (main thread) and InputImage.fromMediaImage() (analyzer thread), with a graceful CameraUnavailableCard -> manual pairing fallback instead of a force-close. Addresses a Galaxy Z Fold7 'keeps crashing during setup' report.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the pet reactivity story (voice · tools · activity). The activity
ramp (intensity, ~0.7 while streaming) was already fed to every avatar but
pets ignored it; now an opt-in pet quickens its clip as the agent works.
- Live playback-rate modulation in PetAvatar.Render, opt-in via
reactive.intensity: the base/working loop's fps scales by
1 + intensity*PET_INTENSITY_RATE (0.6 -> ~1.4x typical, 1.6x peak, capped at
PET_MAX_FPS). Read live via rememberUpdatedState so speed tracks the agent
mid-clip without restarting the long-lived frame loop (re-keying on a
continuously-animated float would thrash). One-shots excluded (!playOnce) so
greet/done keep their authored rate.
- Flipped PET_RENDERER_CAPABILITIES.intensity to true; the loader's existing
reactive.intensity && capability formula now lets a declared intensity:true
through, so the pet honestly advertises Activity. No loader change.
- Tests: declared intensity is honored (Voice · Activity); split the prior
clamp test so tools-without-a-working-clip still stays off the badge.
- docs/pet-spec.md: intensity row rewritten from Reserved to the speedup
behavior; removed from Forthcoming (only attention remains there).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The event tier of pet behavior: a reaction clip that plays ONCE over the base
loop, then returns — the touch that turns a status display into a character
(cf. the Peon Pet's celebrate-on-finish).
- Pet-local triggers, no host plumbing: reactions ride the activity-state
transitions the avatar already sees. PetOneShot.Greet fires on first
composition (the pet appears); PetOneShot.Done fires when a productive turn
ends (Streaming/Speaking -> Idle; Thinking/Error -> Idle don't celebrate).
Both opt-in (only if the pet ships the clip) and require >= 2 frames.
- Play-once-then-revert in PetAvatar.Render: a `playOnce` frame mode runs the
clip 0->end (no modulo wrap), parks on the last frame, clears the active
reaction, and recomposition hands back to the base loop. A reaction overlays
everything (incl. working). Suppressed under reduced motion; an
ONE_SHOT_MAX_MS (4s) backstop guarantees it never lingers on decode failure.
- PetLoader resolves friendly aliases (greet/wake, done/celebrate) from explicit
`states` keys only (no fallback). One-shots are reactions, not a reactivity
signal, so they don't touch the picker badge.
- Test: a pack with greet/done keys loads and the badge stays Voice (no
accidental Tools/Activity coupling). Render-time playback is on-device/
Compose-test territory (flagged in TODO).
- docs/pet-spec.md: new "One-shot reactions" section (Greet/Done table, opt-in,
play-once, reduced-motion), an Expressive authoring tier. `attention`-on-
notification stays Forthcoming (needs a host event the avatar lacks).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Give pets a distinct "agent is running a tool" behavior, separate from
thinking — the strongest cross-system convention (MS Agent Think vs Process;
pi-animations Thinking·Working·Tool) is that acting should look different
from thinking.
- Pet-local overlay derived from the already-plumbed toolCallBurst, NOT a 7th
SphereState — zero blast radius on the Sphere or call sites. PetAvatar.Render
swaps to an optional workingClip when toolCallBurst >= 0.5 during a
thinking/writing turn, releasing ~600ms after the last tool as the burst
decays. Error keeps its own clip; burst is ~0 outside tool activity.
- Opt-in + clip-driven: workingClip resolves only from an explicit `working`
key (no fallback). Shipping one IS the tool-reactivity capability — it drives
both the swap and the Tools badge (reactivity.tools = workingClip != null &&
PET_RENDERER_CAPABILITIES.tools), so the declared reactive.tools flag is no
longer needed and can't over-promise. Flipped PET_RENDERER_CAPABILITIES.tools
to true.
- Tests: a working clip lights the Tools badge; a working clip with missing
files does not; declared-but-no-clip still clamps to Voice.
- docs/pet-spec.md: `working` moved from Forthcoming into the implemented model
(state-table row, "working overlay" subsection, Rich tier = 7 clips,
reactivity table tools row). Forthcoming trimmed to one-shots + intensity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pet picker badge read reactivity straight from pet.json, so a manifest
could advertise tools/intensity the renderer never delivered. Clamp the
effective reactivity to what the renderer actually honors, and document a
real agent-state -> behavior model so pets can show thinking/writing/etc.
- PetAvatar.PET_RENDERER_CAPABILITIES: single source of truth for the live
signals Render consumes today (voice only). PetLoader.toAvatar clamps a
pet's reactivity to declared-AND-supported, so the badge can't over-promise.
- Friendly `writing` clip alias for the Streaming (output) state; tidied the
Speaking/Error fallback chains. Backward compatible.
- docs/pet-spec.md: new "Agent states & pet behavior" section — state meanings,
friendly clip-key vocabulary + fallback chains, a Minimal->Rich authoring
ladder, and a "Forthcoming behavior" tier (working/tool clip, one-shot
reactions, intensity modulation) grounded in prior art (MS Agent .acs set,
pi-animations, Peon Pet). Reactivity table notes the clamp.
- PetLoaderTest: declared tools/intensity are dropped from the badge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On reopen/prewarm the gateway already returns the session's model in the
session.resume RPC result's `info`, but the client read only `session_id` and
discarded it — so the header/picker showed the global DEFAULT until the first
turn's async session.info arrived (~15-30s later), though the send itself
correctly used the session's stored model. Read info.model/provider/effort/yolo/
fast/usage from the resume result (resumeForPrewarm + ensureSession) into the same
_server* flows the session.info event feeds, via a shared applySessionInfo helper,
so a reopened session shows its actual model immediately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The only documented way to install a pet avatar or sphere skin was an
`adb push` to external app-scoped storage, but both loaders read from
internal `filesDir` (`/data/data/<pkg>/files/`), which is not
`adb push`-able on a non-rooted device. The documented side-load path
could never work on either flavor.
- UserContentDir (new): shared resolver preferring external app-scoped
storage (getExternalFilesDir, the /sdcard/Android/data/<pkg>/files/
path adb push reaches, no runtime permission on API 19+) with internal
filesDir fallback. Single source of truth for where pets AND sphere
skins live — fixes the bug once for both.
- PetLoader / SphereSkinLoader: resolve through UserContentDir; add pure
load(dir: File) overloads so the validation/skip-invalid logic is
unit-testable without an Android Context.
- PetLoaderTest (17) + SphereSkinLoaderTest (6): parse, id/label
fallbacks, schema + missing-idle + missing-file rejection, the
safeChild path-traversal guard, fps clamping, one-bad-pack isolation,
sort order, empty/absent dirs.
- AppearanceSettingsScreen: "Add your own pet" pointer so the feature is
discoverable with no pets installed (mirrors the sphere-skin pointer).
- docs/pet-spec.md + docs/sphere-spec.md: correct the storage prose, both
flavor paths, cross-link the two specs, fix an "Agent sphere" naming
drift, add undecodable-image + per-frame-memory authoring caveats.
- user-docs/features/custom-avatars.md (new) + nav: user-facing page on
the avatar→skin model, reactivity badges, adding skins/pets, reduced
motion, troubleshooting.
Follow-ups (TODO.md): per-frame memory cap/downsample, decoded-clip cache.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-chat picker's "Server default" row captioned itself from fallbackModelDetail
(gatewayCurrentModel ?? profile ?? serverModelName). selectModel() force-sets
gatewayCurrentModel to the active override, so once you picked a model the row read
"Current: <your override>" — presenting the override AS the server default. Caption
it from serverModelName (/api/config, never touched by overrides) instead — the same
source the agent drawer already uses correctly. The selected-row highlight was already
right; only the caption was wrong.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Server-local agent images (markdown  via /media/by-path) rendered a
generic "this image is on the server" placeholder on ANY failure, hiding why. The
resolver now returns Result<ByteArray>, the failed phase carries the reason, and
the inline notice shows it (sandbox 403 / not-found 404 / unauthorized / decode /
unsupported path) for debugging. Also gate mediaUrlConfigured() (the media-
capability badge + SSE media hint) on a current paired token, not just a relay
URL, so the badge agrees with what the fetch can actually do.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AgentTextFlow no longer fades lines away — they slide in and PERSIST, scrolling
up within a bounded ~1/3-screen viewport with a soft top-edge fade so the avatar
above stays unobstructed (a calmer, minimal accumulate-and-scroll feel rather
than ephemeral disappearing text). The clean-mode discoverability hint is now a
persistent pill shown ONLY on the empty/new-chat view, replacing the timed popup
that re-fired too often.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
createNewChat pre-created an api_server session for both transports; on the
gateway that handed the next turn a concrete id, forcing ensureSession down the
session.resume branch (the api_ id resumes against the shared launch state.db on
the default profile), which bypasses the model/provider/effort/fast binding that
only runs on session.create. New chats therefore ran the DEFAULT model while the
picker still showed the last pick. On the gateway transport, drop the gateway
session + null the id so the next send hits session.create and binds the
carried-over model. SSE keeps pre-creating (it needs a concrete id). Also fixes
the same latent effort/fast gap on new gateway chats.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full-screen ChatImageViewer and AttachmentViewer call AllowDeviceRotation()
(SENSOR) while open, overriding the app-wide portrait lock so wide images and
video can be viewed in landscape; portrait is restored on dismiss.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Single-activity app, so screenOrientation=portrait on MainActivity locks the
whole app. tools:ignore for the deliberate LockedOrientationActivity lint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clean mode: long-press the chat background enters a full-screen ambient mode
(evolved from ambientMode) — a centered agent avatar with the assistant reply
flowing in as themed monospace text that materializes, dwells, and fades (bounded
6-line buffer), a thin composer, explicit exit, and full reduced-motion/TalkBack
fallbacks to static readable text.
AgentAvatar seam: a swappable AgentAvatar { Render(AvatarRenderState, modifier) }
with SphereAvatar as the default (the morphing sphere + its skin system nested
unchanged). Every sphere call site (chat, clean mode, voice overlay, onboarding,
splash) routes through LocalAgentAvatar; the Appearance picker is now "Agent avatar".
Pets: users can drop animated avatars in files/pets/<id>/pet.json (frame-sequence
or sprite-sheet, no new deps - off-thread BitmapFactory + rate-capped Canvas loop),
selected via an agent_avatar pref (mirrors sphere_skin) and persisted/switched in
Appearance. Fresh install with no pets behaves exactly as today. See docs/pet-spec.md.
Spec: docs/plans/2026-06-18-chat-clean-mode-and-pets.md
Follow-ups in TODO.md: process-scoped pack load, clip re-decode flash, tools/intensity pet reactivity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Overlay: kill click-through (focus-mode pointer-consuming scrim + gesturesEnabled=
!voiceMode on the drawer), de-wrap the topbar (trimmed collapsed header + FlowRow
pills), and add a gear link to Voice Settings that exits voice mode before navigating.
Per-profile voice: VoicePreferencesRepository is now scope-aware — engine mode,
audio route, and the enhanced overrides namespace per (connection, profile) and
layer over global defaults; ergonomic prefs stay global. VoiceViewModel re-seeds
on profile change. The relay path already carried per-profile voice end-to-end.
Settings IA: single Voice scope banner, a "Voice for this profile" section, merged
Enhanced + Voice Output into one Text-to-Speech card (Advanced expander), dead
controls behind a "Coming soon" expander, SectionCards extracted, and the
relay-config fetch lifted into VoiceSettingsViewModel. Standard reads "Global voice".
Waveform: the output/Speaking waveform now unfolds only on the first real
playback-amplitude frame (VoicePlayer attaches the Visualizer on audio-session-id
to fix a deep-buffer cold-start race) instead of leading audio off the state flip.
Spec: docs/plans/2026-06-18-voice-overhaul.md
Follow-ups in TODO.md: connectionId namespacing wiring; realtime-PCM waveform gating.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reloader follow-up (off the same worktree): loadMessageHistory now reconciles
live client-UUID message ids to their server ids (position+role+content,
consume-once) before the delta-merge, and the merge adopts the server id in place
— so gateway assistant rows and user rows carry tokens/badges/attachments by id,
no drop-and-reinsert. Content-fallback drops to a pure safety net. 5 new
ChatHandlerTest cases; build + lint green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DEVLOG records what happened; TODO.md is the single home for follow-ups /
deferred work / known gaps. Adds attachment (B3/A6/C5/thumbnails/D5), voice,
and chat follow-ups to TODO.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The delta-merge + priorById carry are keyed by message id, but live
(optimistic) ids only sometimes match the reloaded server transcript: SSE
assistant rows are swapped to the server id mid-turn (replaceMessageId), but
gateway assistant rows keep a local UUID (the gateway exposes no per-message
server id during the turn) and USER rows of every transport keep a local
UUID. So the id-keyed carry silently missed those rows — a gateway turn's
tokens/badges survived only if a content match happened to cover them, and
user rows were drop-and-reinserted with attachments rescued only by the
content fallback.
Reconcile live ids to server ids inside loadMessageHistory before building
the carry map: match each still-unreconciled, non-clientOnly live row to an
unclaimed server row by (role, marker-stripped content), consume-once in
document order, and adopt the server id (prior.copy now sets id = messageId).
SSE assistant rows already carry a server id and are skipped (no double-swap);
clientOnly orphans have no server row and are never mapped; a row that matches
no slot is left alone (graceful fallback on truncation/compaction/divergence).
The content-keyed outbound-attachment fallback stays as the safety net, but is
now fed only by rows that did NOT reconcile, so a reconciled row and the queue
can't double-supply the same attachment. Net: gateway assistant AND user rows
now carry tokens/badges/attachments BY ID, in place, and every subsequent
reload matches by id.
run.started (SSE/runs) was considered for an earlier user-id swap but omitted:
the gateway (primary transport) exposes no such id, the first-reload
reconciliation already covers SSE/runs user rows, and a new callback through
three SSE methods + GatewayTurnCallbacks + the ViewModel would be redundant
surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inbound: new AttachmentViewer renders image/video/audio/pdf/text in-app
(Media3 + PdfRenderer) with a shared Share/Save/Open-externally toolbar; tapping
an attachment now previews in-app instead of firing ACTION_VIEW. Off-thread card
thumbnails, inline-image save menus, and configurable sensitive-media blur
(OFF/FLAGGED/ALL_IMAGES) applied in card, inline image, and viewer.
Sensitivity is model-emitted metadata only (no classifier): the relay carries a
`sensitive` bit via register_media -> X-Media-Sensitive header ->
FetchedMedia.sensitive -> Attachment.sensitive; the standard path uses a markdown
spoiler/sentinel convention. Adds D6 content re-sniff via _IMAGE_MAGIC.
Outbound: permissionless Photo Picker + camera capture + clipboard paste behind a
Photos/Files/Camera/Paste menu, unified through ingestAttachmentFromUri.
Design spec: docs/plans/2026-06-18-attachment-experience.md
Deferred: download progress/cancel (B3), multi-image gallery (A6), agent-side
sensitivity config gate (C5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings in the reloader-gaps worktree (off dev): preserve user-sent attachments
across reload, replace the id-prefix orphan whitelist with a clientOnly flag, and
delta-merge the history reload instead of wholesale-replacing the transcript.
14 new ChatHandlerTest cases; build + lint green. User-message-id reconciliation
(deeper run.started fix) follows as a separate change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadMessageHistory rebuilt every ChatMessage from server data on each
post-turn reload and reassigned the whole list, carrying client-only state
forward only through a hand-picked field list — the root of the
drop-on-reload class and needless row churn.
Make the per-row reconcile a delta-merge keyed by id: a server message that
matches a local row now copies that row and refreshes only the
server-authoritative fields (content, tool calls, cards, reasoning, role,
timestamp), so EVERY client-only field survives automatically instead of a
curated subset — and an unchanged row produces an equal object, so Compose
doesn't re-render it. A server message with no local row is inserted; a
client-only orphan is kept; a row that was server-backed but is no longer in
the transcript is dropped (genuine server-side delete/fork/truncate). Server
reasoning stays authoritative when present, but live-streamed thinking is no
longer blanked when the transcript omits it. Ordering, media-marker
re-dispatch, card extraction, and the MAX_MESSAGES cap are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Client-only bubbles (no server-side row) survived the post-turn reload
only if their id matched a known prefix (voice-intent-/steer-/ask-/
system-notice-) or they carried an "Error" badge. Any new client-only
bubble type silently dropped, and the badge check could mis-handle a turn
that errored after persisting.
Add ChatMessage.clientOnly (default false) and set it at every creator:
addSystemNotice, appendAskCardMessage, appendLocalVoiceIntentTrace (both
bubbles), appendLocalVoiceIntentResult, the steer echo, and — where
provenance is only known after the fact — markError (gateway terminal
error on a non-persisted turn) and attachRealtimeTurnTrace (a trace is
attached only for provider-only, non-Hermes-backed realtime turns).
loadMessageHistory now preserves any prior message with clientOnly == true
whose id is absent from the reloaded transcript, replacing the id-prefix
whitelist and the Error-badge sniff. A turn that errored after persisting
keeps its Error badge but IS in the transcript, so it reconciles normally;
only clientOnly + absent-from-transcript marks a preservable orphan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadMessageHistory rebuilt each ChatMessage with no attachments, so a
user-sent image/file (outbound Attachment, state LOADED, relayToken null)
vanished from its bubble after the post-turn reload. Inbound media
(MEDIA: markers) is re-fetched via the marker re-dispatch, but outbound
attachments are neither in server content nor re-dispatched, so they were
dropped.
Carry outbound-only attachments (relayToken == null) forward across the
reload. priorById matches by id, but user-message ids are never reconciled
to the server id (only the assistant placeholder is swapped via
replaceMessageId), so an id-only carry never fires for user bubbles. Add a
content-keyed, consume-once fallback so outbound attachments survive even
when the reloaded user row carries a fresh server id. Inbound
(relayToken != null) attachments are intentionally excluded to avoid
double-adding what the marker re-dispatch re-fetches.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the three representations of the architecture model (path-architecture.html,
CombineModel.vue, this SVG) that must be updated together, the canonical gating
sources, and how to regenerate the SVG/PNG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hand-authored SVG (+ PNG raster + editable .excalidraw source) showing the
two-axis model at a glance: Vanilla Hermes (Chat/Manage/Voice, no plugin) as the
always-on backbone, the optional Relay plugin fanning out to the app + CLI
(Terminal/Bridge/relay voice/desktop tools), and the sideload gate sitting on
Device Control.
- Embed the SVG at the top of the user-docs Architecture page (served from public/).
- Add the PNG to the README "What it is" section.
Generated with the excalidraw-diagram skill's design methodology; published as a
dependency-free SVG (the skill's CDN-based render pipeline can't egress in this
sandbox, so the .excalidraw is included as the editable source).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadMessageHistory wholesale-replaces the transcript from server data, which
rebuilds content/tool-calls/reasoning but carries NONE of the per-message state
the server does not persist: token usage + cost, provenance badges, tapped-card
confirmations, and the voice/realtime sync traces. Each had to be patched
individually (badges were; tokens were not), so a normal reply lost its
input/output token subtext the moment the turn finished -- the error bubble kept
it only because errored turns skip the reload.
Replace the badge-only carry map with an id-keyed priorById and carry ALL
client-only fields forward for any message id that still matches -- preserve by
default, instead of a per-field whitelist the next new field always forgets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Model display: the chat header subtitle and the agent detail sheet now resolve
the model from the session scope (selectedModelOverride -> gateway session.info
-> profile -> server default), matching the input chip and footer, so a
mid-session switch shows everywhere. The agent-sheet header took the global model
name but the session provider (showed "gpt-5.5 . xAI Grok"); it now takes a
sessionModelName so model+provider come from one scope, and adds a quiet
"Server default: ..." caption only when the session runs a different model than
the host default -- the always-visible global-vs-session split.
Debug toggle: a default-off "Show system messages" switch in Chat Settings
(DataStore-backed, mirrors parseToolAnnotations) drives ChatHandler.showSystemMarkers
to reveal the otherwise-hidden upstream "[System: ...]" steering markers.
Updates CHANGELOG (Unreleased) and DEVLOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Model switch: selectModel called fire-and-forget prewarm() then setModel(), so
config.set{key:"model"} ran with no live session and upstream applied it as a
GLOBAL write instead of switching the session. Added suspending prewarmAwait()
that selectModel awaits before setModel, so the switch lands session-scoped (the
same _apply_model_switch path the CLI/TUI /model uses) -- or defers to the next
session.create override when there is genuinely no session, never writing global
config.
Errors: dispatchOn (the main-thread turn-callback wrapper) omitted onStatusUpdate,
so the server's terminal-error lifecycle line hit a default no-op -- the turn was
never badged Error and onComplete's post-turn history reload wiped the client-only
error bubble. Wired onStatusUpdate through dispatchOn (also restores live gateway
status lines) and hardened loadMessageHistory to re-inject local Error-badged
messages the server transcript lacks, so no reload path can swallow a failure.
Also hides upstream role:system "[System: ...]" steering markers from the
transcript by default (desktop/TUI parity), behind a ChatHandler.showSystemMarkers
flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Standard" was overloaded — it read as both an app feature tier and the
unmodified-upstream server state, which was confusing. Rename all
user-visible strings, docs, onboarding copy, and the matching test
assertions to "Vanilla Hermes" so the no-plugin path reads unambiguously.
Code identifiers, enum constants, and the persisted "standard" route value
are unchanged — that is an internal name only.
Also lands this session's architecture work:
- docs/path-architecture.html — connection-path + chat-transport
resolution flowchart, plus the build-flavor (googlePlay/sideload)
capability axis.
- user-docs CombineModel "how the pieces combine" three-tier model and
the release-tracks/index wording that makes the plugin-vs-flavor
prerequisites explicit.
- Aligns docs/security.md, upstream-surface-matrix.md, and spec.md on the
device-control 403 codes (device_control_sideload_only / sideload_only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standard (no-plugin dashboard) voice rides upstream POST /api/audio/speak, which
is text-only global TTS — TTSSpeakRequest has no profile field and
text_to_speech_tool has no profile scope (web_server.py) — so switching the chat
profile does not change the spoken voice on standard-only installs. The relay
voice path IS profile-aware and is left untouched.
- On a profile change, when the EFFECTIVE voice route is Standard and the
profile is non-default, record a quiet Voice diagnostics line explaining the
limitation and pointing to the Relay plugin for profile-aware voice.
- AutoVoiceAudioClient gains effectiveRoute, resolving Auto against live
readiness (relay-first) so the notice never claims the relay path has this
limitation.
- StandardHermesVoiceClient passes profile= on /api/audio/speak defensively
(upstream ignores extra fields today; forward-compatible if upstream adds
profile-aware TTS).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The YOLO/Fast controls showed an indefinite "Checking…" spinner whenever their
value was null. After a new chat or profile switch the value is intentionally
unconfirmed and only re-settles from session.info on the user's next message —
so an endless spinner reads as broken. When the gateway is Ready (socket up) but
the value is still null, the placeholder now reads "Confirms on your next
message" instead; the spinner is reserved for the genuine still-probing state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Setting reasoning effort, fast, or YOLO BEFORE a new chat's first message ran a
sessionless gateway config.set, which upstream applies as GLOBAL writes — and
YOLO via os.environ["HERMES_YOLO_MODE"], leaking approval-bypass into every
other session. Profile switches also leaked stale state: a stale personality
overlay was injected onto the new profile's first SSE turn, and reasoning effort
was re-fetched sessionless (reading the launch/global profile's value, not the
newly-selected one).
Verified against upstream tui_gateway/server.py: session.create consumes
model/provider (model_override), reasoning_effort (create_reasoning_override),
and fast (priority service tier) as PER-SESSION overrides, but does NOT accept
yolo.
- GatewaySessionModel now carries nullable reasoningEffort + fast (model also
nullable) and binds them on session.create with upstream's param names; null
fields leave the profile/server default intact.
- selectReasoningEffort/setFast/setYolo skip the sessionless config.set on a
brand-new chat (no live session); effort/fast ride session.create, YOLO is
stashed and applied session-scoped from the turn's onSessionId.
- _selectedReasoningEffort is now nullable (null = unknown) so a profile/
connection switch shows the chip as unconfirmed until session.info, never a
stale value that could ride session.create.
- Profile/connection switches reset personality to default + effort to unknown
(alongside yolo/fast) so neither a stale overlay nor chip carries over; the
optimistic getReasoningSettings() fetch in activateGatewayProfile is dropped.
- GatewayChatClientTest gains reasoning_effort/fast session.create binding cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Badges (Active / "N skills" / SOUL / model) no longer split internally
(maxLines=1, softWrap=false, Clip) — so no vertical "S O U L" or "141\nskills"
under width pressure — and wrap as WHOLE pills in their own FlowRow on a
dedicated line below the description. Long profile descriptions truncate to 2
lines with a gated "More"/"Show less" affordance (only shown on real overflow),
so a long description can't crunch the badges. One new optional ProfileRadioRow
param (secondaryExpandable, default false); only the profile-list caller opts in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UI/UX fixes from a profile-switching + chat-composer audit, verified against
upstream tui_gateway/server.py.
* Model picker now applies on a fresh chat. The gateway model is a per-session
override; we set it via config.set on live sessions only, so a brand-new
chat's config.set carried no session_id (upstream no-ops it) and
session.create omitted the model -> the agent ran on the account's global
default. Added a live GatewayChatClient.sessionModelProvider (mirrors
sessionProfileProvider) that binds model/provider onto session.create, which
upstream honors as the session's model_override -- matching the desktop
client. Mid-session switches still use config.set; a profile switch retires
an explicit pick (the profile owns its model) and seeds the picker label
up-front so it doesn't lag the round-trip. SSE paths already carried the
model. GatewayChatClientTest gains 3 model-binding cases.
* Server-local images render through the relay. Markdown  images only
understood http(s) -> a server path fell to an "image is on the server"
notice that never consulted the relay (only the MEDIA: marker path did).
Added a RelayServerImageResolver CompositionLocal (provided by ChatScreen from
ChatViewModel.resolveServerImage) that fetches an absolute path via the relay
/media/by-path route, decodes, caches (bounded LRU), and renders inline with
tap-to-zoom. On SSE the agent is also told it can surface images/files by path
when a relay route is configured (shown in the "What the agent sees" sheet).
Standard no-plugin connections are unchanged.
* Smoother profile switch. switchProfileContext no longer clears the message
list before the async history fetch; the previous transcript is held and
swapped atomically, so the LazyColumn's animateItem() cross-fades old->new
instead of blanking to an empty/Loading state.
Verified: :app:compileSideloadDebugKotlin + unit-test compile + the
GatewayChatClientTest suite are green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First pass added `requests` and got the suite collecting (18 tests ran), but
one test imports `plugin.relay.tailscale`, which loads plugin/relay/server.py
-> `import aiohttp`. Restore `-r relay_server/requirements.txt` (aiohttp +
pyyaml) alongside fastapi/httpx/requests so the full import chain resolves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the agent guidance work across frameworks that don't read AGENTS.md
natively, and make AGENTS.md self-sufficient beyond Android.
- AGENTS.md: add the Plugin (Python 3.11 aiohttp) and Desktop CLI (Node >=21,
zero-dep) stack rules to the non-negotiables (was Android-only).
- GEMINI.md: thin pointer to AGENTS.md for Gemini CLI.
- .github/copilot-instructions.md: thin pointer + quick non-negotiables for
GitHub Copilot.
Both shims point at AGENTS.md as the single source of truth (which links on to
CLAUDE.md for depth) so rules are single-sourced and can't drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The repo-automation streamline trimmed the dashboard API test deps to
`fastapi httpx`, but `python -m unittest plugin.dashboard.test_plugin_api`
imports the `plugin` package, whose __init__ eagerly loads android_tool and
desktop_tool — both of which `import requests`. Without it the test module
fails to import (ModuleNotFoundError: requests), failing CI on dev.
Restore just `requests` (the only third-party need in that chain beyond the
already-present fastapi/httpx); no need to bring back relay_server/requirements
or pytest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refines the terminal screen against Orca's mobile terminal and hardens the
relay PTY for correct TUI behavior.
Android:
- Extra-keys bar: horizontalScroll with fixed-min-width keys (labels no
longer clip), compacted to ~32dp keys / 12sp to match Orca's sizing.
- Mode-aware special keys: window.termSendKey reads xterm's DECCKM and
encodes arrows/Home/End as SS3 vs CSI; PASTE routes through term.paste()
for bracketed paste so multi-line paste no longer auto-runs.
- Compact header: custom ~52dp row replaces the 64dp TopAppBar; status shown
once inline (dot + word, ellipsized) and tappable for the info sheet. Tab
strip hidden for single-tab sessions (new-tab "+" moves to the header).
- Removed a redundant navigationBarsPadding gap below the keys; added an 8px
bottom gap in the terminal so the last row clears the key bar.
Relay:
- Terminal sessions spawn on a dedicated -L hermes-relay tmux socket with a
generated config: escape-time 0, tmux-256color + truecolor, mouse,
focus-events, set-clipboard, aggressive-resize, status off. Isolated from
the user's own tmux; persistence unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #88 (feature/native-encryption) landed without a DEVLOG entry; record the split connection model (Features vs Route) + plugin secure-proxy route.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backfills the [Unreleased] CHANGELOG bullet for PR #88 (feature/native-encryption), which landed without one: connections now split Features from Route, plus a plugin Secure proxy route.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RelayApp never imports LocalContext (every other use is fully qualified
as androidx.compose.ui.platform.LocalContext); the sphere-skin wiring
used the short form, breaking compilation. Match the file convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix the formerly hardcoded-dark chat/Manage surfaces and add real app
themes plus a pluggable agent sphere.
Brand tokens: convert the dark-only RelayRefresh object into a
snapshot-backed facade over an active BrandPalette, so the ~150 existing
RelayRefresh.X call sites repaint with the theme without edits. The
Material ColorScheme is now derived from the palette (toColorScheme),
and a new LocalBrand CompositionLocal backs new code. Flourishes and
markdown syntax highlighting across 13 files now follow the active
palette (LocalBrand.current.isDark) rather than the system setting.
App themes: ship 8 looks via an AppThemes registry — Hermes Relay
(light+dark) plus ports of the Nous Hermes dashboard baselines (Teal,
Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rose). Hybrid model: the
brand honors Light/Dark/Auto; character themes are fixed-mode. New
appTheme pref + swatch gallery in Appearance.
Hot-swappable sphere: a SphereSkin layer over the untouched core
algorithm (parity mirror preserved). Built-in Adaptive (follows theme),
Classic, Aurora, Solar, Mono skins plus user-authored JSON skins
(SphereSpec/SphereSkinLoader, data-only + validated). Reactivity
(voice/tools/intensity) is declared per skin, gated in the renderer, and
shown as capability badges. Auto-follow-theme with per-skin override.
Format documented in docs/sphere-spec.md.
Reviewed, not compiled (no SDK in worktree). gradlew lint + on-device
verify pending via Android Studio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On mobile the hero phone-preview morphing sphere rendered at ~1/3 size and
hugged the top-left of the frame. The canvas backing store (sized once in
resize() from a clientWidth snapshot, with the dpr transform) drifted from
drawSphere()'s live per-frame clientWidth reads, so the grid was drawn into a
coordinate space that no longer matched the store — and canvas drawing starts
at (0,0), hence the top-left pin. Mobile triggered it via late-resolving 88cqw
container-query width (resize() bailed on cw<=0, leaving the 300x150 default
store with no dpr transform that the truthy-width guard never retried) and via
the 88cqw->80cqw boot->chat width tween that never resized screenEl.
Add syncCanvasSize(): measure the real box with getBoundingClientRect(),
reallocate the backing store only on an actual pixel-size change (re-applying
the dpr transform), and return the css-px dims to draw against. drawSphere()
now calls it every frame and draws against that single measurement, so the
store and draw math can no longer diverge and a not-ready layout self-heals on
the next frame. Point the ResizeObserver at the canvas (not screenEl) so the
boot->chat width tween is tracked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Voice-mode replies get a Voice chip and realtime replies keep Realtime Agent; both share a speaker glyph (MessagePathBadge gained an optional leading icon). composeInjectedContext() single-sources the per-turn system_message build for both startStream (sent) and previewInjectedContext() (shown); tapping the ContextMeterBar opens InjectedContextSheet, with the gateway persona labeled server-side. loadMessageHistory now preserves provenance badges by id across the post-turn reload, also fixing the pre-existing Stopped/Error loss.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RelayErrorClassifier maps a 400 whose body names an unsupported field to a non-retryable Relay-update-needed message, distinct from a bad value such as an unsupported codec. ConnectionStatusToast composites its container over the theme surface so the floating overlay is opaque; the in-flow banner stays translucent by design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On mobile the hero phone-preview morphing sphere rendered at ~1/3 size and
hugged the top-left of the frame. The canvas backing store (sized once in
resize() from a clientWidth snapshot, with the dpr transform) drifted from
drawSphere()'s live per-frame clientWidth reads, so the grid was drawn into a
coordinate space that no longer matched the store — and canvas drawing starts
at (0,0), hence the top-left pin. Mobile triggered it via late-resolving 88cqw
container-query width (resize() bailed on cw<=0, leaving the 300x150 default
store with no dpr transform that the truthy-width guard never retried) and via
the 88cqw->80cqw boot->chat width tween that never resized screenEl.
Add syncCanvasSize(): measure the real box with getBoundingClientRect(),
reallocate the backing store only on an actual pixel-size change (re-applying
the dpr transform), and return the css-px dims to draw against. drawSphere()
now calls it every frame and draws against that single measurement, so the
store and draw math can no longer diverge and a not-ready layout self-heals on
the next frame. Point the ResizeObserver at the canvas (not screenEl) so the
boot->chat width tween is tracked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Public-facing Keep-a-Changelog entry (Added/Changed/Fixed) for the voice work
merged in #83: enhanced voice control (Gemini & xAI), render-path visibility,
spoken-output formatting, and the realtime/synthesis fixes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolved conflicts from dev's ADR 34 network package fence:
- StandardHermesVoiceClient.kt: took dev's refactored network/upstream version
(the interface/adapter/AutoVoiceAudioClient now live in network/shared +
network/relay), then re-applied the standard-voice polish (25MB transcribe
guard, 413/400 copy, MAX_TRANSCRIBE_BYTES).
- network/relay/RelayVoiceAudioClientAdapter.kt: re-applied the
enhancedOverridesProvider param (RelayApp's auto-merged call requires it).
- DEVLOG.md: kept dev's entries + prepended the voice-mode-enhancements entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- upstream-surface-matrix.md: "Voice Surfaces (standard vs. relay)" with an
explicit route-ownership table (every /voice/* route is relay-owned; only
dashboard /api/audio/* is upstream; no upstream streaming/WS audio route) and
an enhanced-voice matrix across both relay paths.
- spec.md Phase V: /voice/synthesize overrides, tts.enhanced block, and the
voice_output auto_speech_tags control.
- user-docs/features/voice.md: "Enhanced Voice (Gemini & xAI)" section, the
streaming speech-tags toggle, the settings Render-path row + Diagnostics
breadcrumb in troubleshooting, and corrected the stale ~/voice-memos note.
- DEVLOG: session entry covering the fixes, enhanced voice, and docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- VoiceViewModel: enrich STABLE_VOICE_INTERFACE_CONTEXT so the model formats
replies for speech (rides the non-persisted system_message slot, no history
pollution). ChatViewModel forces voice turns onto SSE since the gateway
prompt.submit has no system-message slot.
- Enhanced-voice UI: EnhancedVoiceOverrides + EnhancedVoiceCapabilities;
RelayVoiceClient.synthesize sends the generic override fields; provider-aware
"Enhanced Voice (<provider>)" Voice Settings card (curated dropdown for
Gemini, free-text for xAI; persona for Gemini, language for xAI).
- Streaming: VoiceOutputConfig.auto_speech_tags + updateVoiceOutputConfig
param + an "Expressive speech tags" switch in the Hermes Chat + Voice Output
card (xai_tts), persisted with the existing Save buttons.
- Render-path visibility: a per-session DiagnosticsLog entry naming the active
path (streaming /voice/output vs basic /voice/synthesize), plus a persistent
"Render path" row in the settings card derived from voiceOutputConfig.
- Standard voice polish: pre-flight 25MB transcribe guard + friendly 413/400
copy; harden the dashboard audio HEAD probe to also try /api/audio/speak.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness fixes:
- broker.py: non-native realtime-agent loop dropped playback.drained, tearing
down sessions every turn on non-native providers. Extract a shared
_handle_common_client_message dispatcher used by both loops so they can't
drift; add input_audio.clear to the non-native path. Preserves the native
loop's per-message provider_task.done() break.
- voice.py: synthesize now owns a temp output_path and deletes it after
streaming (no more ~/voice-memos leak).
- realtime_voice.py: bind the lab WS session to its creating principal
(_auth_matches_session), mirroring voice_output.py.
Enhanced voice (per-request, no fork; upstream imports isolated in
upstream_voice.py):
- /voice/synthesize accepts voice/model/audio_tags/persona_prompt/language,
mapped onto Gemini (_generate_gemini_tts) or xAI (_generate_xai_tts).
- /voice/config advertises a provider-aware tts.enhanced capability block.
- /voice/output streaming renderer honors xAI auto_speech_tags as a per-profile
voice_output: setting (threaded through config/env/YAML/settings/session/
provider_options/config_payload/PATCH, mirroring text_normalization); the
relay applies upstream_voice.apply_xai_speech_tags() per chunk. No Gemini
streaming provider in voice_lab, so Gemini enhanced voice is synthesize-only.
Tests: non-native playback.drained regression (red-on-bug), Gemini + xAI
synthesize overrides, enhanced-block + extract pure-function coverage,
auto_speech_tags PATCH round-trip, apply_xai_speech_tags call-through/fail-soft.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the agent-profiles cluster into
viewmodel/connection/ProfileController.kt:
- the merged agentProfiles list (relay auth.ok union dashboard
/api/profiles) + refreshDashboardProfiles + profile-scoped
session/message fetch
- the per-connection selected-profile state machine
(selectProfile / resolvePendingProfileFrom / pending-name resolution)
- the three persistence stores (selection / session / displayAlias,
exposed as public vals so the ViewModel's connection-lifecycle
orchestrators keep their clear/persist call sites byte-identical)
- profileDisplayAlias + activeSessionTransport + per-profile
last-session restore
ConnectionViewModel keeps its public getters/functions and delegates.
Because the profile state machine is co-driven by ViewModel-level
lifecycle observers (connection switch, active-connection change,
agent-profile arrival, gateway-availability settle), those observers
stay in the ViewModel and call profileController.* lifecycle hooks in
their original order — the orchestration stays put; only the state +
logic moved, so the state machine is now unit-testable in isolation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the upstream dashboard/gateway transport cluster into
viewmodel/connection/UpstreamTransportController.kt:
- per-connection encrypted DashboardCookieStore cache + accessors
- a single consolidated DashboardApiClient factory (was 4+ build sites)
- the cached GatewayChatClient (lazy build, mid-turn LAN/Tailscale
retarget) + gateway availability tier + sticky-Unsupported verdict
- the per-endpoint capability snapshot + chatMode, and the
streamingEndpoint-preference resolution that reads them
ConnectionViewModel keeps its public getters/functions and delegates;
rebuildApiClient pushes the probed capability snapshot via
setCapabilitiesAndMode. The @Synchronized gateway-cache lock moves with
the state (now the controller instance), preserving mutual exclusion.
Deliberately NOT moved: the HermesApiClient SSE/runs client
(_apiClient/_chatApiClient), API-server reachability/health, and
rebuildApiClient/rebuildChatApiClient — those are written inline by
several ViewModel-level orchestrators (saveStandardApiConnection,
saveApiAndProbeVoice, testApiConnection, updateApiServerUrl, revalidate)
interleaved with diagnostics + callbacks; lifting them would need a wide
mutable surface that relocates the coupling rather than removing it (per
the decomposition plan's stop-if-too-entangled rule).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the paired-devices list (GET /sessions) + management
(load/revoke/extend/revokeChannelGrant) and the insecure-ack DataStore
flags into viewmodel/connection/PairingController.kt. ConnectionViewModel
keeps its public getters/functions and delegates unchanged — a pure
mechanical lift, behavior preserved verbatim.
First step of the ConnectionViewModel decomposition (ADR 34 follow-up).
The pairing orchestrator (applyPairingPayload) stays in the ViewModel:
it is glue across the upstream/relay/connection-store collaborators, not
a cohesive unit that moves cleanly behind this seam.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Worktree-runnable plan to break the 5.5k-line ConnectionViewModel god object
into focused viewmodel/connection/ controllers (Upstream/Relay transport,
Pairing, Profiles) behind its frozen public surface, plus an optional
ChatTransportProvider seam. Behavior-preserving extraction only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prove the Android standard-path route surface exists on unmodified
NousResearch/hermes-agent — the invariant CLAUDE.md asserts but that was never
tested (the staging server runs a fork with relay routes compiled in).
- scripts/check-upstream-route-contract.py source-parses upstream's declared
routes (aiohttp add_* + FastAPI decorators): no server boot, no pip install,
no model keys. Two tiers: REQUIRED standard-path routes fail the build if
missing; mode-dependent routes (auth-gate, /api/pty, /v1/models) only warn.
Refuses to pass against our fork via a fork-marker guard.
- .github/workflows/ci-contract.yml checks out vanilla upstream with NO relay
bootstrap, asserts the checkout is vanilla, runs the contract. Weekly
schedule tracks upstream main as a drift siren; PR/push use a pinned ref.
Verified locally against the upstream clone: 12/12 REQUIRED routes present;
auth-gate routes correctly advisory (absent in the loopback-token build).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a JUnit-level architecture test (Konsist) asserting the ADR 34 package
fence on production code: network.upstream must not import network.relay and
vice-versa, and network.shared imports neither. Turns the "standard path =
vanilla upstream" invariant from a review convention into a failing test.
- Add com.lemonappdev:konsist 0.17.3 as a testImplementation dependency.
- ArchitectureBoundaryTest uses scopeFromProduction() so test-only cross-refs
can't false-fail the boundary.
- Wire it into the ci-android.yml explicit --tests list (the broad aggregate
hangs per issue #32, so the boundary test must be named or it never runs).
Verified: :app:testSideloadDebugUnitTest --tests "*ArchitectureBoundaryTest"
BUILD SUCCESSFUL — Konsist resolves cleanly on Kotlin 2.3.21.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Physically separate vanilla-upstream network surfaces from Relay additions so
changes from either side have a contained blast radius (ADR 34). No behavior
change — pure package move plus import repointing.
- Split app/.../network/ into network/{upstream,relay,shared} (main + mirrored
test sources). Upstream: Hermes/Gateway/Dashboard clients, chat payloads,
ChatHandler, session models. Relay: ConnectionManager, ChannelMultiplexer,
RelayHttp/Voice clients, BridgeCommandHandler, Envelope. Shared: connectivity/
endpoint/LAN/profile-URL utilities + the voice routing seam.
- Split VoiceAudioClient.kt three ways: the VoiceAudioClient interface +
AutoVoiceAudioClient router -> shared; StandardHermesVoiceClient -> upstream;
RelayVoiceAudioClientAdapter -> relay. Co-locating them would force one file
to import both worlds.
- Extract LocalDispatchResult to shared. The move surfaced the one real hidden
upstream->relay coupling: ChatHandler (chat) renders phone-action bubbles from
the bridge's LocalDispatchResult DTO via a same-package reference. As a passive
DTO it belongs in shared; both sides now depend only on shared to speak it.
- ChatHandler placed in upstream (not shared): per ADR 3 chat never flows through
the relay multiplexer; the handler is fed only by upstream transports.
- Update AndroidManifest GatewayKeepAliveService FQCN and the ci-android.yml
RelayUrlDeriverTest path (it moved to network.relay).
Verified: :app:compileSideloadDebugKotlin and
:app:compileSideloadDebugUnitTestKotlin both BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the decision to physically separate vanilla-upstream network surfaces
from Relay additions via three net-additive changes: a package fence
(network/{upstream,relay,shared}), a Konsist import-rule JUnit test, and a
vanilla-upstream route-contract CI job. Documents the placement calls decided
by reading (ChatHandler -> upstream per ADR 3; VoiceAudioClient.kt split three
ways) and the rejected alternatives (ConnectionViewModel transport-strategy
split deferred as too risky; custom ktlint/detekt rule deferred in favor of a
Konsist test that reuses existing JVM test infra).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bubble-growth scroll bugs (Telegram-style tail-follow without reverse layout):
- A bare isAtBottom flip from a growing streaming bubble was misread as "user
scrolled away" — it popped the scroll-to-bottom FAB and aborted auto-follow
though the user never touched the screen. Now userScrolledAway is driven only
by a genuine scroll GESTURE (isScrollInProgress falling edge); content growth
never sets that, so it can't false-trigger. Reaching the bottom re-arms follow.
- Tail-follow is now an atomic single scrollToItem(bottom) per growth instead of
the multi-frame settle loop, which collectLatest cancelled mid-settle on the
next token (~every frame) and stranded the viewport — the visible bounce.
Resume context: on a COLD resume the server's per-session token counters +
compressor are reset, so session.info reports context_used=0 until the first
turn rebuilds the prompt. Painting that would show a misleading 0% on a session
with real history, so only adopt a non-zero figure (warm resume / post-turn);
cold resumes fill on the first exchange. (Server has no pre-turn context to give.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On mobile the hero phone-preview morphing sphere rendered at ~1/3 size and
hugged the top-left of the frame. The canvas backing store (sized once in
resize() from a clientWidth snapshot, with the dpr transform) drifted from
drawSphere()'s live per-frame clientWidth reads, so the grid was drawn into a
coordinate space that no longer matched the store — and canvas drawing starts
at (0,0), hence the top-left pin. Mobile triggered it via late-resolving 88cqw
container-query width (resize() bailed on cw<=0, leaving the 300x150 default
store with no dpr transform that the truthy-width guard never retried) and via
the 88cqw->80cqw boot->chat width tween that never resized screenEl.
Add syncCanvasSize(): measure the real box with getBoundingClientRect(),
reallocate the backing store only on an actual pixel-size change (re-applying
the dpr transform), and return the css-px dims to draw against. drawSphere()
now calls it every frame and draws against that single measurement, so the
store and draw math can no longer diverge and a not-ready layout self-heals on
the next frame. Point the ResizeObserver at the canvas (not screenEl) so the
boot->chat width tween is tracked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Scroll-to-bottom FAB no longer blinks during streaming. It now hides while
we're actively auto-pinning — a programmatic scroll in flight, or
streaming-and-following (smoothAutoScroll on, not scrolled away) — since a
content burst can momentarily make the list scrollable-forward for a frame
before the re-pin. The FAB appears only once the user actually scrolls up.
- Subtle approval-bypass marker: when the server reports approvals effectively
off (YOLO toggle, --yolo, or global approvals.mode=off — all folded into the
session.info `yolo` boolean), the chat header subtitle carries a quiet amber
"⚡ approvals off" so the risk is visible without opening the agent drawer.
The loud toggle + warning stay in the agent drawer (desktop parity).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Context bar on resume: session.info carries upstream's usage block
(context_used/context_max), emitted on session resume. Parse it into a new
serverContext flow and paint the context bar immediately instead of waiting
for the first turn's usage event.
- Model switch is now ephemeral-only: drop the injected "Model switched to X"
system bubble. The pill updating is the confirmation; server warnings/errors
surface via a new transientNotice → snackbar channel (never a chat bubble,
never dropped).
- Recent-prompt chips are now a config option, OFF by default
(chatRecentPromptsEnabled in ConnectionViewModel + a toggle in Chat settings);
the composer row is gated on it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Augment the gateway surface to match the official desktop without showing stale
state. Audit found we dropped most session.info fields and fetched several server
lists once; contract verified against upstream, parallel review confirmed the new
config.set calls match exactly.
- session.info interceptor now also surfaces reasoning_effort, credential_warning,
yolo, fast → serverReasoningEffort/serverCredentialWarning/serverYolo/serverFast
flows; startGatewayStateSync gains one guarded collector each. A /reasoning change
on desktop/TUI reflects live (not just on turn-complete).
- credential_warning surfaced once per distinct warning as a system notice (dedup'd
against the constant session.info echoes, cleared when the key is fixed) — turns
with a missing provider key no longer fail silently.
- YOLO + Fast toggles in the agent sheet: config.set yolo (value 1/0, scope session)
+ config.set fast (value fast/normal), optimistic set+rollback, live state from
session.info, reset across every session/profile/connection switch. YOLO renders
loud (error caption + "Approvals are OFF" banner) and stays session-ephemeral.
- refreshSkills()/refreshModels() on agent-sheet open so server-side skill/model
changes appear without an app reload.
- review fixes: activateGatewayProfile nulls yolo/fast (missing 5th clear site);
setYolo/setFast rollback re-checks client identity after prewarm and only rolls
back if it still owns the optimistic value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 (native-Chat desktop-TUI parity) — all enhancements to the existing
Compose chat, no TUI/xterm surface:
- 2.1 Tool-call cards keep their expand/collapse across scroll-off and
re-render (rememberSaveable keyed per tool call, namespaced by the message
item key). The chevron/rail tree affordance already existed.
- 2.3 Recent-prompt recall: a soft keyboard has no up-arrow, so the composer
surfaces recent prompts as tappable chips while empty (recentPrompts flow,
bounded 15, slash-commands excluded). Tap prefills for tweak-and-resend;
hides on typing / when a queue or fresh chat shows.
- 2.5 Queue management: the queue was count-only. Each queued message is now a
row — tap to edit (pull back into composer), ✕ to drop one (removeQueuedAt /
takeQueuedForEdit). Reorder omitted.
2.2 (context bar) landed earlier; 2.4 (session picker) was already adequate.
Plan doc updated — both phases complete; only 1.7 (inline rename) deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two more mobile-ergonomics wins, completing Phase 1 of the terminal/chat
parity plan:
- Unread dots: background tabs keep rendering output (stacked WebViews) with
no signal. TabState.unreadOutput is set when terminal.output lands on a
non-active tab and cleared on selectTab; a small dot shows on the inactive
tab chip.
- Jump-to-latest: xterm onScroll reports atBottom via a new onScrollPosition
bridge method into TabState.scrolledUp; a tappable pill appears over the
terminal while scrolled up and snaps back to the live tail.
Plan doc updated: 1.1 (history) reclassified — the toolbar up-arrow already
sends ESC[A so shell-native history works; 1.6 (render parity) reclassified —
font cascade + resize contract already present, WebGL addon not vendored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wording ("server" -> "instance"), bullet spacing, and HTML-entity/link
escaping for the Play Console description. (WIP from the parallel session.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Companion checklist to the e2e UX audit, scoped from the 3-way comparison
(Android terminal vs upstream desktop TUI vs web dashboard). Phase 1 terminal
ergonomics, Phase 2 native-Chat parity (explicitly enhancement, not a TUI
replacement).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two mobile-ergonomics gaps in the remote shell. COPY reads the xterm
selection via a new window.getSelectionText() hook and commits non-empty
text to the system clipboard (WebView long-press copy is unreliable; pairs
with the existing PASTE). The new keyboard key toggles the soft keyboard via
WindowInsetsControllerCompat on the active tab, focusing xterm on show, since
tapping the terminal doesn't reliably raise the IME on phones.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Context bar: expose absolute per-session token counts (ContextWindowUsage +
contextWindow flow) from the gateway usage events, reset at all four
per-session points. Rewrite ContextMeterBar from an invisible <50% hairline
into a clean desktop-style gauge: filled bar + `NN% · used/max` readout,
color-graded green/amber/orange/red, shown whenever the server reports a
context window. Drop the redundant header "NN% ctx" suffix.
Cold-open: the effort chip (and transport-gated UI) could lag ~30s because
the dashboard probe that flips gatewayAvailability to Ready was only retried
on the 30s health tick. Add a bounded fast-probe on chat foreground while the
verdict is still Unknown, collapsing it to ~1-3s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ambient orb (MorphingSphere) and the always-on ConnectionStatusBadge
heartbeat drove the whole Compose window at the panel refresh (120Hz)
forever — even idle — which on Android 15 makes the platform log
setRequestedFrameRate every frame and wastes battery. Replace the
infinite transitions with a shared frame-throttled driver:
- New rememberAmbientPhase() runs ~30fps and parks when not running.
- MorphingSphere advances on a manual withFrameNanos loop: full-rate while
active (thinking/streaming/voice), ~30fps idle. dt-accumulation keeps the
motion speed identical.
- ConnectionStatusBadge + the two pulse banners use rememberAmbientPhase.
- Remove ComposeArrWorkaround (+ its 4 call sites): it reflected a field
`isArrEnabled` that became a hardcoded SDK>=35 method in Compose 1.11.2,
so it had been a silent no-op. The NaN log is a platform log of every
ARR vote and is not suppressible from app code; only redraw frequency is.
Measured idle: ~114fps -> ~43fps (~62% fewer draws/logs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/personality is a picker command upstream (model/skin/personality) — the
desktop/TUI never raw-forward it; a named/none value is applied via config.set,
persisted to display.personality + the live session, and echoed on session.info.
The app forwarded it to slash.exec (dead-end on mobile), had no `none` concept,
and never consumed session.info — so it kept injecting a stale per-turn persona
prompt that fought the server.
- preserve `system-notice-` bubbles across the post-turn reconcile so slash
results (incl. the disappearing /personality bubble) no longer vanish
- GatewayChatClient: serverPersonality/serverModel/serverProvider flows,
getPersonality()/setPersonality() (config.get/set), session.info interceptor
- ChatViewModel: selectPersonality() pushes config.set on the gateway and syncs
_selectedPersonality + the model pill from session.info; bare /personality and
/model intercepted as picker commands; refreshPersonalities() on sheet open so
server-supplied changes need no app reload
- startStream: gateway sends no persona/profile prompt (server owns SOUL +
overlay) — fixes profile-SOUL double-injection; SSE keeps client injection
- ConnectionInfoSheet: drop the synthetic "Default" row; show None + the
server-provided personalities (server default tagged); AgentDisplay treats
none/neutral as cleared aliases
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The post-turn server reconcile (loadMessageHistory in onCompleteCb, added in the
1.1.0 session-UX pass) ran unconditionally for gateway/sessions turns. A turn that
ends in an error has NO assistant message persisted server-side, so the reconcile
replaced [user, assistant-error] with the server's [user] — the assistant error
bubble vanished while the user message stayed (the "disappearing reply" regression;
1.0.0 didn't reconcile gateway turns, hence was unaffected).
Skip the message reconcile when the turn carries the "Error" badge (gateway ❌
lifecycle), keeping the local error visible; still refresh the drawer + drain queue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Detail views show "model · provider" (e.g. "gpt-5.5 · Codex"); the chat composer
pill stays model-only by design. Provider resolved from the live gateway current
provider via the model.options provider list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- model.options now parses authenticated / unavailable_models / free_tier /
total_models (the picker hints upstream already sends via build_models_payload).
- the picker is current-provider-first and DISABLES models the account can't use
(free-tier / no-credits → "Not on your plan") and flags unauthenticated
providers ("Needs setup") — matching the desktop picker, so a switch can't land
on a model that 400s / credits-fails (e.g. nous gpt-5.5 with no balance).
- the model pill now opens a full searchable ModelPickerSheet (CommandPalette
style: search + provider group headers + selected check) instead of the cramped
inline dropdown. The composer pill itself stays model-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- never send a generic agent alias ("hermes-agent" / "hermes_agent" / "hermes
agent") as a model on any send-path (in-chat override, gateway setModel /
reset-to-default, SSE modelOverride). The server 400s on it and falls back to
a paid model the account can't afford — the real cause of "no replies."
Resolving the alias to null sends no model, so the server uses its true
configured default. Adds AgentDisplay.requestModelName().
- surface gateway status.update lifecycle (model fallback, retries, errors) as
a live status line above the composer, and stamp an "Error" badge on a turn
that ends in a ❌ error so a failure no longer reads as a normal answer.
- Stop: firm LongPress haptic + a persistent "Stopped" badge on the cancelled
turn (was a near-imperceptible TextHandleMove + transient toast only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gradle-play-publisher reads the Play "What's new" from
app/src/<flavor>/play/release-notes/<locale>/<track>.txt, which never existed —
so the v1.1.0 Production draft uploaded with EMPTY release notes
(RELEASE_NOTES.md only feeds the GitHub Release body, not Play).
- Add app/src/googlePlay/play/release-notes/en-US/default.txt (Play "What's new",
<=500 chars; seeded with the 1.1.0 text).
- bump-android-version.sh "Next steps" now reminds to update it + adds it to the
git-add line.
- RELEASE.md section 2 documents it (separate from RELEASE_NOTES.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
High-level end-to-end UX / daily-use audit (first install -> pair -> standard vs
relay -> all surfaces -> Hermes management), benchmarked against the Hermex client
and the Hermes desktop dashboard, plus a phased fix checklist tracked as work lands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wave 1 (quick wins):
- onboarding: replace "Standard/Advanced" tier cards with capability copy
- power-feature gate: name the server-side Relay-plugin prerequisite
- destructive-verb confirm: make Deny the dominant button, Allow low-emphasis amber
- bridge safety summary: reframe counts as protections, not capabilities
- notification companion: lead with Status + grant action
- chat: send suggestion chips on tap; disable the unimplemented Auto-TTS toggle
Wave 2 (recovery & feedback):
- add RelayUiState.Expired so a revoked/restarted relay session shows
"Pairing expired — tap to pair again" instead of looping a doomed reconnect
(wired through asBadgeState/statusText and the Settings relay pill)
- method-aware pairing-verify timeout copy
- camera-permission denial falls through to manual pairing
- "Stopped" acknowledgment on cancel; "Still working…" after a slow first token
- terminal PASTE key (clipboard -> PTY)
Verified: builds (assembleSideloadDebug) and installs to device.
See docs/audits/2026-06-16-e2e-ux-audit.md and 2026-06-16-ux-fix-plan.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Registers a compact "Relay · connected/offline/unpaired" Badge into the host
dashboard's `header-right` slot via window.__HERMES_PLUGINS__.registerSlot, so
relay state shows on every dashboard page. Polls the plugin's loopback overview
every 15s, derives state, and catches all fetch errors to "offline" — never
throws in the header. Uses the host Nous DS Badge `tone` (success/warning/
secondary) directly. Manifest declares slots:[header-right] for discovery.
Built on the button/badge adapter fix in this PR; bundle rebuilt with both.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adopt four upstream plugin surfaces for easier setup/use:
- requires_env rich form: declare the optional voice-provider keys (XAI/OpenAI/
ElevenLabs) so `hermes plugins install` prompts for them with a "get yours"
link instead of hand-editing ~/.hermes/.env. Standard path needs none.
- Native install: document/support `hermes plugins install
Codename-11/hermes-relay/plugin` for tools-only setups (additive; the full
relay still uses the curl install.sh).
- /relay slash commands (status/devices/pair) usable mid-chat from any platform,
reusing existing relay logic; every path guarded.
- A minimal on_session_start hook: one 0.5s-timeout guarded /health ping,
returns None, can't slow or crash the gateway.
Verified against upstream/main plugin contract (register_command, register_hook
on_session_start, requires_env shape, plugins install subdir).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Skill docs: fix the broken `hermes-relay-doctor` command (-> `hermes relay
doctor`), dead ROADMAP anchor, stale 0.6.0/0.2.0 version samples, and the
pre-gateway "chat -> API server" framing in the pair skill.
- user-docs: gateway-first chat framing across direct-api / relay-server /
architecture pages + README; desktop tool count 9 -> 23 (computer-use marked
experimental); fixed the unsourced "v0.8.0+" requirement.
- Dev docs (relay-protocol.md, relay-server.md, relay_server/SKILL.md): same
gateway-first correction.
- plugin/dashboard/README.md: drop the leftover "Hackathon submission" section.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The host dashboard's __HERMES_PLUGIN_SDK__.components.Button is the Nous DS
button (boolean flags outlined/ghost/invert/destructive + size, NO `variant`
prop); Badge uses `tone`. The plugin passed shadcn-style `variant=...`, which
was silently dropped, so every button collapsed to the solid default
(bg-midground, near-white on this theme) with its label hidden by a
`color: inherit` reset — the "blank white boxes". Added Button/Badge adapters in
ui-shims.jsx mapping our props to the DS contract (+ theme-token fallbacks),
dropped the label-hiding reset, switched tabs/PairDialog to the adapters, and
rebuilt dist/. Generalises the #71 fix (which targeted .bg-primary while the DS
button uses .bg-midground).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Status pills are now exception-only (quiet when healthy); Power tools shows a
single state-aware "Plugin active/required/offline" badge instead of a per-card
"Relay paired" chip; Connections moved to the top, Diagnostics + Developer
options to the App section; status chips restyled to the app's translucent
language and the brand blue deepened. Also fixes the Chat-settings streaming
picker wrapping and makes the system-prompt preview reflect enabled toggles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
install.sh step 2 assumed `python -m pip` exists in the hermes-agent venv,
but venvs created by uv (the upstream default) ship no pip module, aborting
the editable install with "No module named pip". Detect a pip-less venv and
bootstrap pip via ensurepip, or fall back to `uv pip`, with a tolerant version
readback. Venvs that already have pip are unaffected. Verified against the
docker-server uv venv (Python 3.11).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings PR #73 (corrupt-keyset connect force-close fix + dashboard button
contrast fix) together with the dev branch work (per-surface release
notes, Play auto-publish, :ui-preview, dashboard rework, chat UX).
Conflict reconciliation:
- plugin/dashboard/src/styles.css: my #71 contrast rules auto-merged on
top of the dashboard rework, but that rework switched the theme to the
--color-* token convention. Updated .bg-primary / .bg-secondary /
.bg-destructive to var(--color-*-foreground, ...) (chaining the old
names + a hardcoded fallback) so they pick up the reworked theme
instead of falling through to the fallback. dist/style.css regenerated
from src via the package copyFileSync step.
- CHANGELOG.md: combined the dev Added/Changed entries with the #70/#71
Fixed entries under [Unreleased].
- DEVLOG.md: kept all three 2026-06-16 entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scoped reset ".hermes-relay-plugin button { color: inherit }" lands
at specificity (0,1,1), which outranks the host shadcn Button's
text-*-foreground utilities (0,1,0), so solid-variant buttons painted
their label in the inherited container foreground -- which on the
dashboard theme nearly matches the button background, leaving labels
unreadable. Re-assert the paired foreground colour on .bg-primary,
.bg-secondary and .bg-destructive at (0,2,0) so they win back over the
reset without !important; ghost/outline buttons and inputs keep
inheriting, which is what they want. dist/style.css re-synced via the
package's copyFileSync build step.
Fixes#71
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EncryptedSharedPreferences decrypts its Tink keyset eagerly during
construction, so a corrupt legacy keyset (the classic post-upgrade /
post-restore case, where the encrypted blob outlives the hardware
master key it was sealed against) threw AEADBadTagException straight out
of LegacyEncryptedPrefsTokenStore's constructor and force-closed the app
right after a successful pair, on both standard and relay connections.
Every accessor already healed via resetPrefs(), and KeystoreTokenStore
hides construction behind tryCreate's try/return-null, but the
directly-constructed legacy store had no such guard (AuthManager.kt:340).
LegacyEncryptedPrefsTokenStore now builds via buildPrefsResilient(),
which deletes the corrupt file and rebuilds a fresh keyset on failure.
AuthManager.store() wraps the legacy fallback in runCatching and
degrades to a new non-persistent InMemoryTokenStore if even the rebuild
fails, so token-store construction can never force-close. Confirmed
against the android-v1.0.0 stack trace: the frames resolve exactly to
AuthManager.kt:340 and SessionTokenStore.kt:260/266.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CLAUDE.md's Repository Layout showed a flat single-app tree while
settings.gradle.kts has :app, :relay-core, :relay-ui, :ui-preview, and the
quest included build. Add all of them to the layout and Key Files. The
relay-core/relay-ui/quest Quest/XR port modules are flagged [EXPERIMENTAL]
/ in-development (not shipped); ui-preview is the dev-only desktop hot-reload
harness added this session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plugin and CLI GitHub Release bodies were static boilerplate baked into the
workflow YAML. Move them to hand-written PLUGIN_RELEASE_NOTES.md /
CLI_RELEASE_NOTES.md (Summary + Added/Changed/Fixed + Install/Verify), the same
format as Android's RELEASE_NOTES.md.
- release-plugin.yml / release-cli.yml: render the notes file (sed-substituting
__VERSION__, plus __TAG__ for CLI) and pass it via body_path instead of inline
body, so install/pin commands stay version-accurate without manual edits.
- release-cli.yml publish-release: add actions/checkout (it previously only
downloaded build artifacts, so the notes file was absent).
- RELEASE.md: §2 cross-refs all three per-surface files; plugin recipe commits
PLUGIN_RELEASE_NOTES.md; CLI CI section documents CLI_RELEASE_NOTES.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Play Console reorganized its navigation — there is no longer a "Setup > API
access" group. Update §3 to the current path: create the service account +
JSON key in Google Cloud Console, then authorize it via Play Console >
Users and permissions > Invite new users with the granular Release
permissions. Verified against developers.google.com/android-publisher/getting_started.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CI: release-android.yml uploads the googlePlay AAB to Production as a DRAFT
when PLAY_SERVICE_ACCOUNT_JSON is set (stable tags only). sideload publishing
is disabled structurally via playConfigs so only googlePlay can reach Play.
- docs/worktree-workflow.md: one-worktree-per-feature mental model, Orca-manages-
worktrees note, raw git-worktree fallback, and mapping onto the main/dev contract.
- :ui-preview: JVM-only Compose for Desktop hot-reload harness (CMP 1.10.3), sharing
the platform-agnostic MorphingSphereCore from :relay-ui via a srcDir include.
- RELEASE.md (secrets table + §5 note), CHANGELOG [Unreleased], DEVLOG, .gitignore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
§5 hardcoded "Release > Testing > Internal testing" as the upload step, which is
wrong for a stable GA on a live listing. Reframe: the AAB is track-agnostic, a GA
publishes straight to Production (the D-U-N-S org account is exempt from the
closed-testing gate), and Internal/Open/Closed are opt-in channels, not a mandatory
ladder. Also corrects the Play "What's new" source (docs/play-store-listing.md,
not RELEASE_NOTES.md) and the automated-upload track flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Claude Code Review job skips the aggregate dev -> main release PR (feature
work is reviewed before landing on dev; release PRs are gated by CI + release
metadata). Detection required the title to start with "release:", but the actual
release PR is titled "Release vX.Y.Z …", so IS_RELEASE_PR was false — the full
review ran on the entire release diff and hit the action timeout, failing a
required check and blocking the release merge. Per the branching model main only
receives release merges from dev, so base==main && head==dev is the release flow;
drop the fragile title check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile main's 2026-06-12 "deploy refreshed site" snapshot (5c7d649) with dev's
continued docs rework. The 6 conflicting user-docs files (HeroDemo.vue, custom.css,
theme/index.ts, getting-started.md, guide/index.md, quick-start.md) are resolved in
favor of dev — the deliberate, newer, more-complete rechrome that supersedes the
earlier snapshot (e.g. dev's quick-start adds the API-key + QR-scan guidance;
getting-started is the reworked 492-line Google-Play-first funnel vs the 322-line
snapshot). Theme imports verified self-consistent (all 9 components present).
This unblocks the dev -> main release PR for android-v1.0.0.
Non-default agent chats forked a new session on every send. The api_server
(SSE) and gateway transports store sessions in different DBs with different id
namespaces, so a session created by one cannot be resumed by the other on a
non-default profile: api_server (api_* ids) persists to the launch state.db and
ignores ?profile=, while the gateway (YYYYMMDD_* ids) binds the profile's own
state.db. A stale api_ id resumed over the gateway 404s -> fork.
- ProfileSessionStore is now keyed by SessionTransport (GATEWAY/SSE) as well as
connection+profile, so a gateway session and an SSE session never clobber one
slot.
- saveLastSessionId buckets by the session id's namespace (the prefix is the
server's ground truth about what can resume it).
- refreshLastSessionForProfile restores the active transport's slot and defers
while the gateway probe is Unknown; a gatewayAvailability collector re-runs the
restore once it settles. A null save clears only the active known transport
slot, never mid-defer or right after a connection switch.
Also: a newly created session was missing from the drawer until a manual reload
(the only post-creation list refresh fired mid-stream, before the session was
persisted server-side). onCompleteCb now refreshes the session list after the
turn, and the drawer refreshes on open.
Verified on-device via ADB (no fork, clean resume; drawer shows new sessions
without reload). ProfileSessionStoreTest rewritten for the transport key with
slot-independence, forSessionId/forEndpoint, and clear-scope coverage; lint green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gateway/sessions post-turn reload (onCompleteCb) and its error-recovery path
reloaded the server-authoritative transcript via the bare api_server
`/api/sessions/{id}/messages` (no `profile=`). A gateway turn on a non-default
profile persists into THAT profile's own state.db, so that read 404s →
getMessages maps it to emptyList() → loadMessageHistory silently wiped the
just-finished turn (it then reappeared in the drawer, which is profile-scoped).
Route both reloads through loadSessionHistory(sid), which prefers the `?profile=`
dashboard loader on gateway connections. Default profile was unaffected.
Confirmed on-device via logcat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite DEVLOG.md as a factual, third-person engineering log: drop personal-name
attributions and AI/assistant process self-narration, and scrub real server LAN /
Tailscale IPs and the tailnet hostname to neutral placeholders. Technical content,
dates, commit refs, and the public signing-cert identity are preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CHANGELOG: condense the [1.0.0] block to crisp Keep-a-Changelog bullets
(Added/Changed/Fixed), scrub personal names from historical blocks, add the
ephemeral-vs-server-wide profile note, set the release date.
- whats_new.txt / RELEASE_NOTES.md / play-store-listing: add per-conversation
profiles; refine the Play "What's new" around the standard-vs-advanced path,
upstream no-plugin support, UI/UX, QoL, and polish (<=500 chars).
- RELEASE.md: add a "Scrub for public distribution" step to release-prep.
- CLAUDE.md / AGENTS.md (new) / CONTRIBUTING.md: codify public-repo writing
hygiene (no personal names, no private infra, no AI process narration; crisp
changelog at release-prep; depersonalized devlog).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Manage tab's "Activate Profile" sets the server's persistent default agent
(POST /api/profiles/active) for every client — distinct from the ephemeral,
per-conversation profile switch in chat. Route it through the existing confirm
dialog with copy that spells out the server-wide effect, so it can't be mistaken
for the in-chat switch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
README: feature-banner hero + screenshot gallery, Google Play marked live, lean renamed CLI section; drop the stale embedded demo video (GitHub CSP won't render external/Pages video) in favor of a link to the docs demo.
user-docs (getting-started, quick-start): defer first-time server setup to upstream Hermes docs, annotate the API/dashboard config, frame the API key as a user-chosen value, add 0.0.0.0 security notes, document the LAN-scan / manual / agent-generated-QR connect paths, and add non-technical skip-path + 'dashboard is optional' signposts.
Remove orphaned assets/chat_demo.mp4 + poster; the user-docs/public copies the docs site serves are kept.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold start showed the default agent in the header even with a profile persisted;
opening the agent sheet then fetched the profile list, resolved the persisted name
(e.g. "Gary"), and visibly snapped the header + re-scoped the chat.
Root cause: a profile selection is persisted as a NAME and only resolves once the
connection's profile LIST arrives. On a dashboard/gateway connection the relay
auth.ok list is empty and _dashboardProfiles was fetched lazily — only by the agent
sheet's LaunchedEffect — so the pending name couldn't resolve until the picker
opened. Now ConnectionViewModel calls refreshDashboardProfiles() eagerly at the end
of activeConnectionId.collect, and clears _dashboardProfiles on a connection switch
so a pending name can't resolve against the previous connection's list. The
agentProfiles collector resolves the pending name as soon as the eager fetch lands.
(Chat profile selection stays ephemeral/per-session via session.create/resume
{profile} — this only changes WHEN the list is fetched, no new server writes.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-verified against upstream NousResearch/hermes-agent (tui_gateway/server.py,
hermes_cli/web_server.py, apps/desktop). The gateway `session.list` RPC reads one
process-global SessionDB pinned to the launch profile — it can't scope per-profile
over a single socket — so the prior a1a758d approach showed the launch profile's
sessions regardless of the active profile.
Switch the drawer to the dashboard `GET /api/sessions?profile=<name>` surface (and
load each tapped session's transcript via `…/{id}/messages?profile=<name>`), which
opens that profile's own state.db directly — exactly how the official desktop
sidebar scopes, same id-space the gateway resume reads. Without the messages half,
opening a non-default profile's session would render empty.
Also fixes the switch UX + adds the picked QoL polish:
- activateGatewayProfile no longer calls createNewChat() — the profile-context
switch already cancels the in-flight turn and resets the thread; the second reset
raced it (the "reply typing, then a new chat appears" jank).
- A: empty chat reads "Chat with <Agent>" + the agent's description (desktop intro).
- B: leading delay(160) in the profile-context effect coalesces the lastSessionId
null->value churn, skipping the intermediate empty paint on a switch with history.
- C: updateSessions preserves the active optimistic row past the min_messages=1
refresh; sendMessageInternal stamps a new chat's drawer row with the first message.
- D: drawer shows a spinner instead of flashing "No sessions yet" while loading.
Removed the misleading gateway listSessions() + its test; added DashboardApiClient
listSessions/getSessionMessages request-shape tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sessions are profile-bound (each in its profile's state.db), but the drawer
listed via the api_server /api/sessions, which reads ONE shared DB with no
profile concept (verified upstream: _handle_list_sessions takes only
limit/offset/source). So the drawer couldn't scope to a profile.
Match the desktop: add GatewayChatClient.listSessions() → the `session.list`
RPC (the call the desktop session picker uses), which reads the active
profile's own DB and so returns only that profile's sessions. refreshSessions()
now routes through it on gateway connections (api_server /api/sessions stays the
SSE / fallback path), so the drawer re-scopes to the active profile's
conversations and switching a profile shows that agent's sessions.
Test: listSessions parses the gateway session list into SessionItems.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous attempts (config.set {key:"profile"}, then setActiveProfile) were
wrong: the gateway rejected the config key, and the dashboard's active-profile
route doesn't touch a live gateway session — so the header read the new profile
while the running agent still answered as the old one.
Verified against upstream tui_gateway: a profile is a FULL agent (its own
HERMES_HOME/state.db, model, SOUL, personality, skills); sessions are
PROFILE-BOUND (the agent is built once at session.create from the session's
profile and a live session never adopts a new one); there is no profile-switch
RPC — the desktop passes `profile` on session.create / session.resume.
So:
- GatewayChatClient carries the selected profile on session.create AND
session.resume via a live sessionProfileProvider (wired by ChatViewModel from
the selected-profile provider), so a session is built as that agent.
- activateGatewayProfile drops the old session and starts a fresh chat — the
next session.create binds the new profile, so the agent actually becomes it.
- Removed the wrong GatewayChatClient.setProfile (config.set / setActiveProfile).
Tests: session.create binds the selected profile; omits it when none selected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Loading dashboard profiles into agentProfiles regressed the header: a dashboard
profile's description is a verbose SOUL summary ("Builds and maintains…"), and
two paths surfaced it in the agent-name slot.
- effectiveProfile no longer falls back to the advertised "default" profile, so
with no explicit pick the main agent's name comes from the personality
("Victor") instead of the default profile's summary.
- profileDisplayName is now name-first: the profile NAME goes in the name slot;
the description is only a blank-name last resort. A selected profile shows its
name, not its summary.
Also drop the avatar's customized accent ring: the avatar letter already swaps
to the active agent, so the ring was a redundant overlay (and it read as
offset, drawn on a separate gapped box). The avatar is now a plain circle whose
letter swaps. Removed the now-unused `customized` flag + `border` import.
Tests updated: effectiveProfile returns null without an explicit pick; agentName
uses the profile name even when a verbose description exists.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Profile hot-swap key was wrong: the gateway's config.set has no `profile`
key (it answered "unknown config key: profile"), unlike `model`. Switch
GatewayChatClient.setProfile to the dashboard POST /api/profiles/active
(setActiveProfile) — the route Manage and the official desktop use; the live
gateway session adopts the new active profile on its next turn. Dropped the
now-wrong config.set unit test (the route is covered by
DashboardApiClientTest.profileActions_useActiveAndDeleteRoutes).
- Top-bar subtitle: show a NON-default personality BEFORE the model
("Catgirl · gpt-5.5"); the default personality is implied, so it's just the
model. The primary line stays the agent name (unchanged).
- Profile cards cleaner: the profile NAME is the headline, the friendly
description + model share one subtitle, and the verbose "profile: … ·
compatibility overlay · active" caption is gone. Status stays visible — a
prominent "Active" badge on the running profile (plus the green dot), and the
relay-specific Overlay/API badge is dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent sheet's Profile section sourced only the relay's auth.ok profile list,
which is empty on a dashboard-only (non-relay) connection — so the host's actual
Hermes agent profiles (the ones set via Manage → Profiles, like the official
desktop) never appeared. Load them from the dashboard instead:
- DashboardApiClient.listProfiles() — GET /api/profiles, deserialized straight
into the shared Profile type (the @SerialName fields already match the JSON).
Tolerant of the array ({profiles:[…]}/{items:[…]}) and object-map
({profiles:{name:{…}}}) shapes; a sparse row gets name (map key) + empty model
injected rather than failing the list.
- ConnectionViewModel: _dashboardProfiles, merged into agentProfiles as
relay.ifEmpty { dashboard } (relay-paired connections unchanged), plus
refreshDashboardProfiles(); the agent sheet refreshes it on open.
Because dashboard profiles map into the existing Profile type, the Profile
dropdown, selectProfile, the top bar, and the config.set {key:"profile"}
hot-swap all work unchanged — and the picked profile being in the list dodges
the resolvePendingProfileFrom reset.
Tests: listProfiles parses array + object-map shapes into Profiles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent sheet rendered all three lists in full, so a server with many
personalities or models pushed Session/stats far down. Add CollapsiblePickerSection
— a tappable header (SectionLabel + current value + chevron) that collapses its
option rows by default and expands on tap — and wrap the Profile, Personality,
and Model sections in it. The rich rows (SOUL/skills badges, provider-grouped
models, runtime dots) are unchanged; they just live behind the header now, so
the header reads "Personality — Catgirl" until expanded.
Pure wrap, no row rewrite — zero behavior change beyond render-on-expand.
Compile + lint + assemble green; on-device layout pending review.
Also: CHANGELOG/DEVLOG entries for this and the profile hot-swap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Selecting a gateway profile did nothing to the agent: selectProfile only set
client state + rebuilt the SSE client, and the gateway's bare prompt.submit
carries no profile, so the running agent kept the server's active profile.
(SSE turns were fine — they send the profile per-request as profileName.)
Mirror the verified model switch: GatewayChatClient.setProfile(name) dispatches
config.set {key:"profile", value, session_id} — the session-scoped path, so the
live session's agent (SOUL + model + skills) hot-swaps in place with no new
session and no lost context, matching the official desktop's clean profile
swap. ChatViewModel.activateGatewayProfile() wires it (mirrors selectModel):
prewarm → setProfile → "Switched to <profile>" notice (a failed/unknown key
surfaces as an error, not a silent no-op) → refresh model.options so the picker
reflects the profile's model. The agent-sheet profile rows call it alongside
the existing selectProfile state update.
Test: setProfile hot-swaps the live session via config set asserts the RPC
shape (key=profile, value, session_id=live-1). The exact upstream key mirrors
_apply_model_switch; live behavior to be confirmed on-device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WhatsNewDialog pasted the raw whats_new.txt into one Text, so bullets showed as
literal "*" and the Chat/Manage/Voice/Polish section headers had no emphasis.
Parse the format instead — version line -> primary subtitle, blank-separated
sections -> bold headers, "* " bullets with indented continuations -> real "•"
bullets with hanging indent and spacing. Same source file (also the Play
"What's new" field); only the in-app rendering changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Open/save: tapping an image in chat (generated/inline assistant image OR an
inbound attachment) opens a full-screen viewer — pinch-zoom/pan, double-tap
1x/2.5x, Share/Save/Close. Save lands in Pictures/Hermes-Relay with no storage
permission on API 29+ (MediaStore scoped storage); pre-Q and any failure path
fall back to the system share sheet. Non-image attachment cards gain a
long-press Open/Share/Save menu (files -> Download/Hermes-Relay); tap still
opens externally. Saves preserve original bytes (read back from the cached
content:// or base64, never a re-encode); a magic-byte sniff fixes the
extension for remote images that arrive without a usable content-type (also in
stageForShare, so a shared image is named .jpg not .bin).
New: util/MediaSaver.kt (save/share/open + remote fetch + sniff),
ui/components/ChatImageViewer.kt (viewer + ChatImageViewerSource decoupling
Coil-model/bitmap display from a suspend bytesProvider). Wired into
ChatImageContent (remote inline) and InboundAttachmentCard (attachment image +
file-card menu).
Cold-start flash: the chat empty-state briefly showed the loud "Connect to
Hermes" CTA during launch while ConnectionStore hydrated DataStore async (an
empty store and a not-yet-loaded store were indistinguishable). Added
ConnectionStore.isHydrated -> ConnectionViewModel.chatConnectState
(Connecting/Ready/NeedsConnection, seeded Connecting); the empty-state shows a
quiet "Connecting to Hermes..." spinner (with a "Manage connections" escape
hatch) until hydration confirms nothing is configured, only then the CTA.
Verified e2e on-device (gpt-5.5 echoed a picsum image -> rendered -> tap ->
viewer -> Save wrote sunset.jpg + toast; share sheet reads "1 image";
cold-start shows no connect flash). lint + assemble green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lands the gateway desktop-parity wave files that the prior integration
commits referenced but left untracked, so the tree builds consistently.
- Keep connected in background (opt-in, both flavors): GatewayKeepAliveService
(specialUse FGS holding the process up so the gateway socket survives
background/Doze) + GatewayKeepAlivePrefs (shared KEY_GATEWAY_KEEP_ALIVE +
setter); declared in the main manifest so googlePlay ships it too. Driven by
the Chat Settings toggle; MainActivity hands consent before startForeground.
- Turn latency tracing: TurnLatencyTracer emits one durations-only TurnLatency
INFO line per turn (warm/cold connect/session/submit/ttfe/ttft/done) across
the gateway + 3 SSE paths for desktop-comparable diagnosis.
- Slide-down status + update toasts: ConnectionHandoffBanner / UpdateBanner
become floating overlays (swipe-to-dismiss, status-bar inset) instead of
banners that pushed the UI down.
- Gateway carries no phone-context preamble: PhoneStatusPromptBuilder note +
the gateway path keeps prompt.submit bare (preamble persisted into the
transcript and was visible from desktop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The model picker showed only `hermes-agent` (the api_server /v1/models generic
alias) and a tap reported a spurious "/model failed: not a quick/plugin/skill
command" even though the switch applied. Both are now fixed to match the
upstream desktop/TUI picker:
- SOURCE: fetch the curated provider/model list from the gateway `model.options`
RPC (the same source the desktop picker uses) — real models grouped by
authenticated provider: x-ai/grok, openai/gpt-5.5, anthropic/claude-opus-4.8,
google/gemini, etc. Falls back to /v1/models + profile models on SSE. Rides
the live socket (after a gateway turn / when Ready / on picker open), never a
cold /api/ws open for metadata.
- DISPATCH: switch via the gateway `config.set {key:"model", value:"<model>
--provider <slug>"}` RPC (the `_apply_model_switch` path) instead of the
`/model` SLASH path, whose `command.dispatch` fallback reported the spurious
failure. Now shows a clean "Model switched to <model>." notice (+ any
provider warning).
- UI: the Model section renders provider→model groups (provider name header +
model rows) like the desktop two-stage picker, flattened into the agent sheet.
Verified on-device: picker lists grok / gpt-5.5 / claude / gemini by provider;
tapping openai/gpt-5.5 switched the session (session.info model=gpt-5.5
provider=openai-api) and showed "Model switched to openai/gpt-5.5." with no
failure card.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Model switching (b):
- GET /v1/models -> in-chat Model picker in the agent sheet (alongside
Profile/Personality), augmented with the configured profiles' models since
/v1/models often collapses to a single generic alias.
- Picking a model dispatches `/model <name>` on the gateway (surfacing the
model-info confirmation card) and sets a per-turn override for SSE; "Server
default" clears it. Gateway is warmed first so a pick before the first turn
of a session still has a live session for slash.exec.
- Verified on-device: picker renders, tap switches the model + shows the
confirmation.
Gateway route-following (c):
- The gateway client's dashboard target is now mutable: on a SUSTAINED mid-turn
route switch (LAN->Tailscale), activeGatewayChatClient RETARGETS the
in-flight client (reconnect via the new route, keep the live session id) so
the turn follows the route instead of being stranded on the dead one. The
resolved API URL is a key on the gateway-client effect so the retarget
actually fires on a route change.
- Verified on-device: forced sustained Wi-Fi drop -> 'gateway route changed
mid-turn - retargeting active client to follow the route' -> reconnect via
Tailscale keeping the session, turn NOT cancelled, UI not wedged.
- A fresh socket can't replay an in-flight turn's events (upstream
session.resume doesn't reattach), so after a retarget the turn gets a short
30s settle instead of the full 180s watchdog; the reconcile-on-error then
recovers the server's answer. Full live-follow needs an upstream
resume-reattach / per-socket subscription.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generated/inline images now render in chat instead of a blank element:
- Add Coil 3 (coil-compose + coil-network-okhttp) with an explicit singleton
ImageLoader (OkHttp fetcher) so http(s) image URLs load reliably.
- Parse markdown image links () out of assistant content and
render them: remote http(s) URLs load via Coil with loading/error states;
a server-local path (or a load failure) degrades to an inline notice that
explains WHY it can't be shown (with the path / tap-to-open), rather than
the empty space the markdown renderer produced for .
- The image-link token is stripped from the markdown body so it doesn't
double-render; surrounding prose is preserved.
Scroll: add a small slop to the chat list's at-bottom check so a burst of
streaming content (or a sub-frame layout gap before the auto-follow re-pins)
doesn't read as "user scrolled away" and drop the Telegram-style follow.
Note: image rendering compiles + Coil resolves; on-device visual check is
pending (device was locked during the autonomous run).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mid-turn network handling was cancelling or losing gateway chat turns:
- session.resume mints a NEW live session id + fresh agent upstream, so the
old "rejoin via resume" orphaned the running turn (its thread keeps
emitting on the OLD id). Reconnect the socket only and KEEP the live
session id; retry with backoff up to 20s instead of giving up in ~24ms.
- A transient Wi-Fi blip marked the active endpoint unreachable and switched
routes (LAN->Tailscale) mid-blip, rebuilding the chat client and
cancelling the turn. Defer the loss reaction behind a 6s grace, add
endpoint hysteresis (don't switch DOWN in priority on a transient probe
miss), and stop route-change rebuilds from cancelling an in-flight gateway
turn: activeGatewayChatClient keeps an active-turn client, updateApiClient
skips gateway turns, and the route-driven rebuild is deferred while a turn
streams.
- Reconcile server history on error too, so a turn that fails on the client
after the server finished it still surfaces the answer.
Chat UI:
- Suppress the empty timestamp-only assistant bubble (a message carrying
only thinking/tool calls, both rendered outside the bubble).
- A transport failure no longer wedges the composer in "streaming" behind a
dead Stop button; the cancellation flag is reset at each new turn and the
streaming UI is finalized even on a swallowed cancel.
Test: rewrote the mid-turn rejoin test to assert the real no-resume
recovery (tail on the original session id) instead of the prior
resume-based assumption. Verified e2e on-device via forced Wi-Fi drop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-shot the chat screenshot on-device. The old capture showed the
previous footer (separate "/" slash button + mic glyph). The new one
shows the redesigned input bar — pill field, one morphing trailing slot,
GraphicEq waveform voice glyph, no slash button — in the proven
uptime/memory demo, alongside the live "Thought process" thinking cards
and a terminal tool card. Same 1080x2244 framing (top 96px status bar
cropped) as the other assets/screenshots.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- getting-started.md: replace the flat wall of setup commands with a
three-step funnel (install -> point at Hermes -> connect). The
Get-it-on-Google-Play badge is the primary install action; all server
setup, sideload install + SHA256/cert verification, dashboard auth, and
build-from-source detail is preserved behind collapsible details blocks
and OS code-group tabs so new users aren't scared off.
- Add a self-hosted Google Play badge SVG and a reusable <StoreBadge>
component (registered globally), also slotted into the home hero.
- HeroDemo: rebuild the phone-mockup input bar to the redesigned chatbar
(no slash button, one morphing Send/Voice/Stop trailing slot, GraphicEq
waveform voice glyph).
- chat.md: document the new input bar, steering, edit-and-resend, the
context meter, subagent lanes, interactive ask cards, turn-complete
notifications, and the gateway mobile-preamble behavior.
- Normalize "Hermes Relay" -> "Hermes-Relay" in phone-control-tools/voice.
- CHANGELOG + DEVLOG entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The phone-context block (PhoneStatusPromptBuilder.buildPromptBlock) was
forwarded only on the SSE/runs/sessions paths via system_message. The
gateway's prompt.submit is bare text (no system slot — verified upstream),
so when the gateway transport is auto-preferred (Manage signed in) the
agent stopped receiving any phone context.
Add buildGatewayPreamble(), which returns just the non-sensitive mobile
preamble gated by the app-context master toggle, and prepend it to the
gateway wire text as "[preamble]\n\n<message>" — guarded to skip slash
commands (a prepended "/cmd" no longer starts with "/" and would break
server-side slash routing). The local user bubble and session title keep
the clean message; only the persisted wire copy carries the marker. The
richer bridge/permission/safety block stays SSE-only and on the
android_phone_status tool, to avoid bloating every persisted user turn.
Also normalize the product name to "Hermes-Relay" (hyphenated) in
user-facing app strings; bare "Relay" now only ever means the relay
server/plugin component.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire contracts verified against upstream tui_gateway source (spec workflow,
file:line evidence). GatewayChatClient gains: session.steer (Queued/
Rejected/Failed — only accepted mid-tool-batch); the four ask-response
RPCs (clarify/sudo/secret request_id-keyed, approval session-scoped;
secrets/passwords never logged); image.attach_bytes uploads between
session establish and prompt.submit (60s timeout, one legacy
image.attach.bytes fallback on -32601, per-socket name memory; upload
failure → preflight fallback, prompt never submitted); commands.catalog
(per-socket cache, connectIfNeeded gate so composition never cold-opens
sockets) + slash.exec/command.dispatch with JSON-RPC error codes
surfaced; truncate_before_user_ordinal on prompt.submit; ask-aware turn
watchdog (a blocked clarify produces 300s of legitimate event silence —
the flat 180s watchdog was killing the turn and force-denying the ask).
Mapper: tool.generating pre-mints synthetic preparing tools adopted by
the next tool.start (per-name FIFO); five subagent.* cases →
GatewaySubagentEvent; asks re-shaped into structured GatewayAsk
(requestId preserved; approval has none by contract); usage gains
context_used/max/percent. GatewayTurnCallbacks members are REQUIRED —
the compiler forces dispatchOn main-thread wrapping for every addition.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tool cards required an app restart to appear after a gateway turn: live
tool events are gated server-side by display.tool_progress (off on
Bailey''s host — the same key that silences tool-progress spam on chat
platforms; default installs emit, which is why upstream desktop shows
live cards), and the gateway branch skipped the post-turn history reload
the sessions path has always done.
Gateway turns now reload server-authoritative messages on
message.complete — tool cards + persisted reasoning appear immediately
after the reply regardless of the server''s live-event config, and events
lost in a mid-turn rejoin gap are recovered the same way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Caching audit (Bailey): tool calls already persist server-side and
reconstruct on history load, but per-message reasoning — which the server
also persists — was dropped during rehydration, so Thought-process blocks
existed only for the live turn and vanished on returning to a chat.
MessageItem now parses reasoning/reasoning_content and loadMessageHistory
restores it into thinkingContent. Server session DB stays the single
source of truth (no client-side store) — the gap was a dropped field, not
a missing cache layer.
Timestamps: right-aligned h:mm a on the ThinkingBlock header (hidden
while streaming) and on ToolProgressCard merged with duration
("3.1s · 5:32 PM"), matching the time message bubbles already show.
History-restored tool calls fall back to the parent message timestamp
(the OpenAI wire format has no per-call clock).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two mid-session "Software caused connection abort" drops on-device today
(Samsung Wi-Fi power-save/roam), one of which killed a turn 90s into its
reasoning phase. The server keeps generating through a disconnect (orphan
reaper holds the session), and tui_gateway rebinds emits to the new
transport on session.resume — the same recovery the desktop TUI uses.
Socket loss with a turn in flight now triggers a bounded rejoin (max 2
per turn): fresh ticket, reconnect, session.resume, stream continues on
the new socket. Reentrancy-guarded so a connect failure inside a rejoin
cannot spawn a second one; cooldown is bypassed for active turns. Rejoin
failure surfaces the stream error as before.
Tests: mid-turn close → rejoin → completion on the new socket (fresh
ticket asserted); unreachable rejoin → stream error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tool cards did not render on a gateway turn and the only way to localize
it was reading log absences. Log every gateway event SSE-style: delta
types log length only, everything else logs a 300-char payload excerpt —
one tool-calling turn now shows definitively whether tool.start arrives
(client issue) or never leaves the server (display.tool_progress config /
agent callback path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two on-device regressions surfaced by gateway-speed deltas:
- Double typing dots: ChatScreen rendered a standalone StreamingDots
item below the list on top of MessageBubble''s in-bubble dots. The
bubble keeps its dots; the outer item is gone (Telegram-style single
indicator).
- Bottom-pinned stutter during live thinking: the auto-follow ran
animateScrollToItem per delta under collectLatest. At gateway token
frequency (vs SSE''s ~190-char bursts) that is a cancel/restart storm —
every cancellation strands the viewport mid-animation on earlier
content before the next delta yanks it back. Same-turn growth now pins
the bottom instantly (scrollToItem); the animation is reserved for
discrete new-bubble appends. Trailing spacer no longer animateItem()s —
its position shifts on every delta of the bubble above it and a
constant 8dp gap gains nothing from placement animation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-device verification had to infer the transport from the ABSENCE of
SSE logs — the gateway happy path was completely silent. One line on
/api/ws ready and one per submitted turn (with the stored session id)
makes logcat show positively which transport served a send.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- CLAUDE.md: new first Key Instruction — the Standard (no-plugin) path
must work against unmodified upstream hermes-agent (Google Play users;
server-side needs go through upstream PRs or the relay plugin). Noted
the /api/ws event-richness gap (tui_gateway is the only surface with
live reasoning.delta) and added Key Files entries for the three new
gateway files.
- CHANGELOG: [Unreleased] entry for the gateway chat transport.
- DEVLOG: session entry — latency diagnosis (49–71s reasoning dead air),
upstream surface verification, what shipped, bugs the tests caught,
deferred follow-ups.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live thinking lands: with Manage signed in, "auto" now resolves chat to
the gateway transport and reasoning.delta streams into the existing
ThinkingBlock + sphere Thinking state during the previously-dead
reasoning window. Standard-path constraint holds — vanilla upstream only,
no server changes.
- ChatViewModel: activeStream retyped EventSource? → ActiveTurnHandle so
all cancel/teardown sites are transport-agnostic; SSE dispatch
extracted to dispatchSse() and the gateway branch falls back to it per
turn (no client wired / attachments — prompt.submit is bare text /
preflight failure). "sessions" fallback degrades to "completions" when
no server session exists. Voice-intent/card synthetic traces stay
unsynced on gateway turns. Interactive asks (clarify/approval/sudo/
secret) render as a SYSTEM notice via ChatHandler.addSystemNotice —
display-only (desktop CLI v0.1 precedent), never spoken by voice.
- ConnectionViewModel: GatewayAvailability piggybacks on the standard-
voice dashboard probe (/api/status + /api/auth/me — no ticket-burn);
sticky markGatewayUnsupported() on WS-upgrade rejection, reset on
connection switch; gateway client cached per (connection, dashboard
URL) sharing the Manage cookie store; resolution delegated to the pure
resolveStreamingEndpointPreference().
- RelayApp: gatewayAvailability keys the endpoint-resolution effect so a
mid-session Manage sign-in flips auto → gateway without a restart.
- ChatSettingsScreen: 5th endpoint option "Gateway" + sign-in hint row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Newline-delimited JSON-RPC 2.0 over OkHttp WebSocket against the upstream
tui_gateway surface, authenticated with a FRESH single-use ws-ticket per
connect attempt (DashboardApiClient.requestWsTicket — shares the Manage
tab cookie session).
- Connect: 2-attempt loop (stale pooled connections can poison the first
try after a server restart), gateway.ready handshake gate, 5s failure /
300s rate-limit cooldowns, sticky onGatewayUnsupported on 404/403
upgrades.
- Turns: sendTurn() resumes the stored session id (session.create
fallback rotates it via onSessionId), prompt.submit, 180s watchdog
reset on every event, cancel → best-effort session.interrupt.
onPreflightFailure fires only when nothing started server-side, so the
caller can re-dispatch the turn on an SSE endpoint.
- Lifecycle: lazy connect on first send, 30s grace close after app
background (server parks sessions in its orphan reaper; resume picks
them back up), no background reconnect loops.
- onClosing acks peer-initiated close frames — OkHttp does NOT do this
automatically, and without the ack the socket sits half-closed for the
~60s close timeout, stalling reconnects.
- Tests: MockWebServer WS harness — handshake order, fresh ticket per
reconnect, resume→create fallback, foreign-session drop, cancel →
interrupt, mid-turn socket loss → stream error, preflight fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Foundation for the Gateway chat transport (upstream tui_gateway JSON-RPC
over the dashboard /api/ws — the surface hermes-desktop speaks, and the
only vanilla-upstream surface streaming reasoning live).
- GatewayModels: GatewayAvailability, GatewayConnectionState,
ActiveTurnHandle (transport-agnostic turn cancel), GatewayTurnCallbacks,
and pure resolveStreamingEndpointPreference() — "auto" prefers gateway
when the dashboard probe says Ready.
- GatewayEventMapper (pure JVM): per-turn event→callback mapping.
reasoning.delta/thinking.delta stream into the existing thinking UI;
message.complete backfills text/reasoning when nothing streamed and
translates tui_gateway usage keys (input/output/total — NOT the SSE
input_tokens scheme); unknown event types are silently ignored
(forward compat); synthetic FIFO tool ids when tool_id is absent;
interactive asks surface via onInteractionRequest.
- Tests: full mapping table as fixtures + resolution matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HeroDemo.vue rewritten as a ~20s looping recreation of the app: DOM chat
chrome over a canvas running the real preview/web/sphere.js algorithm,
driven through the product state machine (boot gate -> typed prompt ->
execute_code card with toolCallBurst -> streamed answer -> idle).
- Sphere tween rig runs on a monotonic clock (looped scene time fed the
tweens a negative elapsed at every wrap; smoothstep extrapolation
slammed char indices to the ramp floor - rings of periods through the
eye). shadowStrength 0 to match the app's pearl shading.
- Header/navbar 1:1 with the live app: hamburger, light avatar, filled
LAN pill, separate share / code / tune buttons, navy active tab.
- ?demoT=<seconds> scrubber freezes any timeline point for review and
headless capture; reduced-motion gets the completed scene statically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Programmatic re-capture on S25 Ultra (demo mode, 96px status-bar crop in
post): 8 fresh 1080x2244 stills and a new 47s chat demo video + poster,
replacing the outdated set in assets/ and user-docs/public/. Removes the
orphaned foreground_service_demo.mp4 (23.5MB, unreferenced).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Camera bug #2: "loading conversation" varied ~6-28s against the same
LAN server. Structural cause: the API health loop is a flat 30s ticker,
so one transient checkHealth() miss (cold-start race with the route
resolver, Wi-Fi settling, mid-route-swap) parked apiServerReachable
false for a full tick -- the gate holds, the 12s backstop dumps to the
CTA, chat heals at the next tick (the ~28s tail; the rest of the
variance was the one-time keystore hint priming after the reinstall).
- Bounded fast-retry burst: on a transition INTO Unreachable, three
quick re-probes (2.5s/5s/7.5s), re-armed only by a Reachable verdict.
StateFlow dedup makes repeat failures un-retriggerable; a genuinely
down server fails one burst and settles back to the 30s cadence. The
2-consecutive-failures route-re-resolve escalation is untouched.
- Requested diagnostic: when the 12s backstop (not readiness, not a
settled error) opens the startup gate, DiagnosticsLog records a
Warning naming the unmet conditions (chatReady / historySettled /
narration stage / health / route) so future variance is explainable
from Settings -> Diagnostics instead of needing a camera.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bailey still caught a blink of "Connect Standard Hermes" between sphere
exit and full chat. Root cause: the gate''s happy path keyed on
startupApiUp (which accepts the route resolver''s early HEAD /health
evidence), while ChatScreen renders its connect CTA from chatReady
(chat client built + client-based reachability verdict) -- a strictly
later signal. The gate could release with narration done and history
settled while chatReady was still false, exposing the CTA during the
fade until the health verdict landed.
The happy clause is now chatReady && initialChatSettled &&
narrationComplete, and the "conversation" check row''s Done is keyed on
chatReady too -- the narration cannot finish, and the gate cannot
release, until the exact signal the revealed surface renders from is
true. Resolver evidence still drives the route/hermes narration rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also carries the concurrent docs session''s user-docs commit (176fc7f),
which landed on this branch via the shared working tree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Narration choreography: check rows resolve strictly top-to-bottom,
each holding an ASCII-spinner beat (>=350ms) before its verdict lands
-- with the key-less fast path every signal can be true before the
sphere fades in, and an all-checkmarks-at-once reveal read as
"nothing was actually checked". The gate''s happy path waits for the
narration to finish (~1.5s); error and timeout releases don''t.
- System splash blend: dark_background was still the pre-cockpit
#1A1A2E -- now #08090D (= RelayRefresh.Background) so the OS splash
and the sphere screen read as one continuous surface; splash_blank
was a pathless vector that OneUI treats as invalid (falls back to
drawing the launcher mark -- confirmed in the adb capture) and now
carries a real fully-transparent rect path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Rechrome VitePress theme to the RelayRefresh cockpit palette: navy-black
base, warm-white ink + alpha hairlines, electric-indigo accent, grid/dot
home texture, warm-paper light mode; swept hardcoded old-palette colors
from HermesFlow/HermesFlowNode/HeroDemo/ExperimentalBadge/FeatureMatrix
- Reposition marketing: hero "Runs on your machine. Lives on your devices.",
quick-path-first funnel ("Just connect" no-server-install card above the
"Give it hands" relay-plugin power path), SurfaceCards + HowItWorks
components slotted into the home layout, benefit-led feature cards
- Rename "Desktop CLI" -> "CLI" across copy (binary is host-agnostic; path/
track rename deferred to code refactor); Windows-today / macOS-Linux-soon
status on every availability claim incl. hero subtext; drop "self-hosted"
qualifier in favor of plain "Hermes agent"
- desktop/index.md re-led with the remote-hands story; tray/chat copy
rescoped (chat & management belong to hermes-desktop); modes table
reordered Tools/Daemon first
- Sidebar: add voice, voice-intents, phone-control-tools, relay-server,
flavor-differences (existing pages previously unreachable); bump stale
version pins (app 0.8.1, desktop alpha.18)
- SphereMark: fix gaze drift/snap by pinning lightAngleBlend to exactly 1
(partial blends leak the unbounded natural light angle), ambient life
moved into proximity-eased fbm wander, mouse-only pointer tracking,
occlusion halo over the home dot grid, larger + tighter mobile sizing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(DEVLOG also carries the concurrent docs session''s updated marketing
paragraph; its user-docs files land separately.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measured on-device (S25 Ultra, wireless adb, timestamped screencaps +
logcat): the resolver verified the LAN route at +0.5s, then the app sat
behind a continuous wall of serialized StrongBox keystore operations
(~550ms each, keystore2 watchdog firing every second) until +15.1s,
when AuthManager init finally decrypted the store -- whose only finding
was "there is no API key". rebuildApiClient() awaited getApiKey(), so
the API client, health probe, capabilities, and chat restore all queued
behind 15 seconds of crypto, and the startup gate's 12s backstop fired
first, revealing disconnected chat.
- New plain-SharedPreferences hint (api_key_present, boolean only --
never key material) written by setApiKey/clearApiKey and converged in
AuthManager init after the real decrypt. apiKeyForClientBuild() skips
getApiKey() when the connection is known key-less; used by the
cold-start DataStore collector, rebuildApiClient, and
rebuildChatApiClient. Default is "assume present => wait", so a
missing/stale hint can only reproduce the old slow path, never strip
auth off a keyed connection.
- Startup gate: a published activeEndpoint now counts as hermes-online
evidence -- the resolver only publishes a winner after a successful
HEAD /health on that route, which lands ~1s in; the narration no
longer sits on "contacting hermes..." waiting for the client-based
probe to repeat the same check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(DEVLOG also carries the user-docs marketing-reposition entry written by
the concurrent docs session; its files land separately.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Startup gate rework. The splash gate released on the FIRST Unreachable
health verdict (often a probe against the persisted URL moments before
the route resolver landed) and the sphere force-hid itself at 5.5s
regardless of progress -- cold starts played out as a slideshow:
disconnected "connect" CTA, then connected, then the conversation. Now:
- Happy path: gate holds until the server answers AND the last
conversation is restored (new one-way initialChatSettled latch in
ChatViewModel, set on every conclusion path of switchProfileContext;
history fetch wrapped in try/finally so a throw cannot strand
isLoadingHistory or the gate)
- Error path: an Unreachable verdict must survive a 3s settle window
before it releases; the normal UI then owns offline presentation
- Backstop: 12s timeout that RELEASES the gate instead of yanking the
sphere out from under an unfinished startup
- Terminal-style check lines narrate progress at the sphere's bottom
(state restored / route / hermes online / conversation), all rows
always laid out so the column never reflows
Also: Terminal and Settings TopAppBars gain the standard back arrow
(both are pushed destinations with no back affordance), including
Terminal's PowerFeatureGate variant; RelayStatusStrip margins tightened
(top 2->3dp, bottom 8->4dp).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two halves of "fully loaded takes 5-10s":
1. Waterfall: every section fetch re-ran the dashboard auth preamble
(status -> providers -> session -> ws-ticket) before its payload --
8 sections x 5 sequential round trips ~= 40. DashboardPreamble is now
fetched once per sweep and shared; prewarmDashboardManage aborts on an
unreachable/unauthenticated preamble and fans section GETs out
concurrently; the in-screen sibling prewarm reuses the visible
section's verified status/session. Net ~40 sequential -> ~4 + 8
concurrent. Foreground loads keep the full preamble (header needs
fresh status).
2. Cold process: the payload cache was process-lifetime only. New
DashboardManageDiskCache mirrors Loaded entries to plain JSON under
cacheDir (schema-versioned, tmp+rename, mutex-serialized; corrupt or
foreign versions decode to empty) -- deliberately NOT
EncryptedSharedPrefs per the Tink global-lock lesson; the payload
carries no credentials. Hydration at app start preserves
fetchedAtMillis so entries render instantly AND count as stale; the
SWR window and the prewarm (cold filter widened to stale-Loaded)
refresh them quietly. Sign-in/out clear sites also wipe the file.
DashboardSummaryItem/DashboardItemAction/DashboardActionKind moved to
the new file (private -> internal @Serializable); DashboardStatus /
DashboardAuthProvider / DashboardAuthSession annotated @Serializable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Use now" routed through setPreferredEndpointRole, so a one-time route
switch silently persisted preferredRouteRole. Split per act-now-vs-policy:
- useRouteNow(role): transient setManualRoleOverride + probeNow only;
dies on disconnect; null restores the persisted preference
- "Prefer this route" (3-dot menu, now a toggle with Stop preferring)
remains the only writer of preferredRouteRole
- ConnectionManager.manualRoleOverride exposed as a StateFlow so the
Routes card labels Current as automatic / preferred / manual (until
disconnect), plus Cancel-manual-switch and Stop-preferring actions
Tailscale is deliberately NOT auto-preferred: strict priority +
reachability already promotes it when LAN dies and keeps the faster
LAN path at home.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cold starts froze the UI for up to ~11s (Skipped 1386 frames, Davey!
duration=11596ms). Logcat showed the main thread waiting 4s+ inside
AndroidKeysetManager$Builder.build() behind DefaultDispatcher workers:
EncryptedDashboardCookieStore built its Keystore-backed prefs EAGERLY
in its constructor - a 1-4s operation on StrongBox devices that
serializes through a process-global Tink lock - and several paths
(Manage per-fetch client factory, connection validation probe, session
clear, and the new Manage pre-warm at 8 instances per sweep) each
constructed their own copies, stacking seconds-long lock holds that
main-thread keystore users queued behind.
- EncryptedDashboardCookieStore: keystore-backed store is now built
lazily on first cookie access (always an OkHttp/IO thread);
construction is free on any thread.
- ConnectionViewModel.dashboardCookieStoreFor(connectionId): ONE cached
store per connection, now used by Manage, the validation probe,
session clear, standard voice, and the pre-warm - one keyset build
per connection per process instead of one per consumer.
- prewarmDashboardManage: takes the shared store and builds ONE
DashboardApiClient for the whole sweep (core extracted to
fetchDashboardSectionStateWith); NonCancellable client shutdown.
- DashboardOAuthSignInDialog cookieStoreFactory widened to the
DashboardCookieStore interface.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every entry to Manage was a cold load: the payload cache lived in
remember{} and died with the screen, and the skeleton stacked four
progress bars with fake narrative labels that read like three different
failures. The KPI glyphs (ok/.../!) needed decoding and the status
banner crammed five facts into one line that two trailing buttons (one
a duplicate "Connection" link) kept truncating.
- DashboardPayloadCache: process-lifetime singleton keyed
connection|dashboardUrl|section; Loaded.fetchedAtMillis drives a 30s
stale-while-revalidate window (fresh -> no fetch; stale -> cached
content stays up, thin refresh bar only). Sign-in/out clear as before.
- App-start pre-warm: fetch core extracted to
fetchDashboardSectionState(); prewarmDashboardManage() fills cold
keys only, aborts on first unreachable/auth failure, never marks
Loading so it cannot fight the open screen. RelayApp fires it
(1.5s debounce) when the persisted dashboard snapshot says reachable
and signed-in/auth-free, and again after a route handoff.
- Skeleton: one LinearProgressIndicator + three pulsing content-shaped
ghost cards; no per-card spinners, no fake labels.
- KPI strip: section count / tone-colored dashboard state word
(ready / sign-in / offline / error) / server version. RelayMetricCard
gains an optional valueColor.
- Status banner: two-line layout (state + identity + Sign out, then
URL - route - checked time); duplicate "Connection" button removed -
the Connections tile is rendered directly below it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Manage over a roamed route failed opaquely: the dashboard (:9119) is a
separate server from the API (:8642), sessions are host-scoped cookies,
and an explicit dashboard URL override pins the surface - but the tab
never said which URL it was hitting or why a home sign-in did not carry
over to the Tailscale host.
- Persistent "Dashboard: <url> - <route> route" target line under the
Manage mode strip (route suffix only when the resolver has moved the
dashboard off the persisted URL).
- "Dashboard unavailable" card names the exact URL that failed.
- Sign-in card explains per-host cookies when the route has moved:
sign in once here, the app keeps both sessions.
- Overview connection banner gains the route label.
- New ConnectionViewModel.dashboardRouteMovedHint; the existing
standardVoiceSignInRouteHint refactored to reuse it (semantics
unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Standard (no-plugin) voice retargeted at the dashboard surface with
Manage parity (models/keys/profiles/skills hub/SOUL editor); standard-
route network auto-switch (LAN <-> Tailscale roaming without Relay) with
escalation + route-candidate preservation; Routes editor (add/edit/
remove fallback routes); remote-access discoverability across setup and
status; visible route-probe outcomes ("Probe now"/"Use now" no longer
fail silently) and bare-host URL forgiveness with port guidance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- CHANGELOG [Unreleased]: per-route reachability verdicts, bare-host
URL forgiveness + port copy, silent Re-check/Use-now fix.
- DEVLOG: field-report diagnosis (remote phone on tailnet, route never
switched, "Resolving" over the internal relay URL) and the fix set.
- user-docs remote-access: new "Which URL Do I Enter?" section - API
port 8642 vs dashboard 9119 vs relay 8767; raw 100.x Tailscale IP
needs http:// (and an API server bound beyond loopback) while a
*.ts.net hostname behind tailscale serve is https-only-by-name.
- user-docs troubleshooting: pairing Android Studio wireless debugging
to a phone over its Tailscale IP (adb pair vs adb connect ports).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Probe now"/"Use now" failed silently when every saved route lost its
health probe: probeAndReconnect() early-returned without publishing on
the standard (no relay socket) path, leaving the Routes card on
"Current: Resolving" over the connection's relay URL with no feedback,
and probeNow()'s fixed 100ms delay always lost the race against a real
resolve (4s+ when LAN must time out), pointing the follow-up health
checks at the stale route.
- ConnectionManager: awaitable probeAndReconnectNow() that always
publishes the resolve outcome (live-socket transient-miss guard
preserved); probeAndReconnect() is now a launch wrapper.
- EndpointResolver: per-route RouteProbeOutcome map (reachable / human
failure reason, survives clearCache) with the TLS case spelled out -
an https route against the plain-HTTP API server fails every probe
and was previously indistinguishable from "server down".
- ConnectionViewModel: RouteProbeStatus (Idle/Probing/Done(winner));
probeNow() awaits the resolve, rebuilds the API client on route
change, queues a re-run when tapped mid-probe; save/remove route end
in a visible probe cycle.
- Routes UI: "Checking..." progress on Re-check, per-row full URL
(scheme visible) + last verdict, explicit "No route reachable -
using saved URL ..." instead of eternal "Resolving".
- URL forgiveness: Connection.normalizeApiUrlInput() defaults bare
hosts to http:// + the surface's port (API 8642, dashboard 9119);
explicitly-schemed URLs pass verbatim. Applied across the wizard,
route editor, and updateApiServerUrl; field copy names the ports;
route editor previews "Will save: ..." live.
Tests: resolver outcome verdicts, probeAndReconnectNow publish-on-
failure regression, 10 normalizeApiUrlInput cases incl. the bare
Tailscale IP end-to-end journey. Lint + unit suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A user following the wizard's happy path (scan LAN, pick server, connect)
ended up with a LAN-only connection and learned remote access existed
only when stranded on "Hermes API unreachable" away from home. Four
nudges, each at a moment the user is actually paying attention:
- Standard setup: the Tailscale URL field moves out of the collapsed
Advanced expander into the main form as "Remote access - Tailscale URL
(optional)", with a "Tailscale detected on this phone" hint when the
detector fires.
- Setup result card: new "Remote" readiness line - green when a fallback
route exists, neutral "LAN only - add a Tailscale or public route"
otherwise. StandardApiSetupResult gains remoteRouteConfigured.
- Status pill: "Hermes API unreachable" now diagnoses instead of just
reporting - single-route connections get "Away from the server's
network? Add a Tailscale or public route" (sharpened when the phone is
on Tailscale); multi-route connections get "none of the N routes
responded, fallbacks retried automatically".
- Connections card: when the phone is on Tailscale but the connection
has no Tailscale route, an "Add Tailscale route" shortcut opens the
route editor directly (editor state hoisted out of the routes expander
so the nudge works while the list is collapsed).
user-docs: remote-access guide documents the on-phone route editor, the
LAN-only callouts, and the one-sign-in-per-route cookie behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Relay path provisions multi-route candidates via the v3 pairing QR's
endpoints array, but the standard (no-Relay) path had only the wizard's
optional Tailscale field at setup time - no way to add a remote route
after the fact, and no way to edit or remove one. The Routes card was
read-only (prefer / probe / view pin).
- EndpointsCard: "Add route" action, Edit/Remove menu items on fallback
rows (priority > 0; the primary row mirrors the connection's API URL
and stays protected), remove confirmation, and a RouteEditorDialog
with Tailscale/Public/Custom role chips + URL validation. Empty-state
copy now offers manual add alongside the QR path.
- ConnectionViewModel.saveExtraRoute / removeExtraRoute: persist to
Connection.routeCandidates, seed from legacy sources first (per-device
PairingPreferences, or a primary synthesized from saved URLs) so an
edit never hides routes the card was showing, guard host:port
collisions, clear a stale preferred-route override on remove, and kick
a cache-cleared re-resolve so the new route takes effect immediately.
- Wizard's Tailscale field now mentions routes are editable later under
Settings -> Connections -> Routes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Gate network-change socket actions on shouldReconnect: a network event
whose resolved winner differed from the last URL could resurrect a
relay socket the user explicitly disconnected (pre-existing hole the
switchover refactor preserved). Routes still publish for HTTP surfaces.
- refreshActiveEndpoint keeps the live route on a transient probe miss
while the relay socket is Connected, mirroring the network-callback
guard, instead of downgrading every HTTP surface to the saved URL.
- Sign-in route hint now uses the endpoint display label (Tailscale, not
tailscale) and the chat mic toast is route-aware too.
- Reset the unreachable-escalation counter while no API client exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-ups to the standard-route network switchover fix:
- Periodic API health loop now escalates two consecutive Unreachable
probes into a cache-cleared route re-resolve - the safety net for
network changes the NetworkCallback missed (e.g. always-on VPN keeping
"internet available" true through a Wi-Fi -> cell handoff). Client
rebuild stays reactive via the effectiveApiServerUrl collector.
- Voice Settings explains the per-host dashboard cookie gate when the
resolver has moved the dashboard off the persisted route: new
standardVoiceSignInRouteHint flow + route-aware sign-in copy, plus a
Diagnostics entry from the availability probe.
- URL edits no longer wipe stored fallback routes: new
Connection.mergeRouteCandidates preserves priority>0 extras (wizard
Tailscale URL, pairing-payload endpoints) verbatim across
updateApiServerUrl / updateRelayUrl / connectRelay /
testRelayReachable / saveApiAndProbeVoice / saveStandardApiConnection.
- Drop the duplicate networkStatus -> revalidate() collector left
behind by the rechrome.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ConnectionManager's ADR 24 NetworkCallback only registered inside
connect(), and its onAvailable/onLost handlers bailed without a relay
socket URL - so standard connections never re-resolved LAN/Tailscale
routes on network change, and the only recovery was backgrounding the
app (ON_RESUME -> revalidate()).
- Register the NetworkCallback at construction; no-op without context.
- Unify onAvailable/onLost into a debounced re-resolve that publishes
activeEndpoint even with no socket (HTTP surfaces follow via
effectiveApiServerUrl / effectiveDashboardUrl); socket swap/reconnect
behavior for the relay path is preserved.
- refreshActiveEndpoint(clearProbeCache) + revalidate() now clear the
resolver's probe cache so a just-died route can't win the resolve for
the rest of the 60s positive TTL.
- activeDashboardUrl() now delegates to effectiveDashboardUrl, so
standard voice + its availability probe follow the resolved route
instead of pinning to the persisted LAN dashboard URL.
Robolectric coverage: callback registration/unregistration lifecycle,
socketless onAvailable publishing activeEndpoint, and stale-cache vs
clearProbeCache resolve behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New guide/quick-start.md leads the sidebar: install -> connect ->
capability card -> talk, with power tools in a collapsed details
block. Detail stays on Installation & Setup.
- features/dashboard.md Android Manage section now lists the real
per-section capabilities (skills hub browse/preview/install, model
picker with cost confirm, Keys set/reveal/clear, profile create/
describe/SOUL editing) and fixes the stale claim that SOUL editing
needs the paired inspector.
- features/voice.md Requirements split standard-route (dashboard audio,
one Manage sign-in) from relay-route requirements.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Message long-press now opens a Copy / "Quote in reply" menu when the
quote handler is wired (quote drops the text into the input as a
Markdown blockquote); copy-only call sites keep the direct copy.
- Chat top bar gains a Share icon (visible with messages) exporting the
conversation as Markdown via the system share sheet.
- Manage cards with 5+ actions keep three inline and fold the rest
behind a "More" dropdown - profile cards no longer wrap two rows.
- Settings -> Appearance documents the ambient long-press/tap gesture,
keeping the hidden entry discoverable incl. via screen readers.
Audit note: scroll-to-bottom FAB, session drawer search, not-connected
empty state with Connect CTA, stop-during-streaming, and tappable
suggestion chips already existed - no changes needed there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- RelayStatusStrip becomes an inset rounded capsule floating above the
gesture area; the previous zero-radius bordered bar read as a hard
rectangle against rounded display corners.
- Ambient (fullscreen sphere) mode drops its top-bar toggle: long-press
the conversation background to enter (message bubbles keep their copy
long-press and consume first), tap or long-press anywhere to return,
with a transient "tap to return to chat" hint pill on each entry.
- Media settings now state on-screen that they apply only to
Relay-delivered attachments, not standard connections or chat uploads.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kotlin block comments NEST: writing the glob `/api/audio/*` (or
/v1/audio/*) inside a KDoc opens a nested comment that the KDoc
terminator does not close, swallowing code until a later */ - producing
"Unclosed comment" at EOF and ~1080 cascade unresolved-reference errors
(ConnectionViewModel and StandardHermesVoiceClient never compiled).
Spell the routes without the trailing star in all three block comments;
line comments were unaffected. Also add the missing RoundedCornerShape
import used by the skills-hub and SOUL editor dialogs.
These slipped through because the local gate piped gradlew through
`tail`, which made the pipeline exit 0 regardless of build status.
Verified for real this time (pipefail): compileSideloadDebugKotlin,
compileGooglePlayDebugKotlin, :app:lint, and
testGooglePlayDebugUnitTest all pass with GRADLE_EXIT=0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cockpit refresh reworked Welcome and the Connect wizard but left the
middle pages as icon + one sentence. Each now carries three feature rows
in the Welcome page's row style: Chat = streaming / profiles / voice
(no extra install); Manage = control / skills hub / one sign-in unlocks
voice; Power = terminal / bridge / realtime. Copy leads standard-first.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
StandardSetupResultCard already scored Chat / Manage / Relay; complete
the capability card with Voice. StandardApiSetupResult gains
voiceAvailability, settled in the same setup probe (dashboard status ->
auth -> audio-route HEAD) and mirrored into the live availability flow,
so the card and the mic gate are accurate the moment setup completes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Voice Settings fetched three relay configs on open and snackbar-ed every
failure, so a standard-only user got "Relay unreachable" snackbars for a
route they do not use. Gate the fetches on relayVoiceReady and replace
the relay-backed sections (Fallback TTS / Voice Output editor / Realtime
config) with a quiet "Voice Providers" note: speech uses the server-
configured TTS/STT; pair Relay to pick providers from the phone. The
STT section and Test Current Engine stop showing permanent "loading...".
RelayErrorClassifier: preserve IllegalStateException messages (voice
routing throws actionable copy like "needs dashboard sign-in - open
Manage" that was being rewritten into relay advice), and neutralize
connect/timeout/unknown-host bodies to say "server" since those
exceptions also surface from API/dashboard routes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /api/skills/hub/sources populates the browse dialog before the first
search: a "Sources: Official (Nous), skills.sh, ..." line plus the
centralized index's featured skills, marked installed via the same lock
map. Best-effort - failures stay silent and search still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feedback: the brand Electric blue was right everywhere except as a
full-card fill. Revert Electric to #111DFF (cockpit selected panels,
pills, light-theme primary keep the vivid blue) and add ElectricMuted
(#4F5BD5), applied only to the active connection card as a 0.42-alpha
wash in place of the full-opacity primaryContainer fill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Skills tab gains "Browse hub" (multi-source search via
/api/skills/hub/search with installed-state marking, SKILL.md preview
before install, install/uninstall) and "Update installed". Hub
mutations are async server-side spawns ({ok, pid}) - the UI reports
"started" and keeps install rows disabled to prevent double-fires;
dashboard client read timeout raised to 45s so the server's 30s
search fan-out can't die client-side at the edge.
- Profiles gain "Edit SOUL": fetches the full SOUL.md (dashboard GET is
untruncated, safe round-trip), monospace full-file editor dialog,
PUT on save; creates the file when absent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CLAUDE.md dashboard web-server paragraph now lists the audio, model,
env, and profile routes plus the standard-voice cookie-auth model and
the api_server audio_api:false status. DEVLOG entry for 2026-06-10.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#111DFF (near-pure RGB blue) was too saturated against the muted
navy/periwinkle palette and too dark under Paper text on the selected
connections card. #4F5BD5 stays on the Relay/Purple hue axis, roughly
doubles luminance, and keeps Paper text above WCAG AA. Drives
relaySelectedPanel, dark primaryContainer, and light primary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Close the phone-vs-hermes-desktop capability gap on the dashboard surface:
- Models tab: "Change main model" opens an /api/model/options picker
(unauthenticated providers visible but unselectable, pointing at Keys);
POST /api/model/set with the upstream expensive-model confirm_required
round-trip surfaced as a confirmation dialog.
- New Keys tab over GET /api/env: Set (write-only, password-masked),
Reveal (POST /api/env/reveal, server rate-limited), Clear (DELETE with
JSON body). Channel-managed vars stay visible, tagged "channel", since
the app has no Channels page to defer to.
- Profiles tab: New profile (POST /api/profiles, clone-from-default
checkbox), Describe (PUT .../description, blank clears), per-profile
Model via the shared picker (PUT .../model).
- Overview gains Models + Keys tiles; input-backed action kinds route to
dialogs instead of firing immediately; successful dashboard sign-in now
refreshes standard-voice availability so the mic unlocks without
waiting for the next health tick.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
StandardHermesVoiceClient implemented the hermes-desktop /api/audio/*
contract but aimed it at the API server (:8642) with a bearer header.
Verified against upstream/main (d1383a6b1, 2026-06-10): api_server has no
audio routes (capabilities advertise audio_api: false; PR #8199 unmerged) -
the routes live on the dashboard web server behind cookie-session auth.
Standard-only users got an enabled mic and a 404 every turn; Auto-route
relay users uploaded full base64 audio to a 404 before each fallback.
- StandardHermesVoiceClient: dashboardUrlProvider + per-connection
encrypted cookie jar shared with Manage sign-in (new
DynamicDashboardCookieJar resolves the store per request so connection
switches stay correct); bearer dropped; 401/404 copy points at Manage
sign-in / server update.
- New StandardVoiceAvailability (Ready/SignInRequired/Unreachable/
Unsupported/Unknown) fed by probeStandardVoice(): /api/status ->
/api/auth/me when gated -> HEAD route-existence check (405 = present).
Replaces HermesApiClient.probeAudioApi(); re-probes after dashboard
sign-in/out via refreshStandardVoice().
- AutoVoiceAudioClient Auto order flipped to Relay-first: paired Relay is
profile-aware and needs no dashboard sign-in; Standard is the
zero-plugin path for vanilla installs.
- Voice Settings: per-route live status lines, "Sign in via Manage" CTA,
unsupported-build hint; Realtime Agent labelled relay-required with an
inline error + guidance when selected without one. Chat mic toast is
availability-aware.
- DashboardApiClient grows the model/env/profile write methods consumed by
the Manage parity commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
VoicePlayer was migrated to a single Media3 ExoPlayer (gapless TTS queue)
in the V5 voice-quality pass, but two current-state descriptions still
called it a MediaPlayer — the CLAUDE.md Key Files row and the decisions.md
voice references. The CLAUDE.md drift actively misled a crash diagnosis.
Also note audioSessionId is now a thread-safe @Volatile cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Voice chat crashed the instant Hermes began replying when barge-in was
enabled and audio used the legacy /voice/synthesize (Media3) path:
IllegalStateException: Player is accessed on the wrong thread.
Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'
BargeInListener runs its mic reader on Dispatchers.IO and, to attach
AcousticEchoCanceler, polls an audioSessionIdProvider lambda. On the
legacy path that provider read exoPlayer.audioSessionId directly.
ExoPlayer is thread-confined — its getAudioSessionId() getter calls
verifyApplicationThread() and throws off-main. (The realtime PCM path
was immune: it provides an AudioTrack session id, which is thread-safe.)
VoicePlayer.audioSessionId now serves a @Volatile cache populated from
main-thread Media3 callbacks (AnalyticsListener.onAudioSessionIdChanged
plus a belt-and-braces read in onIsPlayingChanged), so it is safe to
read from any thread.
Adds VoicePlayerTest coverage: the getter reflects the cached id, never
re-invokes the thread-confined getter, and defaults to 0 before the
audio track is allocated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The in-stream voice.error handler set uiState.error to the raw relay message
(e.g. 'xAI Realtime auth is not configured ...'). Route it through surfaceError
-> classifyError('voice_config') so provider-auth and other relay failures show
a clear, actionable banner ('Realtime provider auth unavailable ...') plus a
one-shot errorEvents snackbar with a Voice settings action, matching how the
result-failure path already surfaces errors. Raw detail is still recorded to the
Diagnostics log.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wire the persistent session end to end. Realtime Agent voice now opens one
provider session/socket on the first turn (runRealtimeAgent persistent mode in
realtimeSessionJob) and feeds subsequent utterances on realtimeTurnChannel, so
the provider keeps the live conversation across turns.
- Per-turn event state hoisted to fields so the session-lived callback serves
every turn; submitRealtimeTurn / the open path reset it per turn.
- onRealtimeTurnComplete finalizes each spoken turn (re-arms continuous listen);
closeRealtimeSession tears down on exit / engine switch / onCleared / error.
- VoicePreferences.realtimePersistentSession (default true) + a Voice Settings ->
Realtime Agent -> Persistent session toggle fall back to the one-shot path.
Compiles clean (compileSideloadDebugKotlin). Needs on-device validation
(multi-turn follow-ups, barge-in, background promotion mid-conversation,
exit/re-enter) — flag lets you fall back without a rebuild.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add opt-in persistent mode to RelayVoiceClient.runRealtimeAgent: when a
turnInputs ReceiveChannel is supplied, the WebSocket stays open across turns
(voice.response.done fires onTurnComplete instead of closing), subsequent
RealtimeTurnInputs are sent on the same socket with monotonic chunk ids, the
idle/turn guards scope to an active turn only, and the call ends when the channel
closes. One-shot path (turnInputs=null) is byte-for-byte unchanged.
Relay needs no change — _handle_provider_native_ws already loops over
input_audio/commit/response on one socket. Plan: docs/plans/2026-05-24-realtime-persistent-session.md.
VoiceViewModel wiring follows in the next commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The hermes.run.* event handlers mutated UI state silently, so a promoted
background run was invisible in logcat even though it ran. Add Log.i for
run started / progress (tier/floor/status) / promoted / background_completed /
cancelled so the background-task lifecycle is traceable on-device.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Record unconditional per-provider verdicts and mark Phase 0 done:
- OpenAI: hold-floor-ok (empirical 10/20/30s idle probe).
- xAI: hold-floor-ok — not conditional. The shipping Realtime Agent already
holds xai_realtime sessions open across between-turn idle (turn_detection:None
+ resume TTL) with no idle-close reports; a relay-host probe is a regression
check, not a precondition.
Also records that the spike's premise was superseded: Tier B closes the pending
provider call with an interim ack rather than holding an open response, so the
socket only sees the normal between-turns idle gap. No provider needs the
must-reopen fallback; default-on is unblocked.
Updates realtime-voice-poc.md findings, the plan's Phase 0 acceptance (Status:
DONE), and ADR 33's Phase 0 line (RESOLVED).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ran scripts/realtime-provider-idle-probe.py against the live OpenAI realtime API
(VOICE_TOOLS_OPENAI_KEY): the session survived 10s/20s/30s quiescent idle windows
and returned clean audio on every post-idle turn -> verdict hold-floor-ok.
xAI recorded analytically as hold-floor-ok (no dev-box creds; same
turn_detection:None multi-turn model + the promotion path closes the pending
call rather than holding an open response) pending relay-host confirmation.
Fills the docs/realtime-voice-poc.md Idle tolerance findings table, satisfying
the Phase 0 acceptance (a documented per-provider verdict). Logs an incidental
OpenAI session.audio.output.format.rate schema-drift follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Android:
- RealtimeVoiceEvent gains tier/floor; parse hermes.run.promoted +
hermes.run.background_completed in VoiceViewModel, surfaced as a
BackgroundRunState 'working on it' chip in VoiceModeOverlay (cleared on
background_completed / cancel).
- RealtimeVoiceConfig gains a promotion block; new
RelayVoiceClient.updateRealtimeAgentPromotion() PATCH.
- Voice Settings → Realtime Agent → Background tasks: promote toggle, spoken
handoff toggle, and result-delivery segmented control, persisted to the relay.
Docs:
- CHANGELOG [Unreleased], relay-protocol.md (ADR 33 background-runs section),
user-docs/features/voice.md (Background tasks).
Kotlin compiles clean under ./gradlew lint (the only 2 lint errors are in the
gitignored local.properties, absent in CI). Python realtime suite 58 tests green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Flip realtime_voice_promotion_enabled default to true. Safe because the
promotion path closes the pending provider call with an interim ack rather
than holding an open response, so the socket only sees the normal between-turns
idle gap. Phase 0 probe still recommended to confirm per-provider survival.
- Tier C: hermes_run_task(mode='background') detaches immediately (tier=durable),
even when grace-period promotion is off. Schema 'mode' enum gains 'background'.
- Expose promotion settings in /voice/realtime-agent config GET (promotion block)
and accept them in PATCH (_validate_config_updates) so Android can read/write.
test_realtime_promotion gains the Tier C immediate-detach case (58 tests green).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Long Hermes runs in Realtime Agent no longer block the provider event pump.
_run_brokered_tool now shields the run task and waits promote_after_ms; if the
run is still in flight, it detaches to the background (tier=promoted) and returns
control to the pump. _deliver_background_result awaits the task, emits
hermes.run.background_completed, waits for the floor to clear, then speaks the
result once via the existing forced-summary path.
- New events: hermes.run.promoted, hermes.run.background_completed (models.py)
- New realtime_voice settings (config.py + profile_voice.py): promotion_enabled
(default false), promote_after_ms (6000), background_default_mode, spoken_handoff,
progress_spoken_after_ms, progress_repeat_ms, result_delivery, max_background_runs
- Provider-tool-call path closes the pending call with an interim background ack
so the socket isn't left awaiting output; forced path speaks a handoff line
- Cancel (response.cancel / hermes_cancel) stops the background task; background
delivery task cancelled on session close
- Completion replays through the event ring on resume (detach-safe)
test_realtime_promotion: promote+pump-responsive, short=no-promote, cancel,
detach-resume-replays. Full realtime suite (53 tests) green; pre-existing
unrelated xAI-OAuth-pool test failure noted.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add plugin/relay/realtime_agent/floor.py: a pure, single-owner audio floor
(provider | relay_tts | android_filler mouths; idle/provider_speaking/
hermes_filler/result_pending labels) that makes explicit the serialization the
blocking await provided implicitly. Wire it into the broker behavior-
preservingly:
- acquire/release PROVIDER on AUDIO_DELTA/AUDIO_DONE (+ RESPONSE_DONE safety net)
- acquire/release RELAY_TTS around _render_provider_audio
- gate spoken filler by floor.can_speak(ANDROID_FILLER); stamp floor + tier on
hermes.run.progress
Adds session fields hermes_run_tier + floor. No audible change (today's flow has
no contention); invariants proven in test_realtime_floor (background result never
barges, filler suppressed while provider speaks, relay-TTS only when owned).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add scripts/realtime-provider-idle-probe.py and the Idle tolerance section in
docs/realtime-voice-poc.md. The probe holds an xAI/OpenAI realtime socket
quiescent across idle windows and reports a per-provider verdict
(hold-floor-ok | needs-keepalive | must-reopen) that selects each provider's
Tier B strategy. Verdict gates ADR 33 default-on promotion.
Also lands ADR 33 and the companion implementation plan.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The v0.8.0 notes' Verification section published the signing cert CN
(a personal name) in the live GitHub Release. Prior releases never
listed the cert identity — generalize to 'release-signed with the
production upload keystore' to match the house style. Live release body
already updated via gh release edit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:30:49 -04:00
1018 changed files with 189923 additions and 34130 deletions
description:Report a reproducible problem in Hermes-Relay.
title:"[Bug]: "
labels:["bug"]
body:
- type:markdown
attributes:
value:|
Before submitting, remove secrets, access tokens, real hostnames/IPs, private deployment names, and personal names. Public example IPs such as `192.168.1.100` are fine.
- type:dropdown
id:area
attributes:
label:Affected area
description:Pick the closest surface.
options:
- Android app
- Standard Hermes chat or voice
- Relay plugin or server
- Desktop CLI or tray
- Dashboard plugin
- Docs or installer
- CI, release, or packaging
- Unsure
validations:
required:true
- type:textarea
id:summary
attributes:
label:What happened?
description:State the behavior you saw and what you expected instead.
placeholder:|
Observed:
Expected:
validations:
required:true
- type:textarea
id:steps
attributes:
label:Reproduction steps
description:Include the smallest sequence that reproduces the issue.
placeholder:|
1. Pair or configure...
2. Open...
3. Tap or run...
4. See...
validations:
required:true
- type:textarea
id:environment
attributes:
label:Environment
description:Include only the fields that apply.
value:|
- Hermes-Relay version/tag:
- Install surface: Google Play / sideload APK / local build / plugin / desktop CLI
- Android device and OS:
- hermes-agent version or commit:
- Connection mode: LAN / Tailscale / public TLS / other
validations:
required:true
- type:textarea
id:logs
attributes:
label:Sanitized logs, screenshots, or traces
description:Paste the smallest useful log excerpt. Remove tokens, private URLs, hostnames, IPs, and user-identifying data.
render:shell
- type:textarea
id:upstream
attributes:
label:Upstream or standard-path notes
description:If relevant, note whether this reproduces against unmodified upstream hermes-agent or only with the relay plugin enabled.
- type:checkboxes
id:checklist
attributes:
label:Checklist
options:
- label:I searched existing issues first.
required:true
- label:I removed secrets, tokens, private infrastructure, and personal names.
required:true
- label:I included the affected version or install surface where known.
description:Report unclear, stale, or missing docs and setup guidance.
title:"[Docs]: "
labels:["documentation"]
body:
- type:markdown
attributes:
value:|
Use this for docs, installer, setup, release-note, or contribution-guide problems. Remove private hostnames/IPs, tokens, and personal names before posting.
- type:dropdown
id:area
attributes:
label:Documentation area
options:
- README
- User docs site
- Android setup
- Relay plugin setup
- Desktop CLI or tray setup
- Release notes or changelog
- Contributor docs
- Other
validations:
required:true
- type:input
id:location
attributes:
label:Page, file, or section
description:Link the page or name the file and heading.
placeholder:user-docs/guide/getting-started.md, README install section, etc.
validations:
required:true
- type:textarea
id:issue
attributes:
label:What is wrong or missing?
description:Explain what was unclear, outdated, misleading, or absent.
validations:
required:true
- type:textarea
id:expected
attributes:
label:Suggested correction
description:Optional. Include the wording, command, screenshot need, or structure that would help.
- type:textarea
id:context
attributes:
label:Context
description:Optional. Include the version, install path, device, or command you were following.
- type:checkboxes
id:checklist
attributes:
label:Checklist
options:
- label:I checked that this is not already covered in current docs.
required:true
- label:I removed secrets, private hostnames/IPs, internal deployment names, and personal names.
<!-- List the checks you ran, or explain why a check is not applicable. -->
-
## Lineage / contributor credit
<!--
If this PR salvages or supersedes earlier work, link every source PR and name
the original contributor(s). Preserve original commit authors where practical;
otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
-->
- Source PR(s): N/A
- Attribution preserved by: N/A
## Checklist
- [ ]`./gradlew assembleDebug` succeeds
- [ ]`./gradlew test` passes
- [ ] Tested on emulator or device (if UI change)
- [ ]Target branch is `dev`, unless this is a `dev` → `main` release PR or a focused production-tag hotfix PR to `main`
- [ ]Android changes: lint and focused unit tests ran, or rationale is listed above
- [ ] Translation changes: locale status/review references are accurate, `python scripts/check-android-locales.py` ran, and device/emulator review is documented, or N/A
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
- [ ] Desktop changes: `npm run build` or a narrower documented check ran, or rationale is listed above
- [ ] Docs/site changes: docs build or link check ran, or rationale is listed above
- [ ] UI changes were tested on emulator/device or desktop surface when applicable
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
echo "The release workflow was dispatched at that tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
echo "- Play track/status: **Production draft**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "The signed build, DEX scan, and Play draft upload passed. Ensure this exact release tree is on main, then run **Approve Android Release** from main. Console-only reports are informational and non-blocking." >> "$GITHUB_STEP_SUMMARY"
**Experimentalphase.** Assets are unsigned - Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows now ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
@@ -6,6 +6,507 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Added
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
### Fixed
- **Android alerts when a background Gateway turn needs input.** Approval, clarification, elevated-permission, and secret requests post privacy-safe notifications that reopen the correct conversation, survive reconnect replay without duplicates, and clear when the request is answered or expires.
- **Promoted voice tasks keep their Chat row through background delivery.** Completing the provider's initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running background task.
- **Android accepts deliberately installed private certificate authorities.** Google Play and sideload builds now use Android's user CA store alongside system roots for self-hosted HTTPS/WSS connections while preserving certificate-chain, hostname, and Relay pin verification.
- **Malformed code blocks no longer crash Android Markdown rendering.** Syntax highlighting now bounds dependency-provided spans before applying them, preserving valid highlighting while safely ignoring reversed or out-of-bounds ranges.
- **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.
## [Server 1.4.3] - 2026-07-22
### Added
- **Relay diagnostics describe upstream Gateway compatibility.** Doctor and `/relay/info` report optional Gateway health, configuration-route, and capability signals so clients can distinguish an older upstream install from a Relay failure.
### Fixed
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
## [Android 1.5.0] - 2026-07-22
### Added
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
- **Chat shows richer upstream state.** One-turn model selection, queued-recovery and project labels, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
### Fixed
- **Voice settings and active-turn correction remain usable across supported languages.** New voice controls are localized and correction copy accurately describes the turn being replaced.
- **Chat reconnects preserve the running Gateway turn without duplicating it.** Android reactivates the original live session after a socket loss, avoids resubmitting a prompt when its acknowledgement was lost, and de-duplicates session rows before they reach the drawer.
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
## [1.4.9] - 2026-07-19
### Changed
- **Hermes connections now use the Dashboard/Gateway as their standard surface.** Chat, sessions, Manage, and voice share one upstream sign-in; the API server is an optional automatic fallback or headless compatibility path, while Relay remains optional for power features.
- **Connection management and onboarding now explain each path clearly.** Nearby and remote dashboard setup, Tailscale and custom ports, Relay pairing, startup preference, route details, and security posture are presented in dedicated flows.
### Fixed
- **Server default consistently displays Hermes' pinned active profile.** Chat, session drawers, agent details, settings, voice, diagnostics, and profile inspection now use the active profile identity while preserving server-default routing semantics.
- **Discovered connections show useful host identity.** Successful local dashboard probes resolve and retain a hostname without overwriting a user-supplied connection label.
## [1.4.8] - 2026-07-18
### Fixed
- **The Google Play privacy-policy URL is permanently available.** The canonical policy now lives on hermes-relay.dev, the historical GitHub Pages URL serves the complete policy for compatibility, and Android release automation blocks publication if either public page is unavailable.
- **Android opens the hosted privacy policy directly.** The About screen no longer sends users to a repository source file.
## [1.4.7] - 2026-07-18
### Added
- **Android adds German, Brazilian Portuguese, and Japanese.** Complete AI-assisted catalogs cover both product flavors, with language-picker integration and freshness validation against the canonical English resources.
### Fixed
- **Long streamed replies grow smoothly and remain at the latest text.** Android frame-paces bursty token delivery, expands the active bubble within clipped bounds, preserves bottom-following through completion, and avoids replacing the visible live transcript while readers who intentionally scroll up remain undisturbed.
## [Android 1.4.6] - 2026-07-15
### Added
- **Profile display order and visibility are customizable per connection.** The profile manager can reorder every profile, including Server default, selectively hide inactive profiles, restore hidden active profiles, and reset the saved presentation without changing server configuration.
- **Agent icons can come from the phone or paired host.** The profile manager offers the Android document picker and can import conventional host files such as `avatar.png` or `profile.jpg`, storing a per-connection/profile copy on the phone.
### Fixed
- **Profile image import reports host compatibility accurately.** Android now distinguishes an older Relay without the optional avatar endpoint from a profile that genuinely has no conventional image, and presents the system file picker as a clear fallback.
- **Server-default chats use one profile session scope.** Android resolves the Server default row through Hermes' sticky active profile before Gateway create/resume and dashboard session operations, so the drawer, transcript, writes, and agent no longer split across different profile databases when the dashboard was launched under another profile.
## [Plugin 1.4.2] - 2026-07-15
### Added
- **Profile avatars are available to paired clients.** Relay discovers conventional direct-child profile images such as `avatar.png` and `profile.jpg`, validates their type, size, and profile boundary, and serves them through an authenticated profile route.
### Fixed
- **Relay follows Hermes' sticky active profile.** The advertised Server default identity, model, SOUL, profile metadata, and avatar now come from the profile selected by Hermes' `active_profile` marker instead of always describing the root profile.
## [1.4.5] - 2026-07-15
### Fixed
- **Running Android chats survive session switching.** On the upstream Gateway path, opening another chat, profile, draft, or Thread now detaches the visible stream without interrupting Hermes. Each running session keeps its own durable UI checkpoint, reconnects the shared event socket across route loss, and reattaches through `session.activate`/`session.resume` when selected again. SSE fallback remains intentionally single-stream and cancels on navigation.
- **Expired Gateway prompts no longer remain actionable.** Android collapses matching secret and sudo cards when Hermes emits their expiry events, recognizes late expired responses, and is ready for an upstream session-scoped approval-expiry contract without guessing the server timeout.
- **Provider wait notices stay transient.** Canonical Hermes provider-wait, reconnect, and continuation notices now use Chat's live status line instead of accumulating in the assistant reasoning transcript.
## [0.4.0-alpha.2] - 2026-07-13
### Added
- **Desktop chat can use Relay typed streaming over WSS.** The opt-in `--relay-chat` mode sends `chat.send`, renders typed `stream.event` v1 assistant/tool/artifact/memory/skill/error lifecycles, de-duplicates reconnect events, and preserves the existing gateway chat path as the default.
- **Pending computer-use grants are manageable from the CLI.** `hermes-relay grants` lists and interactively approves or rejects local grant-bridge requests, with explicit `approve`, `reject`, and JSON forms for scripts.
- **Desktop use has a durable CLI control plane.** `hermes-relay computer-use` persists enablement, reports daemon and grant state, and cancels active task-scoped grants through the local daemon bridge.
### Changed
- **The optional Windows systray is a native context menu for the CLI.** The WebView dashboard, embedded terminals, overlays, chat, sessions, plugins, voice, and settings windows were removed. The sub-megabyte tray now invokes the single installed CLI for TUI, pairing, daemon control, grants, audit, and logs.
- **Systray daemon controls are state- and privilege-aware.** The menu cross-checks PID liveness, identifies User versus Administrator daemons, disables invalid lifecycle actions, shows pending-grant counts and version metadata, toggles sign-in startup, and requests UAC only for an explicit elevated daemon start or restart.
- **Systray desktop-use controls preserve safety across restart and elevation.** The menu enables or disables the persistent capability, displays active grant mode and expiry, raises a native pending-approval alert, supports immediate cancellation, and warns while Administrator input authority is active.
- **CLI and tray releases use one synchronized version contract.** A single npm lifecycle keeps package, compiled CLI, Cargo, and installer metadata aligned; local verification and tag CI reject drift, off-main release tags, and untested CLI changes before publishing.
### Fixed
- **Compiled CLI diagnostics report the physical executable.** `hermes-relay doctor` no longer mistakes Bun's virtual embedded path for the installed binary, so PATH and install-directory checks describe the executable that actually launched.
## [1.4.4] - 2026-07-12
### Added
- **Android adds AI-assisted Spanish.** A repeatable translation harness and freshness checks keep catalogs structurally complete while tracking fluent review separately.
- **Diagnostics exposes the Relay contract.** A manual refresh reports the installed plugin version, protocol version, capability count, profile enablement state, and last-check time; shared issue reports include sanitized Android and device metadata.
- **What’s New links to complete release history.** The polished modal now provides direct access to every bundled version, with large-text screenshot coverage.
### Fixed
- **Profile operations stay inside the selected Hermes profile.** Session list, history, rename, delete, and in-flight recovery no longer fall through to the default database after a scoped failure; optimistic writes roll back and repeated recovery failures stop cleanly.
## [1.4.3] - 2026-07-11
### Added
- **Language switching is available inside the app.** Settings → Appearance now offers System default, English, and Simplified Chinese, stays synchronized with Android's per-app language setting, and persists the choice on Android 12 and lower.
### Fixed
- **Release builds reject unsupported collection APIs.** CI now scans Kotlin sources and final minified APK bytecode for Java 21 list endpoint calls that can crash on Android versions before API 35.
## [1.4.2] - 2026-07-11
### Added
- **Android now supports Simplified Chinese.** Chat, Manage, Voice, connection setup, settings, diagnostics, notifications, accessibility labels, and both product flavors follow the device language, with Android per-app language discovery on supported versions.
- **Localization is contributor-ready.** CI enforces resource, plural, and format-argument parity; translated README and VitePress entry points establish a repeatable path for adding languages without duplicating fast-moving technical references.
### Fixed
- **Connection scan and queued-message counts use proper plurals.** Count formatting no longer depends on English-only suffix arguments and cannot fail when a locale needs a different plural structure.
## [1.4.1] - 2026-07-11
### Added
- **Background work is visible in Standard Chat.** A live process strip opens a mobile process sheet with running or recent state, output, elapsed time, Stop, and Dismiss controls. It remains compatible with older Hermes servers that do not expose process details.
- **Background work has a clearer Chat home.** Realtime work appears as a titled task card with working, waiting, delivery, and completion states, queued work, and an expandable tool timeline.
- **Multi-image messages open as galleries.** Adjacent images render in a compact grid and open at the selected image in a swipeable viewer while preserving sensitive-media reveal and original-file actions.
- **Voice gains commands and presets.** Spoken commands can stop speech, cancel background work, pause or resume listening, repeat a result, or start Standard voice chat. Hands-free, Low latency, Careful tools, and Quiet presets tune existing interaction settings.
### Changed
- **Streaming Chat content stays steadier and more readable.** Settled prose and headings adopt final Markdown styling during generation, wide tables scroll with readable columns, the thinking indicator respects system motion and TalkBack settings, and the jump-to-bottom control counts unread messages.
- **Offline Demo mode no longer starts Voice.** The mic action now explains locally that a Hermes connection is required.
### Fixed
- **An in-flight Chat turn survives reopening the app.** Session-backed replies restore partial text, live reasoning, lifecycle status, tool/subagent cards, background-task state, and unanswered approval or clarification cards. Current Hermes gateways reattach to the same running turn; older or finished sessions reconcile from history without duplicating the prompt or losing the final answer.
- **Realtime Agent delivery is protected.** Hermes results use exact provider speech where supported, delivery validation, generation-safe confirmation, and a single relay-TTS fallback if the provider closes or rejects delivery. Voice commands no longer leave synthetic cancellation turns or mute a later background answer.
- **Standard Chat receives background-process completions automatically.** When Hermes completes detached work and starts a follow-up turn on the originating Gateway session, Android shows the unsolicited assistant stream in the open conversation and reconciles history after a cold reconnect. The synthetic process prompt is rendered as a compact process notice rather than a user-authored message.
## [1.4.0] - 2026-07-09
### Added
- **Android model pickers can refresh the server catalog.** Chat's model sheet and Manage's main/profile model dialogs now expose upstream's explicit **Refresh Models** action, so dynamic/custom provider model lists can be reloaded on demand without making every picker open probe providers.
- **Server-backed session cleanup plumbing.** The dashboard client now supports single-session export, the upstream `/api/sessions/prune` route with a mandatory dry-run preview before destructive apply, plus soft archive/restore helpers and an `archived` session-list filter for the Manage surface.
- **Notification triggers MVP.** Settings → Notifications now has explicit opt-in proactive rules for the Notification companion: match by app package plus optional title/text filters, post a safe local "Ask Hermes?" prompt, show the latest trigger activity, and pause everything instantly with a kill switch.
- **Android bridge: multi-device targeting.** The relay can keep multiple Android bridge clients connected at once, route commands by `device` selector (`phone`, `pixel`, `fold`, `boox`, `note`, `notemax`, `tablet`, or device ID), expose `/bridge/devices` and `/bridge/select-active`, and advertise an optional `device` argument on the `android_*` tool schemas.
- **Voice: a second long request gets queued, not refused.** Ask for another long task while one is already running in the background and it's now queued (up to three) and starts automatically when the current one finishes — with a short spoken transition. The task card shows "+N queued", and cancelling the current task clears the queue.
- **Voice: background answers start speaking sooner and can never be silently lost.** The spoken summary now streams as it's generated (it used to be held until fully complete — a noticeable dead gap, then the whole answer at once). Delivery is verified two ways: the summary must actually reflect the answer's content (not just avoid known filler phrases), and if no spoken delivery lands within 30 seconds the answer is posted as text instead of vanishing.
- **Voice: tap the finished-task card to hear the answer again.** After a background task's card settles to "finished," tapping it replays the delivered answer. The card also now shows in the compact voice view (it previously existed only in the full-screen layout), a "Drafting the answer…" status appears as the reply is being composed, and leaving voice mode with a task still running leaves a note in chat so the work stays visible.
- **Voice: quick questions answered while a background task runs.** Realtime voice used to refuse *any* second request while a long task ran in the background — even a two-second lookup. A quick second ask is now answered inline on a side session (within the same few-second window that decides backgrounding); anything that turns out to be long still gets the "a task is already running" answer, and the running task is never disturbed.
- **Voice: the background-task card no longer vanishes mid-answer.** The card used to disappear the instant the spoken answer started (exactly when the waveform returned), reading as the task being lost. It now settles to a "Background task finished." state, lingers for a few seconds while the answer plays, then dismisses itself — and its ✕ during that settled state just dismisses the card instead of sending a cancel.
- **Voice: the "Thinking" pill no longer spins forever.** The server streams its drafting text as an internal pseudo-tool that never reports completion, and the app rendered it as a live tool pill — which then ran indefinitely in both chat and the voice overlay. Internal tool events no longer become pills (their text still feeds the thinking trace).
- **Voice: background-task answers can't be lost to a stray cancel.** Tapping cancel/stop after a background task had already finished used to mark the finished run "cancelled" — losing the answer that was about to be spoken. Cancel now only cancels a run that's actually still running; stopping the current speech works as before.
- **Voice: no more spoken run IDs or phantom queue state.** The realtime voice model no longer reads 32-character run IDs aloud after starting a background task (identifiers stay out of everything it's asked to speak), no longer claims a request was queued unless the relay accepted it, and a completed task's answer is spoken directly — deferral filler like "one moment while I look that up" in place of a finished result now triggers the fallback that speaks the real answer.
- **Voice: finished-task answers keep the realtime voice.** A completed background task's answer is now spoken by the same realtime voice you've been talking to — read word for word from the authoritative Hermes answer — instead of switching to the standard TTS voice mid-conversation. The answer always lands: if the realtime model goes off-script or the provider connection drops, standard TTS speaks it, and if you start talking mid-delivery it's posted as text instead of interrupting you. The "When the answer is ready" setting keeps its four modes (Exact / Summary / Notify / Show), now explained behind an info icon in Voice Settings.
- **Voice: realtime models refreshed.** OpenAI realtime now defaults to `gpt-realtime-2.1` (with the cheaper `gpt-realtime-2.1-mini` selectable), the versioned `grok-voice-think-fast-1.0` pin is available alongside xAI's `grok-voice-latest` alias, and session logs record which model the provider *actually* served — so provider-side alias moves no longer happen invisibly.
- **Voice: session logs clean up after themselves.** Realtime voice session logs are swept after 14 days by default (`realtime_voice.run_retention_days`, 0 disables), and the per-response TTS audio capture is now opt-in debug tooling (`debug_audio_tap`) instead of an always-on multi-MB tap.
- **Voice: one-command delivery health report.** `python -m plugin.relay.realtime_agent.report` summarizes recent voice deliveries — how many were spoken by the realtime voice vs fell back to TTS or text, and why — for quick health checks after live testing.
### Changed
- **Bootstrap compatibility layer slimmed to true gaps.** The optional compatibility hook no longer injects session CRUD/messages or the legacy skills list — current Hermes serves those natively; it now covers only surfaces with no native replacement yet (session search, memory, legacy skill detail/toggle, config, available-models, and the slash-command middleware). Older pre-session-API Hermes builds degrade to the standard completions/runs chat paths.
- **Dependency floor: aiohttp ≥ 3.14.1.** Raised from 3.9 across plugin requirements and package metadata to the patched line covering the 2026 aiohttp security advisories.
### Fixed
- **Realtime voice recovers after background route loss.** A recorded turn now waits for a relay-confirmed resumed socket, retains unacknowledged follow-up PCM for replay, and reports transport rejection instead of sitting on a dead persistent connection. Resume handshakes are coalesced, and the relay requires a valid resume claim before replacing the active phone socket, so a slower stale connection cannot detach background-result delivery. Long-lived sessions start their bounded retry window when the route actually drops instead of at voice-mode entry, and a bare socket open cannot reset it. Late callbacks from a retired session are ignored. Exiting voice mode clears its detached reconnect and confirmation state before another session opens; rejected or unacknowledged cancels no longer leave an undismissable background-task pill. Provider transcription no longer impersonates active microphone capture, Stop settles the local turn even when the route is gone, and provisional `Listening...` / `Still working...` rows cannot remain stuck in chat.
- **xAI exact background answers bypass model deferral.** Non-structured **Exact** deliveries now use xAI's provider-native forced speech event, preserving the selected realtime voice and normal assistant history while speaking the authoritative Hermes answer without asking the model to follow a read-verbatim prompt. Structured results and summary modes still use natural model summarization, and the validator plus standard-TTS fallback remain as safety nets.
- **Background voice handoffs no longer repeat themselves.** If the realtime provider already spoke an acknowledgement before calling Hermes, promotion keeps that first line and suppresses the redundant "running in the background" follow-up; silent tool calls still receive the configured spoken handoff. Provider protocols that report both response creation and output-item creation now also produce one client `response.started` event instead of two.
- **Realtime voice model and voice picks now apply to the next session.** Voice Settings persists the selected Realtime Agent model and voice per connection/profile and sends both when opening a session, so choosing a pinned model immediately controls the next session instead of requiring **Save realtime agent** to rewrite the relay config. The active voice UI reflects the override, changing it retires any prewarmed session, and the choice survives an app restart.
- **Fresh realtime sessions emit one ready event.** Android's required `session.start` acknowledgement no longer causes the relay to send a second `voice.session.ready`, avoiding duplicate event IDs and duplicate session-ready telemetry on every new voice conversation.
- **Relay media can no longer serve credential files.** `/media/by-path` now always blocks paths that resolve into credential or system locations (`~/.hermes/.env`, `auth.json`, `config.yaml`, OAuth/MCP token stores, `pairing/`, `~/.ssh`, and similar) even in the default permissive mode — mirroring upstream Hermes' media-delivery hardening — so a prompt-injected `MEDIA:` marker can't deliver live secrets to a paired phone. Symlinks are resolved before the check, and the relay's own QR-signing secret and session-token store are covered too.
- **Long agent turns no longer die or duplicate at the transport.** Gateway chat (Android and the desktop CLI) now gives `prompt.submit` up to 30 minutes to acknowledge — matching upstream desktop and the server's own turn ceiling — instead of short generic RPC timeouts that could falsely fall back to SSE (duplicating the turn on Android) or kill a legitimately long deep-reasoning turn. Turn liveness is governed by idle-progress watchdogs (no events at all for a stretch), never a hard cap while output is still streaming.
- **Manage → Models keeps providers that still need keys.** Newer Hermes hides unconfigured providers from the model catalog unless a management UI opts in; Android Manage now opts in and keeps rendering greyed provider rows with their key-setup guidance on both old and new servers. In-chat model picking is unchanged (configured providers only).
- **Phone-local context actually reaches the server on fallback chat paths.** The sessions/runs streaming payloads carried voice-intent traces, card dispatches, and attachments in fields the server never reads — silently dropping them. That context now rides channels the server actually consumes (a per-turn context digest, real history fields where they exist, inline images on the completions path), and any attachment with no supported channel is reported instead of silently discarded.
- **Relay plugin works under the native `hermes plugins install` path.** The plugin's runtime imports assumed the repo's editable layout, so upstream's native installer (which loads plugins under its own package namespace) broke `hermes relay start` and `hermes pair` with `ModuleNotFoundError: No module named 'plugin'`. All runtime imports are now package-relative, the dashboard module boots correctly when the upstream web server loads it standalone, and `hermes relay doctor` now exercises the real import chain so this class of breakage can't pass doctor again. (#165)
- **Installer handles modern venv layouts.** `install.sh` now autodetects the classic venv, uv-managed `.venv`, and containerized layouts — and everything it generates (the systemd unit and all four command shims) points at the interpreter it actually detected instead of a hardcoded classic path. On immutable container images it steers to the native install path with a clear message instead of dying mid-run. (#165)
- **Doctor catches dashboard URLs pointed at the wrong Hermes surface.** `hermes relay doctor` now distinguishes the dashboard/Manage surface from an API-server/headless backend URL and tells operators to use `hermes dashboard` when a configured dashboard URL is actually pointing at `hermes serve` / the API server.
- **Doctor and installer catch stale duplicate plugin copies.** The gateway plugin loader picks a discovered plugin by manifest name, so a second directory declaring `name: hermes-relay` (a leftover backup copy or a stray extra install) could win and make the gateway load stale code — silently ignoring every later deploy. `hermes relay doctor` now warns when more than one directory under the plugins dir declares the same plugin name, and `install.sh` removes any such duplicate so only the canonical plugin symlink remains.
- **Crash-safety on Android 14 and earlier.** Built against SDK 35, Kotlin's `removeFirst()`/`removeLast()` resolve to the new Java `List` methods that don't exist below Android 15, crashing older devices. All such calls in the app are now `removeAt(...)`, and Tink (pulled in by encrypted storage) is pinned ahead of the transitive version whose `HybridConfig` tripped the same Google Play pre-launch check.
- **No crash when a relay address is malformed.** A corrupt or hand-edited pairing address with an invalid host could crash the app the moment it opened the relay connection (the connection is built on a background thread, so the error escaped uncaught). A bad relay address is now handled as a normal connection failure — shown as disconnected with a "re-pair to refresh" note — instead of crashing. The same guard now also covers the relay's media, session, and voice HTTP calls. (relay half of #131)
- **Voice: cleaner error recovery.** A failed or timed-out voice turn no longer shows the same error twice (the top overlay banner and a duplicate bottom banner) and can now be **dismissed**, not just retried — so a stuck error state can't block the screen.
- **Voice: fallback-spoken answers no longer play into a frozen overlay.** When an answer is delivered by the standard TTS fallback (or replayed from the finished-task card), the voice screen now shows the waveform and the answer text while it speaks — previously it sat on "Thinking" with no visuals even though audio was playing.
- **Voice: a quiet realtime session no longer dies with a raw provider error.** xAI ends a realtime conversation after 900 seconds of inactivity, and no keepalive traffic resets that timer — so a voice session left open through a long background task (or simply left open) died with a raw provider error. That provider timeout is now treated as routine expiry: the session ends cleanly with no error banner, and your next voice turn transparently opens a fresh provider conversation that picks up from the same durable Hermes chat session.
- **No crash when a malformed server address reaches a chat send.** The three streaming chat paths built their HTTP request before any error handling, so a corrupt or hand-edited API URL could throw instead of failing the turn gracefully. They now surface "Invalid server address — edit the connection's API URL or re-pair" through the normal in-chat error channel (closes the remaining #131 crash-class gap).
- **Demo mode: typing a message now gets an honest reply.** Sending a message in the offline demo used to do nothing (the composer silently ignored it, reading as broken). The demo now echoes your message and answers with a short notice explaining it's an offline sample, pointing at the Connect action to chat for real.
- **Voice: realtime conversations reliably reach your chat history.** Turns the realtime voice model answers directly (without calling Hermes) are folded into the chat session on your next message — but on the default gateway connection that hand-off could be deferred indefinitely, so the agent never learned what was said in voice. The turn that carries them now routes so the sync actually lands. Synced voice turns also render cleanly when a chat reloads: a quiet "Realtime Agent" chip instead of a raw provenance footnote, and no more duplicated voice exchange after the sync.
## [1.3.0] - 2026-07-06
### Added
- **Voice settings: edit your server's voice engine.** Voice settings now has a **Server voice config** section that reads and writes the host's text-to-speech and speech-to-text settings — provider, voice, model, language, and per-provider options — over the dashboard, the same config the official desktop app edits. It includes an **ElevenLabs voice picker** that lists the voices available on your server's ElevenLabs key (and tells you when no key is set). Works on the no-plugin (Standard) path; sign in to Manage to use it.
- **Desktop CLI: `hermes-relay audit`.** Shows what the remote agent has actually run on this machine through the desktop tools — tool, status, and a short detail per call — read from a local log, no network or auth. Answers "what did the agent just do?" at a glance.
- **Desktop CLI: `hermes-relay relay`.** Inspect the relay server itself: `relay info` (version, uptime, sessions — on the relay host), `relay security` (runtime auth toggles), `relay context` (audit the system-prompt context the relay injects into the agent, which works from a remote machine with your session), and `relay queue` (list — or `--clear` / `--cancel <id>` — the messages your agent queued for an offline phone; on the relay host).
- **Desktop CLI: background daemon.** `hermes-relay daemon start` runs the headless tool router in the background (no console window, survives closing the terminal), with `daemon stop` and `daemon status` to manage it. `daemon status` reports state, uptime, relay, and advertised-tool count; bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
- **Desktop CLI: per-command help.** Every subcommand now answers `--help`, and `devices`/`sessions`/`plugins`/`voice`/`relay` print their own usage (sub-commands, flags, examples) instead of a terse "unknown sub-verb".
- **Desktop CLI: startup banner.** A slim "Hermes Relay" wordmark shows atop `--help`, the first-run welcome, and the chat REPL — and `hermes-relay logo` prints it on demand. Suppressed for piped/`--json`/`--no-color` output.
- **Animated "thinking" indicator.** While a reply streams, the in-bubble working indicator can now be a small dot-matrix animation instead of the three dots. Pick a motion (Wave, Pulse, Bounce, Sparkle) and a color (match-text or a brand accent) in Chat settings, with a live preview. It follows light/dark and your app theme, and goes static when animations are turned off.
- **Proactive messages from the agent to your phone.** Your Hermes agent can reach out to the paired phone on its own — via `send_message target=phone` or a cron `deliver=phone`. Messages surface as a system notification, collect in a dedicated Hermes inbox, and can be injected into the active chat to continue the conversation (selected per message). Off by default and gated on pairing: nothing is pushed unless you enable it on the server (`PHONE_ENABLED`) and opt in on the phone ("Let Hermes message me"). Delivered over the existing relay connection through the upstream platform-plugin API (no fork).
- **Reply to your agent's messages (two-way).** A proactive message is now a conversation, not a one-way ping: reply straight from the notification (inline Reply) or from the Hermes inbox, and your answer goes back to the agent and continues the same thread. The phone behaves like any other Hermes messaging platform — the reply arrives as an inbound message the agent processes and answers. Rides the same paired relay connection; no extra setup beyond the proactive opt-in above. If your phone is offline when the agent answers, the message is queued and delivered when you reconnect — not lost.
- **Pick your font.** A Font picker in Appearance sets the app-wide typeface — **Inter** (the new default), **Nunito**, or your **system** font — each previewed in its own face and applied instantly across the app, no restart. Code and timestamps stay monospaced. (Bundled faces are SIL OFL.)
- **Quick Controls in Settings.** A Quick Controls card at the top of Settings groups the switches you flip most often — **Persistent connection** and **Turn-complete alerts** — so they're one tap from the Settings root instead of buried in a sub-screen.
- **Connections: a cleaner list and a tabbed detail.** Settings → Connections is now a scannable list — each server shows an **Active** badge and an at-a-glance capability summary (API · Dashboard · Voice · Relay) — and tapping a server opens a focused detail screen with **Overview**, **Routes**, **Advanced**, and **Security** tabs. Rename / re-pair / revoke / remove moved into the detail's **⋮** menu, and **relay sessions** (review and revoke the phones paired with that server) get a clear home under Security.
- **Keep connected through deep sleep (sideload).** When **Persistent connection** is on, Settings offers a one-tap "Allow unrestricted battery" prompt so the connection survives Android's deep-sleep (Doze) — without it, the OS pauses background networking after the screen's been off a while even with a foreground service. (Sideload only; Google Play restricts this permission.)
### Changed
- **Reporting a diagnostic now files the right kind of issue.** The Report button on a diagnostics entry used to turn routine log lines into "[Bug]" GitHub issues with an empty template. Now informational entries first ask "what were you expecting to happen?" and file as a "[Diagnostic]" question, error entries keep the direct bug flow, and every report carries the connection mode you were actually on instead of a placeholder line. (#155, #154, #146)
- **Simpler release downloads.** Each Android release on GitHub now attaches just two files — the tap-to-install sideload APK and the Play Store upload bundle — plus checksums, with the release notes leading with the one file most people want. The extra "parity/testing" artifacts are gone from the release page (still reproducible from the tag via CI). (#144)
- **Clearer, snappier voice capture and playback.** Voice now engages the device's echo-cancellation and noise-suppression while recording (matching the desktop's microphone setup), and requests audio focus before the first reply so the opening words aren't clipped on a cold start. Listening timing also matches the official desktop: auto-stop ~1.25s after you stop speaking (was 3s), give up after 12s with no speech, and cap a turn at 60s.
- **Refreshed chat look.** Message bubbles are wider and denser, each assistant turn shows a small Hermes avatar to its left (once per group), and code blocks are richer — a language label, a copy button, and a clearer inset so fenced code and inline `code` no longer blend into the bubble.
- **Desktop CLI: visual + ergonomics refresh.** A single color theme across the CLI, aligned tables for `devices`/`sessions`, status dots for on/off states, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
- **Persistent connection (was "keep chat connected").** The background keep-alive and its notification are reframed from a "chat connection" to your overall connection to Hermes — it holds the app's connection open in the background so messages and live features stay responsive, and for relay-paired setups also keeps device control and notification mirroring reachable. The toggle moved out of Chat settings into the new top-level Quick Controls card.
- **Chat is the home; simpler top-level navigation.** The Chat / Manage / Bridge mode strip is gone — Chat is now full-height, and Manage and Bridge are reached from Settings (Settings → Hermes management / Bridge), each with a back arrow to Chat. Terminal and Settings remain quick icons in the chat top bar.
- **Gentler reconnects when your server is unreachable.** After the server has been unreachable for a while, the app stops retrying every ~15 seconds and drops to a slower poll — easier on the battery — and still reconnects immediately the moment the network changes or the server comes back.
- **Connection status stays out of your way.** Connection feedback now sits exactly where it matters and never covers the nav or shifts the screen. Your **agent's** connection shows in the header subtitle under the agent name — it reads *Reconnecting…* / *Connecting…* / *Disconnected* and crossfades back to the model when it recovers, the same place messaging apps put it. The **relay** link (bridge / terminal / voice) shows only as a small amber *Reconnecting…* cue in the bottom status strip, since it doesn't block chat. Returning to the app from the background is now fully silent instead of flashing a misleading "connection changed" for the same connection re-handshaking.
- **Realtime voice: quieter progress.** The periodic spoken status updates during a long task ("Using cronjob…") are now off by default — the agent speaks at the milestones that matter (task started in background, finished, or failed) and the visual progress chip covers the in-between. A server setting brings the timed narration back if you prefer it.
- **Realtime voice: a live background-task chip.** The "working on it" chip in voice mode now actually shows what's happening: the current step ("Running command"), how many steps have finished, and a running timer — with a pulse so you can tell it's alive. It also reads the connection honestly ("Reconnecting — your task is still running" during a blip, "Done — delivering the answer…" while the reply queues up), and a ✕ on the chip cancels the task outright.
- **Realtime voice: snappier long-task handoffs and first turns.** When a clearly long-running tool starts (cron, desktop, browser work), the agent hands the task to the background right away instead of waiting out the full grace period — and the voice session now warms up when you open voice mode, so the first turn skips the connection setup it used to pay.
### Removed
- **Two voice controls that did nothing.** The disabled "Auto-TTS" toggle and the "STT language" picker under "Coming soon" in Voice settings are gone: the official desktop doesn't read every typed message aloud, and speech-to-text language is a server-side setting now editable in the new Server voice config section.
### Fixed
- **Realtime voice: you can keep talking while a background task runs.** Progress updates from a background task were flipping the voice UI back into "Thinking" with a Stop button on every tick, so the mic never came back until the task finished. Progress now feeds only the task chip; the conversation stays open the whole time.
- **Realtime voice: leaving voice mode no longer cancels a running task.** Exiting (or tapping Stop to interrupt speech) used to kill an in-flight background task and could overwrite its already-delivered answer with "Cancelled." in the chat. Exit now detaches — the task keeps running and the result arrives on your next session or as a notification — and a delivered answer always keeps its text (a Stopped badge marks a genuine cancel). The chip's ✕ remains the one deliberate way to cancel.
- **Long answers are no longer lost when the connection drops mid-turn.** On slow local models (or skills that delegate long background work), the phone could drop the stream mid-turn — the server finishes and saves the answer, but the chat sat on "Still working…" forever. The app now detects the dropped stream and quietly re-checks the conversation until the finished answer arrives, then completes the turn normally (with the usual done-notification if you've backgrounded the app). Switching chats or sending something new cancels the wait. (#166)
- **Onboarding slides fit every screen.** Intro slide text could run past the bottom of the screen with no way to scroll on short displays or large font sizes. Slides now scroll when needed and compact their artwork on short viewports, so no setup guidance is unreachable. (#145)
- **Docs: fixed stale setup labels and broken links.** The setup guide referenced a "Vanilla Hermes" button the app hasn't shown since v1.2.2 (it's labeled "Hermes"), several deep links into the getting-started page were dead, and the README under-counted the available phone tools. (docs site)
- **Back button on Manage and Bridge now works.** The back arrow on the Manage ("Hermes management") and Bridge screens did nothing — it tried to jump to Chat in a way that silently no-op'd. Back now reliably returns to the screen you opened it from.
- **Dropped relay connections from a status-report race.** The phone's periodic device-status report could occasionally be sent to the relay *before* the connection had finished authenticating, which made the relay reject the whole connection and forced a reconnect. The app now holds every message until the connection is authenticated, so the handshake always completes first.
- **Fewer needless connection re-checks when switching apps.** Returning to the app after a quick glance at another app no longer triggers a full connection re-probe (and the brief "checking…" flash) when the connection was already healthy — it only re-checks after a longer absence or if something actually looks off.
- **No more scary "server isn't accepting connections" pop-up on first load.** A bare bottom message could flash on cold start while the app was still establishing its first connection (the background session-list load failing before the server was reachable). That state is now shown only by the themed connection banner at the top — the redundant pop-up is suppressed for cold-start/reconnect bootstrapping, while real failures while you're using the app still surface normally.
- **Reconnect loop on remote (Tailscale) connections.** Connecting from off your home network could make chat loop — repeatedly reconnecting before it finally settled — because a brief route-probe miss flipped the active route back to the (unreachable) home address and rebuilt the chat connection against it. The app now keeps the last working route through a transient miss, tolerates a slow first handshake on remote links, and absorbs VPN-interface churn, so a remote connection settles quickly instead of thrashing.
- **Realtime voice: background tasks survive a brief disconnect.** Asking the voice agent to run a longer task in the background no longer loses the result to a momentary network drop — the server keeps the run alive across the reconnect and delivers the answer once you're back, and a task that runs too long is now stopped cleanly instead of hanging silently.
- **Realtime voice: the spoken answer is no longer dropped when a background task finishes.** When the agent completed a longer background task, a harmless internal provider notice was being treated as a fatal error and closed the voice session right as the reply was about to be spoken (surfacing an "xAI realtime error" toast with Retry). Those transient notices no longer end the turn, so the answer is actually spoken.
- **Realtime voice: the answer waits for you instead of playing to a dead connection.** If a background task finishes while your phone is disconnected, the spoken summary is now held and delivered when the voice session reconnects — and the phone keeps retrying that reconnect for several minutes instead of giving up after one attempt. If the voice session is gone for good, the result arrives as a notification instead (the full answer is always in the chat).
- **Realtime voice: asking for a second task while one is running no longer breaks the first.** The agent now tells you the earlier task is still in progress (wait, check status, or cancel) instead of silently losing its result.
## [1.2.6] - 2026-06-27
### Added
- **Session drawer refresh.** A refresh button in the session drawer re-pulls the chat list on demand, so a title the server generates a moment after a turn shows up without waiting for the next reload.
### Changed
- **Calmer connection status.** Transient connection status — reconnecting, checking, LAN↔Tailscale handoffs — now renders as a thin banner at the top that takes its own space (the screen slides down) instead of a card floating over the chat. The floating alert is reserved for persistent errors. Frequent confirmations (copied, profiles updated, profile/personality switches) moved to the same top banner instead of the bottom pop-up.
### Fixed
- **Chats stuck showing "Untitled".** The session drawer no longer overwrites a chat's first-message preview with a blank title when the server hasn't auto-named it yet (and the SSE path never does), so chats stop reading "Untitled"; titles also reconcile once the turn settles. (#133)
- **Rename on a non-default agent profile.** Renaming a chat while a non-default profile is active now persists to that profile's own store instead of the shared one — matching the earlier session-delete fix.
## [1.2.5] - 2026-06-27
### Added
- **Demo mode.** A "Try the demo" option on the setup / Connect screen — and on the empty chat screen if you skip setup — opens an offline preview of the real Chat UI: a sample conversation with Markdown, a tool-progress card, and a rich card, with zero setup and zero network (works in airplane mode). A persistent "Demo mode — sample data, not connected" banner offers a one-tap Connect that opens the real setup wizard; other tabs show a friendly "connect your Hermes server" empty state. Lets a first-run user — or a Play reviewer with no server — see what the app does before connecting.
### Fixed
- **Crash when a non-address is entered as a server URL.** Typing or pasting non-URL text (for example a label, or a line copied from the docs) into the API server or Dashboard URL field could force-close the app on the Manage / sign-in screen: the value was handed to the networking layer as a host, which rejected it with an uncaught error on the main thread. The setup fields now reject anything that isn't a valid host or `http(s)://` URL with an inline error, and the dashboard and voice request paths treat a malformed address as "unreachable" instead of ever crashing. (#131, #132)
## [1.2.4] - 2026-06-25
### Added
- **Connection security indicator.** The chat status chip, the connection card, and the route picker now show at a glance whether your connection is encrypted — 🔒 **Encrypted · TLS**, 🛡️ **Encrypted · Tailscale** (both secure), 🛡️ **Mixed routes**, or ⚠️ **Not encrypted** — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale/WireGuard route is now correctly shown as encrypted rather than implied insecure. Adds a new "Is my connection secure?" docs page explaining the difference between TLS and overlay (WireGuard) encryption.
### Fixed
- **Crash when a dashboard connection drops mid-check.** A transient network blip on the dashboard session check (e.g. a pooled connection aborting or timing out over Tailscale) could close the app: the check returned a result type but re-threw the network error instead of reporting it, and it surfaced on the main thread. The check now reports the failure cleanly, and the connection probe degrades gracefully instead of ever crashing. (#129)
## [1.2.3] - 2026-06-23
### Fixed
- **Crash on connect over TLS / Tailscale.** Connecting to a server over an encrypted link (Tailscale Serve or public HTTPS) could hard-close the app with `NetworkOnMainThreadException`. Tearing down an HTTP client closed live SSL sockets on the main thread, and a TLS socket close performs a network write — which Android forbids on the main thread. Client shutdown now always closes sockets off the main thread, so connecting over a secured link no longer crashes. (#118, #124; likely the v1.1.0 / Tailscale crash in #70)
## [1.2.2] - 2026-06-22
### Added
- **Diagnostics: status timeline.** Diagnostics now opens full-screen and leads with a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a clear pass / warning / fail state and, when something's wrong, the reason why; tap a failing check for full detail. The recent-activity log stays below it.
### Changed
- **Connections wording simplified.** The default connection is now just "Hermes" (previously "Vanilla" / "Standard Hermes"), and the optional power features are labelled "Relay" / "Relay plugin", across the connection setup, switcher, voice, and permissions screens.
- **Clean chat mode shows more text.** The distraction-free chat view gives its text a noticeably taller, scrollable area instead of capping it near a third of the screen.
### Fixed
- **Deleting a session on a non-default profile now sticks.** Removing a chat while a non-default agent profile was active could leave it on the server, so it reappeared after the list refreshed; the delete is now scoped to the active profile.
- **Session drawer opens on the right profile from a cold start.** When launching with a non-default profile selected, the session list could briefly show the default profile's chats and then snap to the correct ones; it now waits for the profile to resolve and loads the right list directly.
## [1.2.1] - 2026-06-21
### Added
- **Profile lock.** Settings → Profile lock pins the app to a single agent profile and hides the rest from the pickers; the lock screen stays the one place that lists every profile, with a clear notice if the locked profile isn't on the current server.
- **In-app What's New & changelog.** A new Settings entry shows the current and past release notes any time — not just the post-update popup.
- **Diagnostics: tap for detail + report.** Logged errors now carry clean titles and open a detail view with Copy / Share / Create-GitHub-issue (the same flow as crash reports); classified errors across voice, chat, and connection are captured centrally.
- **Update-available nudge.** A dismissable in-app banner when a newer version is live — Google Play In-App Update on Play installs, GitHub Releases on sideload. Per-version dismissal, throttled, never nags.
### Changed
- **Crash reports can be shared without GitHub.** The crash dialog now has a **Share** action alongside Copy and Report, handing the full report to the system share sheet (email, chat apps, notes, Drive). This covers users without a GitHub account and sideload installs that Play vitals never sees. Every outbound path stays user-initiated — nothing is sent automatically.
### Fixed
- **Voice override applies in Auto mode.** A chosen per-profile/enhanced voice now takes effect when the engine is on Auto with the relay paired — previously only "Relay" mode applied it. Per-profile voice settings are also namespaced by connection.
- **Realtime voice "Stop" stops immediately.** Tapping Stop while the agent is speaking now halts realtime playback at once; over-chatty spoken status is throttled; and long background tasks no longer time out the turn (relay keeps the session alive while the task runs).
- **Realtime Agent: brokered Hermes turns no longer fail (relay).** When the Realtime Agent reached back to Hermes for context or tool work, a session-namespace mismatch could make the API Server reject the turn with `session_not_found`. The relay now mints or reuses a valid API Server session and retries once, and reads the API Server's current nested create-session response. Provider-native turns are unaffected.
- **Hold-to-talk no longer releases on accidental drift.** The mic button holds until the finger genuinely lifts, instead of cancelling when it drifts off the button.
- **Voice overlay is readable.** The voice dropdown panel and its status bubbles are opaque (no bleed-through), and the Focus/Overlay/Exit labels no longer wrap to two lines; invalid engine/route combinations are no longer selectable.
- **Connection status overlay clears faster.** Resolved (error/warning) connection toasts auto-dismiss within ~5s instead of lingering.
## [1.2.0] - 2026-06-20
### Added
- **Sensitive-media classification (relay).** The relay teaches the agent — server-side, via a removable system-prompt block — to mark private/NSFW media so the phone blurs it per your setting. **On by default for relay installs** (installing the relay is itself the opt-in); reversible from the "Agent context" toggle in the Relay dashboard, or `RELAY_AGENT_CONTEXT_ENABLED=0`. The exact injected instruction is visible in the chat "What the agent sees" sheet under "Relay context (server-side)". No on-device or relay-side classifier — sensitivity stays model-emitted. Vanilla upstream (no plugin) is unaffected. See `docs/plans/2026-06-20-relay-enhancement-layer.md`.
- **Transport path is visible (chat).** The chat status strip now shows which streaming path is actually in use — ⚡ Gateway (live thinking), 📡 Sessions, Completions, or Runs — instead of a generic "api online", and Chat Settings adds a basic→best tier ladder explaining the active path and its fallback.
- **Injected-context audit (chat).** Tap the context-usage meter in chat to open a "What the agent sees" sheet showing the exact extra context prepended to your next turn — persona/profile, phone status, and any per-turn (voice) hint. On the gateway path it notes the persona is applied server-side, so the audit is honest about what the phone does and doesn't send.
- **Spoken-turn badges (chat).** Voice-mode replies now carry a "Voice" chip and realtime replies a "Realtime Agent" chip — both with a speaker glyph — so spoken turns are distinguishable from typed ones in the scrollback.
- **App themes.** A new theme picker in Settings → Appearance ships eight looks: the signature Hermes Relay brand (with full light/dark) plus ports of the Nous Hermes baselines — Hermes Teal, Nous Blue (light), Midnight, Ember, Mono, Cyberpunk, and Rosé. The whole app — brand chrome, accents, and chat background — follows the chosen theme. Light/Dark/Auto applies to themes that ship both modes; fixed-mode themes show their own complete look.
- **Hot-swappable agent sphere.** The orb is now a pluggable "skin": an Adaptive skin that recolors to match your theme, built-in Classic / Aurora / Solar / Mono looks, and support for **user-authored skins** loaded from a small JSON spec. Each skin declares which live signals it reacts to (voice, tool bursts, activity), shown as capability badges in the picker. See `docs/sphere-spec.md`.
- **Connections separate features from routes (Android).** Connection settings now distinguish what a connection can *do* (a **Features** section) from how this phone *reaches* Hermes (a **Route** section), so you can enable Relay features over whichever transport you prefer. A plugin-provided **Secure proxy** route is surfaced alongside LAN, Tailscale, public, and custom routes. The standard direct-to-upstream path is unchanged and still needs no plugin. See `docs/plans/2026-06-18-native-secure-routes.md`.
- **Enhanced voice control (Gemini & xAI).** When the relay uses a Gemini or xAI voice provider, Voice Settings can now steer it: pick a Gemini voice and model and turn on expressive tone tags (with optional natural-language voice direction), or set an xAI voice with expressive speech tags. Expressive tags also apply to xAI on the streaming voice-output renderer. Standard (no-plugin) voice stays configured server-side.
- **Voice render-path visibility.** Voice Settings shows which path is rendering speech (streaming vs. basic), and Diagnostics records it each session, making voice issues easier to troubleshoot.
- **Agent pets — a living, swappable avatar.** The orb can be replaced with an animated "pet" that reacts to what the agent is doing: idle / thinking / writing / speaking / listening states, a distinct **working** pose during tool calls, one-shot **greet** / **celebrate** reactions, and a loop that quickens as output streams. Add or remove pets right in Settings → Appearance (no `adb` needed), with a live state preview, a playback-speed slider, and optional frame auto-stabilization; capability badges (Voice · Tools · Activity) show honestly what each pet actually reacts to. Pets are pure data — an AI authoring kit and a JSON schema let you generate one from sprite art. See `docs/pet-spec.md` and the custom-avatars guide.
- **Per-profile agent icon + single-image avatars.** Each agent profile can wear its own small icon beside its name (client-side, never sent to Hermes), shown in chat, the agent sheet, the top bar, and Settings. Importing an avatar now also accepts a single image (auto-wrapped as a one-frame pet) — no animated pack required.
- **In-app crash reporting.** If the app ever force-closes, the next launch shows a clean dialog with the stack trace — **Copy** it, or **Report** to open a pre-filled GitHub issue from the bug template. The report persists until you acknowledge it, and the handler re-raises so the OS still records the crash in Play vitals.
- **Clean text-flow mode (chat).** A distraction-free chat layout where your sent text slides up into a continuous flow, paired with the swappable-avatar/pet system.
- **Permissions review screen.** A central page makes the permission model explicit — standard Chat and Manage need no phone-control permissions, while voice, camera, notifications, and sideload Device Control stay opt-in — reading the same live grants Bridge does.
- **In-app attachment previews + richer capture.** Attachments preview inline before sending, sensitive media is blurred per your setting, and the capture flow is richer.
### Changed
- **Much faster cold start.** The app was building several hardware-keystore-encrypted stores at launch, which serialize on a process-global lock and stalled the chat header (model, personality, approvals) for seconds. It now builds a single keyset and the dashboard cookies share it, cutting measured time-to-connected from ~2.9 s to ~1 s after first frame, with the keystore lock contention gone. Existing sign-ins are migrated automatically on first launch.
- **Honest loading, never stale, never hidden.** Model, personality, and approvals now show a brief "checking…" state and fade in once the server confirms them, instead of popping in or showing a possibly-wrong value. Standard upstream controls (Model, YOLO, Fast, reasoning effort) are no longer hidden while loading or when unavailable — they always appear: a live control when ready, "checking…" while a value loads, or a cleanly disabled control with the reason (e.g. "available over the gateway transport") when this connection can't use them. The chat composer's reasoning-effort chip now shows alongside the model chip instead of lagging seconds behind the gateway check, and picker lists (models, personalities) show a brief, bounded "loading…" cue. The same fade-in is applied to the context meter, session drawer, and Manage panels.
- **Tidier chat header.** The LAN/Tailscale chip was dropped from the top bar (the bottom status strip already shows the route, and is now tappable to open Connections), and a `none` personality is no longer shown — leaving more room for the model name.
- **Connection toast reads like the cold-start screen.** The floating connection status toast now shows a live checklist — Route / API / Relay each with a spinner, ✓, or ✕ as the checks land — instead of flat text, matching the splash screen's stepper. Swiping it up now tracks your finger (slide + fade) rather than snapping, and connection problems get an explicit "Open Connections →" link at the bottom so the path to the detailed view is obvious.
- **Tidier chat header.** The "approvals off" warning moved out of the agent subtitle into a single amber ⚡ icon in the top bar (tap for the full explanation in the agent sheet), and Share folded into a ⋮ overflow menu — so the personality · model subtitle no longer gets clipped by the trailing action icons.
- **Voice replies are formatted for listening.** In voice mode the assistant is now guided to answer in short, conversational sentences without markdown, emoji, or raw URLs — without changing what is stored in chat history.
- **Leaner terminal screen (Android).** The extra-keys bar scrolls horizontally with compact, fully-legible keys (no more clipped "CTRL"), the header is a single compact row showing one inline connection-status dot plus state, and the tab strip is hidden for single-tab sessions — the new-tab "+" moves into the header — reclaiming vertical space for the terminal.
- **Relay terminals run on an isolated, TUI-tuned tmux.** Sessions now use a dedicated tmux server/socket with its own config — instant ESC (`escape-time 0`), truecolor `$TERM`, mouse and focus events on, and no status bar — so editors and full-screen tools behave correctly, without touching the user's personal tmux.
- **"Standard" is now "Vanilla Hermes" throughout.** The user-facing name for the no-plugin upstream path is now **Vanilla Hermes**, so it's clear the default path runs on a plain Hermes agent.
- **QR pairing degrades gracefully on unusual cameras.** On foldables and devices where the camera can't initialize, the scanner now shows a "camera unavailable — pair manually" card instead of force-closing.
- **Image & attachment viewers rotate to landscape.** The full-screen image / attachment viewers can rotate to landscape even though the rest of the app stays portrait-locked.
### Fixed
- **Clearer error when a feature needs a newer relay.** Toggling a setting an older relay plugin doesn't recognize (e.g. xAI expressive speech tags) now shows "Relay update needed" instead of a generic HTTP 400 with a dead Retry button. Genuine input errors are unaffected.
- **Connection status toast is no longer see-through.** The floating connection-lost/switching toast renders fully opaque so content behind it no longer bleeds through and hurts legibility.
- **Provenance badges survive the post-turn history reload.** "Voice", "Realtime Agent", "Stopped", and "Error" chips are now preserved when the conversation reloads after a turn, instead of silently vanishing.
- **Chat and Manage no longer stay dark in Light mode.** Brand-styled surfaces bypassed the theme and were effectively hardcoded dark; they now follow the selected theme and light/dark mode, and the glow/border flourishes key off the active theme rather than the system setting.
- **Realtime voice no longer drops the conversation mid-session with some providers.** A normal end-of-turn signal was being rejected on certain voice providers, ending the session every turn.
- **Relay voice synthesis no longer leaves temporary audio files behind** on the server.
- **Clearer voice errors and an oversize-recording guard.** Standard voice now rejects an over-long recording before uploading it and shows a helpful message for audio the server can't read, instead of a generic HTTP error.
- **Terminal paste no longer auto-runs multi-line text.** The key-bar PASTE now uses bracketed paste, so multi-line content lands intact in shells and editors instead of executing line by line.
- **Terminal on-screen arrows behave inside TUIs.** Arrow/Home/End keys follow the running app's cursor-key mode (application vs. normal), so they work correctly in vim, less, and fzf.
- **Terminal footer spacing.** A small gap now keeps the last terminal row clear of the key bar (it could previously look like the footer overlapped it), and a redundant navigation-bar inset that left empty space below the keys was removed.
- **In-chat model picker now actually applies on a new chat.** Picking a model and provider in the chat composer (e.g. Grok 4.3 via your xAI subscription) is bound to the new conversation, so the agent runs on the picked model instead of silently falling back to the account's global default. Switching profiles retires an explicit pick so the profile's own model takes over, and the picker label updates immediately instead of lagging a round-trip.
- **Server-generated images render in chat when paired to the relay.** An assistant image that points at a server-side file path is now fetched through the relay's media route and shown inline (tap to zoom), instead of degrading to an "image is on the server" notice. On the SSE chat path the agent is also told it can surface images and files by path when a relay route is configured (visible in the chat "What the agent sees" sheet). Standard (no-plugin) connections are unchanged.
- **Smoother profile switching.** Switching profiles no longer blanks the conversation to an empty/"Loading…" state before the new history loads; the previous transcript is held and cross-fades to the new one.
- **In-chat model switch now applies mid-conversation, not just on new chats.** Picking a model in an already-started chat switches the live session in place — the same path the desktop/TUI `/model` uses — instead of racing into a global-default write, so the turn runs the model you picked.
- **Server-side turn errors always surface.** A failed turn (e.g. a provider rejecting the request) now stays on screen as an error bubble with the message, instead of appearing for a moment and then vanishing when the conversation reconciled after the turn.
- **The model shown in chat matches the live session.** The chat header and the agent detail sheet now show the model the current session is actually running (reflecting a mid-session switch) rather than the profile/global default, and the agent sheet no longer pairs the global default model name with the session's provider — it now also names the host's "Server default" when the session runs something different.
- **Server steering markers no longer appear as chat bubbles.** The "[System: the active model/personality changed]" notes the server injects into history for the agent's benefit are hidden from the transcript by default (matching the desktop/TUI); a new "Show system messages" debug toggle in Chat Settings can reveal them.
- **Per-reply token counts (and other per-message details) survive the post-turn reload.** The input/output token subtext, provenance badges, tapped-card state, and voice/realtime sync traces are now preserved when the conversation reconciles against the server after a turn — previously a normal reply lost its token line once the turn finished (the error bubble kept it only because errored turns skip that reload). The reloader now preserves client-only message details by default instead of dropping any it doesn't re-derive from the server.
- **PDF viewer no longer crashes when the document closes mid-render.** A PDF preview that was torn down during a layout pass could read a closed renderer and throw `IllegalStateException: Document already closed`; the renderer is now guarded so it returns nothing instead of crashing.
- **No crash opening a chat with a server-local image.** Rendering a relay-fetched image could throw `ClassCastException: kotlin.Result cannot be cast to byte[]` because a `suspend` function returned `kotlin.Result` (which collides with the coroutine machinery's own wrapper); a purpose-built result type fixes it.
- **Side-loaded avatars and sphere skins are reachable again.** Both loaders read internal storage while the docs (correctly) pointed `adb push` at external app-scoped storage, so a side-loaded pet or skin never appeared. Both now resolve through one external-preferred location, so the documented install path works.
- **Reopened chats paint the session's real model** (not the profile/global default), the model-picker "Server default" caption shows the true default rather than the active override, and a chat's media badge shows only when paired — with the underlying server-image fetch-failure reason surfaced when a fetch fails.
## [1.1.0] - 2026-06-16
### Added
- **Automated Play Console upload on release.** When a `PLAY_SERVICE_ACCOUNT_JSON` secret is configured, pushing a stable `android-v*` tag uploads the `googlePlay` App Bundle to the Production track as a draft (a human still starts the rollout). Prereleases are skipped, and the `sideload` flavor is structurally blocked from ever publishing to Play. Without the secret, the release builds publish to GitHub Releases exactly as before.
- **Desktop UI preview harness (`:ui-preview`).** A non-shipped Compose for Desktop module renders presentational composables in a window on the PC with Compose Hot Reload, for fast UI iteration without a device build/install loop. It reuses the shared sphere algorithm as its single source of truth.
- **Plugin: guided env-key setup.** The relay plugin declares its optional voice-provider keys (`XAI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`) in its manifest, so `hermes plugins install` prompts for them (masked, with a "get yours" link) instead of hand-editing `.env`. The standard no-plugin path needs none.
- **Plugin: native install path.** Tools-only setups can install via `hermes plugins install Codename-11/hermes-relay/plugin`; the full relay still uses the curl `install.sh`.
- **`/relay` slash commands.** `relay status · devices · pair` usable mid-conversation from any platform (CLI / Discord / TUI).
- **Dashboard relay-status widget.** A `Relay · connected / offline / unpaired` badge in the dashboard header, visible on every page.
- **Session-start relay health check.** A minimal, fully-guarded `on_session_start` hook records relay reachability without slowing the gateway.
### Changed
- **Release names normalized by surface.** Future GitHub Releases are named `Hermes-Relay-Android`, `Hermes-Relay-Plugin`, and `Hermes-Relay-CLI`, with future tags on `android-v*`, `plugin-v*`, and `cli-v*`. The CLI installer and updater still understand historical `desktop-v*` prereleases during the migration.
- **Per-surface release notes.** Plugin and CLI GitHub Releases now use hand-written `PLUGIN_RELEASE_NOTES.md` / `CLI_RELEASE_NOTES.md` files (Summary + Added/Changed/Fixed + Install/Verify) — the same format as Android's `RELEASE_NOTES.md` — instead of static boilerplate baked into the workflow. The release workflows substitute the version into the install commands automatically.
- **Settings screen overhaul (Android).** Status pills are now exception-only — they appear only when a surface needs attention and stay quiet when healthy. The Power tools section shows a single state-aware **Plugin active / required / offline** badge instead of an identical "Relay paired" chip on every card. Connections moved to the top (above the Hermes section), Diagnostics + Developer options moved into the App section, the status chips were restyled to match the app's translucent-bordered language, and the brand blue was deepened.
### Fixed
- **Force-close on connect when the stored credential keyset was corrupt.** A corrupt encrypted token store (which can happen after an app upgrade or device restore) threw during construction and crashed the app right after a successful pair, on both standard and relay connections. The token store now heals a corrupt keyset on the spot, and credential storage degrades to a re-pair instead of crashing if the device keystore is unusable.
- **Dashboard plugin: unreadable button labels.** Solid buttons in the relay dashboard panel inherited the container text colour, which matched their background. Solid button variants now keep their proper contrast colour.
- **Installer failed on uv-managed Hermes hosts.** `install.sh` assumed `pip` lived in the hermes-agent virtualenv, but environments created by `uv` (the upstream default) ship no `pip` module, so the editable install aborted at step 2. The installer now bootstraps `pip` via `ensurepip`, or falls back to `uv pip`, so the plugin installs cleanly on uv-managed cores.
- **Chat settings (Android).** The streaming-endpoint picker no longer wraps "Gateway"/"Sessions" onto a second line, and the system-prompt preview now reflects the enabled context toggles (foreground app, battery, safety rails) with representative placeholder values instead of looking inert.
- **Dashboard plugin: buttons rendered as blank boxes.** The host dashboard's Nous design-system `Button`/`Badge` use boolean variant flags (`outlined`/`ghost`/`invert`) and a `tone` prop — not the shadcn-style `variant` prop the plugin passed — so every button collapsed to a solid near-white fill with an invisible label. The plugin now translates its props to the design-system contract via an adapter, and drops a label-hiding CSS reset.
## [1.0.0] - 2026-06-14
### Added
- **Relay plugin diagnostics and install guidance.** `hermes relay doctor` now reports standard upstream API/dashboard reachability, Relay loopback state, dashboard plugin presence, plugin-manager layout, and whether the legacy bootstrap monkeypatch is installed. The plugin manifest now advertises its Android and desktop tools, and `after-install.md` gives the upstream plugin manager a first-run handoff.
- **Plugin-owned compatibility hook lifecycle.** `hermes relay compat status/install/remove` now owns the optional `hermes_relay_bootstrap.pth` startup hook, so the monkeypatch can be inspected, added, or removed without rerunning the legacy installer. The standard v1.0.0 path does not require this hook.
- **Legacy cleanup alignment.** The legacy installer now installs the optional `.pth` hook through the plugin compat lifecycle, and the uninstaller removes every shell shim it creates (`hermes-pair`, `hermes-status`, `hermes-relay`, `hermes-relay-update`, `hermes-relay-tailscale`) while delegating hook cleanup to `hermes relay compat remove` when available.
- **Gateway chat transport with live thinking.** Chat can ride the upstream dashboard `/api/ws` (the `tui_gateway` surface the official hermes-desktop client speaks) — the only vanilla-upstream path that streams reasoning *live*, so the Thinking block and sphere light up during generation. "Auto" prefers it when the dashboard is reachable and Manage is signed in, and falls back to the SSE endpoints per turn.
- **Gateway desktop parity.** Native image/PDF/file attachments (with an in-chat notice when a turn falls back to a transport that can't carry files), mid-turn **steering**, **edit & resend**, interactive **approval / clarify / sudo / secret** cards, live **subagent lanes**, a **context-window meter**, server **slash commands** in autocomplete, and **turn-complete notifications** when the app is backgrounded.
- **Gateway warm-start + Keep connected in background.** Pre-warming the gateway on foreground moves the cold session-setup cost off the send path. An opt-in foreground-service toggle (both flavors; `specialUse`, off by default) holds the socket open in the background so a long-backgrounded conversation resumes instantly.
- **Switch agent profiles from chat.** Pick a different agent — model, SOUL, personality, and skills — per conversation. The selection is **ephemeral** (bound to the session like the official desktop; it never changes the server's default agent for other clients). The session drawer scopes to the active profile and loads that profile's history, and the right agent is restored on cold start. The Manage tab's server-wide **Activate Profile** action now confirms first.
- **Manage parity with the desktop dashboard.** Change models from the full provider catalog, manage provider keys (write-only, masked, reveal), create/edit profiles and SOUL.md, and browse/install/update skills. Manage data is cached to disk for an instant cold launch.
- **Open & save chat images and attachments.** Tap an image for a full-screen viewer (pinch-zoom, double-tap, Share/Save); non-image attachments gain an Open/Share/Save menu. Saves land in `Pictures`/`Download/Hermes-Relay` with no permission on Android 10+, preserving the original bytes.
- **Persistent Realtime Agent voice + background runs (ADR 33).** The realtime engine keeps one session across turns (follow-ups retain context); a long Hermes run is promoted to a tracked background task and spoken when ready, so the conversation stays responsive.
- **Redesigned chat input bar.** A Telegram-clean pill field with one trailing button that morphs between Send / Voice / Stop / Steer / Queue; the slash button is gone (typing `/` still opens autocomplete).
- **Routes card reachability verdicts** ("Reachable", or the specific failure reason) and per-turn **latency tracing** (`TurnLatency`, durations only) for diagnosing transport speed.
### Changed
- **Relay plugin/server version aligned to v1.0.0.** The Python package, plugin manifest, dashboard manifest, and relay runtime now use the same `1.0.0` line as the stable Android release so a retagged source checkout describes one product version.
- **The standard (no-plugin) path is first-class.** Chat, Manage, and voice all work against an unmodified upstream Hermes agent; standard voice rides the dashboard audio surface (`/api/audio/*`) with the Manage sign-in, and relay-paired voice is the profile-aware fallback. The relay plugin is now purely additive.
- **Seamless connection UX.** LAN↔Tailscale handoffs and reconnects no longer reload the chat; connection and update status are now in-theme slide-down toasts over the content instead of banners that pushed the UI around.
- **Editable, roaming routes.** Add/edit/remove routes in Settings → Connections; bare-host URLs default their scheme and port (and preview what will be saved); remote-access (Tailscale) is surfaced in the main setup flow with a "Remote" readiness line.
- **Faster Manage.** A shared auth preamble plus concurrent payloads cut a full load from ~40 round trips to ~12; a process-lifetime cache and startup pre-warm render the last-seen data instantly, and Manage now names which dashboard URL it's talking to.
- **Faster, calmer cold start.** Key-less connections skip the multi-second keystore decrypt; the startup sphere is now the actual loading screen with narrated check lines, and the OS splash blends into it.
- **Docs + branding.** The docs site was rechromed to the app theme and repositioned around the two-path story; the README and Play listing were refreshed standard-first; product-name copy normalized to **Hermes-Relay**.
- **Quality-of-life.** Quote-in-reply, share-conversation-as-Markdown, ambient mode as a long-press gesture, a floating status pill, decluttered Manage cards, back buttons on pushed screens, and a softer active-connection card.
### Fixed
- **No "Connect to Hermes" flash on cold start.** The empty-state now distinguishes "still hydrating from disk" from "nothing configured" (`ConnectionStore.isHydrated` → `chatConnectState`), showing a quiet "Connecting to Hermes…" spinner until ready and the connect CTA only once hydration confirms no connection exists.
- **In-app What's New renders cleanly** — parsed into a version subtitle, bold section headers, and real bullets instead of raw text with literal `*`.
- **App-start UI freeze from Keystore lock contention.** The encrypted cookie store built its StrongBox-backed prefs eagerly in its constructor (1–4 s under a process-global lock) from several code paths at once; it now builds lazily on an I/O thread and is shared per connection.
- **Standard connections now follow LAN↔Tailscale changes**, standard voice follows the resolved route (not the persisted URL), and a stale probe cache can no longer pin a dead route after a handoff or resume.
- **Editing a URL no longer wipes fallback routes** (edits merge with stored extras instead of rebuilding from the edited URL alone); **"Re-check" / "Use now" no longer fail silently** (the probe always publishes its outcome and per-route failure reasons); and a network change can no longer resurrect a deliberately disconnected relay socket.
## [0.8.1] - 2026-05-26
### Fixed
@@ -32,7 +533,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Google Play Bridge Core split.** The Google Play Android track keeps relay pairing, chat, profiles, voice, terminal/TUI, media, notification companion, relay sessions, diagnostics, and status while removing AccessibilityService-backed Device Control declarations and permissions. Sideload remains the track for screen reading, gestures, screenshots, SMS/calls, contacts/location, overlays, wake locks, and unattended control.
- **Release lanes now use explicit product tags and names.** Future Android releases use `android-v*`, server/Python releases use `server-v*`, and desktop continues on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Server vX.Y.Z`, and `Hermes-Relay-Desktop vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
- **Release lanes now use explicit product tags and names.** Future Android releases use `android-v*`, plugin/Python releases use `server-v*`, and CLI releases continue on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Plugin vX.Y.Z`, and `Hermes-Relay-CLI vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
- **Realtime voice instructions are provider-neutral.** Realtime providers receive active interface context, local date/time, provider/model/voice/profile metadata, and guidance to ask Hermes for current facts, research, device/desktop state, project context, precise/versioned data, and any requested checks instead of guessing from model knowledge.
@@ -124,7 +625,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Desktop CLI alpha.14 — `Ctrl+A ?` chord re-displays the chord-help banner.** The attach-time banner scrolls off as soon as anything writes to the terminal, so users mid-session forgot the verb list and had to detach + re-attach (or guess). New `Ctrl+A ?` (and `Ctrl+A h` synonym) reprints the banner to stderr without leaving the session. Banner text refactored into a single `CHORD_HELP` constant so the attach-time print, the `?` chord, and the unknown-chord hint can't drift out of sync. Unknown-chord hint now also lists `?` as one of the known verbs.
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Bailey: *"This isn't cohesive — we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Reported gap: *"...we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
### Fixed
@@ -134,9 +635,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Android connection/profile state no longer leaks across switches.** Connection switches now clear the outgoing profile object immediately, load the destination connection's saved profile name only after that connection is active, and resolve it against the destination server's current profile list. The default local relay URL is now `ws://localhost:8767`, and auto-managed relay URLs are derived from the active API URL before reconnecting.
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** Bailey saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** A user saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** Bailey on alpha.9 ran`hermes-relay update --check`, expected to see alpha.10, got "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** On alpha.9,`hermes-relay update --check` expected to see alpha.10 but reported "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
- **Desktop CLI alpha.10 — `hermes-relay paste` always returned "No image on clipboard" on Windows even when an image was present.** Root cause: the PowerShell invocation in `captureClipboardWindows` (`src/chatAttach.ts`) was missing the `-STA` flag. `powershell.exe -Command` defaults to MTA (Multi-Threaded Apartment), and `[System.Windows.Forms.Clipboard]::GetImage()` only returns a valid image from STA threads — from MTA it silently returns null, indistinguishable from "no image present." Also affects the `chat` REPL's `/paste` command which routes through the same Windows code path. Fix: added `-STA` to the powershell args list (now `['-NoProfile', '-NonInteractive', '-STA', '-Command', ps]`). Live verification: empty clipboard returns null; a cyan 100×80 PNG placed via `[System.Windows.Forms.Clipboard]::SetImage` returns the expected 305-byte capture with correct dimensions. Affects `desktop-v0.3.0-alpha.7` through `desktop-v0.3.0-alpha.9`.
@@ -158,20 +659,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke added to`release-desktop.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke runs in`release-cli.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
- **`hermes-relay --version` printed `0.0.0` in compiled binaries.** `readVersion()` tried to read `package.json` via `__dirname + '../package.json'`, which doesn't resolve in a Bun `--compile` binary (no real filesystem layout). Replaced with a build-time-generated `src/version.ts` module (`npm run gen:version` writes the version from package.json before every build and every `build:bin:*`). `readVersion()` now just returns the embedded constant. Works identically in tsx / Node / Bun.
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from release-desktop.yml, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from the CLI release workflow, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
- **Installer couldn't find alpha-only releases.** GitHub's `/releases/latest/download/` URL deliberately skips prereleases, so the default `curl | sh` / `irm | iex` one-liner failed against alpha.1 with "maybe no Windows release for this version yet?" Both `install.sh` and `install.ps1` now query the Releases API directly (`GET /repos/.../releases`, filter to `desktop-v*` tags, take first) when `HERMES_RELAY_VERSION=latest`. Pinned versions unchanged.
### Added
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a Server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://172.16.24.250:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://192.168.1.100:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
- **Desktop CLI v0.2 — PTY shell, local tool routing, multi-endpoint pairing, reconnect + TOFU, devices, contextual banner.** The `@hermes-relay/cli` package at `desktop/` grew from a chat-only scripting surface into a full Hermes-experience thin client. Bare `hermes-relay` now drops into `shell` mode (interactive PTY pipe through the existing relay `terminal` channel → `tmux new-session -A` + post-attach `exec hermes` → the full local `hermes` banner/skin/session id verbatim, zero server changes). `Ctrl+A .` detaches preserving tmux; `Ctrl+A k` destroys it. New `devices` subcommand drives the relay's `GET/DELETE/PATCH /sessions` HTTP endpoints for listing, revoking, and extending server-side paired-device tokens. Status now surfaces `grants:` (per-channel expiry) and `expires:` (session TTL) pulled from the `auth.ok` handshake the transport already received — `RemoteSessionRecord` gained `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented` (additive, back-compat preserved via a `SaveSessionOptions | string | null` overload on `saveSession`). Contextual connect banner (`Connected via LAN (plain) — server 0.6.0`) replaces the flat `Connected (server X)` line across `chat` + `shell`. Multi-endpoint pairing (ADR 24): `--pair-qr <payload>` / `HERMES_RELAY_PAIR_QR` accepts a full v3 QR payload (compact JSON or base64), decodes the `endpoints[]` array, probes each candidate with strict-priority-within-tier racing (`Promise.any` + `AbortSignal.any`, 4 s per-candidate timeout, 60 s reachability cache), and auto-selects the first reachable — role propagates into the banner + stored record. Reconnect-on-drop: `RelayTransport` gained a `ReconnectState` machine (`idle|connecting|connected|reconnecting`), exponential backoff (1 s → 30 s, 5 min on 429), `reconnectGate` re-checked both at schedule time and post-backoff (matches Android's mid-sleep purge-race lesson), `'reconnecting'` + `'reconnected'` events, and bufferedEvents-cleared-on-reconnect. TOFU cert pinning: TLS probe runs before the WebSocket opens on `wss://`, extracts peer-cert SPKI sha256 (`sha256/<base64>`, OkHttp-compatible), compares against the stored pin or captures it first-time; mismatches error out with a human-readable "re-pair to reset" pointer. Client-side tool routing (Phase B): new `desktop` relay channel on the server (`plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` registering `desktop_read_file` / `desktop_write_file` / `desktop_terminal` / `desktop_search_files` / `desktop_patch`) forwards tool calls from Hermes to the connected Node CLI; client-side `DesktopToolRouter` dispatches to in-process handlers (`fs`, `terminal`, `search`) under a 30 s AbortController, 30 s heartbeat advertising the tool names. Gated behind a one-time per-URL consent prompt (`toolsConsented` on the session record) + `--no-tools` kill-switch; non-TTY stdin fails closed. New files on the client: `src/banner.ts`, `src/endpoint.ts`, `src/pairingQr.ts`, `src/certPin.ts`, `src/commands/devices.ts`, `src/tools/router.ts`, `src/tools/consent.ts`, `src/tools/handlers/{fs,terminal,search}.ts`. New files on the server: `plugin/relay/channels/desktop.py`, `plugin/tools/desktop_tool.py`, `docs/relay-protocol.md §3.5`. Still zero runtime deps on the client (Node ≥21 global `WebSocket` + `fetch` + `tls.connect` + `node:crypto` X509Certificate + `AbortSignal.any`). Build clean; live smoke passed for `status` / `tools` / `devices`; interactive `shell` + tool-call smoke pending user walk-through. Delivered as four parallel implementation agents (multi-endpoint, reconnect+TOFU, server-side desktop, client-side tool handlers) + one synthesis-and-integration pass; the `connectAndAuth → {relay, url, endpointRole}` return-shape refactor in `chat.ts` / `shell.ts` / `tools.ts` unifies how `--pair-qr`'s winning-endpoint URL overrides `--remote` across every subcommand.
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same**`~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://172.16.24.250:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same**`~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://192.168.1.100:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
### Changed
@@ -210,13 +711,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Changed
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of Bailey's double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of the double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
### Added
- **Card-dispatch → server session sync** (completes ADR 26). Every [HermesCardDispatch] now carries a `syncedToServer` idempotency flag; on the next chat send, `CardDispatchSyncBuilder` synthesizes unsynced dispatches into OpenAI-format `assistant`+`tool` pairs under a namespaced synthetic tool name `hermes_card_action` and splices them into the request body alongside the existing voice-intent synthetic messages. `ChatHandler.markCardDispatchesSynced` commits the flag after the API client accepts the request — same post-handoff timing as voice intents, so a thrown request-building exception leaves both streams retryable. Guarantees the LLM sees prior card interactions ("you approved the `Run shell command?` card") across server restarts and reconnects, including `open_url` dispatches that never go through `sendMessage`. Unit-tested under `CardDispatchSyncBuilderTest` (pure-function JVM tests, no Android deps).
- **Rich cards in chat via `CARD:{json}` inline markers** (ADR 26). Assistant messages can now surface structured Material 3 cards — skill results, approval prompts, link previews, calendar entries, weather — emitted as a single-line `CARD:{...}` alongside prose text. Follows the same streaming-endpoint-agnostic marker recipe as `MEDIA:`, so it works unchanged on `/v1/runs`, `/api/sessions/{id}/chat/stream`, and `/v1/chat/completions`. New `HermesCard` data class (`@Serializable`, `ignoreUnknownKeys=true` so newer agent schemas don't crash older phone builds) carries `title` / `subtitle` / `body` (markdown) / `fields` / `actions` / `footer` / `accent` (`info`/`success`/`warning`/`danger`). Built-in types: `skill_result`, `approval_request`, `link_preview`, `calendar_event`, `weather`; unknown types render via a generic fallback. `approval_request` intentionally mirrors Slack's exec-approval pattern (Allow / Deny with primary/danger button styles) so upstream Phase B adapter parity is a translation exercise, not a data-model rethink. Action dispatch (`send_text` default, `slash_command`, `open_url`) routes through `ChatViewModel.dispatchCardAction`, which stamps a `HermesCardDispatch` on the owning message before forwarding so the card collapses into a "Chose: X" confirmation even if the side effect fails. Renderer is `HermesCardBubble.kt` — accent stripe + Icon + Title/Subtitle + markdown body + fields table + FlowRow of action buttons. Cards render between the assistant's prose and any attachments in `MessageBubble`.
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (Bailey's call: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (deliberate: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
- **MorphingSphere on the docs site.** New `SphereMark.vue` component (in `user-docs/.vitepress/theme/components/`) renders a 58×34 sphere directly above the "Install in 30 seconds" block — mounted in the `home-hero-after` slot alongside `InstallSection` for a hero → sphere → install stack. Imports `preview/web/sphere.js` directly so `MorphingSphereCore.kt` remains the single source of truth across app / preview / docs. The cursor reactivity is **eye-only** — the sphere body stays anchored while the bright-spot gaze tracks the pointer (no canvas translate / body bounce). Gaze composition: **scroll-tracking is the always-on baseline** — the eye anchors to the Install section's top edge (via `.install-section` DOM query), not to the viewport center. `installGap = installRect.top − viewportH` is the runway until install enters view; as it shrinks below 50 % viewport-height, `scrollVy` ramps linearly to 1, so by the time install's top crosses into the viewport the eye is already looking straight down at it. Before that runway, the eye sits forward (`scrollVy = 0`). **Cursor-tracking is a soft overlay** — inside a rectangular detection band (full viewport width × container height, linear falloff over 1.0 × container height past the top/bottom edges) the cursor's unit-vector direction crossfades into the scroll target via `cursorWeight`. The eye always has one coherent target — no mode switching, no fbm drift fighting the cursor at the band boundary, no eye-flip between modes. Palette retarget Idle ↔ Listening is gated on `cursorWeight` (0.2 / 0.5 hysteresis) so the sphere reads as *calmly watching* at the scroll baseline and *attentive* on direct hover. A tiny fbm wander (±0.07 on top of the target) keeps the eye breathing when both scroll and cursor are stationary. Fallback when the install element isn't on the page: viewport-center reference preserves the gaze-follows-scroll feel without the anchor. Pointer inputs pass through a per-frame EMA low-pass (180 ms direction / 280 ms proximity time constants) before any math runs — stops the per-event jitter from `pointermove`'s big discrete jumps; asin/acos inputs are capped at ±0.9 so we stay off the infinite-slope end of the inverse-trig curves. Canvas is square (`aspect-ratio: 1 / 1`, `clamp(280px, 48vw, 420px)`) so the sphere fills the frame at the algorithm's natural 0.60-envelope sizing — no dead space between the phone video and the Install block. Respects `prefers-reduced-motion` (zeroes the gaze blend so the eye stops tracking but the ambient animation continues), pauses drawing while scrolled off-screen via `IntersectionObserver`, and resizes via `ResizeObserver` on the container. SSR-safe without a `<ClientOnly>` wrapper — `sphere.js` has no side-effectful imports and all DOM access lives inside `onMounted`, which Vue 3 never runs on the server.
- **`SphereFrame` gaze-bias fields in `MorphingSphereCore.kt` (mirrored in `sphere.js`).** New `lightAngleBiasX`, `lightAngleBiasY`, `lightAngleBlend` (all default 0f / 0) let callers aim the sphere's bright spot at a specific direction without touching the sphere body. The light-angle computation blends between the natural `t * lightSpeedX + noise` rotation (`blend = 0`) and the caller-supplied bias (`blend = 1`). Defaults preserve byte-identical behavior for every existing caller — Android `MorphingSphere.kt` composable, the parity test, and the JS parity harness all stay green because they never set the new fields. First consumer: `SphereMark.vue` on the docs site, which uses the bias to make the sphere's eye track the reader's cursor without bouncing the canvas.
- **`SphereFrame.shadowStrength`** (mirrored in `sphere.js`, default 0f / 0). Darkens `distBrightness` on the hemisphere facing away from the light, scaling it by `(1 − shadowStrength · (1 − directionalLight))` — the lit side is untouched, the shadow side dims proportionally. At 0 the legacy uniform "pearl" shading is preserved byte-for-byte. Docs-site `SphereMark.vue` uses 0.6 so the eye reads clearly against the unlit half of the sphere; Android composable doesn't set it and stays on legacy shading.
@@ -593,7 +1094,7 @@ sees the toggle, never installs the wake lock, and never invokes
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per Bailey's 2026-04-14 on-device repro).
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per a 2026-04-14 on-device repro).
- **Implementation.** Each phone-local voice intent now records a structured `VoiceIntentTrace` (tool name, JSON args, success, JSON result envelope) on the post-dispatch chat-trace bubble it produces. `VoiceIntentSyncBuilder` walks the chat history before each `POST /v1/runs` / `POST /api/sessions/{id}/chat/stream` call and synthesizes OpenAI-format `assistant` (with `tool_calls`) + `tool` (with `tool_call_id`) message pairs from any unsynced traces. The synthesized array rides under the existing payload's new `messages` field — additive, ignored by older servers, picked up by anything OpenAI Chat Completions–shaped. Idempotency: traces flip to `syncedToServer=true` the moment the API client takes ownership of the request, so subsequent turns don't re-emit them.
- **Zero server changes.** Frontend-only, no hermes-agent edits needed.
- **Files.** `data/ChatMessage.kt` (new `voiceIntent: VoiceIntentTrace?` field), `voice/VoiceIntentSyncBuilder.kt` (pure-function builder + helpers), `network/HermesApiClient.kt` (optional `voiceIntentMessages` parameter on both stream methods), `viewmodel/ChatViewModel.kt` (build + sync + flag flip in `startStream`), `viewmodel/VoiceViewModel.kt` (extended dispatch callback wires the structured trace into the chat-trace bubble), `voice/VoiceBridgeIntentHandler.kt` (new `androidToolName` + `androidToolArgsJson` on `IntentResult.Handled`), sideload `VoiceBridgeIntentHandlerImpl.kt` populates them per intent, sideload + googlePlay `VoiceBridgeIntentFactory.kt` typealias updates. Tests in `test/voice/VoiceIntentSyncBuilderTest.kt` (12 cases — empty input, single success, failure with error_code, idempotency, chronological order, prefix gate, blank-args gate, call-id pairing, helpers) and `test/network/handlers/ChatHandlerTest.kt` (4 new cases for trace storage + `markVoiceIntentsSynced`).
@@ -1175,7 +1676,14 @@ MVP release — native Android companion app for Hermes agent with direct API ch
> Read this before touching code. Then read docs/spec.md and docs/decisions.md.
> Read [AGENTS.md](AGENTS.md) first. It is the provider-neutral canonical agent
> context. Branch, release, staging, and hotfix rules live in `AGENTS.md` and
> [RELEASE.md](RELEASE.md); this file only adds Claude-specific project and tool
> guidance. Then read `docs/spec.md` and `docs/decisions.md`.
## What This Is
A native Android app (Kotlin + Jetpack Compose) paired with a Python relay server (aiohttp) for the Hermes agent platform. Chat connects directly to the Hermes API Server via HTTP/SSE; bridge and terminal use a relay over WSS.
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Vanilla Hermes chat, Manage, and dashboard voice work against unmodified upstream Hermes. The Relay plugin adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management via the official Hermes web dashboard.
**Current state:**v0.8.0 (release-prep on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode (stable Hermes Chat + Voice Output plus opt-in provider-native Realtime Agent with reliable low-latency playback and a text/mic Voice Lab), bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, connection diagnostics, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative, Bridge Core without Device Control) and `sideload` (full-capability).
**Current state:**Reference latest released version for stable state and current dev branch for working state. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Vanilla Hermes voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
## Architecture
```
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
Phone (WSS) → Relay Server (:8767) [bridge, terminal]
Chat goes directly to the APIserver via HTTP/SSE. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
The Vanilla Hermes path must stay upstream-only. API-server bearer auth and dashboard cookie auth are separate. Terminal and bridge require Relay pairing; Vanilla Hermes chat, Manage, and dashboard voice must not.
### Upstream Hermes API Reference
**IMPORTANT:** Always verify endpoints against the actual hermes-agent source (`gateway/platforms/api_server.py`). The upstream repo is the source of truth — not our docs, not our memory, not assumptions from other frontends.
**Standard endpoints (confirmed in hermes-agent source):**
**Vanilla Hermes endpoints (confirmed in hermes-agent source):**
| Endpoint | Purpose | Tool Call Format |
|----------|---------|-----------------|
| `POST /v1/chat/completions` | OpenAI-compatible chat (stream=true for SSE) | Inline markdown text (`` `💻 terminal` ``) — no separate tool events |
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
These endpoints are not in stock upstream `gateway/platforms/api_server.py`. There are three ways a hermes-agent install can serve them:
1. **Codename-11 fork** (`feat/session-api` branch, deployed on the `axiom` branch) — adds them natively. Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) *"feat(api-server): add session management API for frontend clients"* — scope is broader than the title: sessions CRUD + session chat/stream + memory + skills + config + available-models.
2. **Bootstrap injection** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file. Does NOT inject `/api/sessions/{id}/chat/stream` — use `/v1/runs` for chat.
3. **Upstream-merged** (post PR #8556) — bootstrap auto-detects and no-ops.
**Compatibility endpoints (not all native upstream API-server routes):**
| Endpoint | Purpose | Provided by |
|----------|---------|-------------|
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Fork OR bootstrap OR upstream-merged |
| `GET /api/sessions/{id}/messages` | Conversation history | Fork OR bootstrap OR upstream-merged |
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap OR upstream-merged |
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Fork OR upstream-merged ONLY (NOT bootstrap) |
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap OR upstream-merged |
| `GET /api/skills`, `/{name}` | Skill discovery (list + detail) | Fork OR bootstrap OR upstream-merged |
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap OR upstream-merged |
| `GET /api/available-models` | Provider model list | Fork OR bootstrap OR upstream-merged |
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions` or `runs` based on the capability snapshot.
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file, injecting only compatibility-only surfaces (session search, memory, legacy skill detail/toggle, config, available-models, slash middleware). Sessions CRUD/messages/fork and the legacy skills list are **retired** — native upstream owns them (#33134/#33016) and the bootstrap carries no fallback for old builds. Native routes still win per method/path for the remaining set. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
**Dashboard web server (separate surface — loopback-only):**
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info`, `/api/logs`, `/api/analytics/usage`. Auth is a page-injected `window.__HERMES_SESSION_TOKEN__` — loopback-only, no external issuance. **Do not proxy this surface over the relay.** Phone consumes the narrower, fork/bootstrap `api_server.py` surface or relay-native profile-scoped endpoints.
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions`, `completions`, or `runs` based on the capability snapshot.
**Dashboard web server (separate surface — standard Manage / Desktop remote gateway):**
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and `**POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **Vanilla Hermes (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`. Android uses it for Manage, Vanilla Hermes voice, and the gateway chat transport. `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths remain the SSE fallback. Relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
**Tool call rendering paths:**
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
2. **Sessions API** — No structured tool events during streaming; reloads message history on stream complete ("session_end reload" pattern).
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection path — gateway/API chat, Manage, and Vanilla Hermes voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
- **Bootstrap maintenance:** Remove `hermes_relay_bootstrap/` in one PR once PR #8556 merges. It's no-op-compatible, so leaving it in place during rollout is harmless.
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Done: sessions CRUD/messages/fork and the legacy skills list are retired from the bootstrap (native upstream #33134/#33016, no old-build fallback kept). Remaining: config, memory, legacy skill detail/toggle, available-models, session search, and slash middleware still need explicit replacement decisions before full removal.
- **docs/** — spec, decisions, security, and any other long-form documentation
- **DEVLOG.md** — update at end of each work session with what was done, what's next, blockers
- **DEVLOG.md** — update at end of each work session with what was done + verification (the factual record of *what happened*). It churns; do NOT park forward work here.
- **TODO.md** — the single home for follow-ups / deferred work / known gaps ("what's next"). Record them here — never buried in DEVLOG or scattered through code/doc comments where they get lost.
- **CLAUDE.md hygiene:** Key Files entries must stay one line — implementation detail belongs in the file or `docs/`. Run `/revise-claude-md` after feature-heavy sessions to trim drift.
### Public-repo writing hygiene
This is a **public, distributed repo** — every committed file (CHANGELOG, DEVLOG, README, docs, release notes) is public-facing. Write accordingly:
- **No personal names** in prose — attribute impersonally ("a user reported", "observed"). Author identity lives in git history + the signing cert, not the changelog.
- **No private infrastructure** — real server hostnames/IPs, internal deployment names, `~/SYSTEM.md` contents. (Generic example IPs like `192.168.1.100` in setup docs are fine.)
- **No AI/assistant process self-narration** — no "I should have…", no course-correction confessionals. State the technical conclusion, not the path to it.
- **No internal jargon / fork-branch plumbing** in user-facing notes — keep *what changed*, drop *where we staged it*.
- **CHANGELOG** uses Keep-a-Changelog grouping (Added / Changed / Fixed). Detail may accumulate during iteration, but at **release-prep the version block is condensed to crisp public bullets** (1–2 lines each) — deep "how we debugged it" stays in commits/DEVLOG. See [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution".
- **DEVLOG.md** is a committed, factual engineering log — what changed, why, and verification — depersonalized and third-person, not a diary.
### Code Style — Android (Kotlin)
- **Jetpack Compose** — no XML layouts. Material 3 / Material You.
- **kotlinx.serialization** — not Gson. Type-safe, faster.
- **OkHttp** for WebSocket + SSE — `okhttp` for WSS relay, `okhttp-sse` for API streaming
- **Single-activity** — Compose Navigation for all routing
- **Node ≥21** — uses built-in global `WebSocket` (no `ws`/`undici` dep). Strict TS, ES modules, `NodeNext` resolution.
- **Zero runtime deps** — `@types/node` + `tsx`/`rimraf`/`typescript` are devDeps only. Ship compiled `dist/`, not tsx.
- **One binary, subcommands** — idiomatic for Node CLIs (codex, continue, vite pattern). Bare invocation is `chat`.
@@ -138,148 +178,183 @@ hermes-android/
- **Dev loop:** `npx tsx src/cli.ts <args>` (no rebuild). `npm run build` + `npm link` before pushing to verify the bin shim. Never ship tsx in the published tarball — pre-build with `tsc` so Windows `npm install -g` can cmd-shim the JS directly.
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-server-version.sh` for `server-vX.Y.Z`, and `desktop/package.json` for `desktop-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
- **Branch/release policy:** follow the branch-contract table in `AGENTS.md` and
the executable release and hotfix procedures in `RELEASE.md`. Do not maintain
a Claude-specific parallel policy here.
### Testing
- **Android:** JUnit + Compose testing for UI, MockK for mocks
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-server.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass beforemerge to `dev`; release-merge to `main` requires the same.
- **CI and release gates:** follow the repository-wide requirements in
`AGENTS.md` and `RELEASE.md`; Claude-specific guidance does not redefine them.
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret`; canonical form preserves `endpoints` array order + role strings verbatim (ADR 24) |
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — `onChannel('desktop')` dispatch under 30s `AbortController`; heartbeat enriched with host/platform/version/uptime_ms + sticky `last_error` for `desktop_health` |
| `desktop/src/tools/handlerSet.ts` | Single source of truth for the desktop tool map — `DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`; consumed by `chat.ts` / `shell.ts` / `daemon.ts` so adding a tool is a one-file change |
| `desktop/src/tools/handlers/jobs.ts` | Job API — `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is source of truth across daemon restarts; `taskkill /T` on Windows so build trees die fully |
| `desktop/src/tools/handlers/transfer.ts` | `copy_directory` via `fs.cp`, `zip`/`unzip` via tar > zip > PowerShell probe, `checksum` streamed (sha256/sha1/md5) |
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
| `desktop/src/relayUrlPrompt.ts` | First-run URL fallback — `resolveFirstRunUrl()` auto-picks single stored session, numbered picker for multiple, welcome banner for zero; throws on non-interactive + ambiguous |
| `desktop/src/version.ts` | Build-time-generated constant (`npm run gen:version` before every build) — Bun compiled binaries can't read package.json via `__dirname` so version is embedded at build |
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — download prebuilt Bun binary (no Node required), SHA256-verified, API-resolver for `latest` that includes prereleases, version-aware pre/post-install readback |
| `desktop/scripts/uninstall.sh` / `uninstall.ps1` | 3-tier removal — default (binary + PATH), `--purge` (also wipes `~/.hermes/remote-sessions.json`), `--service` (stub for future service installers); Windows iex-safe env-var fallback |
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
| `release-desktop.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
| `ui/RelayApp.kt` | Main scaffold (Scaffold + Compose nav); Chat is home — no mode strip, Manage/Bridge reached via Settings; `bottomBar` is a status pill, not a NavigationBar |
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
| `network/GatewayChatClient.kt` | Gateway chat transport — JSON-RPC over dashboard `/api/ws` (tui_gateway); live `reasoning.delta`; fresh ws-ticket per connect; per-turn SSE fallback via `onPreflightFailure`; `prewarm()` (connect+resume off the send path); `setKeepAliveInBackground()` suppresses the 120s idle-close |
| `network/GatewayKeepAliveService.kt` | Opt-in `specialUse` foreground service (BOTH flavors; declared in main manifest; Play needs a Console FGS declaration) holding the process up so the gateway socket survives background/Doze; driven by ConnectionViewModel from the `KEY_GATEWAY_KEEP_ALIVE` toggle; stops on task-removal |
| `data/GatewayKeepAlivePrefs.kt` | Shared `KEY_GATEWAY_KEEP_ALIVE` pref key + `Context.setGatewayKeepAlive()` — used by ConnectionViewModel (StateFlow/setter) and the FGS Stop action |
| `network/GatewayEventMapper.kt` | Pure-JVM gateway event→callback mapping for one turn; unknown event types silently ignored; tui_gateway usage-key translation |
| `util/MediaSaver.kt` | Save/share/open for chat media — MediaStore scoped-storage save (Pictures/Download `Hermes-Relay`, no perms on API 29+; pre-Q → share sheet); FileProvider share staging; remote-byte fetch; magic-byte image-MIME sniff for correct extensions |
| `ui/components/ChatImageViewer.kt` | Full-screen image viewer — pinch-zoom/pan (`detectTransformGestures`), double-tap 1×/2.5×, Share/Save/Close; `ChatImageViewerSource` decouples Coil-model/bitmap display from a suspend `bytesProvider` so Save keeps original bytes |
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic; image tap → ChatImageViewer, file card long-press → Open/Share/Save menu |
| `ui/components/ChatImageContent.kt` | Parses `` out of assistant content; remote http(s) → Coil (tap → ChatImageViewer), server-local/failed → inline "can't render" notice with the path |
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
| `util/TurnLatencyTracer.kt` | One `TurnLatency` INFO line per chat turn — `warm/cold` + `connect/session/submit/ttfe/ttft/done@…ms`; gateway + 3 SSE paths use it for desktop-comparable latency diagnosis; durations only |
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret`; canonical form preserves `endpoints` array order + role strings verbatim (ADR 24) |
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat`; command-scoped `--help` falls through to each command |
| `desktop/src/lib/table.ts` | Zero-dep column-aligned table renderer (ANSI-width aware, last column flexes to terminal width) — used by devices/sessions/audit |
| `desktop/src/lib/spinner.ts` | Stderr braille spinner for slow ops (pair probe, gateway connect); no-op when piped/quiet/json |
| `desktop/src/lib/auditLog.ts` | Local desktop-tool audit JSONL (`~/.hermes/desktop-audit.jsonl`); router appends per dispatch; backs `audit` command (relay's ring is loopback-only) |
| `desktop/src/commands/audit.ts` | `hermes-relay audit` — tails the local audit log into a table (WHEN/TOOL/STATUS/DETAIL); `--limit`, `--json` |
| `desktop/src/commands/relay.ts` | `hermes-relay relay info/security/context/queue` — relay-server management surface; info/security/queue loopback-only, context works remote with bearer; `queue` lists/cancels the agent→phone outbound buffer (`--clear` / `--cancel <id>`) |
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — `onChannel('desktop')` dispatch under 30s `AbortController`; heartbeat enriched with host/platform/version/uptime_ms + sticky `last_error` for `desktop_health` |
| `desktop/src/tools/handlerSet.ts` | Single source of truth for the desktop tool map — `DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`; consumed by `chat.ts` / `shell.ts` / `daemon.ts` so adding a tool is a one-file change |
| `desktop/src/tools/handlers/jobs.ts` | Job API — `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is source of truth across daemon restarts; `taskkill /T` on Windows so build trees die fully |
| `desktop/src/tools/handlers/transfer.ts` | `copy_directory` via `fs.cp`, `zip`/`unzip` via tar > zip > PowerShell probe, `checksum` streamed (sha256/sha1/md5) |
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
| `desktop/src/relayUrlPrompt.ts` | First-run URL fallback — `resolveFirstRunUrl()` auto-picks single stored session, numbered picker for multiple, welcome banner for zero; throws on non-interactive + ambiguous |
| `desktop/src/version.ts` | Build-time-generated constant (`npm run gen:version` before every build) — Bun compiled binaries can't read package.json via `__dirname` so version is embedded at build |
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — download prebuilt Bun binary (no Node required), SHA256-verified, API-resolver for `latest` that includes prereleases, version-aware pre/post-install readback |
| `desktop/scripts/uninstall.sh` / `uninstall.ps1` | 3-tier removal — default (binary + PATH), `--purge` (also wipes `~/.hermes/remote-sessions.json`), `--service` (stub for future service installers); Windows iex-safe env-var fallback |
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
| `release-cli.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
| **Gradle modules — experimental Quest/XR (in development)** | |
| `relay-core/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.core`) — shared pairing/transport/terminal/voice/wire for the Quest port; not yet wired into the shipped `:app` |
| `relay-ui/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.ui`) — shared Compose UI (sphere, terminal WebView, QR scanner) for the Quest port; carries its own sphere copy |
| `quest/` | [EXPERIMENTAL] Meta Spatial SDK Quest/XR app — gradle `includeBuild("quest")`; needs further development, not shipped |
| **Tooling — dev iteration (not shipped)** | |
| `ui-preview/` | Desktop Compose Hot Reload harness — JVM Compose for Desktop; source-shares `MorphingSphereCore` from `:relay-ui`; `Main.kt` gallery; see `ui-preview/README.md` |
| `app/src/test/.../screenshots/StoreScreenshotTest.kt` | Roborazzi host-side store/docs screenshot renderer — deterministic, no device, exact 1080×2160; reuses real components+chrome with mock data; `capture(name, themeId){…}` renders any view; see `docs/screenshot-automation.md` §Deterministic rendering (JDK-21 + no-plugin gotchas) |
## What NOT to Do
@@ -288,16 +363,20 @@ hermes-android/
- **Don't use Ktor for networking** — OkHttp for WebSocket
- **Don't use plaintext WebSocket** — `wss://` only, even in development
- **Don't put documentation in root** — long-form docs go in `docs/`
- **Don't forget DEVLOG.md** — update it
- **Don't forget DEVLOG.md** — update it (record *what happened*)
- **Don't bury follow-ups** — deferred work / known gaps go in `TODO.md`, never in DEVLOG or one-off code/doc comments
- **Don't touch production / remote hosts** — automation and orchestrated agents must NEVER SSH into, deploy to, pull/restart/reconfigure, or push code to a live/remote Hermes host. Building, on-device testing, and server deployment are owner-driven (see Server Deployment). Stop at committing on your branch; surface "this needs a deploy/on-device check" rather than doing it.
## MCP Tooling
Two MCP servers are configured for AI-assisted development. See `docs/mcp-tooling.md` for full reference.
@@ -327,8 +406,8 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
1. **Edit locally** — Windows checkout. Both plugin (`plugin/`) and app (`app/`) live here.
2. **Python syntax check** — `python -m py_compile plugin/<file>.py`. Full tests run on the server.
3. **Kotlin changes** — do NOT run `gradle build`. Bailey builds via Android Studio's ▶ button. Never `adb install` from Claude.
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs (see `.github/workflows/ci.yml` → `gradlew lint` fallback) and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Lint is a hard blocker in CI: Build + Test show "skipping" until lint passes, and lint prints only the **first failure** before aborting — so CI iterations reveal errors one at a time while a single local lint run surfaces all of them.
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Android CI runs lint alongside build/test for faster feedback, but a local lint run still surfaces issues before the workflow spends runner time compiling and packaging.
5. **Commit + push** — follow `AGENTS.md` and `RELEASE.md`; normal work PRs to `dev`.
6. **Pull + restart on server** — see Server Deployment below.
7. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
@@ -336,83 +415,105 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
Server is a Linux box running hermes-agent with hermes-relay editable-installed (`pip install -e`). Sensitive details (IP, user, secrets) in `~/SYSTEM.md` on the server — not in this repo.
**Compat hook:** `hermes relay compat status/install/remove` manages only the
optional `hermes_relay_bootstrap.pth` startup hook. New installs load the
plugin-owned bootstrap from `plugin/hermes_relay_bootstrap/`; the repo-root
package is only a legacy import shim. Vanilla Hermes chat, Manage, and dashboard voice
must not depend on this hook.
**Key conventions:**
- Phone re-pairs after each relay restart (SessionManager is in-memory; wiped on restart)
- Phone pairing **survives** relay restart — `SessionManager` persists sessions to `~/.hermes/hermes-relay-sessions.json` (`server.py:88-90`, `persistence_path` from `RelayConfig.from_env`); a trusted-device refresh token recovers a lost/revoked/reset session without a new QR scan. (Only the in-memory *live-connection presence* clears on restart; the phone reconnects automatically.)
- Use `python -m unittest` not `pytest` — conftest imports `responses` which may not be installed
- `_env_bootstrap.py` loads `~/.hermes/.env` on every relay start — no stale API keys
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
| Capabilities | `HEAD /api/sessions`, `HEAD /v1/runs`, etc. | HEAD avoids CORS 403 on OPTIONS preflight |
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list | revoke <prefix> | extend <prefix> --ttl <s>`; bearer token from stored session; token prefix only (never full token) |
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list |
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
## Upstream References
| Topic | Upstream File |
|-------|--------------|
| API endpoints | `gateway/platforms/api_server.py` — all registered HTTP routes |
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.
**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.
## 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.
### 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`.
### 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.
@@ -92,9 +92,79 @@ After the plugin is in place, restart hermes and verify pairing with `hermes-pai
We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
**Branching model (as of 2026-04-19): `main` + `dev`.** Feature branches — `feature/<name>`,`fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back into `dev` via `--no-ff` PRs. `main` is released state only; it receives release merges from `dev` and nothing else. There is no straight-to-main exemption — even single-file typos go through `dev`.
`fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back
into `dev` via merge-commit/no-ff PRs. This includes small documentation fixes.
`main` is release history, not the normal contribution target; it receives
approved release PRs from `dev` and focused hotfix PRs based on production tags.
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
Feature completion means merged and verified on `dev`; it does not mean the
change has been released. A separate Forge release issue/session owns release
preparation, the `dev` → `main` release PR, tagging, artifacts, rollout or
deployment, and live verification. Release-prep commits land on `dev`; tags are
cut from the resulting `main` tip as `android-vX.Y.Z`, `server-vX.Y.Z`, or
`desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release and hotfix
procedures.
## Stale PR salvage and contributor credit
A valuable pull request can become unsafe to merge when `dev` has materially
changed around it. Maintainers may create a replacement **salvage PR** from the
current `dev` instead of resolving a stale branch by choosing whole conflict
sides.
A salvage PR must:
- Link the original PR and contributor in its title or opening summary.
- Recover only the intended feature; unrelated fork, release, signing, and
generated migration changes stay out.
- Preserve the original commit author when a substantive commit can be safely
cherry-picked.
- Use a verified `Co-authored-by: Name <email>` trailer when the implementation
must be reconstructed or substantially rewritten.
- Include a `Lineage` section listing source and superseded PRs, plus a concise
explanation of integration changes made for current `dev`.
- Run current verification rather than relying on checks from the stale branch.
- Leave a comment linking the replacement before the source PR is closed.
The maintainer remains the committer for integration commits. The original
contributor remains the author or co-author of the recovered work. Do not guess
an email address: use the source commit's verified address or ask the
contributor.
## Localization contributions
English resources are canonical and Android locale catalogs must retain exact
resource and format-argument parity. Read [docs/localization.md](docs/localization.md)
before changing user-facing strings or adding a language.
Translation PRs should cover one locale or one clear catalog refresh. They must
not include custom APK publishing, signing configuration, version bumps, or
fork-specific branding. Run:
```bash
python scripts/check-android-locales.py
./gradlew lint
```
Update `docs/localization-status.json` with the actual review level. AI-assisted
translations may ship as `ai-translated`; do not claim fluent review unless a
review reference is recorded. Focused correction PRs from fluent contributors
are the canonical way to improve wording and can advance a locale to
`community-reviewed` or `verified` under `docs/translation-playbook.md`.
Translated READMEs use separate `README.<locale>.md` files; `README.md` remains
the canonical project description. User docs may be added incrementally under
`user-docs/<locale>/`, with links back to canonical English reference material.
## Changelog & writing conventions
This is a **public repo** — `CHANGELOG.md`, `DEVLOG.md`, the README, and everything under `docs/` ship publicly. Keep them clean:
- **`CHANGELOG.md`** follows [Keep a Changelog](https://keepachangelog.com/) (Added / Changed / Fixed). Append your change to the `## [Unreleased]` block in the PR. Entries can carry detail while they accumulate, but at release-prep the version block is **condensed to crisp public bullets** (1–2 lines each) — the deep "how we debugged it" narrative belongs in commit messages and `DEVLOG.md`, not the public changelog.
- **`DEVLOG.md`** is a factual engineering log — what changed, why, and how it was verified. Keep it depersonalized and third-person; it's a record, not a diary.
- **No non-public wording anywhere committed:** no personal names (attribute impersonally — identity lives in git history), no real server hostnames/IPs or internal deployment names, no AI/assistant process self-narration, no fork/branch plumbing in user-facing notes. Generic example IPs in setup docs are fine.
Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/play-store-listing.md`) are theme-framed and user-facing; see [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution" for the full checklist.
This patch hardens Relay authorization, adds upstream-aware diagnostics, and keeps plugin bootstrap work off the Gateway event loop.
It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and power features. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
## What's changed
### Added
- **Upstream-aware Gateway diagnostics.** Doctor and `/relay/info` expose optional health, configuration-route, and capability signals so clients can explain compatibility gaps without treating an older upstream install as a broken Relay.
### Fixed
- **Privileged Relay paths enforce host authorization and active grants.** Pairing, Android bridge, terminal, session policy, remote profile configuration, and voice provider origins retain their intended trust boundaries.
- **Plugin bootstrap remains responsive.** Database initialization and compatibility inspection run outside the Gateway event loop while preserving compatibility with older upstream bootstrap contracts.
- **Windows Gateway detection is non-signalling.** Starting Relay and periodic profile rescans no longer risk terminating an existing Gateway process.
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — your Hermes agent, in your pocket" width="800">
</p>
<h1 align="center">Hermes-Relay</h1>
<p align="center">
<strong>Runs on your machine. Lives on your devices.</strong><br>
A native Android companion for your <a href="https://github.com/NousResearch/hermes-agent">Hermes agent</a> — streaming chat, hands-free voice,
and full agent management. Plus a single-binary CLI that gives the agent hands on any machine you pair.
</p>
<p align="center">
<strong>One Hermes agent. Two ways to use it.</strong><br>
A native Android remote-control app for your phone, plus a desktop CLI that lets you<br>
use a server-deployed Hermes from your laptop as if it were running locally.
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
- **📱 Android app** — streaming chat, hands-free voice, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. On sideload builds, the agent can read your screen and act on it.
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**. Add the optional relay only when you want terminal, phone control, or the CLI's tools. **Pair once from either surface; both work.**
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — Vanilla Hermes (Chat, Manage, Voice) runs with no plugin; the optional Relay plugin adds Terminal, Bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build." width="900">
</p>
---
## Quick Start (Android)
## Two surfaces, one pair
Install → connect → talk, in about two minutes.
| Surface | What | Status |
|---------|------|--------|
| **[Android app](#1a-android-app)** | Native phone control — chat, voice, the agent reads your screen and acts on it (tap, type, swipe), notification companion, multi-Connection. | Available — Google Play (Internal testing) + sideload APK |
| **[Desktop app + CLI](#1b-desktop-app--cli-experimental)** | Use a server-deployed Hermes from your laptop **like it's local**. Windows gets the native tray app first: pair, start/pause the daemon, view devices, task log, settings, overlay status, and emergency stop. The CLI remains the terminal/headless surface and powers macOS/Linux installs. Experimental computer-use tools are opt-in. | **Experimental** — `desktop-v0.3.0-alpha.18` (Windows tray installer + native CLI binaries, no Node required) |
### 1 · Install the app
Both share `~/.hermes/remote-sessions.json` and the same WSS relay. **Pair once from either, both work.**
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://hermes-relay.dev/docs/guide/getting-started.html#sideload-apk).
---
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
## Quick Start
### 2 · Have the Hermes Dashboard running
Three steps: pick your surface (or install both), then install the relay plugin on your Hermes server.
### 1a. Android app
<!-- TODO: Uncomment when Play Store listing is live
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80"></a>
-->
- **Google Play** — coming soon (currently on Internal testing)
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and choose the newest Android release (`android-v*`; historical Android releases used bare `v*`)
#### Sideload APK (GitHub Releases)
Prefer not to wait for Google Play? Grab the signed APK directly:
1. Download the file ending in **`-sideload-release.apk`** from the newest Android release (`android-v*`; historical Android releases used bare `v*`) on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) — that's the full-featured "Hermes Dev" build. (Skip any `.aab` file — those are the Google Play bundle format and won't install directly.)
2. On your phone: **Settings → Apps → Special app access → Install unknown apps** and allow your browser (first time only).
3. Open the APK from your downloads and tap **Install**.
4. Optionally verify integrity against `SHA256SUMS.txt` from the same release (`sha256sum` on macOS/Linux, `Get-FileHash -Algorithm SHA256` on Windows).
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
**Staying up to date (sideload):** the app checks GitHub for a newer release on cold start (at most once every 6 hours) and shows a dismissable banner when you're behind. Tapping **Update** opens the next APK in your browser so Android's Downloads notification hands it to the system installer — no second app required. You can also trigger a check manually under **Settings → About → Updates**. Google Play installs get auto-updates through the Play Store and don't show this banner.
### 1b. Desktop app + CLI (experimental)
The desktop surface talks to a server-deployed Hermes over WSS. On Windows, the default installer launches the native tray app with pairing, daemon control, devices, task log, settings, overlay status, pause, and emergency stop. The same release still ships the `hermes-relay` CLI for shell/TUI use, scripting, headless daemon mode, and macOS/Linux.
The remote agent can also reach back through the relay and run `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, etc. **on your machine** while its brain stays on the host. One pair, two surfaces (with the Android app), no `ssh`.
The normal Android connection uses the upstream Hermes Dashboard/Gateway for
chat, sign-in, sessions, Manage, and voice. Installing Hermes and choosing a
provider is vanilla Hermes setup:
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
hermes setup --portal # install / log in / pick a provider — skip if already done
hermes dashboard # start the standard Dashboard/Gateway surface
```
Make the dashboard reachable from your phone over a trusted LAN, Tailscale, or
an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started)
covers Windows, remote access, and dashboard authentication. You do not need to
enable the separate API server or invent an API key for the standard path.
### 3 · Connect and talk
Open the app, choose **Connect to Hermes**, and enter or discover the dashboard
address (conventionally `http://<host>:9119`). Sign in through the dashboard's
configured provider when prompted. The app probes the available upstream
capabilities and finishes with a connection summary.
The separate API server can be discovered automatically or added later under
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
key is requested only when that optional endpoint is configured. Existing
API-first setup QRs remain importable.
The wizard probes everything and finishes with a capability card:
| Line | What it means |
|------|---------------|
| **Chat** | Dashboard/Gateway ready — you can talk |
| **Manage** | Models, keys, skills, and profiles are available from the phone |
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
| **API fallback** | Optional API route available/unavailable |
| **Relay** | Optional extensions — fine to leave unpaired |
One dashboard sign-in unlocks Chat, Manage, sessions, and standard voice. That's
the whole Vanilla Hermes setup.
> **Going places?** Add the Dashboard's Tailscale address — for example `http://100.x.y.z:9119` or a separately published `https://host.ts.net` URL — under **Settings → Connections → Routes**. Android tests it as a Dashboard route; no API server or API key is required. The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
### 4 · Optional: install Relay for power tools
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
```bash
hermes-relay pair --remote ws://<host>:8767 # once
hermes-relay # interactive Hermes TUI in tmux
hermes-relay "summarize the last commit"# one-shot
hermes-relay --json "..."| jq # structured events for scripting
hermes-relay daemon # headless tool router (agent reaches you anytime)
hermes-relay update # self-update via GitHub Releases
**Native paste workflow** (the killer demo): inside `hermes-relay shell`, hit `Win+Shift+S` to screenshot, then `Ctrl+A v` — the client reads your clipboard, ships the image to the server's inbox, and types `/paste` into the TUI for you. Identical UX to native local-Hermes paste. The same chord set works on macOS (`Cmd+Shift+4` → `Ctrl+A v`) and Linux (Wayland/X11 detected automatically).
**No Node required** — the Windows tray installer bundles the compiled CLI sidecar; CLI-only installs use Bun-compiled native binaries (~60–110 MB per platform) via curl/irm. Version-aware install (`upgrading X → Y`), collision-safe `hermes` short alias for CLI installs, self-update via `hermes-relay update`. Assets are **unsigned** during the experimental phase — SmartScreen/Gatekeeper warnings are expected. Code signing, multi-client server-side routing, and service installers (sc.exe / systemd / launchd) land with v1.0.
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your client via either of these equivalent entry points:
The plugin-manager install owns the plugin code, dashboard tab, CLI commands,
and agent tools. `hermes relay compat status/install/remove` manages only the
optional legacy API compatibility hook when an older Hermes build needs it. Scan
the QR from the phone's Connections screen — or use
`hermes pair --register-code ABCD12` with the manual code from Android
**Settings → Connections → Advanced**.
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type`/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR + 6-char code inline. Shortest path if you're already chatting with the agent.
- **From a shell**:`hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. For Android: read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. For the desktop CLI: just pass it as `hermes-relay pair ABCD12 --remote ws://<host>:8767`. Composes with `--ttl` / `--grants`.
- **Plugin-manager uninstall:**`hermesrelay compat remove --all` if you installed the optional hook, then `hermes plugins remove hermes-relay`.
- **Legacy installer update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
- **Legacy installer uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — removes the service, shims, clone, external skill path, editable package, and compat hook. It never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a **Relay** tab (paired devices, bridge activity, media tokens) appears in the web UI.
Scan the QR from the Android app's onboarding screen, OR paste the 6-char code into `hermes-relay pair --remote ws://<host>:8767` on your laptop, and you're connected. One pair configures **both** the direct-chat API server **and** the relay (WSS for terminal / bridge / TUI / desktop tools, HTTP for voice routes) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat from the Android app, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
**Dashboard plugin.** If your hermes-agent install has the Dashboard Plugin System (upstream `axiom` branch), Hermes-Relay ships a plugin at `plugin/dashboard/` that surfaces paired devices, bridge command activity, and active inbound-media tokens in the gateway's web UI. It auto-registers through the same `~/.hermes/plugins/hermes-relay` symlink created by `install.sh` — restart the gateway and a "Relay" tab appears. See [docs/relay-server.md](docs/relay-server.md) and `user-docs/features/dashboard.md` for details.
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ on the server. The API server and Relay are optional.
**Updating:**`hermes-relay-update` (shortest path — installed as part of the one-liner) or re-run the same `curl … | bash` from above. Both are equivalent and fully idempotent: pulls latest main, refreshes the editable install, recreates all three shims, restarts `hermes-relay`, and prompts before restarting `hermes-gateway`. Set `HERMES_RELAY_RESTART_GATEWAY=1` to opt into the gateway restart non-interactively. For routine plugin/skill updates without restarting anything, a plain `cd ~/.hermes/hermes-relay && git pull` is enough — the editable install picks up the new code on next process start.
## Screenshots
**Uninstalling:**`bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
<td align="center" width="25%"><img src="assets/screenshots/06_manage.png" alt="Manage your agent" width="100%"><br><sub><b>Manage your agent</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/07_connections.png" alt="Connections and routes" width="100%"><br><sub><b>Connections & routes</b></sub></td>
<td align="center" width="25%"><img src="assets/screenshots/08_appearance.png" alt="Agent avatar & skins" width="100%"><br><sub><b>Avatars & skins</b></sub></td>
</tr>
</table>
**Requirements:** Android 8.0+ (SDK 26) for the Android app · macOS / Linux / Windows for the desktop CLI · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server.
If you have an AI assistant (Claude, GPT, etc.) and want it to install or maintain Hermes-Relay for you, paste the block below into the chat. The agent will fetch the canonical setup recipe from this repo and walk you through it — verification, pairing, troubleshooting included.
The Android app ships complete AI-assisted catalogs for **Deutsch**, **Español**,
**日本語**, **Português (Brasil)**, and **简体中文**. Choose a language from
**Settings → Appearance → Language**; translation status and fluent review are
tracked independently so community corrections remain easy to contribute.
<p align="center"><sub>▶ <a href="https://hermes-relay.dev/docs/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
## Features
### Android
- **Streaming chat** — rides vanilla Hermes, preferring the dashboard gateway (`/api/ws`, live thinking) when signed in to Manage and falling back to API-server SSE otherwise, with live markdown, tool-call cards, session history, a searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing.
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage keys (write-only, masked, rate-limited reveal), create and edit profiles including `SOUL.md`, and browse/install/update skills. One dashboard sign-in covers it all.
- **Hands-free voice** — talk on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice and an opt-in provider-native Realtime Agent with background task handoff.
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
- **Multi-Connection + profiles** — pair multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay a profile's model + `SOUL.md` per chat.
- **Phone control (bridge)** — with Relay paired, the agent reads the screen and acts: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros. Guarded by per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
- **Notification companion** — opt-in access so the agent can triage, summarize, and route incoming notifications.
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks).
## 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.
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.
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.
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
<br>
If an AI assistant manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
```text
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a CLI + a Python plugin for the Hermes AI agent platform.
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair` (Android), OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
- Connecting my phone by Vanilla Hermes API URL/key first, then optionally pairing Relay via `hermespair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the Hermes-Relay CLI (`irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (CLI)
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
```
Already have Hermes-Relay installed? The same recipe is auto-loaded as a Hermes skill — invoke it from any chat with `/hermes-relay-self-setup` for re-setup, troubleshooting, or "is everything wired correctly?" checks. Single source, two delivery modes (raw URL pre-install + Hermes skill post-install), no drift.
Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `/hermes-relay-self-setup` from any chat for re-setup or "is everything wired correctly?" checks.
## What It Does
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — native on Android, native in the terminal, with the agent able to reach back through the relay and act on either surface.
| Surface | Channel | What | Status |
|---------|---------|------|--------|
| Android | **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
| Android | **Voice** | Real-time voice conversation via relay TTS/STT | Available |
| Android | **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
| Desktop CLI | **In-shell paste / screenshot** | `Ctrl+A v` (clipboard image → server inbox → `/paste` auto-typed). `/screenshot` is multi-monitor by default. | Available (experimental) |
| Desktop CLI | **Local tool routing** | Agent calls `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` — runs on YOUR machine over the same relay | Available (experimental) |
| Desktop CLI | **Daemon** | Headless tool router — keeps tools advertised even when no shell is open | Available (experimental) |
| Desktop CLI | **Self-update** | `hermes-relay update` polls GitHub Releases, atomic-swaps the binary | Available (experimental) |
## What's new in v0.6.0
- **Connect from anywhere** — multi-endpoint pairing with first-class Tailscale support; plug in any VPN or reverse proxy mode. See [`docs/remote-access.md`](docs/remote-access.md).
- **Multi-Connection support** — pair with multiple Hermes servers (home + work, dev + prod, etc.) and switch in one tap from the Chat top bar. Each Connection keeps its own sessions, personalities, profiles, and relay state; theme and safety preferences stay global. Existing installs migrate transparently.
- **Agent Profiles** — the relay auto-discovers upstream Hermes profiles at `~/.hermes/profiles/*/` and the phone overlays the selected profile's model + `SOUL.md` on chat turns. Ephemeral, chat-only, clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED` (default on).
- **Consolidated agent sheet** — Profile + Personality selection and per-session analytics now live in one scrollable bottom sheet opened from the Chat top-bar agent name.
See the [changelog](CHANGELOG.md) for the full list.
## Features
### Android
- **Streaming chat** — Direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, personality picker, searchable command palette (29+ gateway commands), file attachments, and send-while-streaming message queuing
- **Multi-Connection + agent profiles** — Pair with multiple Hermes servers and switch targets from the top bar; select an upstream-discovered agent profile to overlay model + `SOUL.md` on chat turns. Three-layer model: Connection (server) → Profile (agent directory) → Personality (prompt preset)
- **Voice mode** — Experimental server-mediated voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Hermes owns chat, tool calls, and approvals, while relay voice output defaults to provider-neutral streaming TTS (`xai_tts` first) with realtime voice-agent providers kept as a separate lab mode.
- **Phone control (bridge)** — The agent can read what's on screen and act on it — tap, long-press, drag, swipe, scroll, type, and press system keys — plus take screenshots, read/write the clipboard, and control system-wide media playback. Gesture reliability is hardened for dim/idle screens, and a smarter tap-fallback cascade handles apps where labels sit inside non-clickable wrappers
- **Screen understanding** — Filtered accessibility-tree search, per-node property lookups with stable IDs, cheap screen-hash change detection, and multi-window reads (system overlays, popups, notification shade) so the agent can reason about UI without guessing
- **Workflow automation** — Batched macro execution for multi-step flows, real-time accessibility event streaming for "wait until something happens" waits, and a raw-Intent escape hatch for apps that expose deep-link actions
- **Notification companion** — Opt-in notification access so the agent can triage, summarize, and route incoming notifications
- **Analytics** — Stats for Nerds with TTFT, token usage, stream health, and peak-time charts
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the full sideload capability matrix.
### Desktop CLI
- **Shell mode (default)** — bare `hermes-relay` pipes the host's actual `hermes` Ink TUI through a PTY in tmux. Same banner, same skin, same slash commands as a local install. `Ctrl+A .` detaches (preserves tmux), `Ctrl+A k` kills, `Ctrl+A v` pastes a clipboard image, `Ctrl+A ?` re-prints chord help, `Ctrl+A Ctrl+A` literal.
- **Chat mode** — REPL or one-shot or piped stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. REPL slash commands `/paste` (clipboard), `/screenshot` (multi-monitor by default; `primary` / `1` / `2` to narrow), `/image <path>` attach the next message.
- **Local tool routing** — agent calls `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — all run on YOUR machine over the same WSS relay. One-time per-URL consent gate; `--no-tools` kill-switch; non-TTY stdin fails closed; agent-proposed patches render as colored diffs with `y/n/e/r` interactive approval. Experimental `desktop_computer_*` control tools require `--experimental-computer-use` / `HERMES_RELAY_EXPERIMENTAL_COMPUTER_USE=1`, task-scoped grants, and visible local approval.
- **Daemon mode** — `hermes-relay daemon` runs the tool router headless so the agent can reach you even when no shell is open. JSON-line lifecycle logs by default, auto-human on TTY. Fails closed on missing consent.
- **Self-update** — `hermes-relay update` polls GitHub Releases (SemVer-max picker, prerelease-aware), verifies SHA256, atomic-swaps the binary on POSIX (running daemon keeps inode), cooperative `.new.exe` swap on Windows.
- **Multi-endpoint pairing + reconnect-on-drop + TOFU cert pinning** — same as the Android app. One QR carries LAN + Tailscale + public; client races candidates in priority order, re-probes on every network change.
- **Workspace awareness** — on connect, client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay (server-side prompt-context consumption coming).
- **Conversation picker on attach** — without `--conversation` / `--new`, you get a numbered list of recent server-side hermes sessions to resume.
- **One install, one binary, no Node required** — Bun-compiled native binaries via curl/irm one-liners; collision-safe `hermes` short alias auto-installed.
## Getting Started
**Android:**
1.**Install the app** from the [link above](#1a-android-app)
2.**Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding, or scan a QR via `/hermes-relay-pair`
3.**Start chatting** — the app connects directly to the Hermes API Server
2.**Pair once** — `hermes-relay pair --remote ws://<host>:8767` (mint code via `hermes-pair` or `/hermes-relay-pair` on the server first)
3.**Drop into the shell** — bare `hermes-relay` opens the full Hermes TUI in tmux on the host
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
## How It Works
```
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
Phone (HTTP) --> Server (:8767) [voice routes — API key or relay session]
Phone (WSS/HTTP) --> Server (:8767) [terminal, bridge, media, sessions]
Desktop CLI (WSS) --> Server (:8767) [tui, terminal, desktop tools]
```
Chat from the Android app connects directly to the Hermes API Server with the Hermes API key — same pattern used by Open WebUI and other Hermes frontends. Voice calls the relay's `/voice/*` HTTP routes and authenticates with that Hermes API bearer when present, falling back to the relay session token for paired devices. Remote control surfaces such as terminal, bridge, TUI, media/session management, and desktop tools require relay pairing on `:8767`, so one scan can configure both the API route and the relay route without merging their auth models.
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 18`android_*` and 9`desktop_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
Then restart hermes and run `hermes pair` to verify. The 35`android_*` and 25`desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
## Hermes Agent
</details>
## Built for Hermes Agent
Hermes-Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — an open-source AI agent platform by [Nous Research](https://nousresearch.com). See the [Hermes Agent docs](https://hermes-agent.nousresearch.com) for server setup, gateway configuration, and plugin development.
## Found a bug? Let us know!
## Found a bug? Let us know
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line "this didn't work on my Pixel 7" / "the alpha.14 Windows binary segfaults on my Surface" is genuinely useful.
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line *"this didn't work on my Pixel 7"* is genuinely useful.
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
---
**Release Date:**July 22, 2026
## Download
v0.8.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
> Installing on your phone? Download `hermes-relay-1.5.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
| Flavor | File | Who it's for |
|---|---|---|
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://hermes-relay.dev/docs/guide/sideload) for installation help.
---
## Summary
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
## Added
- Standard and Realtime voice settings now have distinct, organized cards for provider, model, and voice selection, with upstream-aware discovery, descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
- Standard Hermes speech now streams completed reply segments as they arrive. Starting another preview or reply stops the prior audio, and leaving voice mode stops playback.
- Manage and diagnostics consume upstream health hints and compatibility details, follow canonical Gateway redirects, compress larger RPC payloads, and preserve profile-scoped behavior.
- Chat surfaces one-turn model selection, queued recovery, project labels, interim Gateway events, and image-generation progress.
## Fixed
### Voice mode crash with barge-in on legacy TTS playback
- Voice settings and active-turn correction copy remain complete across supported languages.
- Chat reactivates the original live Gateway session after a connection loss, avoids duplicate prompt submission when an acknowledgement is lost, and prevents duplicate session rows from crashing the drawer.
- Relay pairing retains Tailscale and other QR fallback routes when added to an existing Standard connection, recovers older stored routes, and shows a route-specific Dashboard sign-in action instead of leaving remote Chat loading.
- Remote route checks use the API server's supported `GET /health` contract. Selecting Tailscale now moves Dashboard/Gateway, sessions, Manage, Standard Voice, API, and Relay together instead of leaving dashboard-backed features on the saved LAN host; Manage also labels host-side Nous provider authentication separately from Dashboard sign-in.
- Hosted Manage OAuth remains bound to the selected dashboard, direct-chat image memory is bounded, and session reset and recovery behavior follow upstream contracts.
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
## Install / Verify
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
- App version: **1.5.0** (versionCode **33**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
@@ -14,7 +14,7 @@ Native Android companion for the [Hermes agent platform](https://github.com/Nous
### Desktop track (parallel lane to Android) — **experimental**
Release tags: `desktop-v*` (separate cadence from Android `android-v*` and Server `server-v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
Release tags: `cli-v*` (separate cadence from Android `android-v*` and Plugin `plugin-v*`). Historical alpha prereleases used `desktop-v*`, and the installer/updater keep a migration fallback. Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-cli.yml`](.github/workflows/release-cli.yml).
**Shipped (2026-04-23 — first tagged release `desktop-v0.3.0-alpha.1`):**
@@ -47,11 +47,11 @@ Release tags: `desktop-v*` (separate cadence from Android `android-v*` and Serve
**Earlier alpha.2–alpha.5 workstreams (now in-flight / done — see DEVLOG 2026-04-23 entries for specifics):**
- **`hermes-relay update` subcommand + auto-update nudge.** The binary today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls the GitHub Releases API, filters to `desktop-v*`, compares to `readVersion()`, and either shells out to the installer or downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
- **`hermes-relay update` subcommand + auto-update nudge.** The binary self-update path polls the GitHub Releases API, prefers `cli-v*`, falls back to historical `desktop-v*` prereleases during migration, compares to `readVersion()`, and downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
- **Workspace-awareness — desktop client sends cwd/git/hostname on connect.** Biggest lingering "is the agent working against the right tree?" problem. On WSS auth, the client advertises an ephemeral workspace descriptor — `cwd`, `git_root`, `git_branch`, `git_status_summary` (staged/modified counts), `repo_name`, `hostname`, `platform`, `active_shell`. Server-side `DesktopHandler` stashes it as live session metadata (NOT persistent state). New hermes-agent plugin hook injects a one-line ephemeral prompt prefix into the session context — *"Active desktop workspace: machine=Bailey-PC · repo=hermes-relay · branch=dev · staged=3"* — so the LLM reads it every turn without the operator having to explain. Also default `desktop_terminal` / `desktop_read_file` / `desktop_search_files``cwd` to the repo root when unset. Expose the snapshot in `hermes-relay doctor` + `hermes-relay status` + a new `hermes-relay workspace` subcommand + a relay dashboard tab so both operator and agent have a common view. Pair with a `.hermes/workspace-context.json` file-based fallback for when the socket path can't be reached. Requires: new WSS envelope (`desktop.workspace` on connect), hermes-agent plugin hook for ephemeral context injection, schema coordination with the upstream `ContextVar` multi-client work.
- **Service installers** — `scripts/install-service-{win,linux,mac}.{ps1,sh}` — Windows Service via `sc.exe create`, `systemd --user` unit with `loginctl enable-linger`, `launchctl load` plist for macOS. Auto-start on login so the daemon is always reachable.
- **Multi-client routing on the `desktop` channel** — replace single-client MVP with per-token indexing + device-id reconnect handoff. Hermes session state carries `desktop_session_token` via a new `ContextVar` in `gateway/session_context.py` (hermes-agent PR candidate — won't affect Android). Natural pairing with the workspace-awareness envelope — the ContextVar scheme determines which client's workspace the active session sees.
- **Harden `release-desktop.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
- **Harden `release-cli.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
- **Signed binaries** — Windows EV code-signing (~$300/yr, DigiCert or SSL.com) + Apple Developer ID + notarization ($99/yr). Removes SmartScreen/Gatekeeper warnings. Prerequisite for the auto-update path.
- **npm registry publication** — future v1.0 distribution work. The package name is local workspace metadata today; current install paths are GitHub Release binaries or local clone + `npm link`.
- **HMAC verification on QR payloads** — defer until a client-accessible secret story exists (same deferral as the Android app). Not blocking GA.
@@ -101,7 +101,7 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
**What the middleware can do (near-term, ships via install.sh).** New aiohttp middleware in `hermes_relay_bootstrap/_command_middleware.py`, installed at the same `_PatchedApplication.__setitem__` hook as the current route injection so it lands before `AppRunner.setup()` freezes the app. Filters by `request.path in ("/v1/runs", "/v1/chat/completions")` — zero-cost fast path for everything else. On chat paths: parses the body, lazy-imports `GATEWAY_KNOWN_COMMANDS` + `resolve_command()` + `gateway_help_lines()` from `hermes_cli.commands`, and splits on command type:
- **Stateless commands** (`/help`, `/commands`, and any others the upstream Option B PR ends up supporting without router state) — actually dispatch, emit a synthetic SSE stream matching the runs handler's existing event shape so the Android client at `HermesApiClient.kt:655-715` renders it as a normal assistant turn.
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` (post-PR-#8556) or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
**On no match** (unknown command, cli-only command, or plain text): falls through to `handler(request)` unchanged. Fork-detects the same way the existing injection does — if the upstream preprocessor PR lands first, the middleware no-ops.
@@ -109,7 +109,7 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
**Files.** New `hermes_relay_bootstrap/_command_middleware.py` (~150 LOC), one-line append in `_patch.py` inside `_maybe_register_routes`, stdlib `unittest` coverage in `plugin/tests/test_bootstrap_command_middleware.py` mirroring the existing `test_bootstrap_patch.py` harness. Mirrors the upstream Option B PR exactly so the two can be reviewed side-by-side.
**Phase 2 — stateful dispatch on the session chat stream endpoint (post PR #8556).**Once PR #8556 merges and `/api/sessions/{id}/chat/stream` ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless, statefulness lives on `/api/sessions/*`. Blocked on #8556 landing.
**Phase 2 — stateful dispatch on the session chat stream endpoint (unblocked by PR #33134).**Since `/api/sessions/{id}/chat/stream` now ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless and statefulness lives on `/api/sessions/*`.
Hermes-Relay is the native Android client for the Hermes agent platform. Point it at your own Hermes instance and chat with your agent, talk to it hands-free, and manage models, keys, skills, and profiles from anywhere.
It is not a hosted AI service. It is a companion app for the Hermes agent you run, and it talks only to the instances you configure.
QUICK START
1. Run hermes-agent with its API server and dashboard enabled on your computer or home server.
2. Install Hermes-Relay and enter your server address, for example http://192.168.1.100:8642.
3. The setup wizard checks what your server supports and shows a readiness card, then you are ready to chat.
A plain Hermes install is enough. Chat, management, and voice work with no plugin or extra service.
HOW IT WORKS
Chat streams directly from your Hermes API Server or dashboard gateway in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
GOOGLE PLAY BUILD
The Google Play build ships Hermes Bridge Core only. It has no AccessibilityService Device Control: it cannot read your screen, tap, type, swipe, screenshot, send SMS, place calls, or access contacts or location. Device Control is reserved for sideload builds distributed outside Google Play.
FEATURES
- Streaming Chat: real-time responses with reasoning, markdown, tool-call visibility, attachments, mid-turn steering, edit-and-resend, and a searchable command palette.
- Manage Your Agent: use your Hermes dashboard from your phone to switch models, manage provider keys, edit profiles, and browse, install, and update skills.
- Voice Mode: talk hands-free using your server's speech providers. Relay-paired setups add per-profile voices and an experimental realtime engine.
- Works Away From Home: add LAN, Tailscale, or public routes and the app chooses the best available path on connect.
- Sessions: create, switch, rename, and delete chats. Message history loads on demand.
- Multiple Servers and Profiles: connect to more than one server and switch in a tap; overlay an agent profile or personality per conversation.
- Relay Power Tools: optional QR pairing for remote terminal, relay-session management, media handoff, and per-feature grants.
- Notification Companion: optionally forward notification metadata to your paired relay so your assistant can summarize it. Toggle it anytime in system settings.
- Stats for Nerds: local-only counters for response timing, token usage, cost, and stream health.
- Material You: Material 3 dynamic color, light/dark/system themes, and haptics.
SECURITY AND PRIVACY
- API keys and relay tokens are stored in encrypted Android storage.
- HTTPS is enforced for remote connections; cleartext is limited to localhost or LAN setups.
- No telemetry, ads, tracking, or third-party analytics SDKs.
- Notification access and the microphone are optional and user-controlled.
- All app traffic goes only to servers you configure.
REQUIREMENTS
- Android 8.0 or later.
- A running Hermes agent for chat, management, and voice.
- Optional Hermes relay service for power tools such as terminal, notifications, and media.
- Network access to your server by local network, VPN, or internet.
OPEN SOURCE
Hermes-Relay is MIT licensed. Source, docs, and issue tracking are on GitHub.
This app is a community project and is not affiliated with or endorsed by NousResearch.
Browse Standard and Realtime voice providers, models, and voices in a cleaner layout with inline previews. Standard Hermes replies now speak completed segments while the answer is generated, and new audio stops prior playback. This release also expands Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
android:value="Keeps the user's connection to their Hermes agent open in the background so messages and live features stay responsive, only when the user has explicitly enabled 'Persistent connection'."/>
"Standard and Realtime paths now organize provider, model, and voice choices in focused cards with upstream-aware discovery and descriptions.",
"Preview voices inline with loading feedback and a lighter waveform, then expand and scroll the voice browser without leaving the page."
]
},
{
"header":"Natural streaming speech",
"bullets":[
"Standard Hermes replies begin speaking completed segments while the rest of the answer is still being generated.",
"Starting new audio stops the prior preview or reply, and leaving voice mode stops playback."
]
},
{
"header":"More upstream-aware controls",
"bullets":[
"Manage and diagnostics consume Gateway health and compatibility details while keeping Standard Hermes usable without Relay.",
"Chat now shows one-turn model choices, queued recovery, project labels, interim events, and image-generation progress."
]
}
]
},
{
"version":"1.4.9",
"title":"Clearer Hermes connections",
"date":"2026-07-19",
"sections":[
{
"header":"Dashboard-first setup",
"bullets":[
"Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice; API fallback and optional Relay remain available.",
"Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, startup, route, and security choices."
]
},
{
"header":"Consistent identity",
"bullets":[
"Server default now displays Hermes' pinned active profile consistently across the app.",
"Successful local discovery adds useful hostname identity without replacing a custom connection label."
]
}
]
},
{
"version":"1.4.8",
"title":"Privacy policy restored",
"date":"2026-07-18",
"sections":[
{
"header":"Google Play compliance",
"bullets":[
"The privacy policy now lives at hermes-relay.dev and the historical store URL remains valid for compatibility.",
"The About screen opens the hosted policy directly, and releases verify it is publicly available before publishing."
]
}
]
},
{
"version":"1.4.7",
"title":"Smoother replies, more languages",
"date":"2026-07-18",
"sections":[
{
"header":"Smooth streaming",
"bullets":[
"Long replies grow at a display-paced cadence and stay anchored at the newest text through completion.",
"Scrolling into history preserves your reading position instead of forcing the conversation back to the bottom."
]
},
{
"header":"More languages",
"bullets":[
"Use German, Brazilian Portuguese, or Japanese throughout both Android product flavors.",
"Catalog freshness validation keeps every shipped translation aligned with the canonical English resources."
]
}
]
},
{
"version":"1.4.6",
"title":"Profiles stay together",
"date":"2026-07-15",
"sections":[
{
"header":"One Server-default profile",
"bullets":[
"Server default now keeps the selected agent, session drawer, transcript, and new messages in Hermes' sticky active profile.",
"Reorder or hide profiles per connection without changing server configuration."
]
},
{
"header":"Profile icons",
"bullets":[
"Choose an image through Android's file picker or import avatar.png/profile.jpg from an updated paired Relay.",
"Host import now distinguishes an outdated Relay from a genuinely missing profile image."
]
}
]
},
{
"version":"1.4.5",
"title":"Chats that keep running",
"date":"2026-07-15",
"sections":[
{
"header":"Keep moving between chats",
"bullets":[
"Switch to another chat, profile, draft, or Thread without stopping a running Gateway reply.",
"Return to the session and reattach to its live checkpoint and progress."
]
},
{
"header":"Cleaner live state",
"bullets":[
"Expired secret and sudo prompts collapse when Hermes reports their expiry, so stale actions no longer look usable.",
"Provider wait, reconnect, and continuation notices stay in Chat's live status line instead of cluttering the conversation."
]
}
]
},
{
"version":"1.4.4",
"title":"Spanish and clearer diagnostics",
"date":"2026-07-12",
"sections":[
{
"header":"Language that is ready to grow",
"bullets":[
"Use Spanish throughout the app from Settings → Appearance.",
"Translation freshness checks flag catalogs whenever the English source changes, while fluent verification remains tracked separately."
]
},
{
"header":"Know what is connected",
"bullets":[
"Refresh Diagnostics to see the Relay plugin version, protocol, capability count, profile status, and last-check time.",
"Open the complete release history directly from the cleaner What’s New modal."
]
}
]
},
{
"version":"1.4.3",
"title":"Language switching inside the app",
"date":"2026-07-11",
"sections":[
{
"header":"Language at your fingertips",
"bullets":[
"Choose System default, English, or Simplified Chinese from Settings → Appearance without leaving Hermes-Relay.",
"The picker stays synchronized with Android's per-app language setting and persists the choice on Android 12 and lower.",
"Release builds reject collection APIs that can crash on Android versions before API 35."
]
}
]
},
{
"version":"1.4.2",
"title":"Simplified Chinese and scalable localization",
"date":"2026-07-11",
"sections":[
{
"header":"Simplified Chinese throughout the app",
"bullets":[
"Use onboarding, connection setup, Chat, Manage, Voice, settings, diagnostics, notifications, and accessibility labels in Simplified Chinese across both product flavors.",
"Switch between English and Simplified Chinese through Android's per-app language settings on supported versions, or follow the device language elsewhere."
]
},
{
"header":"Localization built to grow",
"bullets":[
"Automated catalog checks protect resource, plural, and format-argument parity, while contributor docs and translated entry points make another language easier to add safely.",
"Connection scan and queued-message counts now use locale-aware Android plurals."
]
}
]
},
{
"version":"1.4.1",
"title":"Chat that keeps up",
"date":"2026-07-11",
"sections":[
{
"header":"Chat that stays with you",
"bullets":[
"Follow background terminal work from a compact process strip and expandable sheet. Its completed answer appears in the same conversation automatically.",
"Close and reopen while a reply runs: partial text, thinking, tool progress, background-task state, and pending approvals return in the same chat without repeating your prompt."
]
},
{
"header":"Voice you can direct",
"bullets":[
"Use spoken commands to pause or resume listening, stop speech, cancel background work, repeat a finished result, or start Standard voice chat.",
"Hands-free, Low latency, Careful tools, and Quiet presets tune existing voice behavior without changing your selected voice or route."
]
},
{
"header":"Clearer conversations",
"bullets":[
"Browse adjacent images as a gallery, read smoother streaming Markdown and wide tables, and see background-process completion as a compact process notice."
]
}
]
},
{
"version":"1.4.0",
"title":"Realtime voice that finishes the job",
"date":"2026-07-09",
"sections":[
{
"header":"Voice that keeps going",
"bullets":[
"Quick follow-ups can be answered while a long Hermes task runs, another long request can wait in a bounded queue, and the finished answer can stay in the selected realtime voice.",
"Voice route recovery now waits for relay confirmation, replays unacknowledged input without starting a second Hermes run, and rejects stale sockets or sessions before they can overwrite a healthy connection.",
"Listening, thinking, reconnecting, and cancellation states now settle cleanly after Stop, exit, route loss, or terminal retry failure."
]
},
{
"header":"Models and phone automation",
"bullets":[
"Realtime Agent model and voice choices apply to the next session, persist per connection/profile, and survive restart.",
"Chat and Manage can refresh dynamic provider model catalogs on demand.",
"Opt-in notification rules can offer a local Ask Hermes action, and Bridge tools can target a specific paired Android device."
]
},
{
"header":"Reliability and safety",
"bullets":[
"Long chat turns avoid premature transport fallback, and supported voice, card, and attachment context now reaches upstream Hermes through channels it consumes.",
"Malformed server addresses fail through normal connection errors, older Android versions avoid newer collection APIs, and relay media blocks credential and token paths.",
"Model management keeps unconfigured providers visible with key-setup guidance, and session cleanup gains export, prune preview/apply, archive, and restore plumbing."
]
}
]
},
{
"version":"1.3.0",
"title":"Voice that multitasks & sturdier chats",
"date":"2026-07-06",
"sections":[
{
"header":"Voice, hands-free",
"bullets":[
"Ask for something big and keep talking — long tasks hand off to the background with a live chip showing the current step, steps done, and a running timer, with a tap-to-cancel. The answer is spoken when it's ready, even after a brief disconnect — and if the voice session is gone, it arrives as a notification (the full answer is always in the chat).",
"Leaving voice mode (or tapping stop to interrupt speech) no longer cancels a running background task — the chip's ✕ is the one deliberate kill switch, and a delivered answer keeps its text instead of flipping to \"Cancelled.\"",
"Quieter and quicker: the agent speaks at milestones instead of narrating every step, clearly long tasks hand off to the background right away, and the first turn starts faster — the session warms up when you open voice mode."
]
},
{
"header":"Chats that keep their answers",
"bullets":[
"An answer is no longer lost when the connection drops mid-reply on a long turn (slow local models, delegating skills) — the app quietly re-checks the conversation and completes the turn when the server finishes, with the usual done-notification if you've switched away.",
"Markdown reads like chat: headings are proportionate instead of billboard-sized, lists and paragraphs share one size, links are clearly styled, and timestamps show once per message group."
]
},
{
"header":"Your agent can reach out",
"bullets":[
"Proactive messages: your Hermes agent can message your phone first (off by default, opt-in on both server and phone), and you can reply straight from the notification or the new Hermes inbox — the conversation continues like any other chat."
]
},
{
"header":"Make it yours",
"bullets":[
"Pick your app font — Inter (new default), Nunito, or your system font — applied instantly, everywhere.",
"The in-bubble working indicator can be a small animated dot-matrix (Wave, Pulse, Bounce, Sparkle) with a color of your choice.",
"Quick Controls at the top of Settings puts Persistent connection and Turn-complete alerts one tap away."
]
},
{
"header":"Setup & housekeeping",
"bullets":[
"Onboarding slides now scroll on small screens and large font sizes, so no setup guidance is cut off.",
"Reporting a diagnostic files the right kind of issue: informational entries ask what you expected and file as a question, and every report carries your actual connection mode.",
"Connections is a scannable list with a tabbed detail screen (Overview, Routes, Advanced, Security), and voice settings can now read and edit your server's voice engine (provider, voice, model) over the dashboard."
]
}
]
},
{
"version":"1.2.6",
"title":"Tidier chats & calmer status",
"date":"2026-06-27",
"sections":[
{
"header":"Tidier chats",
"bullets":[
"Chats no longer get stuck showing \"Untitled\" — your first message stands in as the title until the chat is named, titles refresh once a turn settles, and a new refresh button in the session drawer pulls the latest on demand. Renaming a chat now sticks when you're on a non-default agent profile."
]
},
{
"header":"Calmer status",
"bullets":[
"Connection status — reconnecting, checking, network handoffs — now shows as a thin banner at the top that gently slides the screen down, instead of a card floating over your chat; the floating alert is kept for persistent errors. Quick confirmations (copied, profiles updated, profile/personality switches) land in the same calm banner instead of a pop-up at the bottom."
]
}
]
},
{
"version":"1.2.5",
"title":"Stability + Try the demo",
"date":"2026-06-27",
"sections":[
{
"header":"Stability",
"bullets":[
"Fixed a crash that could close the app when a non-URL value — a UI label, or a line copied from the docs — was entered in the API server or Dashboard URL field. The setup fields now reject anything that isn't a valid host or http(s) URL with an inline error, and the dashboard and voice request paths treat a bad address as unreachable instead of crashing."
]
},
{
"header":"Try the demo",
"bullets":[
"A new \"Try the demo\" option on the setup screen — and on the empty chat screen if you skip setup — opens an offline preview of the real chat experience: a sample conversation with Markdown, a tool-progress card, and a rich card, with no server, account, or network. A banner shows it's a demo, with a one-tap Connect to set up for real."
]
}
]
},
{
"version":"1.2.4",
"title":"Stability + connection security",
"date":"2026-06-25",
"sections":[
{
"header":"Stability",
"bullets":[
"Fixed a crash that could close the app when the dashboard connection check hit a transient network failure — a pooled connection aborting or timing out over Tailscale. The check now reports the failure cleanly and the connection probe degrades gracefully instead of force-closing."
]
},
{
"header":"See if you're secure",
"bullets":[
"The chat status chip, connection card, and route picker now show at a glance whether your connection is encrypted — Encrypted · TLS, Encrypted · Tailscale (both secure), Mixed routes, or Not encrypted — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure."
]
}
]
},
{
"version":"1.2.3",
"title":"Connection crash fix",
"date":"2026-06-23",
"sections":[
{
"header":"Stability",
"bullets":[
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
]
}
]
},
{
"version":"1.2.2",
"title":"Multi-profile polish",
"date":"2026-06-22",
"sections":[
{
"header":"Profiles that behave",
"bullets":[
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
]
},
{
"header":"Clearer diagnostics",
"bullets":[
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
]
},
{
"header":"Small touches",
"bullets":[
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
"Distraction-free chat mode gives its text a taller, scrollable area."
]
}
]
},
{
"version":"1.2.1",
"title":"Polish & control",
"date":"2026-06-21",
"sections":[
{
"header":"Yours to control",
"bullets":[
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
]
},
{
"header":"Find your way back",
"bullets":[
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
]
},
{
"header":"When something breaks",
"bullets":[
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
]
},
{
"header":"Voice fixes",
"bullets":[
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
"Realtime turns that reach back to Hermes no longer drop with a session error."
]
}
]
},
{
"version":"1.2.0",
"title":"Make it yours",
"date":"2026-06-20",
"sections":[
{
"header":"Personalize",
"bullets":[
"Eight app themes in Settings → Appearance — the Hermes Relay brand plus ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rosé), with light/dark.",
"Swap the agent orb for an animated pet that reacts to what the agent is doing — add, preview, and tune pets right in the app, or generate one from sprite art with the AI authoring kit.",
"Reskin the sphere, and give each agent profile its own icon."
]
},
{
"header":"See what's happening",
"bullets":[
"The chat status strip names the actual streaming path (Gateway, Sessions, Completions, Runs), with a basic→best tier ladder in Chat Settings.",
"Tap the context meter for a \"What the agent sees\" sheet — the exact extra context prepended to your next turn.",
"Voice and Realtime turns are badged in the scrollback."
]
},
{
"header":"Privacy",
"bullets":[
"When paired to the relay, the agent can mark private media and the phone blurs it per your setting — sensitivity stays model-emitted."
]
},
{
"header":"Faster & more reliable",
"bullets":[
"Cold start is about 3× faster, and model/personality/approvals load honestly instead of showing a maybe-wrong value.",
"In-app crash reporting offers a one-tap, pre-filled bug report.",
"QR pairing no longer force-closes on unusual cameras (foldables); fixed crashes opening server images and PDFs; in-chat model picks now apply."
]
},
{
"header":"Voice & terminal",
"bullets":[
"Enhanced voice control for Gemini and xAI providers.",
"Leaner terminal with TUI-correct input and an isolated, tuned tmux."
]
}
]
},
{
"version":"1.1.0",
"title":"Release plumbing & polish",
"date":"2026-06-16",
"sections":[
{
"header":"New",
"bullets":[
"Automated Play Console upload when a release tag ships (a human still starts the rollout).",
"/relay slash commands — status, devices, and pair from any platform — plus a relay-status badge in the dashboard header.",
"The relay plugin prompts for its optional voice-provider keys on install, and a tools-only native install path."
]
},
{
"header":"Improved",
"bullets":[
"Settings overhaul: status pills are now exception-only, Power tools shows a single Plugin active/required/offline badge, and Connections moved to the top.",
"Release names and notes are now split per surface (Android, plugin, CLI)."
]
},
{
"header":"Fixed",
"bullets":[
"No more force-close on connect when the stored credential keyset was corrupt — it now heals in place.",
"The installer works on uv-managed Hermes hosts, and the dashboard relay panel buttons are readable again."
]
}
]
},
{
"version":"1.0.0",
"title":"Stable launch",
"date":"2026-06-14",
"sections":[
{
"header":"Gateway chat with live thinking",
"bullets":[
"Chat can ride the upstream dashboard gateway — the only vanilla-upstream path that streams reasoning live, so the Thinking block and sphere light up during generation. \"Auto\" prefers it and falls back to the SSE endpoints per turn.",
"Desktop parity: native image/PDF/file attachments, mid-turn steering, edit & resend, approval/clarify/sudo/secret cards, live subagent lanes, a context-window meter, server slash commands, and turn-complete notifications.",
"Warm-start and an opt-in Keep connected in background toggle so long-backgrounded conversations resume instantly."
]
},
{
"header":"Agents, Manage & media",
"bullets":[
"Switch agent profiles per conversation — model, SOUL, personality, and skills — with the selection bound to the session, never changing the server default for other clients.",
"Manage parity with the desktop dashboard: change models, manage provider keys, edit profiles and SOUL.md, and browse/install skills.",
"Open and save chat images and attachments — full-screen viewer with pinch-zoom, plus an Open/Share/Save menu."
]
},
{
"header":"Standard path is first-class",
"bullets":[
"Chat, Manage, and voice all work against an unmodified upstream Hermes agent; the relay plugin is now purely additive.",
"Seamless connection UX — LAN↔Tailscale handoffs and reconnects no longer reload the chat, and status shows as in-theme slide-down toasts.",
"Persistent Realtime Agent voice that keeps one session across turns, with long runs promoted to tracked background tasks."
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.