The gateway already stored a created timestamp per channel/thread but
never sent it over the wire. Now:
- protocol.py: _channel_payload() includes created (unix seconds)
- Protocol.kt: ChannelInfo.created (default 0.0 for legacy gateways)
- ChatScreen: topic switcher sorts threads created-desc (newest right
after General, swipe new -> old), name as tie-break
- frames.schema.json: document the created field
- ChannelCreatedWireTest: wire deserialization + ordering tests
- MarkdownText: streaming branch on the library's StreamingMarkdownState
(rememberStreamingMarkdownPipeline) — incremental append, no full
re-parse per snapshot; static path keeps retainState=true
- client-side reveal: buffer snapshots, reveal at a steady rate
(streamCharsPerSecond = 60/smoothness, 300..75 c/s) with rate-relative
catch-up (jump when backlog > 2s of reveal time)
- keep the streaming renderer alive after message.stop until the reveal
catches up (useStreaming gate); artifact card waits for the same
- cursor drawn via annotator, never part of the parse input
- new 'Streaming speed' setting (0.2-0.8s) in Settings, persisted per
device via SecureStore, live-updatable
- docs/05-streaming: rate mapping, catch-up, wait-for-reveal semantics
docs/09 §9.4 promised a SSH-host-key-style fingerprint confirm on first
pair, but the app built a default OkHttpClient with no certificate
handling — self-signed gateway certs were simply rejected.
Build the flow:
- TlsPinning.kt: PinningTrustManager wraps the platform default trust
manager; a rejected cert is accepted only when its SHA-256 fingerprint
matches the user-confirmed pin, anything else fails with
TlsFingerprintRequired (hostname verification still applies).
- SecureStore.pinnedCertFingerprint (Android EncryptedSharedPreferences +
desktop settings.json), cleared on forget().
- GatewayClient: pinning socket factory on the shared client (all legs
inherit it), new terminal State.TlsConfirmRequired, unwrap the nested
TlsFingerprintRequired in connect loop / watchdog / SSE / poll /
testHello.
- ConnectScreen: confirm dialog showing the fingerprint ("Confirm &
pin" re-runs the connect); IrisApp routes TlsConfirmRequired there;
ChatScreen + desktop tray handle the new state.
- Docs: §9.4 now describes the real flow (incl. SAN requirement), gap
table item 6 → implemented, setup.md limitation note updated.
- Tests: TlsPinningTest (fingerprint vs openssl, pin accept/reject,
live pin read, unwrap) + TlsPinningIntegrationTest (real TLS
handshake: unpinned → confirm data, pinned → 200).
Live E2E verified on the phone: first pair against a self-signed
IRIS_HTTP_CERT gateway shows the dialog, confirm pins, chat works,
auto-reconnect after gateway restart uses the pin.
Auth previously used the shared IRIS_TOKEN as the security principal:
a leaked token meant access to all devices, and a compromised device
could not be isolated.
Gateway:
- pairing.py: devices.token column (in-place migration) + revoked
denylist table; issue_token (idempotent, 64 hex), token_for,
reissue_token, revoke/unrevoke/is_revoked/list_revoked. The token
never leaks into device dicts (push fan-out / listings).
- http_server.py: auth accepts the shared token (bootstrap/legacy) OR
the device's own token (both constant-time); a revoked device_id is
rejected with 401 before either comparison. On SSE open (pairing)
the per-device token is minted and returned in hello.ack.
- protocol.py: hello_ack(..., device_token).
- adapter.py: setup flow (hermes gateway setup -> Iris) now offers
'Remove a paired device?' on an existing setup: numbered select
menu (last option = exit the removal loop), confirmation, back to
the menu for further removals.
- tools/iris_devices.py: operator CLI (list / revoke / unrevoke /
reissue), stdlib only.
App:
- SecureStore.deviceToken (Android: EncryptedSharedPreferences;
Desktop: second keyring slot iris-device-token / device_token.enc).
- HelloAckPayload.deviceToken; GatewayClient stores it on hello and
presents it instead of the shared token from then on (live provider
in HttpGateway); savePairing/clear wipe it for re-pairing.
Docs: 09 §9.3 stretch -> implemented (revocation semantics, both
control surfaces), 04 hello.ack example, frames.schema.json, M7 row 13.
Tests: 8 new Python tests (issuance, acceptance, revocation,
isolation, unrevoke, registry unit x2, setup-flow menu) - 94/94 pass;
2 new Kotlin wire tests - green. Live-verified against a running
gateway (hello.ack token matches devices.db; revoke -> 401 even with
shared token; unrevoke -> 200; setup TUI both paths).
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.
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.
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>
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.
- 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