Tracks per-lane unread counts for finalized assistant messages. A message
counts as unread unless the user is actively reading that lane (current
lane, app focused, newest content at the bottom of the viewport).
- ChatStore: ephemeral per-lane unread map (markUnread/markLaneRead/unreadFor).
- IrisController: 'being read' decision on incoming messages; clears the
current lane when the app returns to the foreground at the bottom.
- Bridges: push foreground changes to the controller.
- ChatScreen: per-channel badges in the drawer/rail, an 'N new' pill in the
header (tap glides to the latest message), and a red dot on the hamburger
(single-pane/mobile only) when another channel has unread.
Closes#3.
Issue #4: approvals were only sent as a banner + text /approve prompt,
while the app already had the interactive choice-picker card (used by
clarify and slash commands).
Add send_exec_approval() to the iris adapter. Hermes auto-detects this
method and calls it when the agent wants to run a dangerous command. It
now emits a high-priority approval notification (wakes a backgrounded
device) plus a picker.choice card showing the command + reason with
Allow Once / Session / Always / Deny buttons (gated by the same
allow_session/allow_permanent/smart_denied flags as the native adapters).
A tap resolves via resolve_gateway_approval (same primitive as the text
/approve and /deny handlers), unblocking the agent, and posts a short
confirmation. No live device -> report failure so hermes falls back to
the text prompt.
No app changes needed: picker.choice cards are rendered generically.
When tool verbosity is 'truncated' (the default) and the card is
collapsed, fold the preview into the title row so a tool call takes a
single line of vertical space: 'Terminal | ls -lah' with the preview
in monospace. Tapping still expands to the full args + output.
Add a todo.update frame (server->app) carrying the agent's full current
todo list. The gateway emits it whenever the hermes todo tool completes
(the tool result is authoritative even for merge writes) and re-sends a
snapshot right after hello so a reconnecting device re-learns the plan.
Ephemeral: never outboxed.
The app renders it as a compact strip above the composer (max 3 lines,
the rest scrollable) mirroring the hermes desktop composer status stack:
pending = hollow ring, in_progress = spinner, completed = green check,
cancelled = struck through. It auto-scrolls to the current task whenever
the active task changes, and hides itself once the list is empty or fully
resolved.
Clarify questions with a finite option set now render as the same
tappable picker card used by /reasoning and /fast, instead of a numbered
text list. The change is gateway-only: it reuses the existing
picker.choice frame and the app's PickerCard UI, so no app change or
reinstall is needed.
- send_clarify: single-select + live device emits a picker.choice frame
(one button per option + an "Other (type your answer)" button) and
registers a pending picker; the selection resolves via
resolve_gateway_clarify (the agent then continues and replies).
- "Other" flips the entry to text-capture (mark_awaiting_text) and
prompts the user to type; an unmappable value also flips to text so a
clarify never dead-ends.
- Multi-select, open-ended, and no-live-device clarifies keep the
numbered-text fallback (unchanged behavior).
- New helpers _clarify_is_multi / _clarify_picker_callback; positional
option values (c0..cN, other) mirror the relay adapter.
Tests: updated test_clarify_emits_banner_and_message to multi-select
(text fallback) + 3 new tests (single-select emits picker & resolves,
Other flips to text, no-device falls back to text). Full suite 90/90.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slash commands with a finite set of options (/reasoning, /fast, ...) now
render a tappable card with buttons (2 per row, ✓ on the current value)
instead of a plain text status card. The mechanism is generic: any command
that calls the adapter's send_choice_picker() gets a picker automatically.
Wire protocol (docs/04, frames.schema.json):
- picker.choice (server→app): {picker_id, title, choices[]}
- picker.select (app→server): {picker_id, value}
- pickers capability flag now True in server_caps
gateway-plugin:
- protocol.py: picker.choice/picker.select frame types + picker_choice()
- dispatch.py: route picker.select → adapter.on_picker_select
- adapter.py: send_choice_picker() (fails cleanly with no live device so
hermes falls back to text), on_picker_select(), in-memory pending pickers
(gateway restart expires them; stale select is a no-op), pickers=True
app (KMP):
- Protocol.kt: PickerChoice/PickerChoicePayload + pickerSelectFrame()
- ChatStore.kt: PickerItem + onPickerChoice (idempotent) + resolvePicker
(optimistic, one-shot)
- ChatDb.kt: persist PickerItem in the messages table (polymorphic decode)
- IrisController.kt: picker.choice routing + selectPicker() action
- ChatScreen.kt: PickerCard composable (locks after selection)
Tests:
- python: 3 picker tests (roundtrip, no-device fallback, stale-select noop)
- kotlin: ChatStorePickerTest (add/idempotent/resolve/one-shot/noop/serialize)
- fixture fix: clear leaked IRIS_HTTP_PORT/IRIS_WS_HOST env so the adapter
binds the ephemeral port (a prior test's interactive_setup() polluted the
process env, colliding with a live gateway on 8791)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- User send: always scroll so the bottom of the own message is visible
above the composer (scroll = new content: message + spacers), even
while reading history.
- Agent message: while at the bottom, land on the natural reading
position — top-aligned with the viewport top when the bubble is
taller than the viewport, bottom-aligned otherwise.
- Streaming follow: keep the live bubble's bottom visible while it fits
the viewport; once it outgrows the viewport, top-align it once and
hand over to the user (no yank-back on later deltas).
tool.start gains an optional cosmetic 'emoji' field, resolved server-side
through hermes' own display layer (active-skin overrides, then the tool
registry's per-tool emoji) so icons track the user's hermes theme and
new/plugin tools get their glyph for free. Omitted for unknown tools so
the app falls back to its default wrench.
- protocol.py: tool_start(emoji=...) kwarg, payload field when set
- adapter.py: _tool_emoji() helper (lazy import, None on unknown/failure)
- frames.schema.json + docs/04: field documented
- app: ToolStartPayload.emoji -> ToolItem.emoji -> ToolCard header
- tests: frame shape, resolution/fallback, end-to-end tool.start emoji
Gateway (docs/19):
- Remove ws_server.py; frame dispatch factored into dispatch.py
- http_server: media upload/pull, pairing over HTTP
- protocol: media frames mirrored; tests + ws_probe updated for HTTP
App:
- HttpGateway: postFrame/uploadMedia/pullMedia no longer throw on
network failure (PostResult ok=false / Result.failure) — uncaught
SocketTimeoutException on Dispatchers.Default crashed the app
- GatewayClient: dead-stream watchdog (health probe every 10s, 2
failures -> redial in ~20s instead of the 45s SSE read timeout);
state flips to Reconnecting when the stream dies, restored from the
last hello.ack on long-poll success; poke() + backoff reset on app
resume (MainActivity.onResume)
- Offline sends: composer enabled while disconnected; a send with no
response (status 0) stays queued (Pending) and is auto-resent on the
next (re)connect after a 2s outbox-replay grace; gateway 4xx
rejections fail the bubble (tap to retry, no auto-loop)
- ChatStore: echo-replace and thread-relocate also match Failed
bubbles (POST response lost in a network drop); loadHistory dedupes
local failed bubbles the server already has; failMessage()
- MainActivity: poke() on resume so a backgrounded app reconnects
promptly instead of waiting out the backoff
- e2e.py: scenario 13 (http fallback) — drives a full turn over the
HTTP leg (health + POST /v1/frame + SSE /v1/events, no WS) and
asserts the user echo lands on the SSE stream in < 1.5 s.
- ws_probe.py --http: prints '== user echo in X.XXs' (the docs/19
sendable-in-fallback timing assertion) alongside the existing
health/POST/SSE output; same assertion flags as the WS leg.
- docs: 19 status flipped to implemented; 09-pairing-security §9.4
cross-reference (second door, same lock: token + device allowlist,
64 KiB cap, rate limit, optional TLS, unauthenticated /v1/health);
13-testing manual scenario 15 + automated pointers.
When the WS is down but the gateway is reachable over HTTP, the app
stays sendable instead of waiting out the WS redial backoff:
- HttpGateway.kt: OkHttp client for the HTTP leg — GET /v1/health
(2 s probe), POST /v1/frame (accept-and-ack; 4xx error frames parsed),
SSE GET /v1/events (hand-rolled line parser: event/id/data, comments,
multi-line data, Last-Event-ID bookkeeping), long-poll GET /v1/poll.
Per-purpose call timeouts (SSE heartbeat 15 s / poll hold 25 s exceed
OkHttp's 10 s default read timeout). deriveHttpUrl: ws(s)://host:port
-> http(s)://host:8791 (pure, unit-tested).
- GatewayClient.kt: new State.HttpFallback (sibling of Connected, both
implement State.HelloInfo). connectLoop races the WS dial against the
health probe (sendable in < 1 s on a dead WS port); on WS loss it
enters fallback immediately (no backoff gate on the send path); on WS
reconnect it stops the HTTP leg (media available again). sendMessage/
sendFrame route to POST in fallback (mediaRefs dropped — media is
WS-only in v1); SSE frames feed the same _events flow, so request-id
correlation is unchanged. The SSE hello is treated like hello.ack
(caps/channels/lastPushedCursor + onHelloAck fast path). After two
consecutive SSE open failures the receive loop switches to long-poll
until the next full (re)connect.
- IrisController.kt: onHelloAck/onConnectedLane take State.HelloInfo;
state collector + deep-link/commands-catalog gates accept fallback.
- ChatScreen.kt: send gate accepts fallback; attach button disabled in
fallback (media needs the live connection); status pill shows
'connected · http' (green).
- Tests: HttpGatewayTest (URL derivation + SSE parser, 8 tests); full
:shared desktop + android-host suites green.
Second, short-lived-connection transport next to the WS: same frames,
same outbox/cursor, same token, served over plain HTTP (stdlib
ThreadingHTTPServer bridged into the asyncio loop; zero new deps).
- http_server.py: /v1/health (unauthenticated), POST /v1/frame
(accept-and-ack; validation rejections as 4xx error frames), SSE
/v1/events (outbox catch-up with id=cursor, event: hello, 15s
heartbeat, bounded-queue backpressure), long-poll /v1/poll (25s hold).
Bearer token + X-Iris-Device (same allowlist as WS hello), 64 KiB body
cap, per-device rate limit, optional TLS, non-fatal bind failure.
- ws_server.py: inbound dispatch chain extracted to shared
dispatch_frame() used by both transports.
- adapter.py: ANDROID_HTTP_PORT/CERT/KEY config; start/stop next to the
WS; delivery counting in _broadcast_or_log (an SSE subscriber is a
live subscriber -> no push, docs/19 19.8); _reply() routes
point-to-point replies into the in-flight HTTP response (reply sink)
or broadcasts when the device has no live WS (19.7); status/typing/
channel events fan out to both transports.
- ws_probe.py: --http mode (health + POST + SSE turn drive, same
assertion flags); tests/README updated.
- Tests: hermes-agent/tests/gateway/test_android_http.py (23 tests,
incl. the 19.8 delivery-counting regression); test_android.py (74)
still green.
scrollToItem(last) top-aligns the last row, so a final message taller
than the viewport stayed cut off at the bottom. New scrollToBottom()
brings the last row into view, then aligns its bottom edge with the
viewport bottom. The isAtBottom check now also treats 'can't scroll
forward' as at-bottom, so auto-scroll and the back-to-bottom button
behave correctly when the newest message is taller than the viewport.
The app previously showed 'Gateway restarting' on every connection loss.
Now the gateway broadcasts status{state=restarting} on its shutdown path
(before closing the sockets), and the app:
- posts 'Gateway restarting' immediately on that frame (not on the
socket-drop transition, which lags by the ~20s WS ping timeout)
- posts 'Gateway online' on the next reconnect only when the restart
notice was posted (latch) - a plain network drop shows neither, just
the reconnect banner
- drops the 'Gateway is restarting...' banner (replaced by the chat notice)
Docs (04-wire-protocol, frames.schema.json) updated: restarting is no
longer reserved. Test for the disconnect broadcast added to the local
hermes-agent test mirror (git-ignored, not committed).
- upload-artifact@v4+ fails on Gitea/act_runner (GHESNotSupportedError —
the GitHub artifacts API is not implemented), so merge the android,
desktop and release jobs into one: build APK+AAB + desktop zip/deb,
then create the release and upload attachments from the workspace
- jpackage --type deb needs fakeroot, which the runner image lacks —
install it via apt
- sync CI-SETUP.md §3 with the restructured workflow
- pytest lives in hermes-agent's dev extra; plain uv sync left the CI venv
without it and run_tests.sh refused to run (gateway job)
- 'yes | sdkmanager --licenses' dies with SIGPIPE (exit 141) under Gitea's
bash -e -o pipefail; feed a finite number of y's from a file instead
(kotlin + android jobs)
- release.yml android job: build APK + AAB (bundleRelease/bundleDebug)
- androidApp: versionCode overridable via -PappVersionCode (Play requires
an incrementing versionCode per upload)
- make_release_keystore.sh: PKCS12 has no separate key password (keytool
ignores -keypass) — print the store password for ANDROID_KEY_PASSWORD
- Cache.sq / ChatDb: lanes, channels and last_lane persisted as JSON
snapshots; sanitize on restore (Pending->Failed, streaming->false);
ephemeral items (tool, system) skipped
- Platform drivers via expect/actual: AndroidSqliteDriver (app db dir)
/ JdbcSqliteDriver (~/.iris/iris_cache.db)
- IrisController: restore before connect, debounced (750ms) snapshot
persistence, synchronous flush on dispose, clearAll on forget
- History robustness: historyLoaded marked only when the response is
processed (lost request/response retried on reconnect); events
collector wrapped in try/catch; onHelloAck fast path so the history
request fires on the WS thread instead of the starved state
collector; frame-decode and history-load logging
- Protocol: HistoryMessage.media nullable (defensive vs older
gateways that sent "media": null)
- Tests: ChatDbTest (jvmTest, JDBC in-memory), ChatStoreCacheTest,
HistoryPayloadTest, HistoryWireTest (real captured 92KB response)
- docs/10: §10.7 implemented schema, new §10.9 cache behavior
history() wrote "media": null for streaming finals, but the frame
schema says array. The app's HistoryMessage.media was non-nullable, so
deserialization threw and every history page was silently dropped.
Omit the key when media is absent instead.
Deleting a thread, channel, or message was a no-op/soft-delete: messages
were only dropped from the plugin outbox (still in hermes' session store,
hence searchable/recoverable) and channels/threads were merely archived.
Now deletion is complete and non-recoverable, with no search trace:
- purge.py (new): hard-delete from hermes' session store (state.db).
delete_lane wipes a channel's/thread's sessions + messages; deleting a
messages row also drops it from the FTS5 index via the delete triggers.
delete_message removes one message, matched by (session, role, exact
content, closest timestamp) since plugin m_<hex> ids aren't persisted.
- channels.py: delete() hard-deletes the row (and a channel's child
threads) instead of archiving.
- outbox.py: add delete_lane() (wipe all frames for a lane) and
message_info() (read a message's final role/text/ts for the match).
- adapter.py: on_channel_delete wipes outbox + session store;
on_message_delete purges the session-store row per message.
- App: delete confirmations no longer claim history stays for search;
ChannelStore removes a channel's threads on channel delete.
- Docs updated to describe hard deletion.
Hermes can append a text "runtime footer" (model, context %, workdir,
latency, cost) to final replies, but only when display.runtime_footer is
enabled in the hermes config. We want the same info but controlled by the
APP, not the gateway config. So the gateway now ALWAYS sends the data as a
structured `runtime` object on final assistant messages, and the app decides
whether/what to show.
Gateway (gateway-plugin/):
- protocol.py: new runtime_footer() helper + `runtime` field on the
message / message.stop frames. Keys (all optional, absent when the data
is unavailable — e.g. no cost for local models): model (vendor prefix
dropped), context_pct (0-100), cwd (home-relative), latency (seconds),
cost (USD).
- adapter.py: a post_api_request plugin hook captures the turn's model +
prompt tokens + start time (platform-filtered to android so other
platforms don't pollute the buffer). _build_runtime_footer() resolves the
model's context window (cached, best-effort, off the event loop via
asyncio.to_thread with a timeout) and computes context_pct. The runtime
object is attached on every final send (streaming message.stop and
non-streaming message, plus the fallback paths).
- outbox.py: `runtime` preserved in history reconstruction so the footer
survives a restart / first open.
App (app/shared/):
- Protocol.kt: RuntimeMeta data class + `runtime` on MessagePayload /
MessageStopPayload / HistoryMessage.
- ChatStore.kt: `runtime` on MessageItem, wired through live + history
reconciliation.
- SecureStore.kt (+ Android/Desktop actuals): runtimeFooterEnabled +
runtimeFooterFields (persisted per device).
- IrisController.kt: StateFlows + toggleRuntimeFooter() /
toggleRuntimeField(); RUNTIME_FIELD_KEYS / default set / parser.
- SettingsScreen.kt: "Runtime footer" switch; when on, an expandable chip
menu (Model · Context % · Workdir · Latency · Cost) to pick fields.
- ChatScreen.kt: footer rendered on the SAME line as the timestamp (footer
left, time right, Telegram-style), only for final non-streaming assistant
answers; Inspector pane now shows the runtime fields too.
Docs: 04-wire-protocol.md + frames.schema.json document the `runtime`
object.
Verified end-to-end on device: final replies carry
`qwen3.8-27B-exl3-4.5bpw · 53% · ~ · 38s` with the time right-aligned on
the same line; 69/69 gateway tests pass, Kotlin builds + tests pass.